概要
学习完本文,你可以掌握的知识:
- JS中的基础知识:词法环境;
- 闭包的产生以及闭包带来的问题(闭包过期);
- React函数式组件为什么会遇到闭包过期;
- React函数式组件中如何解决闭包过期;
发现问题
之前使用IntersectionObserver API
在React中封装了一个无限滚动Hook,在封装过程中遇到了一个奇怪的现象:我在useEffect
中添加了一个事件监听器,这个监听器的回调需要使用并改变state
,逻辑上十分通畅,但是每次触发时,并不能得到想要的结果,state
一直是初始值。 问题:useEffect 中的 state 不更新,无法获取到当前最新值
虽然最后解决了,但是并没有深入的理解造成问题的原因,因此不断查找后得知,这是一个由闭包造成的闭包过期问题,在React中十分常见。
这里有个很重要的点需要提前明确:闭包过期并不是React特有的
,普通js环境中也存在,只是在React中由于某些场景会遭遇这个问题,因此本文的最终目是知晓如何在React中解决该问题。
为了吃透这个问题,故写下此文。
复现问题
复现该问题,这里写了一个Counter组件,我们希望在useEffect
中通过button的ref
为按钮添加一个点击事件的监听器,每当点击事件发生时更新 count
状态并渲染。
然而这里只有第一次点击时count
变成了1
,之后不在改变。
经过debug:我们可以确定并不是setCount
失效,而是count值一直是初始值0
,因此一直执行的都是setCount(1)
js
export default function Counter() {
const [count, setCount] = useState(0);
const buttonRef = useRef(null);
const handleClick = () => {
setCount(count + 1);
};
useEffect(() => {
const buttonDom = buttonRef.current;
if (buttonDom) {
buttonDom.addEventListener('click', handleClick);
}
return () => {
if (buttonDom) {
buttonDom.removeEventListener('click', handleClick);
}
};
}, []);
return (
<div>
{count}
<button ref={buttonRef}>count++</button>
</div>
);
}
通过查找资料,我们进一步确定了这是一个闭包过期问题。
剖析问题
为了理解什么是闭包过期 以及闭包过期是如何产生的 ,我们将介绍下图所示内容,并逐步理解造成闭包过期的原因。
代码块
在JS中,代码块就是-->{...}
,每个代码块中的变量都仅在其内部可见。
更加通俗的讲:同级代码块不能相互访问其中的变量,子集可以访问父级代码块中的变量
js
{
let message = 'some message';
{
alert(message); // √
}
}
{
alert(message); // 'message' is not defined.
}
解释解释
嵌套函数
在日常开发中,我们都如何创建一个代码块? 函数!
在JS中,你可以在任何地方创建一个函数,也可以将函数作为参数,或者返回一个函数。因此存在嵌套函数,即函数内部创建一个函数。这样就创建了两个代码块
,我们知道,内部函数可以访问外部函数的变量,因此可以写出如下代码:
js
function generateRandomNum() {
let num = Math.random();
function logNum(){
console.log(num);
}
logNum();
return num;
}
词法环境
前面两点都很简单,相信大家都知道,接下来讲词法环境,逐渐深入。
在 JavaScript 中,每个运行的函数,代码块 {...}
以及整个脚本,都有一个被称为 词法环境(Lexical Environment) 的内部(隐藏)的关联对象。
词法环境由两部分组成:
- 环境记录(Environment Record):一个对象,存储局部变量
- 对外部词法环境的引用,与外部代码相关联
词法环境的生成在代码执行过程中会不断改变(记住这句话,接着往下看)
变量声明 和 函数声明
对于变量的声明和函数的声明,他们在生成词法环境时状态的变化不同:
- 首先会扫描到所有的变量和函数
- 变量会被初始化为
uninitialized
状态 - 函数会立即被初始化,这也是为什么可以在函数声明前调用的原因(思考下这句话)
- 变量会被初始化为
- 代码逐步执行
- 初始化变量,**对于
let const
声明的变量,其会被初始化为<uninitialized>
,而var
声明的变量会被先初始化为undefined
- 如果变量的值需要调用函数返回,则调用函数时对于
let const
声明的变量我们无法访问,而var
声明的变量可以访问,且为undefined
; - 如果不需要调用函数,则正常赋值;
- 如果变量的值需要调用函数返回,则调用函数时对于
- 初始化变量,**对于
- 函数运行时,会自动创建一个新的词法环境存储这次调用所涉及的变量及参数
文字可能有些晦涩,下面将给出图示,相信大家一下就能看明白了 下图为开始执行脚本后,词法环境变化的过程:箭头为外部引用,全局词法环境无外部引用,所以指向null
:
-
扫描所有变量和函数
-
变量声明
name
,初始化为"Ys_OoO"
-
变量声明
age
,先初始化为了<uninitialized>
,由于赋值由函数决定,需要进一步分析 -
执行函数,生成了新的词法环境,
birthYear
变量的声明与上述name
相同,不过多分析。 -
函数执行完毕,返回值赋给了
age
,最终的到如下词法环境
对于上述分析,请格外注意下面的情况(这种代码不应该出现在你的任何项目中,仅用于区分):
js
function getV() {
console.log(a); // undefined
console.log(b); // Reference error
return 1;
}
var a = getV();
let b = getV()
这段代码中,请留意 var
和 let const
在声明变量时的词法环境初始值不同!
不同嵌套函数的词法环境
当代码要访问一个变量时 ------ 首先会搜索内部词法环境,然后搜索外部环境,然后搜索更外部的环境,以此类推,直到全局词法环境。(这也是为什么我们访问代码块外部变量的原因)
上述情况的词法环境很好分析,上图并没有给出整个词法环境生成的过程,那如果是一个函数返回一个函数的形式呢?
如下代码片段:
其词法环境生成相对复杂,在执行到不同代码时,其词法环境也在变化,具体如下:
-
stage 1:开始执行脚本,扫描变量和函数
-
stage 2:调用了
createCounter
,此时执行到count
的声明,counter
还未赋值。 -
stage 3:此时返回了一个函数将其赋值给
counter
-
stage 4:执行
counter()
,这里需要知道,所有的函数在"诞生"时都会记住创建它们的词法环境 ,内部都会有一个隐藏的[[Environment]]
对象存储创建他时的词法环境引用,因此可以通过[[Environment]]
找到词法环境并正确执行 -
stage 5:所有代码执行完毕后的词法环境
深入理解[[Environment]]
:此时如果我们后面还有一次counter()
的调用,会怎么样? 刚刚说了每个函数创建时,我们仍可以找到其创建时对应的词法对象,因此,我们再次调用时词法环境如下: 在这个词法环境中,我们的_count
由于上次调用已经变成了1
,当本次调用结束后,count
将变为1
。
闭包
了解了前面的这些知识,现在我们再来看看什么是闭包:[闭包]是指一个函数可以记住其外部变量并可以访问这些变量。在某些编程语言中,这是不可能的,或者应该以一种特殊的方式编写函数来实现。
- 如何记住的?就是通过上面所提到的
[[Environment]]
,可以获取到创建时的词法环境; - 如何访问的?我们通过词法环境获取代码块中的变量,并且可以通过词法环境的引用一步步向外查找,这其实就是作用域链;
由于JS的这种机制,我们可以更加灵活的利用闭包,但是这之中存在闭包过期问题是我们额外需要注意的!
闭包过期
首先明确一点:闭包过期是由于我们代码的逻辑导致的问题。 例如如下代码:
js
function createIncrement(i) {
let value = 0;
function increment() {
value += i;
console.log(value);
const message = `Current value is ${value}`;
return function logValue() {
console.log(message);
};
}
return increment;
}
const inc = createIncrement(1);
const log = inc(); // logs 1
inc(); // logs 2
inc(); // logs 3
log(); // logs "Current value is 1"
看看上述代码,为什么log()
时输出的是1
而不是3
呢? 答:因为我们在创建log
时,inc()
的调用中,词法环境中的message
存储的就是1
,我们之前提到的所有的函数在"诞生"时都会记住创建它们的词法环境 ,这个log
的函数诞生时存储的词法环境中的message就是1。
这就是闭包过期 ,由于闭包导致了log()
不能像预期一样输出最新值,所以说他 "过期了"。
不能输出最新值?你可能已经联想到了我们一开始提到的Demo了。
React中为什么会出现闭包过期
实际上很好解释:
- React中发生的闭包过期是在函数式组件中的,函数式组件实际就是一个函数,只不过其返回的JSX能够被框架解析得到对应的Dom。既然他是个函数,那么你是不是就能理解了。
- 闭包过期是由于
Hook
引起的
组件第一次创建后执行了useEffect
,该Hook
只执行一次,此时Hook
中需要使用count
,这就是一个由useState
创建的变量而已,所以handleClick
中想要访问的count
实际上是在创建handleClick
时保存的词法环境中的值,这个值永远是初始值0
,每当我们调用handleClick
时,其内部保存的[[Environment]]
总能找到当时创建时的词法环境,而setCount
操作并没有真正的更新那个count
js
export default function Counter() {
const [count, setCount] = useState(0);
const buttonRef = useRef(null);
const handleClick = () => {
setCount(count + 1);
};
useEffect(() => {
const buttonDom = buttonRef.current;
if (buttonDom) {
buttonDom.addEventListener('click', handleClick);
}
return () => {
if (buttonDom) {
buttonDom.removeEventListener('click', handleClick);
}
};
}, []);
return (
...
);
}
实际上,React中很多Hook都存在闭包过期问题(useCallback,useMemo等),再遇到了状态更新失败,你应该已经可以自己分析出来了🤩。
解决方案
下面给出三种解决方案
方案一:添加依赖项(不推荐👎)
我们可以将要改变的状态作为依赖添加到依赖项中:
js
useEffect(() => {
const buttonDom = buttonRef.current;
if (buttonDom) {
buttonDom.addEventListener('click', handleClick);
}
return () => {
if (buttonDom) {
buttonDom.removeEventListener('click', handleClick);
}
};
}, [count]);
这种方式为什么不推荐呢?
- 因为当一个组件复杂时,依赖项的增加会导致
Hook
中逻辑的复杂性 - 此外,这种方式生效的原因是因为我们触发了
setCount
导致值变化了,useEffect
再次执行,实际上是创建了一个新的函数,新的词法环境,我们还需要注意其中副作用的清理。
方案二:回调形式的setState
React 为我们提供的setState
可以使用回调形式,这样我们总能拿到上一个值,然后在此基础上进行修改:
js
const handleClick = () => {
setCount(preCount=>preCount+1);
};
这种方式生效的原因是尽管我们词法环境并没有发生变化,但是我们每次触发setCount
时使用的不再是词法环境中保存的那个count
了,而是React获取了上一次的状态。
方案三:使用Ref同步状态
之前我们讲过React 受控和非受控组件,其中我们就是使用Ref
来保证状态永远是最新值:
js
const [count, setCount] = useState(0);
const countRef = useRef(count);
const buttonRef = useRef(null);
const handleClick = () => {
countRef.current += 1;
setCount(countRef.current);
};
这种方式生效的原因是,我们调用setCount
时,使用的都是ref中存储的值,而不是当前词法环境中的count
你发现了吗:方案二 和 方案三 十分相似,都是避免使用词法环境中的count,将状态保存在其他地方进行修改和使用,setState
方案相当于React帮助我们保存了一份count
,而Ref
相当于我们自己保存了一份