1. 背景
初入 React Hooks 的小伙伴可能比较疑惑,为什么 useCallback 这个 Hook 每次写一个都要传入相应的 deeps 呢?,简直不要太麻烦了。
2. 源码阅读
跟前面一篇文章里提到的类似,useCallback 也是用链表来进行存储和和初始化的。
2.1 mountCallback
mount 阶段,会执行 mountCallback,它本身需要传入两个参数:
callback:实际需要执行的函数deps: 函数执行时需要传入的依赖
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
hook.memoizedState = [callback, nextDeps];
return callback;
}
可以看到,mountCallback 内还会执行 mountWorkInProgressHook 来生成链表,拿到的 hook 其实就是链表中的某个节点。
最终,会把我们传入的 callback 和 deps 都绑定在 hook.memoizedState 上。
2.2 updateCallback
update 阶段,会执行 updateWorkInprogressHook 来更新链表,之后,会拿 nextDeps 和 prevDeps 来进行对比:
function areHookInputsEqual(
nextDeps: Array<mixed>,
prevDeps: Array<mixed> | null,
): boolean {
if (__DEV__) {
if (ignorePreviousDependencies) {
// Only true when this component is being hot reloaded.
return false;
}
}
if (prevDeps === null) {
if (__DEV__) {
console.error(
'%s received a final argument during this render, but not during ' +
'the previous render. Even though the final argument is optional, ' +
'its type cannot change between renders.',
currentHookNameInDev,
);
}
return false;
}
if (__DEV__) {
// Don't bother comparing lengths in prod because these arrays should be
// passed inline.
if (nextDeps.length !== prevDeps.length) {
console.error(
'The final argument passed to %s changed size between renders. The ' +
'order and size of this array must remain constant.\n\n' +
'Previous: %s\n' +
'Incoming: %s',
currentHookNameInDev,
`[${prevDeps.join(', ')}]`,
`[${nextDeps.join(', ')}]`,
);
}
}
// $FlowFixMe[incompatible-use] found when upgrading Flow
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// $FlowFixMe[incompatible-use] found when upgrading Flow
if (is(nextDeps[i], prevDeps[i])) {
continue;
}
return false;
}
return true;
}
代码很简单,其实就是对 prevDeps 和 nextDeps 做了一层浅比较:
- 相等,则返回之前缓存的
callback - 不相等,则返回当前定义的
callback
完整代码:
function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const prevState = hook.memoizedState;
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
}
}
hook.memoizedState = [callback, nextDeps];
return callback;
}
所以可以看出, useCallback 就是对函数做了一层缓存,deps 在比较时是做的浅比较。
3. useCallback 为什么难用
3.1 hook 带来的闭包问题
3.1.1 什么是闭包
闭包是由捆绑起来(封闭的)的函数和函数周围状态(词法环境)的引用组合而成。换言之,闭包让函数能访问它的外部作用域。在 JavaScript 中,闭包会随着函数的创建而同时创建。
简而言之,当函数在创建时,依赖了外部作用于的变量,此时就会 形成闭包。
一个最简单的例子:
function createCounter() {
let num = 0
return {
count: () => {
return ++num
}
}
}
let counter = createCounter()
counter 在创建完成之后,始终会依赖 createCounter 里创建的变量,此时就形成了闭包。
3.1.2 React Hook 里的闭包
举个例子:
import React from 'react';
export default function App() {
const [count, setCount] = React.useState(0);
const updateCount = React.useCallback(() => {
setCount(count + 1)
console.info('count value is ', count)
}, []);
return (
<div className='App'>
<div>count is: {count}</div>
<button style={{ marginTop: 8 }} onClick={updateCount}>
update
</button>
</div>
);
}
这个例子里面,count 拿到的值始终都是 0。
跟上面的例子很像,不过要再理解一下 React 的运行过程:即每次渲染的之后 App 都会重新执行一次,相当于每次都会创建 App 里都会形成新的闭包。
但是在这个例子里,由于我们没有传入 deps,所以即使触发了 App 重新渲染,updateCount 依赖的上下文也始终没有改变过,所以不难知道 count 的值始终都会是 0。
4. 总结
4.1 useCallback 为啥要传 deps
- 为了解决 React Hook 带来的闭包问题
- 可以减少函数创建带来的性能开销
| 优点 | 缺点 |
|---|---|
| 节省函数创建时间;避免闭包问题 | 心智负担重,并且有额外内存开销 |
4.2 最佳实践
- 不要直接使用
useCallback,需要的时候再用它:
- 不使用的场景:比如直接在
render内使用的函数,其实就没太大必要包一层useCallback - 使用的场景:比如在
useEffect或者其他hook中用到了这个函数,就可以考虑用useCallback套一层。否则会因为每次渲染时callback都是一个新引用而导致重复执行 hook。
- 建议使用 ahook 封装的
useMemoizedFn来包一层callback(而不是使用useCallback)