作者简介
张悦:在减肥的路上越走越远。。。
1、前言
近期组内要做 React 系列分享,不幸又幸运的是我抽到了第一个,有优先分享 & 优先选题权,作为一个 React 小白,我选择了 useEffect 原理主题。
2、前置知识
组件的每一次渲染都是相互独立的(state、props、事件处理函数、effect 都是固定的)
🌰:阅读下方代码并按照步骤操作会弹出什么?页面上会显示什么?
答案:弹出 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,
deps: Array<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、参考
react.docschina.org/docs/hooks-…
LBG开源项目推广:
还在手写 HTML 和 CSS 吗?
还在写布局吗?
快用 Picasso 吧,Picasso 一键生成高可用的前端代码,让你有更多的时间去沉淀和成长,欢迎Star
开源项目地址:https://github.com/wuba/Picasso
官网地址:https://picassoui.58.com