跳到主要内容

useEffets执行时机

useEffect​

该 Hook 接收一个包含命令式、且可能有副作用代码的函数。在函数组件主体内(这里指在 React 渲染阶段)改变 DOM、添加订阅、设置定时器、记录日志以及执行其他包含副作用的操作都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。使用 useEffect 完成副作用操作。赋值给 useEffect 的函数会在组件渲染到屏幕之后执行。你可以把 effect 看作从 React 的纯函数式世界通往命令式世界的逃生通道。

异步执行​

为了防止渲染帧时间太长,React 将状态导致的副作用 useEffect 放在了额外的帧里执行,所以 useEffect 是一个名副其实的异步函数。

在未开启 Concurrent Mode 的情况下,React的 render phase, commit phase 都是同步执行,因为这一帧包含了太多的任务了,所以给我们做一些和渲染无关的任务 useEffect 就放到了渲染帧执行结束后再执行。如果有和渲染相关的任务,专门提供了一个 useLayoutEffect 的 hook放在 commit 之后同步执行。如下图

同步执行​

如果只有一次render被触发,那么useEffect是异步执行的,但是如果两个render触发,react为了保证严格的 render useEffect render useEffect 顺序,一定会在下一次render来之前执行掉useEffect,所以useEffect也可能是同步触发的

先 destroy 再 create​

在组件状态更新后,React 将先调用所有 destroy 函数,再调用所有 create 函数。在虚拟 DOM 树中,回调函数的执行顺序如下:

  • 对于删除的虚拟 DOM,以先序遍历虚拟 DOM 树的顺序调用 destroy。
  • 对于更新的虚拟 DOM,以后序遍历虚拟 DOM 树的顺序调用 destroy。
  • 以后续遍历虚拟 DOM 树的顺序调用 create
useEffect(
function create() {
// 创建副作用的回调函数
return function destroy() {
// 清理副作用的回调函数
}
},
// 副作用对应的依赖
[...deps]
)

调度原理​

调度器:调度更新 => 协调器:决定更新的内容 => 渲染器:将更新的内容渲染到视图中

  • 协调器:触发更新时,FunctionComponent被执行,执行到useEffect时会判断他的第二个参数deps是否有变化
  • 协调器:如果deps变化,则useEffect对应FunctionComponent的fiber会被打上Passive(即:需要执行useEffect)的标记。
  • 协调器:他会为需要更新的内容对应的fiber(可以理解为虚拟DOM)打上标记,这些被打标记的fiber会形成一条链表effectList.
  • 渲染器:遍历effectList过程中遍历到该fiber时,发现Passive标记,则依次执行该useEffect的destroy(即useEffect回调函数的返回值函数)与create(即useEffect回调函数)。

父子节点的顺序​

协调器的工作流程是使用遍历实现的递归。所以可以分为递与归两个阶段:

  • 递是从根节点向下一直到叶子节点
  • 归是从叶子节点一路向上到根节点

effectList的构建发生在归阶段。所以,effectList的顺序也是从叶子节点一路向上 由于归阶段是从Child到Parent到App,所以相应effectList也是同样的顺序。

function Child() {
useEffect(() => {
console.log('child');
}, [])

return <p>hello</p>;
}

function Parent() {
useEffect(() => {
console.log('parent');
}, [])

return <Child/>;
}

function App() {
useEffect(() => {
console.log('app');
}, [])

return <Parent/>;
}
// console child -> parent -> app

与componentDidMount不能类比​

useEffect整个过程都和生命周期钩子没有关系。事实上生命周期钩子只是附着在这一流程上的钩子函数

调度 -- 协调 -- 渲染
构建effectList -- 遍历effectList执行对应操作

源码如何实现​

基于useEffect回调延迟调用(实际上就是异步调用) 的需求,在实现上利用scheduler的异步调度函数:scheduleCallback,将执行useEffect的动作作为一个任务去调度,这个任务会异步调用。Scheduler 任务调度是基于时间分片的思想,每隔一段时间就把主线程还给浏览器,避免长时间占用主线程,内部的异步调用特性是借助 MessageChannel 实现的,MessageChannel就是一个MacroTask宏任务。

不选择其他方案的原因:

  • setTimeout(fn, 0):最后间隔时间会变成 4 毫秒,而不是最初的 1 毫
  • setInterval(fn, 0) 的效果,其结果与 setTimeout 相同

React 源码将 useEffect 回调的处理交给了 Scheduler 进行调度。React 源码如下:

if (
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
(finishedWork.flags & PassiveMask) !== NoFlags
) {
// 如果fiber节点上的flags存在Passive调度useEffect
if (!rootDoesHavePassiveEffects) {
rootDoesHavePassiveEffects = true
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects()
return null
})
}
}

可将 useEffect 的回调称为 Passive Effect。

flushPassiveEffects最后的步骤,执行effect的destroy和create。过程就是先循环待销毁的effect数组,再循环待创建的effect数组,这一过程发生在flushPassiveEffectsImpl函数中。循环的时候每个两项去effect是由于奇数项存储的是当前的fiber。

// 可以近似的认为,React 做了这样一步,实际流程中要复杂的多
const Task = () => {
const preDestory = element.destroy;
if (!preDestory) prevDestroy();
const destroy = create();
element.destroy= destroy;
};

// MessageChannel构造宏任务
const channel = new MessageChannel()
const port = channel.port2

// 每次 port.postMessage() 调用就会添加一个宏任务
// 该宏任务为调用 scheduler.scheduleTask 方法
channel.port1.onmessage = scheduler.scheduleTask

const scheduler = {
scheduleTask() {
// 挑选一个任务并执行
const task = pickTask()
const continuousTask = task()

// 如果当前任务未完成,则在下个宏任务继续执行
if (continuousTask) {
port.postMessage(null)
}
},
}

但 useEffect 的回调函数不一定在下个宏任务中执行。如果在 useEffect 的回调触发之前,React 组件又进行了一次状态更新,React 会先将之前的 Passive Effect 都处理掉。

useLayoutEffect​

其函数签名与 useEffect 相同,但它会在所有的 DOM 变更之后同步调用 effect。可以使用它来读取 DOM 布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect 内部的更新计划将被同步刷新。

useLayoutEffect 在渲染时是同步执行,其执行时机与 componentDidMount,componentDidUpdate 一致

与useEffect共用一套结构来存储effect链表。整体流程上都是先在render阶段,生成effect,并将它们拼接成链表,存到fiber.updateQueue上,最终带到commit阶段被处理。他们彼此的区别只是最终的执行时机不同,一个异步一个同步。

建议将修改 DOM 的操作里放到 useLayoutEffect 里:

  • DOM 已经被修改,但但浏览器渲染线程依旧处于被阻塞阶段,所以还没有发生回流、重绘过程。
  • 由于内存中的 DOM 已经被修改,通过 useLayoutEffect 可以拿到最新的 DOM 节点
  • 并且在此时对 DOM 进行样式上的修改,假设修改了元素的 height,这些修改会在 react 做出的更改一起被一次性渲染到屏幕上,依旧只有一次回流、重绘的代价。

放在 useEffect 里,useEffect 的函数会在组件渲染到屏幕之后执行,此时对 DOM 进行修改,会触发浏览器再次进行回流、重绘,增加了性能上的损耗。

总结​

  • 一、单独就 useEffect 或 useLayoutEffect 而言,会先执行所有的 destroy 回调再执行所有 create 回调。
  • 二、在虚拟 DOM 树中,回调函数的执行顺序如下:
    • 对于删除的虚拟 DOM,以先序遍历虚拟 DOM 树的顺序调用 destroy。
    • 对于更新的虚拟 DOM,以后序遍历虚拟 DOM 树的顺序调用 destroy。
    • 以后续遍历虚拟 DOM 树的顺序调用 create。
  • 三、useEffect 和 useLayoutEffect 的区别在于: useLayoutEffect 是在提交阶段同步执行,而 useEffect 是在未来某时刻执行。
  • 四、useEffect 回调的执行时机为以下两种情况之一:
    • 由 React Scheduler 调度,在后续宏任务中执行
    • 在下一次调和阶段之前执行。