本文记录了在探究 React 函数组件之间 useEffect hook 的执行顺序时,所进行的实验及其结论。
具体来说,考虑如下组件树,两个 <Test>
组件中各有一个 effect hook,这两个副作用操作谁先,谁后?进一步地,如果这两个 effect hook 含有清除操作呢?
function Test({ name, children = null }) {
useEffect(() => {
console.log(`${name} effect`);
return () => {
console.log(`${name} cleanup`);
};
});
return children;
}
function App() {
return (
<Test name="parent">
<Test name="child" />
</Test>
);
}
effect hook 的定义
首先复习一下 React 中 effect hook 的定义。以下内容整理自 React 文档。
useEffect(didUpdate);
useEffect(didUpdate, dependencies);
- 用途:effect hook 用于完成副作用操作。
参数:
didUpdate
参数接收一个包含命令式、且可能有副作用代码的函数。dependencies
参数接收一个数组,数组中的元素表示 effect 所依赖的值。
执行时机:
- 默认情况:
didUpdate
会在每轮组件渲染完成后执行。 - 条件执行:当传入
dependencies
参数时,didUpdate
仅在依赖值发生变化时执行
- 默认情况:
- 清除:
didUpdate
函数可以返回一个清除函数以清除副作用操作(如取消订阅、清除定时器等)。如果组件多次渲染,则上一个 effect 会在下一个 effect 执行之前被清除。
探究 effect 及其清除的执行顺序
<Test>
组件接收一个 name 参数以区分不同的元素<Test>
组件中使用一个 effect hook,其执行的副作用为:输出一行{name} effect
的 log,清除操作为:输出一行{name} cleanup
的 log。mount
这个 state 用于控制根组件的挂载与否forceUpdate
函数可以触发 react 重新渲染<App>
组件<App>
组件中创建了一个三层的<Test>
组件树- 使用
setTimeout
延时执行forceUpdate()
以及setMount(false)
看代码,猜输出。你可以 在 CodeSandbox 中运行以下代码 并观察 Console 中的输出。
function Test({ name, children = null }) {
useEffect(() => {
console.log(`${name} effect`);
return () => {
console.log(`${name} cleanup`);
};
});
return children;
}
export default function App() {
const [mount, setMount] = useState(true);
const [, forceUpdate] = useReducer((v) => v + 1, 0);
useEffect(() => {
setTimeout(() => {
console.log('\n* forceUpdate');
forceUpdate();
}, 1000);
setTimeout(() => {
console.log('\n* unmount');
setMount(false);
}, 2000);
}, []);
return mount && (
<Test name="1">
<Test name="1-1">
<Test name="1-1-1" />
<Test name="1-1-2" />
</Test>
<Test name="1-2">
<Test name="1-2-1" />
<Test name="1-2-2" />
</Test>
</Test>
);
}
从以下输出可以看出,不同组件之间的 effect hook 的执行顺序类似于组件树的一次深度优先遍历。这表明 effect hook 的执行顺序与组件树相关。
另外,值得留意的一点是,组件重新渲染时触发的 effect 清除,与组件 unmount 时触发的 effect 清除的顺序不同。
1-1-1 effect
1-1-2 effect
1-1 effect
1-2-1 effect
1-2-2 effect
1-2 effect
1 effect
* re-render
1-1-1 cleanup
1-1-2 cleanup
1-1 cleanup
1-2-1 cleanup
1-2-2 cleanup
1-2 cleanup
1 cleanup
1-1-1 effect
1-1-2 effect
1-1 effect
1-2-1 effect
1-2-2 effect
1-2 effect
1 effect
* unmount
1 cleanup
1-1 cleanup
1-1-1 cleanup
1-1-2 cleanup
1-2 cleanup
1-2-1 cleanup
1-2-2 cleanup
考虑运行时插入新的组件的情况
如果在根组件 mount 以后,向组件树中间插入一个新的组件,使得 effect 的执行顺序与组件树的遍历不同,那么 effect 清除的顺序,是组件树的遍历,还是 effect 执行顺序的逆序?观察以下代码的输出,可以发现,effect 清除的顺序,应该是组件树的遍历,而不是 effect 执行顺序的逆序。
function DelayedMount({ children }) {
const [mount, setMount] = useState(false);
useEffect(() => {
setTimeout(() => {
console.log('\n* mount 1-append');
setMount(true);
}, 1000);
}, []);
return mount ? children : null;
}
function Test({ name, children = null }) {
useEffect(() => {
console.log(`${name} effect`);
return () => {
console.log(`${name} cleanup`);
};
});
return children;
}
export default function App() {
const [mount, setMount] = useState(true);
const [, forceUpdate] = useReducer((v) => v + 1, 0);
useEffect(() => {
setTimeout(() => {
console.log('\n* force update');
forceUpdate();
}, 2000);
setTimeout(() => {
console.log('\n* unmount all');
setMount(false);
}, 3000);
}, []);
return mount && (
<Test name="1">
<Test name="1-1">
<Test name="1-1-1" />
<Test name="1-1-2" />
</Test>
<DelayedMount>
<Test name="1-append">
<Test name="1-append-1" />
<Test name="1-append-2" />
</Test>
</DelayedMount>
<Test name="1-2">
<Test name="1-2-1" />
<Test name="1-2-2" />
</Test>
</Test>
);
}
输出:
1-1-1 effect
1-1-2 effect
1-1 effect
1-2-1 effect
1-2-2 effect
1-2 effect
1 effect
* mount 1-append
1-append-1 effect
1-append-2 effect
1-append effect
* forceUpdate
1-1-1 cleanup
1-1-2 cleanup
1-1 cleanup
1-append-1 cleanup
1-append-2 cleanup
1-append cleanup
1-2-1 cleanup
1-2-2 cleanup
1-2 cleanup
1 cleanup
1-1-1 effect
1-1-2 effect
1-1 effect
1-append-1 effect
1-append-2 effect
1-append effect
1-2-1 effect
1-2-2 effect
1-2 effect
1 effect
* unmount all
1 cleanup
1-1 cleanup
1-1-1 cleanup
1-1-2 cleanup
1-append cleanup
1-append-1 cleanup
1-append-2 cleanup
1-2 cleanup
1-2-1 cleanup
1-2-2 cleanup
结论
effect hook 在不同组件之间的执行顺序遵从如下规律:
- 组件渲染后,执行 effect 的顺序:组件树的后序深度优先遍历
- 组件重新渲染时,清除 effect 的顺序:组件树的后序深度优先遍历
- 组件 unmount 时,清除 effect 的顺序:组件树的前序深度优先遍历
本文仅通过实验的方式总结了以上规律。针对这一规律在代码实现层面的解释,可能需要参考 React Fiber 的相应实现。