介绍
最近我正在学习 React hooks 的 API,已经被它的表现惊呆了。Hooks 让我可以通过极少的行数来重写数十行的样板代码。不幸的是,这种便利性需要付出一些代价,我发现一些更高级的 hooks 例如 useCallback 和 useMemo 难以学习,在一开始有点反直觉。
在本文中,我将通过一些简单的例子来说明为什么我们需要这些 hooks,在什么时候需要使用它们以及怎么使用。这不是一篇关于 hooks 的介绍,你需要熟悉 useState 以了解下面的内容,
问题
在我们开始之前,让我们引入一个帮助按钮组件。我们将使用 React.memo 把它放入一个记忆组件中。这将强制让 React 不再重新渲染它,除非它的某些属性值改变了。我们还需要添加一个随机颜色作为背景以便我们能够在组件重新渲染时跟踪它。
import React, { useState, useCallback } from 'react';
// 当被调用时随机生成颜色
const randomColour = () => '#'+(Math.random()*0xFFFFFF<<0).toString(16);
// props 的类型
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement>;
// 一个有着随机背景色的记忆按钮
const Button = React.memo((props: ButtonProps) =>
<button onClick={props.onClick} style={{background: randomColour()}}>
{props.children}
</button>
)
现在让我们看一看下面的简单的 app 例子,它展示了两个数字 - c 和 delta。一个按钮允许用户对 delta 加一。另一个按钮允许用户通过添加 delta 以增加 Counter。我们将创建 increment 和 incrementDelta 两个方法,并把它们绑定到按钮的点击事件。让我们看看当用户点击按钮时,这样的函数会被创建多少次:
import React, { useState } from 'react';
// 跟踪 app 存在时所有创建的函数
const functions: Set<any> = new Set();
const App = () => {
const [delta, setDelta] = useState(1);
const [c, setC] = useState(0);
const incrementDelta = () => setDelta(delta => delta + 1);
const increment = () => setC(c => c + delta);
// 注册函数以便计算它们
functions.add(incrementDelta);
functions.add(increment);
return (<div>
<div> Delta is {delta} </div>
<div> Counter is {c} </div>
<br/>
<div>
<Button onClick={incrementDelta}>Increment Delta</Button>
<Button onClick={increment}>Increment Counter</Button>
</div>
<br/>
<div> Newly Created Functions: {functions.size - 2} </div>
</div>)
}
当我们运行 app 并且开始点击按钮,我们观察到一些有趣的事情。每次点击按钮都会创建两个新的函数!并且,每次改变两个按钮都会重新渲染!

c,我们为什么需要重新创建 incrementDelta 函数呢?这不仅是记忆 - 它导致了子组件不必要地重新渲染。这将迅速成为一个性能问题。
一个可能的解决方案是将这两个函数移到功能组件 App 的外面。不幸的是,这样并不会奏效,因为它们使用了 App 作用域外的状态变量。
天真的解决方案 - 为什么依赖很关键
这是为什么需要引入 useCallback 的原因。它以一个函数作为参数,并返回一个缓存的\记忆的版本。它还需要第二个参数,稍后再做介绍。让我们用 useCallBack 重写:
const App = () => {
const [delta, setDelta] = useState(1);
const [c, setC] = useState(0);
// No dependencies (i.e. []) for now
const incrementDelta = useCallback(() => setDelta(delta => delta + 1), []);
const increment = useCallback(() => setC(c => c + delta), []);
// Register the functions so we can count them
functions.add(incrementDelta);
functions.add(increment);
return (<div>
<div> Delta is {delta} </div>
<div> Counter is {c} </div>
<br/>
<div>
<Button onClick={incrementDelta}>Increment Delta</Button>
<Button onClick={increment}>Increment Counter</Button>
</div>
<br/>
<div> Newly Created Functions: {functions.size - 2} </div>
</div>)
}
这将阻止新函数的实例化以及不必要的重新渲染。然而,当我们重新运行 app,我们注意到我们以及引入了一个 bug。如果我们把 detla 增加到2,然后试着增加计数器,它的值增加了1而不是2:

无论 delta 的状态有没有改变,不会有新的函数被创建。在初始化的渲染中, useCallback 创建了一个单独、缓存的 “increment” 版本,封装了 detla 的状态值,在后面的每次重新渲染时都重复使用。
这是因为在 increment 函数的初始化时,delta 的值是1,该值会被函数的作用域捕获。由于我们缓存了 increment 实例,它不会重新创建并会使用初始的作用域的值 detla = 1。.
useCallback 创建了一个单独、缓存的 increment 版本,封装了 delta 的初始值。当 App使用不同的 detla 值重新渲染时, useCallback 返回一个先前的 increment 函数的版本,该函数保留第一次渲染时的delta旧值。
我们需要告诉 useCallback 在每次 delta 改变时创建新的、缓存的 increment 版本。
依赖
这是 useCallback 第二个参数出现的地方。它是一系列值的数组,代表了缓存的依赖。如果依赖项的值相等,则在随后的任何两个重渲染中,useCallback将返回相同的缓存函数实例。
我们可以使用依赖以解决前面的 bug:
const incrementDelta = useCallback(() => setDelta(delta => delta + 1), []);
// Recreate increment on every change of delta!
const increment = useCallback(() => setC(c => c + delta), [delta]);
现在我们可以看到,只有 delta 变化时,新的 increment 函数才会创建。所以,counter 按钮只会在 delta 改变时才会重新渲染,因为新的 onClick 属性实例被添加。换句话说,我们只会创建一个新的回调,如果它使用的闭包部分(即依赖)自上一次渲染时改变了。

每次 delta 的改变都会创建一个新的 increment 函数。仅重新创建依赖改变的函数
useCallback 的一个很有用的特性是,如果依赖没有改变,它会返回了相同的函数实例。因此我们可以在其它的 hooks 的依赖列表中使用它。例如,让我们创建一个缓存/记忆函数来增加全部数字:
const incrementDelta = useCallback(() => setDelta(delta => delta + 1), []);
const increment = useCallback(() => setC(c => c + delta), [delta]);
// Can depend on [delta] instead, but it would be brittle
const incrementBoth = useCallback(() => {
incrementDelta();
increment();
}, [increment, incrementDelta]);
新的 incrementBoth 函数依赖 delta,我们可以使用 useCallback(... ,[delta])。然而,这是一个十分脆弱的方法!如果我们改变 increment 或者 incrementDelta 的依赖,我们不得不记住 incrementBoth 的依赖的改变。
由于 increment 或者 incrementDelta 的引用不会改变,除非它们的依赖改变了,我们才能使用它们。依赖可以被忽略!这是一个简单的规则:
在功能组件范围内声明的每个函数都必须使用 useCallback 进行存储/缓存。如果它从组件作用域引用函数或其他变量,则应在其依赖列表中列出它们。
可以由linter强制执行此规则,以检查useCallback缓存相关性是否一致。
两个相似的 hooks - useCallback 和 useMemo
React 引入了另一个相似的 hook,叫做 useMemo。它有相同的签名,但是工作方式不同。不像 useCallback 缓存提供的函数实例,useMemo 调用提供的函数并缓存其结果:
const [c, setC] = useState(0);
// This value will not be recomputed between re-renders
// unless the value of c changes
const sinOfC: number = useMemo(() => Math.sin(c) , [c])
与使用 useCallback 一样,useMemo 返回的值可以用作其它 hooks 的依赖项。
有趣的是,useMemo 也可以缓存函数值。换句话说,它是 useCallback 的通用版本,在以下示例可以替换
// Some function ...
const f = () => { ... }
// The following are functionally equivalent
const callbackF = useCallback(f, [])
const callbackF = useMemo(() => f, [])