从原理解析react闭包陷阱
详细介绍 hooks 出现闭包陷阱的原因和应对方法。以及介绍 react 最新的尚处于提案阶段的 hook
何为 React 的闭包陷阱
两种常见出现闭包 bug 的代码
setInterval / setTimeout
定时器jsxfunction Timer() {const [count, setCount] = useState(0);useEffect(() => {const handleCount = () => {setCount(count + 1);};const timer = setInterval(handleCount, 500);}, []);return (<div><p>Count: {count}</p></div>);}
绑定事件jsxfunction EventListener() {const [count, setCount] = useState(0);const countRef = useRef(null);useEffect(() => {const handleCount = () => {setCount(count + 1);};countRef.current.addEventListener('click', handleCount);}, []);return (<div><div>Count: {count}</div><button ref={countRef}>Count + 1</button></div>);}
这两段代码在触发定时器或点击按钮过后, count 将永远都是 1。(上面的 Live Editor 是可编辑的,可以自己调试一下。)
ChatGPT 给的闭包陷阱的文字描述:在 react 钩子中定义了一个函数(handleCount
)并将其作为回调函数传递给另一个函数或组件时,由于 JavaScript 的闭包特性,这个函数将会捕获它所在的函数作用域中的变量,而不是传入的 props 或 state。
文字描述里有两个关键词 “闭包特性” 和 “捕获变量”。如果你不能深入理解这两个东西,那看完文字描述等于还是啥都不懂。
闭包和过时闭包(stale closure)
第一步,先搞懂闭包(clousure)以及过时闭包(stale clousure)
闭包
如果你还不知道什么是闭包,先去补扫盲知识 闭包基础知识
深入了解闭包需要知道:作用域链,执行上下文,JS 垃圾回收和词法环境的概念
闭包是很多语言的难点,只言片语肯定说不清楚。要从头到尾说的话,我自己也没有自信能解释清楚。
一个函数和对其周围状态(Lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(Closure)。
即闭包由 函数 及声明该函数的 词法环境 组合而成。
词法环境是一个无法访问到的对象,从对象的角度来理解闭包会比较抽象晦涩。
闭包本意是一种让函数可以访问外部函数体内的变量的手段,内存得不到释放只是其副作用(并且通常有害)。但从内存和 GC 的角度来理解闭包刚好会更加容易理解过时闭包的产生原理。
闭包的值捕获
当你去找网上的资料的时候,会发现大部分人对保存外部词法环境这个步骤的描述止步于capture
,并没有说明这个capture
究竟是如何实现的。
是值的拷贝还是词法环境对象引用地址的拷贝?存在哪里?同一函数的如果返回多个闭包,这些闭包capture
的值会互相影响还是各管各的?如何释放?我们有必要搞懂这个问题,否则无法完全理解过时闭包的所有场景。下面就回答这些问题。
- 是值的拷贝还是词法环境对象引用地址的拷贝?
闭包的实现依赖于函数对象的隐藏属性[[Environment]]
, 这个属性储存了函数声明时的词法环境的引用地址,词法环境是一个无法读取和修改的对象,它的 outer 属性又指向外部词法环境的引用(形成作用域链),我们可以通过编译器(V8)实现的[[Scopes]]
作用域链属性中的Closure
来观察。
所以第一个问题有了答案:capture
捕获的是函数声明时的外部词法环境的引用地址,而不是新开辟一片内存把引用到的值复制进去。通过[[scope]]
观察可以看到如下(该图片是过时闭包的例子,将在紧接的段落里详细解释)
可以看到[[Scopes]]
内有一个Closure(App)
(及外部函数App
的词法环境对象)
- 储存在哪里?
上面其实已经回答了,外部词法环境的引用被保存在了闭包函数对象的隐藏属性[[Enviroment]]
内
- 同一函数返回多个闭包的情况是如何的?
这个其实可以从回答 1 的截图里看到。该App
函数以数组对象的形式返回了两个闭包函数。log
只引用了message
,但是其[[Scopes]]
属性内的Closure
却有count: 2
。
同样,打印increment
会发现其Closure
中有message: "Count is 0"
所以结论是,如果同一函数返回的多个闭包,他们[[Enviroment]]
中储存的是同一个词法环境的引用地址。
- 如何释放?
GC 会回收掉没有被引用的变量,只要还有作用域引用该词法环境,那其中的变量就不会被回收。
在上面的例子中,哪怕我们将log
手动标记为清除:log = null
,Closure(App)
中的变量仍将因为increment
的引用而一直存在于内存中。引用message
的闭包函数log
虽然已经被清除了,但却没有任何机制将message: "Count is 0"
删除,这就容易导致性能问题,假如这个message
是一个很大的对象,那这个对象占用的内存就不会被回收,可能会造成内存泄漏。
只有当increment
也被标记为清除的时候,Closure(App)
中的变量才会被 GC 回收。
复现过时闭包
staleClosurejsxfunction App() {let count = 0;function increment() {count = count + 1;console.log(count);}let message = `Count is ${count}`;function log() {console.log(message);}return [increment, log];}const [increment, log] = App();increment();increment();log();
猜猜log()
执行结果是什么?
不是 2,而是 0
outputshell12Count is 0
🔍 分析上面的代码的执行过程(口语化,忽略变量提升):
- 执行
App
函数 - 声明局部变量
count
,分配内存,存入值0
- 声明函数
increment
,在函数内引用count
,形成闭包,在环境记录里存入App
词法环境的引用 - 声明局部变量
message
,分配内存,存入值"Count is 0"
- 声明函数
log
,在函数内引用message
,形成闭包 - 全局作用域以解构的方式声明
increment
和log
两个变量,分别来储存从函数App
return 的两个函数的引用地址 App
执行完毕,其函数执行上下文出栈销毁,但词法环境被闭包引用,避免被 GC。需要注意的是并不是整个词法环境被保留,JS 引擎有优化,只保留了编译阶段被引用的count
和message
- 执行
increment
, 直接修改count
的值为 1 - 执行
log
,直接打印message
的值为"Count is 0"
App
函数在执行完过后其内部的局部变量就会被 GC 回收,但count
和 message
由于分别被increment
和log
所引用,被闭包捕获,会一直存在于闭包堆内存(Closure(App)
)中。
函数increment
直接引用并修改count
, 所以执行两次increment()
都可以正常打印count
的值。
log
打印出旧值的原因是在App
执行完毕过后,该函数执行上下文已经出栈销毁了,闭包内的message
和count
已经没有任何关系了。increment
在闭包中修改了count
的值,但并不会影响message
。打印出来的还是App
初始化的时候的值
修正过后的代码
fixStaleClosurejsfunction App() {let count = 0;function increment() {count = count + 1;console.log(count);}function log() {const message = `Count is ${count}`;console.log(message);}return [increment, log];}const [increment, log] = App();increment();increment();log();
outputshell12Count is 2
把 message 放在log
里面,这样调用log
时就会重新生成message
。
另一个过时闭包的例子
staleClosure2jslet count = 0;const increment = () => {count += 1;const message = `Count is ${count}`;return () => {console.log(message);console.log(count);};};const log = increment();increment();log();
outputshellCount is 12
函数log
的执行结果依然没有拿到最新的message
值。
🔍 分析上面的代码的执行过程:
- 声明全局变量
count
,存入值0
- 执行
increment
- 修改全局变量
count
为1
- 声明局部变量
message
, 存入值Count is 1
- 返回匿名函数
- 声明全局常量
log
并储存上步返回的匿名函数。increment
执行完毕出栈。log
引用并捕获message
,message
得以不被 GC 回收 - 再次执行
increment
, 修改全局变量count
为2
,新声明局部变量message
,分配内存,存入值Count is 2
, 这个message
和上一步的message
在不同的执行上下文,分配在不同的内存地址,将其假设为message2
- 返回匿名函数,但并没有声明变量存下来,其引用的局部变量
message2
也被 GC 回收 - 执行第六步的
log
函数,打印message
的值,该message
为message1
,即Count is 1
log
打印出 count 旧值的原因是闭包函数引用的是第一次执行increment
时的词法环境,其message
是第一次执行increment
时的值。
修正过后的代码
fixStaleClosure2jslet count = 0;const increment = () => {count += 1;return () => {const message = `Count is ${count}`;console.log(message);console.log(count);};};const log = increment();increment();log();
同样地,将message
的声明和赋值放到了匿名函数内。
hooks 和闭包的关系
第二步,搞懂日常用的 hook 和闭包的关系。
简化版的 useState,实际的 hook 实现肯定比这复杂,但是原理都差不多。
statejsxlet state = null;export const useState = (value: number) => {state = state || value;function dispatch(newValue) {state = newValue;// 假设此方法能触发页面渲染render();}return [state, dispatch];};
在其他模块中引用
Demo.jsxjsximport React from 'react';import { useState } from './state';export function Demo() {const [counter, setCounter] = useState(0);return (<div onClick={() => setCounter(counter + 1)}>hello world, {counter}</div>);}
闭包在哪?
ES Module 的原理
- ES6 之前: ES6 之前拆分模块和防止变量污染主要靠自执行函数,AMD/CMD 规范都是基于函数。webpack 打包 ES6 到 ES5 也会自动把代码里的 ES module 语法打包成函数和自执行函数。
自执行函数版statejs(function () {var state = null;var useState = function (value) {state = state || value;function dispatch(newValue) {state = newValue;render();}return [state, dispatch];};return { useState };})();
- ES6 之后: 参考ES modules: A cartoon deep-dive, 掘金翻译版。
ES module 虽然原理和自执行函数不同,但表现形式十分相似。module 也会在编译阶段构建 AST 时会跳过未被其他 module import 的变量。
被useState
引用的state
变量则会存在于内存中,只要该变量还被标记为引用状态,就不会被 GC 回收。
在 Demo module 内通过 useState
引用 state module (外部词法环境)里的变量,实际上也是一个闭包。
再看 useEffect
定时器jsx// 你可以将两个 debugger取消注释,打开控制台,可以直观地看到两处debugger所处的作用域const { useMyState } = (function () {let state = null;const useMyState = function (value) {state = state || value;function dispatch(newValue) {state = newValue;// debugger;}return [state, dispatch];};return { useMyState };})();function Timer() {// 这是个假的state,不会触发render,所以你会看到计数始终为0const [count, setCount] = useMyState(0);useEffect(() => {const handleCount = () => {// debugger;setCount(count + 1);};setInterval(handleCount, 500);}, []);return (<div><p>Count: {count}</p></div>);}render(<Timer />);
useEffect
的第二个参数如果是[]
,那么该副作用只会在 函数组建 mounted 时执行一次。
🔍 分析首次useEffect
的代码的执行过程:
- 组件 mounted, 触发
useEffect
,执行传入的匿名回调函数 - 声明函数
handleCount
handleCount
内引用了外部词法环境Timer
中的变量count
,形成了闭包,[[Scopes]]
内新增Closure(Timer)
。- 函数组件
Timer
执行完毕,出栈销毁。变量count
因为被handleCount
引用得以保留。 - 500ms 后执行
handleCount
,调用dispatch
修改state
的值 可以看到dispatch
的闭包内,state
已经变成了 1
你可能会疑惑,按照前面的结论,此时闭包函数handleCount
和dispatch
应该引用的同一个词法环境才对。实际上呢?
最简单的,看上面两个截图,在 scope 的Closure
那里,一个是Closure(Timer)
一个是Closure
handleCount
中捕获的count
, 是在Timer
执行生成的:const count = useMyState(0)[0]
也就是 count: 0
, 和前面章节第一个过时闭包的例子一模一样let message = "count is " + count
useEffect
, useMemo
, useCallback
等 hooks 都会面临相同的过时闭包问题,而且这类型 bug 很难发现。这也是为什么最新的 react 文档建议开发者最好开启 deps eslint,以及最好不要 ban 掉 eslint 以 欺骗 react
结合 hooks (后面都以useEffect
为例)的特性,复盘一下:
在 useEffect
的回调函数中引用了外部作用域的变量,但是没有将该变量添加进 useEffect
的 deps 数组内。useEffect
的回调函数只有useEffect
重新触发时才会重新执行。
由于我们少传或没有传相应的 deps,当漏网之鱼发生变化时,useEffect
并没有触发,useEffect
内的闭包函数所捕获的还是上一次 useEffect
执行时的词法环境中的变量,就产生了过时闭包的 bug。
不指定依赖就一定会出现 bug 吗?
从前一章节我们知道了闭包陷阱的原因。但实际 项目里我们绝大部分人都写过下面这句 eslint-disable
js// eslint-disable-next-line react-hooks/exhaustive-deps
项目并不会出 bug,为什么?
因为从上面举的很多例子中我们可以看到,出过时闭包的 bug 需要满足几个条件:
- 这个被忽略掉的 dep 本身是可变的。
- 我们要在事件绑定或者定时器中用到这个可变的数据(延迟执行的 event)
另外,useEffect
的特性是只要 deps 数组中有一项数据发生了变化就会执行回调函数,如果有一个变量b
和被忽略的变量a
总是同时更新(比如他俩来自于同一个接口数据),那么哪怕 deps 数组里只有变量b
,useEffect
的每次触发,闭包函数都能绑定最新的a
useEffect
最大的心智负担就在这里:eslint 强迫我们将useEffect
中所有用到的 prop / state 全部放到 deps 数组中,即将该数组当成了闭包会引用的变量的全家福。
但 useEffect
本身的作用是响应状态变更,执行副作用,绝大部分场景下我们不想对所有的依赖进行响应,我们只想要响应一个或两个状态的变化,然后在该 Effect 中能拿到最新的依赖,执行我们想要的操作。
我们想要 deps 数组有业务含义,而不是长破天的全家福。我们想要 deps 数组里不用再传入毫无意义的 handle 或 dispatch 函数。
目前我们只能在熟悉上下文的情况下,手动添加 eslint-disable😅,还得再写一条注释解释 ban 掉 eslint 的原因。
React 官方并不欢迎我们这么做。其理由是即便 开发 Dev 在了解上下文并保证没有 bug 的情况下 ban 掉了 eslint,他依然无法预知代码的后续维护者是否清楚上下文。这很容易给后续的维护留下隐患。
解决闭包陷阱的旧方案
除了写全 deps,还有一些方案可以用
useState 回调函数
定时器jsxfunction Timer() {const [count, setCount] = useState(0);useEffect(() => {const handleCount = () => {setCount((count) => count + 1);};setInterval(handleCount, 500);}, []);return (<div><p>Count: {count}</p></div>);}
ref
定时器jsxfunction Timer() {const [count, setCount] = useState(0);const countRef = useRef(count);useEffect(() => {const handleCount = () => {countRef.current += 1;setCount(countRef.current);};setInterval(handleCount, 500);}, []);return (<div><p>Count: {count}</p></div>);}
ref 的原理依旧是闭包,每次Timer
执行,useRef
都会返回同一个固定的对象引用。我们可以在闭包内通过访问对象的 current 属性拿到最新的count
值
ref 的办法比较适合用来处理一些依赖较多的函数,比如下面的例子, 我只想 useEffect 在 mounted 的时候触发一次:
functionDemojsxconst Demo = ({ name, height, weight, onError, onOk, path }) => {const complexFunction = () => {console.log(name, height, weight, path);onError();onOk();};useEffect(() => {complexFunction();// eslint-disable-next-line react-hooks/exhaustive-deps}, []);};
用 ref 就不用 ban eslint,也不用担心过时闭包。
refDemojsxconst Demo = ({ name, height, weight, onError, onOk, path }) => {const complexFunctionRef = useRef(() => {console.log(name, height, weight, path);onError();onOk();});useEffect(() => {complexFunctionRef.current();}, []);};
新的 hook - useEffectEvent
useEffectEvent
的内部实现就是基于 ref,其作用就是减少 hooks 不必要的 deps。
useEffectEventDemojsxconst Demo = ({ name, height, weight, onError, onOk, path }) => {const complexEvent = useEffectEvent(() => {console.log(name, height, weight, path);onError();onOk();});useEffect(() => {complexEvent();}, []);};
有了这个 hook 之后,useEffect
的 deps 就可以只保留业务上用于触发 effet 的状态,其他的都丢给useEventEffect