JavaScript 中的闭包可能是该语言中最令人畏惧的特性之一。即使是无所不知的 ChatGPT 也会告诉你这一点。闭包也是最隐蔽的语言概念之一。我们在编写任何 React 代码会使用它,大部分时候甚至没有意识到它的存在。但最终我们无法避开闭包:如果我们想编写复杂且性能良好的 React 应用程序,就必须了解闭包。
问题
想象一下你正在实现一个带有几个输入字段的表单。其中一个字段是来自外部库的非常繁重的组件。你无法访问其内部实现,因此无法修复其性能问题。但你确实需要在表单中使用它,因此你决定用 React.memo
包装它,以在表单状态改变时最小化其重新渲染。代码看起来大概是这样的:
1 | const HeavyComponentMemo = React.memo(HeavyComponent); |
到目前为止,一切顺利。这个繁重的组件只接受一个字符串属性,比如 title
,还有一个 onClick
回调。这将在点击组件内部的 “完成” 按钮时触发。你希望在点击时提交表单数据。这也很简单:只需传递 title
和 onClick
属性。
1 | const HeavyComponentMemo = React.memo(HeavyComponent); |
现在,你会面临一个难题。正如我们所知,React.memo
包装的组件中的每个属性都需要是原始值或在重新渲染之间保持持久性。否则,记忆化(memoization)将不起作用。因此,技术上我们需要用 useCallback
包装 onClick
:
1 | const onClick = useCallback(() => { |
但是,我们也知道 useCallback
钩子应该在其依赖项数组中声明所有的依赖项。所以如果我们想在回调中提交表单数据,就必须将该数据声明为依赖项:
1 | const onClick = useCallback(() => { |
这就是难题:即使 onClick
是记忆化的,它仍然会在每次有人输入内容时发生变化。因此,我们的性能优化就变得毫无意义。
好吧,没关系,我们可以寻找其他解决方案。React.memo
有一个称为比较函数的特性。它允许我们更细粒度地控制 React.memo
中的属性比较。通常情况下,React 会自己比较所有的“之前”和“之后”属性。如果我们提供了这个函数,它将依赖于函数的返回结果。如果返回 true
,React 就会知道属性是相同的,组件不需要重新渲染。听起来正是我们需要的。
我们只需要关心更新一个属性,即 title
,所以这并不复杂:
1 | const HeavyComponentMemo = React.memo( |
整个表单的代码将看起来像这样:
1 | const HeavyComponentMemo = React.memo( |
它看起来有效了!我们在输入框中输入内容,繁重组件没有重新渲染,性能也没有受到影响。
除了一个小问题:它实际上并没有起作用。如果你在输入框中输入内容并按下按钮,onClick
中输出的 value
是 undefined
。但它不应该是 undefined
,输入框的行为是正常的,如果我在 onClick
之外添加 console.log
,它会正确输出。只是 onClick
中不行。
1 | // 这些输出是正确的 |
这就是所谓的“陈旧闭包”问题。为了修复它,我们首先需要深入了解 JavaScript 中可能最令人畏惧的主题:闭包及其工作原理。
JavaScript、作用域与闭包
我们先从函数和变量开始。当我们通过正常声明或箭头函数在 JavaScript 中声明一个函数时,会发生什么?
1 | function something() { |
通过这种方式,我们创建了一个局部作用域:代码中的一个区域,里面声明的变量在外部是不可见的。
1 | const something = () => { |
每次我们创建一个函数时,这种情况都会发生。一个函数内部创建的函数将拥有其自己的局部作用域,在外部函数中是不可见的。
1 | const something = () => { |
然而,反方向是开放的。最内层的函数可以“看到”外部声明的所有变量。
1 | const something = () => { |
这就是闭包的形成:内部函数“闭合”了所有来自外部的数据。它本质上是对所有“外部”数据的快照,这些数据被单独存储在内存中。
如果我不在 something
函数内部创建那个值,而是将它作为参数传递并返回 inside
函数:
1 | const something = (value) => { |
我们将得到这样的行为:
1 | const first = something('first'); |
我们用值 “first” 调用了 something
函数,并将结果赋值给一个变量。结果是一个指向内部声明函数的引用,形成了一个闭包。从现在开始,只要保存这个引用的变量存在,我们传递的 “first” 值就会被冻结,inside
函数将始终能够访问它。
同样的情况发生在第二次调用:我们传递了一个不同的值,形成了一个闭包,返回的函数将永远可以访问这个变量。
这对于在 something
函数中声明的任何局部变量都是有效的:
1 | const something = (value) => { |
这就像给动态场景拍照:按下按钮的那一刻,整个场景就被“冻结”在照片中。下一次按下按钮并不会改变之前拍摄的照片。
在 React 中,我们在不经意间一直在创建闭包。每一个在组件中声明的回调函数都是闭包:
1 | const Component = () => { |
useEffect
或 useCallback
钩子中的一切都是闭包:
1 | const Component = () => { |
所有这些函数都能访问在组件中声明的状态、属性和局部变量:
1 | const Component = () => { |
每个组件内的函数都是闭包,因为组件本身就是一个函数。
陈旧闭包问题
以上所有内容,对于没有闭包概念的语言背景下的人来说可能略显不寻常,但还是相对简单的。你创建了几个函数,使用一段时间后就会变得自然。事实上,理解“闭包”这一概念并不是写 React 应用的必要条件。
那么问题出在哪里?为什么闭包是 JavaScript 中最让人害怕的东西之一,并且让许多开发者感到困惑呢?
这是因为只要函数的引用存在,闭包就会一直存在。而函数的引用只是一个值,可以被赋值给任何东西。让我们来挑战一下大脑。看看这个返回闭包的函数:
1 | const something = (value) => { |
但 inside
函数在每次调用 something
时都会被重新创建。如果我决定对其进行缓存呢?像这样:
1 | const cache = {}; |
表面上看,这段代码看起来无害。我们只创建了一个名为 cache
的外部变量,并将 inside
函数赋值给 cache.current
属性。现在,这个函数不会在每次调用时重新创建,而是返回已经保存的值。
然而,如果我们尝试多次调用它,会发现一个奇怪的现象:
1 | const first = something('first'); |
无论我们用不同的参数调用多少次 something
函数,输出的值总是第一个!
我们刚刚创造了所谓的“陈旧闭包”。每个闭包在创建时都会被冻结。当我们第一次调用 something
函数时,形成了一个包含变量 “first” 的闭包。然后,我们将其保存到 something
函数之外的一个对象中。
当我们下次调用 something
函数时,返回的不是新创建的函数和新的闭包,而是之前创建的那个函数,它的变量 “first” 已经被永远冻结。
为了修复这种行为,我们希望每次 value
发生变化时都重新创建函数和它的闭包。像这样:
1 | const cache = {}; |
将 value
保存在一个变量中,以便与下一个值进行比较。当变量发生变化时,更新 cache.current
闭包。
现在它可以正确输出变量,如果我们比较相同值的函数,比较结果将是 true
:
1 | const first = something('first'); |
React 中的陈旧闭包:useCallback
我们刚刚实现的几乎和 useCallback
钩子为我们做的事情一样!每次我们使用 useCallback
,都会创建一个闭包,并缓存传递给它的函数:
1 | // 内联函数被缓存,正如前面的部分 |
如果我们需要在函数中访问状态或属性,则需要将它们添加到依赖项数组中:
1 | const Component = () => { |
依赖项数组让 React 刷新缓存的闭包,正如我们在比较 value !== prevValue
时所做的一样。如果我忘记了这个数组,闭包就会变得陈旧:
1 | const Component = () => { |
每次触发回调时,输出的只有 undefined
。
React 中的陈旧闭包:Refs
除了 useCallback
和 useMemo
钩子,第二个最常见引入陈旧闭包问题的方式是 Refs。
如果我尝试用 Ref 代替 useCallback
钩子来处理 onClick
回调,会发生什么呢?有时,网上的文章建议这样做以便在组件中缓存 props。表面上看,它似乎更简单:只需将一个函数传递给 useRef
,并通过 ref.current
访问它。没有依赖项,也不用担心。
1 | const Component = () => { |
但是,每个在组件内部的函数都会形成一个闭包,包括传递给 useRef
的那个函数。我们的 ref 只会在首次创建时初始化,并且不会自动更新。基本上,这和我们最开始创建的逻辑类似,只不过这次我们传递的是需要保存的函数,而不是值。代码类似这样:
1 | const ref = {}; |
因此,在这种情况下,闭包是在组件初次挂载时形成的,并且会被保存下来,永远不会刷新。当我们尝试访问存储在 Ref 中的函数中的 state 或 props 时,我们只能得到它们的初始值:
1 | const Component = ({ someProp }) => { |
要修复这个问题,我们需要确保每次尝试访问内部的内容时都更新 ref 的值。基本上,我们需要实现类似 useCallback
钩子的依赖数组功能:
1 | const Component = ({ someProp }) => { |
React 中的陈旧闭包:React.memo
最后,我们回到了文章的开头,以及引发所有这些讨论的“谜团”。让我们再看一下有问题的代码:
1 | const HeavyComponentMemo = React.memo( |
每次点击按钮时,日志中输出的都是“undefined”。onClick
中的 value
从未更新。你现在知道为什么了吗?
当然,这是因为陈旧闭包。当我们创建 onClick
时,闭包最初是用默认的状态值(即 “undefined”)形成的。我们将该闭包与 title
prop 一起传递给我们的被 memoized 的组件。在比较函数中,我们只比较 title
。它从未改变,只是一个字符串。比较函数始终返回 true
,HeavyComponent
从未更新,结果是它始终引用最初的 onClick
闭包,保持了冻结的 “undefined” 值。
现在我们知道问题出在哪了,怎么修复它呢?说起来容易做起来难……
理想情况下,我们应该在比较函数中比较每个 prop,因此我们需要把 onClick
也包括进去:
1 | (before, after) => { |
但是,这样的话我们其实是在重新实现 React 的默认行为,和没有比较函数的 React.memo
的作用完全一样。所以我们可以简单地去掉它,只保留 React.memo(HeavyComponent)
。
但这样的话,我们需要将 onClick
包装在 useCallback
中。然而它依赖于状态,因此会随着每次输入的变化而改变。我们回到了起点:我们的重组件会在每次状态更改时重新渲染,正是我们想要避免的情况。
我们可以尝试通过组合的方式,提取并隔离状态或 HeavyComponent
,但这并不容易:输入框和 HeavyComponent
都依赖于那个状态。
我们可以尝试很多其他方法,但其实并不需要做任何繁重的重构来逃脱闭包陷阱。这里有一个很酷的小技巧可以帮助我们。
用 Refs 逃离闭包陷阱
这个技巧绝对让人惊叹:它非常简单,但可能会永远改变你在 React 中 memoize 函数的方式。或者也许不会……但无论如何,这个技巧可能会派上用场,所以让我们深入了解它。
首先,我们先去掉 React.memo
中的比较函数和 onClick
实现。只保留一个纯状态组件和被 memoize 的 HeavyComponent
:
1 | const HeavyComponentMemo = React.memo(HeavyComponent); |
现在我们需要添加一个在重新渲染之间保持稳定,但也能访问最新状态的 onClick
函数。
我们要将它存储在 Ref 中,所以让我们先添加一个空的 Ref:
1 | const Form = () => { |
为了让函数访问最新的状态,它需要在每次重新渲染时重新创建。这是闭包的本质,与 React 无关。我们应该在 useEffect
中修改 Refs,而不是直接在渲染中修改,所以让我们这么做。
1 | const Form = () => { |
没有依赖数组的 useEffect
将在每次重新渲染时触发。这正是我们想要的。因此,现在 ref.current
中的闭包会在每次重新渲染时重新创建,因此在其中记录的状态始终是最新的。
但我们不能直接将 ref.current
传递给被 memoize 的组件。每次重新渲染时该值都会变化,所以 memoization 根本无法工作。
1 | const Form = () => { |
因此,我们创建一个用 useCallback
包装的小空函数,没有依赖项:
1 | const Form = () => { |
现在,memoization 完美工作了——onClick
永远不会改变。唯一的问题是:它什么都没做。
这是关键的魔法:要让它正常工作,我们只需在 memoized 回调中调用 ref.current
:
1 | useEffect(() => { |
请注意 useCallback
中没有 ref
作为依赖项?它不需要。ref
本身永远不会改变。它只是一个对可变对象的引用,由 useRef
钩子返回。
虽然闭包冻结了周围的一切,但它并不会使对象不可变或冻结。对象存储在内存的不同部分,多个变量可以引用同一个对象。
1 | const a = { value: 'one' }; |
如果我通过其中一个引用修改了对象,然后通过另一个引用访问它,修改后的内容仍然存在:
1 | a.value = 'two'; |
在我们的例子中,甚至连这种情况都没有发生:在 useCallback
和 useEffect
中,我们拥有完全相同的引用。因此,当我们在 useEffect
中修改 ref
对象的 current
属性时,我们可以在 useCallback
中访问它。这个属性正是捕获了最新状态数据的闭包。
完整代码如下:
1 | const Form = () => { |
现在,我们得到了两全其美的结果:重组件被正确地 memoize,不会在每次状态更改时重新渲染。而 onClick
回调可以访问组件中的最新数据而不破坏 memoization。现在我们可以安全地将所有需要的内容发送到后台了!
总结
希望以上内容对你有帮助,现在闭包对你来说应该已经是小菜一碟了。在你走之前,有几点关于闭包的内容需要记住:
- 每当一个函数在另一个函数内部创建时,都会形成闭包。
- 由于 React 组件本质上是函数,因此在组件内部创建的每个函数都会形成闭包,包括像
useCallback
和useRef
这样的钩子。 - 当调用形成闭包的函数时,闭包周围的所有数据都会被“冻结”,就像一个快照。
- 要更新这些数据,我们需要重新创建这个“闭合”的函数。这就是类似
useCallback
这样的钩子依赖数组的作用。 - 如果我们漏掉了某个依赖,或者没有刷新分配给
ref.current
的闭合函数,闭包就会变得“陈旧”。 - 我们可以利用 Ref 是一个可变对象的特性,来避免 React 中的“陈旧闭包”陷阱。我们可以在陈旧闭包之外修改
ref.current
,然后在闭包内访问它,它将包含最新的数据。