sunilwang

V1

2022/07/26阅读:9主题:绿意

浅谈useEffect

作者简介

张悦:在减肥的路上越走越远。。。

1、前言

近期组内要做 React 系列分享,不幸又幸运的是我抽到了第一个,有优先分享 & 优先选题权,作为一个 React 小白,我选择了 useEffect 原理主题。

2、前置知识

组件的每一次渲染都是相互独立的(state、props、事件处理函数、effect 都是固定的) 🌰:阅读下方代码并按照步骤操作会弹出什么?页面上会显示什么? image.png 答案:弹出 3,页面显示 5 原因:在上面我们说到,组件的每一次渲染都是相互独立的,所以在此例中每次渲染的 count 和 handleAlertClick 都是相互独立的。

  • 初次渲染的时候 count 为 0;
  • 第一次点击 count 为 1;
  • 第二次点击 count 为 2;
  • 第三次点击 count 为 3。此时再点击 show alert,由于在当前这轮渲染中 count 为 3,也就是说定时器中的 count 为 3,不论是过 3s 弹出还是 300s、3000s,弹出的内容均为 3。此时弹出的 count 只与本轮渲染有关,与定时器时间及后续轮次渲染中的 count 均无关;
  • 第四次点击 count 为 4;
  • 第五次点击 count 为 5;

3、useEffect 是什么

useEffect 是一个 hook,这个 hook 可以让你在函数组件中执行副作用操作。如果比较熟悉 React class 的生命周期函数,我们可以把 useEffect 这个 hook 看作 componentDidMount、componentDidUpdate 和 componentWillUnmount 这三个函数的组合。 用法:useEffect(effect, [dep])

  - effect:渲染时执行的函数体
  - [dep]:依赖项,可以有多个,逗号分隔

🌰1:首次渲染时执行,第二个参数为空数组。

useEffect(() => {
  getDetail()
}, [])

🌰2:每次渲染都执行,第二个参数不填。

useEffect(() => {
  getDetail()
})

🌰3:按状态加载。其中 a 是我们自定义的状态,只有当 a 改变时 effect 函数体才会执行。

useEffect(() => {
  getDetail()
}, [a])

🌰4:需要清除的 effect。例如 effect 中需要对 dom 进行监听,如果不使用清除函数,a 每更新一次都会再重新创建一个监听事件,导致一个 dom 上会绑定多个。

useEffect(() => {
  const tableDom = document.getElementById(id);
  scrollDom!?.addEventListener('scroll', handleScroll);

  // 清除函数
  return () => scrollDom!?.removeEventListener('scroll', handleScroll);
}, [a])

4、useEffect 依赖项的作用与原理

useEffect 第二个参数是选填的,如果不填,在每一次渲染的时候,effect 函数都会被打上【需要执行】的 tag;如果填了的话,只有在依赖项发生改变的时候才会被打上【需要执行】的 tag,如果依赖项没有发生变化,则会被打上【不需要执行】的 tag。

4.1、effect 执行的时机与原因

React 将状态导致的副作用 useEffect 放在了额外的执行帧里,目的是防止渲染帧的事件过长,阻塞 UI 渲染。所以 effect 是在 UI 渲染完成后,再依据 tag 来执行。

4.2、原理

  • 组件加载时
// package/react-reconciler/src/ReactFiberHooks.new.js
function mountEffect({
  ...
  return mountEffectImpl(fiberFlags, HookLayout, create, deps)
}

function mountEffectImpl(fiberFlags, hookFlags, create, deps{
  const hook = mountWorkInProgressHook(); // 新建hook对象
  const nextDeps = deps === undefined ? null : deps; // useEffect不传依赖为null
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect( // 赋值,hook对象的初始值
    HookHasEffect | hookFlags,
    create,
    undefined,
    nextDeps,
  );
}

// pushEffect做了两件事情:创建了一个effect,把它放在更新队列里。create没有被立即执行。
function pushEffect(tag, create, destroy, deps{
  const effect: Effect = {
    tag, // tag
    create, // 第一个参数
    destroy, // 清除函数
    deps, // 第二个参数
    // Circular
    next: (null: any),
  };
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    componentUpdateQueue.lastEffect = effect.next = effect;
  }
  ...
  return effect;
}
  • 组件更新时
// package/react-reconciler/src/ReactFiberHooks.new.js
function updateEffect(
  create: (
) => (() => void) | void,
  depsArray<mixed> | void | null,
): void 
{
  return updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}

function updateEffectImpl(fiberFlags, hookFlags, create, deps): void {
  const hook = updateWorkInProgressHook(); // 新建hook对象
  const nextDeps = deps === undefined ? null : deps; // useEffect不传依赖为null
  let destroy = undefined;

  ...
  if (areHookInputsEqual(nextDeps, prevDeps)) { // 比较是否变化
    hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
    return;
  }
  ...
}

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
{
  ...
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) { // 如果deps没有变化的话,打上tag
      continue;
    }
    return false;
  }
  return true;
}

// packages/shared/objectIs.js
function is(x: any, y: any{
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}
const objectIs: (x: any, y: any) => boolean =
  typeof Object.is === 'function' ? Object.is : is;

export default objectIs;


5、清除函数的作用及执行时机

5.1、清除函数的作用

作用:消除副作用,在 effect 下次执行之前,清除上一个 effect。例如下方定时器例子。

5.2、执行顺序

先渲染 -> 清除上一个 effect -> 运行这次 effect

// package/react-reconciler/src/ReactFiberWorkLoop.new.js
// 如果存在挂起的被动效果,请计划回调以处理它们。
// 尽可能早地执行此操作,使其在任何其他操作之前排队
// 可能在提交阶段安排。(见#16714)
if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    if (!rootDoesHavePassiveEffects) {
      rootDoesHavePassiveEffects = true;
      pendingPassiveEffectsRemainingLanes = remainingLanes;
      pendingPassiveTransitions = transitions;
      scheduleCallback(NormalSchedulerPriority, () => {
        // 触发useEffect
        flushPassiveEffects();
        // This render triggered passive effects: release the root cache pool
        // *after* passive effects fire to avoid freeing a cache pool that may
        // be referenced by a node in the tree (HostRoot, Cache boundary etc)
        return null;
      });
    }
  }

在 commit 阶段的 before-mutation 阶段之前,会使用 scheduleCallback 调度 useEffect。

// package/react-reconciler/src/ReactFiberWorkLoop.new.js
export function flushPassiveEffects(): boolean {
  ...
  // 执行上次render的清除函数 && 本地render的函数
  return flushPassiveEffectsImpl();
}

function flushPassiveEffectsImpl({
  ...
    // 调用所有useEffect的清除函数,调用这个useEffect在上一次render时的清除函数
  commitPassiveUnmountEffects(root.current);
  // 执行所有useEffect的回调函数,调用useEffect本次render的回调
  commitPassiveMountEffects(root, root.current, lanes, transitions);
}

commitPassiveUmmountEffects 最终会调用这个 commitHookEffectListUnmount 函数,他会遍历 Effectlist,然后调用 destory 函数。useEffect 的执行需要保证所有组件的 useEffect 的销毁函数执行完才能执行,因为多个组件可能公用一个 ref,如果不是按照全部销毁再全部执行的顺序,那么组件的 useEffect 的销毁函数修改的 ref.current 可能影响另一个组件 useEffect 的执行。

// package/react-reconciler/src/ReactFiberCommitWork.new.js
function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null,
{
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        // Unmount
        const destroy = effect.destroy;
        effect.destroy = undefined;
        if (destroy !== undefined) {
          ...
          // 销毁清除函数
          safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
          ...
        }
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

遍历 effectList,然后执行回调函数,获取 destroy,存放在 effect 上,effect 就是带有 effectTag 的 fiber。

// package/react-reconciler/src/ReactFiberCommitWork.new.js
function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber{
  const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any);
  const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {
        ...
        // Mount,执行函数
        const create = effect.create;
        if (__DEV__) {
          if ((flags & HookInsertion) !== NoHookEffect) {
            setIsRunningInsertionEffect(true);
          }
        }
        // 获取destroy放入effect上
        effect.destroy = create();

        ...
      }
      effect = effect.next;
    } while (effect !== firstEffect);
  }
}

小结: useEffect 的调度顺序就是:

  • commit 阶段的 before-mutation 阶段之前通过 scheduleCallback 进行调度 flushPassiveEffects 函数。
  • 因为 flushPassiveEffects 函数会遍历 effect,所以 layout 阶段之后,会将 effectList 放入一个全局变量。
  • 适当的时机,useEffect 会在页面渲染后,即 layout 阶段后执行。
  • flushPassiveEffects 做的事情就是:获取 effectList,遍历执行 effect 的 useEffect 销毁函数,然后再遍历执行 effect 的 useEffect 执行函数,将 destory 存放在每个 fiber.destory 上。

6、错误的依赖项会导致什么问题?

一旦 effect 中使用的 props 或 state 没在包含在依赖项中,即依赖项中有遗漏,这时 useEffect 就不能明确的知道它应该在何时执行。导致性能、交互等一些莫名其妙的 bug。

🌰1:不写依赖项

useEffect(() => {
  document.title = `你好,${props.name}`;
});

问题:每次渲染时都会执行,导致 effect 进行了多次不必要的执行。可能会造成页面卡死的情况,影响性能。

🌰2:依赖项为[]

useEffect(() => {
  document.title = `你好,${props.name}`;
}, []);

问题:只在第一次渲染时执行一次,后续 name 有变化不会继续执行,影响逻辑

🌰3:定时器例子

const [count, setCount] = useState(0);

useEffect(() => {
  const intervalId = setInterval(() => {
      setCount(count + 1);
  }, 1000);

  return () => clearInterval(intervalId);
}, []); // 这是错误的

return <h1>{count}</h1>;

问题:count 一直为 1,没有改变。 原因:在 useEffect 中使用了 state(即 count),但是它的依赖项却是[],所以 effect 只在初次渲染时执行。虽然 effect 只执行了一次,但是定时器并没有清除(由上述清除函数执行的时机可知,该定时器会在下次渲染完成后执行/组件销毁前),会导致定时器每隔 1s 就会执行一次,但是每次的结果都为 1(由于每次渲染都有独立的 state,该 effect 只渲染了一次,所以每次 count+1 都为 1)。 解决方案:将 count 设置为依赖项

useEffect(() => {
  const intervalId = setInterval(() => {
      setCount(count + 1);
  }, 1000);

  return () => clearInterval(intervalId);
}, [count]);

每次 count 发生变化时,effect 会重新执行。缺点:每次重新执行都需要把定时器清除掉,开一个新的定时器。 解决方案优化:

useEffect(() => {
  const intervalId = setInterval(() => {
      setCount((n) => n + 1);
  }, 1000);

  return () => clearInterval(intervalId);
}, []);

由于并没有在 useEffect 中使用 count,可以将其修改为 setState 的函数形式。告知 react 这里仅仅是去递增一个状态,并不关心它的值,所以可以把 count 从依赖项中去掉。虽然 effect 只执行了一次,但是 count 会随着定时器执行而变化,并且只在第一次渲染时开启一个定时器,在组件销毁前移除。

小结:凡是在 useEffect 中使用到了函数组件的任何 state 或 props,都应该将它放入依赖项中。这样 useEffect 才能明确应该在什么时间执行。

7、把函数设置成依赖项?

如果我们在 effect 里面调用了函数(A),如果函数(A)没有使用组件里的任何 props 或 state,就不需要给他设置依赖项。如果函数(A)使用了 props 或 state,就相当于在 effect 中使用了 props 或 state,就需要设置依赖项。

const [count, setCount] = useState(0);

/**
 * 更改count
 */

const handleCount = () => {
  setCount(count + 1);
};

/**
 * 打印count
 */

const handleConsole = () => {
  console.log(count);
};

// 此 useEffect 与下方 useEffect 的结果相同
useEffect(() => {
  handleConsole();
}, []);

useEffect(() => {
  console.log(count);
}, []);

return (
  <Button onClick={handleCount}>点击</Button>
);

问题:由于没有写依赖项,所以只会执行一次,当点击按钮时 useEffect 不会执行。

方法一:将 handleConsole 函数放到 useEffect 里,再把 count 设置为依赖项(适用于 handleConsole 仅在这个 useEffect 中使用,如果我们需要在多个地方使用 handleConsole 函数不适用此方法)。

useEffect(() => {
  const handleConsole = () => {
    console.log(count);
  };

  handleConsole();
}, [count]);

方法二:将函数作为依赖。

const [count, setCount] = useState(0);

/**
 * 更改count
 */

const handleCount = () => {
  setCount(count + 1);
};

/**
 * 打印count
 */

const handleConsole = () => {
  console.log(count);
};

// 此 useEffect 与下方 useEffect 的结果相同
useEffect(() => {
  handleConsole();
}, [handleConsole]);

useEffect(() => {
  handleConsole();
});

return (
  <Button onClick={handleCount}>点击</Button>
);

问题:每一次判断依赖是否有改变时,都是有改变的。原因在最上面的前置知识中,每一次渲染函数都是独立的,所以每个函数的索引都不相同。useEffect 在比较时使用的是 Object.is,函数属于引用类型,比较的是索引,所以每一次依赖项都是有改变的。 正确的方案:使用 useCallback 包装一下函数,将 count 作为 useCallBack 的依赖。

const handleConsole = useCallBack(() => {
  console.log(count);
}, [count]);

useEffect(() => {
  handleConsole();
}, [handleConsole]);

原因:将 props 或 state 作为 useCallBack 的依赖,这样只有在依赖项有变化的时候 useCallBack 才会重新运行,useCallback 的返回值(函数的索引)才会发生变化,导致 useEffect 执行。

小结:一般建议把不依赖 props 和 state 的函数提到组件外面,并且把那些仅被 effect 使用的函数放到 effect 里面。如果这样做了以后,你的 effect 还是需要用到组件内的函数(包括通过 props 传进来的函数),可以在定义它们的地方用 useCallback 包一层。

8、参考

https://react.docschina.org/docs/hooks-effect.html[1]

https://juejin.cn/post/6927619414095298573#heading-0[2]

https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/#tldr[3]

https://blog.csdn.net/lin_fightin/article/details/123307093[4]


LBG开源项目推广:

还在手写 HTML 和 CSS 吗?
还在写布局吗?
快用 Picasso 吧,Picasso 一键生成高可用的前端代码,让你有更多的时间去沉淀和成长,欢迎Star

开源项目地址:https://github.com/wuba/Picasso
官网地址:https://picassoui.58.com

参考资料

[1]

https://react.docschina.org/docs/hooks-effect.html: https://react.docschina.org/docs/hooks-effect.html

[2]

https://juejin.cn/post/6927619414095298573#heading-0: https://juejin.cn/post/6927619414095298573#heading-0

[3]

https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/#tldr: https://overreacted.io/zh-hans/a-complete-guide-to-useeffect/#tldr

[4]

https://blog.csdn.net/lin_fightin/article/details/123307093: https://blog.csdn.net/lin_fightin/article/details/123307093

分类:

后端

标签:

后端

作者介绍

sunilwang
V1