实现最简单的useEffect
调用时机是在 React 完成对 DOM 的渲染之后(commitWork(root.child)),并且浏览器完成绘制之前(wipRoot = null)。
首先写一个最简单的useEffect 调用:打印一个字符串
js
function Foo() {
console.log("re foo");
const [count, setCount] = React.useState(10);
const [bar, setBar] = React.useState("bar");
function handleClick() {
setCount((c) => c + 1);
setBar(() => "bar");
}
React.useEffect(() => {
console.log("init");
}, []);
return (
<div>
<h1>foo</h1>
{count}
<div>{bar}</div>
<button onClick={handleClick}>click</button>
</div>
);
}
【思路】遍历fiber树,逐个查看是否有effect,有的话就执行:
- 写useEffect函数,将callback和deps存入fiber。
- 然后在commitWork(wipRoot.child)后从根节点开始按fiber链表结构调用每个fiber节点的effectHook。
- 这里
commitEffectHooks
的节点遍历与commitWork
中的稍有不同:不像commitWork
需要append到parent,这里只是遍历。所以不需要查找和处理parent,只需要逐级遍历fiber节点本身, child和sibling。
- 这里
js
function useEffect(callback, deps) {
const effectHook = {
callback,
deps,
}
wipFiber.effectHook = effectHook;
}
function commitRoot() {
deletions.forEach(commitDeletion)
commitWork(wipRoot.child);
commitEffectHooks();
wipRoot = null;
deletions = [];
}
function commitEffectHooks() {
function run(fiber) {
if (!fiber) return
fiber.effectHook?.callback()
run(fiber.child)
run(fiber.sibling)
}
run(wipRoot)
}
至此,已经实现了每一次渲染都执行effect。下一步就是根据依赖数组元素值是否变化来决定是否执行effect。
实现依赖数组
对于依赖数组的判断,分两种情况:
- 【初始化】如果fiber.alternate 没有值,那就是第一次执行
- 【更新】否则就是更新:如果老的effectHook中存储的依赖数组中有数据和新的有不同,就执行effect
js
function commitEffectHooks() {
function run(fiber) {
if (!fiber) return
if (!fiber.alternate) {
fiber.effectHook?.callback()
} else {
const oldDeps = fiber.alternate.effectHook?.deps
const newDeps = fiber.effectHook?.deps
const hasChanged = newDeps.some((dep, i) => dep !== oldDeps[i])
if (hasChanged) {
fiber.effectHook?.callback()
}
}
run(fiber.child)
run(fiber.sibling)
}
run(wipRoot)
}
将count加入依赖数组,并且注释掉handleClick
中对count的改变。
js
function Foo() {
const [count, setCount] = React.useState(10);
const [bar, setBar] = React.useState("bar");
function handleClick() {
setCount((c) => c + 1);
setBar(() => "bar");
}
React.useEffect(() => {
console.log("update");
}, [bar]);
return (
<div>
<h1>foo</h1>
{count}
<div>{bar}</div>
<button onClick={handleClick}>click</button>
</div>
);
}
发现在bar不变的时候,不会再执行effect了。effect只在第一次渲染时执行。
支持多个useEffect
js
function Foo() {
const [count, setCount] = React.useState(10);
const [bar, setBar] = React.useState("bar");
function handleClick() {
setCount((c) => c + 1);
setBar(() => "bar");
}
React.useEffect(() => {
console.log("init");
}, []);
React.useEffect(() => {
console.log("update");
}, [bar, count]);
...
类似于前一天课程的useState, 我们也使用数组来存储和调用多个effect
js
let effectHooks;
function useEffect(callback, deps) {
const effectHook = {
callback,
deps,
}
effectHooks.push(effectHook);
wipFiber.effectHooks = effectHooks;
}
function updateFunctionComponent(fiber) {
stateHooks = [];
stateHookIndex = 0;
//对于每个fiber节点进行初始化
effectHooks = [];
wipFiber = fiber;
const children = [fiber.type(fiber.props)]
reconcileChildren(fiber, children)
}
原来的单个hook调用改为依次调用
js
function commitEffectHooks() {
function run(fiber) {
if (!fiber) return
if (!fiber.alternate) {
fiber.effectHooks?.forEach(hook => hook?.callback());
} else {
fiber.effectHooks?.forEach((newHook, index) => {
const oldDeps = fiber.alternate.effectHooks[index]?.deps
const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
if (hasChanged) {
newHook.callback();
}
})
}
run(fiber.child)
run(fiber.sibling)
}
run(wipRoot)
}
实现cleanup
- useEffect 返回的一个函数。用来在执行本次effect之前,清理上一次effect。
- 当
deps
为空的时候不会调用返回的cleanUp
设计一下Foo组件,让它先后打印update和cleanup。
js
function Foo() {
const [count, setCount] = React.useState(10)
const [bar, setBar] = React.useState("bar")
function handleClick() {
setCount(c => c + 1)
setBar(() => "bar")
}
React.useEffect(() => {
console.log("init")
return () => {
console.log("cleanUp 0")
}
}, [])
React.useEffect(() => {
console.log("update", count)
return () => {
console.log("cleanUp 1")
}
}, [count])
React.useEffect(() => {
console.log("update", count)
return () => {
console.log("cleanUp 2")
}
}, [count])
return (
<div>
<h1>Foo : {count}</h1>
<div>{bar}</div>
<button onClick={handleClick}>click</button>
</div>
)
}
- 执行
hook.callback
并且将返回的函数赋值给hook.cleanUp
- 写一个
runCleanup
函数,在run
之前执行- React选择"先整树清理,再整树执行回调"的设计,本质是出于副作用隔离 和闭包值稳定性 的考虑。
- 若逐个节点交替执行cleanup和callback,可能导致不同节点的副作用相互干扰(例如前一节点的callback修改了全局状态,影响后续节点的cleanup逻辑)
- 清理函数中访问的
props
或state
是上一次渲染时捕获的值。若清理函数与回调函数交替执行,可能导致新回调中使用的值与清理函数中的旧值产生冲突。
- React选择"先整树清理,再整树执行回调"的设计,本质是出于副作用隔离 和闭包值稳定性 的考虑。
js
function commitEffectHooks() {
function run(fiber) {
if (!fiber) return
if (!fiber.alternate) {
fiber.effectHooks?.forEach(hook => {
hook.cleanUp = hook?.callback(); // 将返回的函数赋值
});
} else {
fiber.effectHooks?.forEach((newHook, index) => {
if (newHook.deps.length > 0) {
const oldDeps = fiber.alternate.effectHooks[index]?.deps
const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
if (hasChanged) {
newHook.cleanUp = newHook.callback(); // 将返回的函数赋值
}
}
})
}
run(fiber.child)
run(fiber.sibling)
}
function runCleanup(fiber) {
if (!fiber) return
fiber.alternate?.effectHooks?.forEach((hook) => {
if (hook.deps.length > 0) {
hook?.cleanUp && hook?.cleanUp()
}
})
runCleanup(fiber.child)
runCleanup(fiber.sibling)
}
runCleanup(wipRoot)
run(wipRoot)
}
看看效果:
- 依赖数组为空的useEffect, 其cleanup函数不会被执行。因为这个effect并不会被再次执行
- 依赖数组元素变化的useEffect, 其cleanup会和fiber树上的其它cleanup一起首先被执行。然后effect才会被执行。
支持不传依赖数组
按照react的设计,当不传依赖数组参数时,应当每次都执行effect,也会在下次 effect 执行前或组件卸载时运行cleanup。
js
React.useEffect(() => {
console.log("init every time")
return () => {
console.log("cleanUp every time")
}
}, undefined)
添加一些判断即可完成
js
function commitEffectHooks() {
function run(fiber) {
if (!fiber) return
if (!fiber.alternate) {
fiber.effectHooks?.forEach(hook => {
hook.cleanUp = hook?.callback();
});
} else {
fiber.effectHooks?.forEach((newHook, index) => {
if (newHook.deps?.length > 0) {
const oldDeps = fiber.alternate.effectHooks[index]?.deps
const hasChanged = newHook?.deps.some((dep, i) => dep !== oldDeps[i])
if (hasChanged) {
newHook.cleanUp = newHook.callback();
}
} else if (newHook.deps === undefined) {
// 依赖数组为空,则每次都执行effect
newHook.cleanUp = newHook.callback();
}
})
}
run(fiber.child)
run(fiber.sibling)
}
function runCleanup(fiber) {
if (!fiber) return
fiber.alternate?.effectHooks?.forEach((hook) => {
// 依赖数组为空也执行cleanup
if (hook.deps?.length > 0 || hook.deps === undefined) {
hook?.cleanUp && hook?.cleanUp()
}
})
runCleanup(fiber.child)
runCleanup(fiber.sibling)
}
runCleanup(wipRoot)
run(wipRoot)
}
尾声
至此,我们的mini-react 已经完成了。接下来我们可以使用它来做一个小应用:todos。