前言
前段时间在项目的函数组件中使用了hook的useEffect, 类似于下面这个App
函数组件。
function App() { const[count ,setCount] = useState(1); console.log(`outer count = ${count}`) useEffect(() => { setInterval(() => { console.log(`inner count = ${count}`) }, 1000); },[]) const onClick = () => { setCount(count++) } return (<div onClick={onClick}> {count} </div>); }
useEffect
内部闭包中的这个定时器打印的count始终是1,即使onClick
事件触发了很多次。我就很好奇了,为啥count拿不到最新值?经过不断的google,终于找到一篇文章能完全解释其中的原理,这篇文章算是对原文的一种翻译解读,,如果有理解不到位的情况,欢迎大家一起讨论,一起进步。
作用域链和闭包
函数组件本质上是执行一个函数后返回组件, 说到函数就绕不开作用域链和闭包了
1. 什么是作用域
作用域就是一个变量生效的范围,作用域对象是一个语法内部的概念,无法用代码具现。
对于函数而言,每次执行
这个函数时都会生成一个新的作用域对象,这个作用域对象会将这个函数的实参、this、内部变量等作为一个个属性来记录。
这里需要注意的是,每次函数执行都会生成一个一个新的对象去保存函数内部数据,我们拿上面的问题来解释下这个点:
function f1() { let a = 1; }
当执行f1()
时,会生成一个f1的作用域对象Envirionment Record,它长的类似下面这样,有个属性a,并且a的值为1。
{ a: 1 }
并且每次执行f1()
,都会生成这样一个对象,当函数执行完毕后,这个作用域对象没有被其他地方引用,就会自动被gc了。
2. 什么是作用域链
顾名思义,多个作用域联系在一起,我们就叫做作用域链。
作用域链解决的问题是内部的作用域能够访问到外部的作用域的变量。
我们还是以上面的问题为例来解释下:
function f1() { let a = 1; setInterval(() => { a++ }, 1000); }
- 当执行
f1()
时,会生成一个作用域对象{a: 1}
。 - f1内部定时器定义了个匿名函数的回调,1s过后,内部匿名函数被执行,此时,内部匿名函数也会生成一个作用域对象
{}
,不过它是一个空对象,因为这个匿名函数是箭头函数,所以它没有this,它也没有定义变量,没有实参,所以它没有任何属性。 - 接着代码执行到
a++
, js引擎会在当前作用域寻找变量a
, a 不存在于当前作用域。
js引擎会因此报错吗? 显然并没有! 它很机智的向外围的f1函数对应的作用域对象寻找a
,外围作用域的确有a
, 然后对a
进行了自增操作。 - 整个变量查找逻辑就如上面分析的那样,从当前作用域开始,沿着作用域链一直向外围查找,直到查找到变量或者查到全局作用域结束,如果查到全局作用域还没有找到,说明这个变量没有定义,系统直接就报错了。
这里需要说明的是js是词法作用域,也就是说一个函数在被定义的时候,它的外部作用域链已经被确定了。我们举个例子:
function f1() { let a = 1; const f2 = () => { console.log(a); } return f2; } const fn2 = f1(); let a = 2; fn2();
- 这段代码被执行时,先是函数f1被定义,然后是执行f1赋值给fn2。
- 执行f1时,生成作用域对象
obj
{a:1, f2}
, 其中f2指向了一个箭头函数,这个函数在赋值给f2时已经明确了当它被执行时,作用域链的上一层指向了的是执行f1时生成的作用域对象obj
,这句话需要好好理解。 - 所以即使在执行
f1()
得到fn2
后,声明了let a = 2
, 然后再执行fn2()
,输出的结果依然是1。
3.什么是闭包
闭包是指有权访问另一个函数作用域的变量的函数
function f1() { let a = 1; let b = 2; return function() { return a+b; } } const f2 = f1(); console.log(f2());// 3
通过闭包的定义,我们可以知道,f1函数返回的匿名函数是一个闭包,因为这个匿名函数能访问f1内部定义的变量a
和b
。
4.闭包原理
通过上面几个概念的解释,大家估计已经知道了闭包的原理就是通过作用域链去查找变量。
实践出真知
Q1
function f1() { let a = 1; // step1 setTimeout(() => { a++ }, 1000); // step2 setTimeout(() => { console.log(a) }, 1200); // step3 } f1();
- step1执行后 , f1作用域对象可表示为
{a:1}
- step2行: 执行
setTimeout(() => { a++ }, 1000);
, 这个定时器内部的匿名回调函数定义在f1内部,故它的外部作用域明确是上面step1生成的作用域对象。回调函数在1s后将外部作用域对象的a属性进行自增,此时a 等于 2
。 - step3行: 执行
setTimeout(() => { console.log(a) }, 1200);
, 这个定时器内部的匿名回调函数定义在f1内部,它的外部作用域也明确是上面step1生成的作用域对象, 故step2和step3中的匿名回调函数执行的都是同一个外部作用域对象。回调函数在1.2s后打印外部作用域对象的a属性,所以最终打印出2.
Q2
function makeCounter() { let count = 0; return function() { return count++; }; } let counter = makeCounter(); // step1 let counter2 = makeCounter(); // step2 alert( counter() ); // step3 alert( counter() ); // step4 alert( counter2() ); // step5 alert( counter2() ); // step6
makeCounter
函数执行时会生成作用域对象{count: 0}
。- step1执行后,counter指向的函数的外部作用域对象
obj1
{count: 0}
。 - step2执行后, counter2指向的函数的外部作用域对象
obj2
{count: 0}
,这个对象和上面的外部作用域对象看上去长得一样,可是指向的是不同的内存地址,是不同的对象。 - step3中执行
counter()
,通过作用域链寻找到外部作用域对象obj1
的count
属性,count进行自增后属性值变成1,输出 1。 - step4中执行
counter()
, 通过作用域链寻找到外部作用域对象obj1
的count
属性,此时count
的值经过step3后,已经是1,再次进行自增操作,变成2, 输出2。 - step5中执行
counter2()
,通过作用域链寻找到外部作用域对象obj2
的count
属性,count进行自增后属性值变成1,输出 1。 - step4中执行
counter2()
, 通过作用域链寻找到外部作用域对象obj2
的count
属性,此时count
的值经过step5后,已经是1,再次进行自增操作,变成2, 输出2。
Q3
function Counter() { let count = 0; this.up = function() { return ++count; }; this.down = function() { return --count; }; } let counter = new Counter(); alert( counter.up() ); // ? alert( counter.up() ); // ? alert( counter.down() ); // ?
输出 1,2,1
Q4 函数组件
function App() { const[count ,setCount] = useState(1); console.log(`outer count = ${count}`) useEffect(() => { setInterval(() => { console.log(`inner count = ${count}`) }, 1000); },[]) const onClick = () => { setCount(count++) } return (<div onClick={onClick}> {count} </div>); }
-
当这个组件第一次渲染时,
App
函数会被执行,此时生成生成作用域对象obj
{count: 1, setCount, onClick}。 -
useEffect内的闭包只在App组件第一次渲染的时候执行, 这个闭包的外部作用域就是上面的
obj
对象。在这个闭包内定时器每个1s打印一次count
值, 这个变量count
显然是从外围作用域对象obj
上找到的, 而obj
的count
属性是const修饰的,它不可能在App内发生改变的,因此打印的始终是1(这就是我们经常出现异常的地方,发现count没能更新)。 -
点击div,调用setCount触发App组件重新渲染,
App
函数会重新执行,此时通过useState
拿到最新的count
值为2。生成新的作用域对象obj2
{count: 2, setCount, onClick},因此打印的outer count = 2
。 -
App重新渲染时,useEffect内的闭包并不会执行,定时器拿到的count始终是第一次App执行的时候生成的作用域对象的count属性值1, 拿不到最新的count值。
-
怎么解决闭包拿不到最新的count值,通常的解决方案用到了useEffect的第二个参数,这个参数发生变化时会执行最新的闭包。
useEffect(() => { const interval = setInterval( () => { console.log(`inner count = ${count}`) },1000); return () => {clearInterval(interval)} },[count])
实际场景中useEffect内的闭包可能是这样:
useEffect(() => { domElement.addEventListener('click', () => { console.log(`${count}`) }) },[])
这个click事件的回调函数中读取count变量就会出现问题。
在这里为啥没有将依赖的count变量加入到useEffect的第二个参数中,这样每次count变化,闭包都会更新。因为这个useEffect的目的是给domElement
添加click
监听事件,不要因为闭包带来的副作用就需要反复给domElement
解除、添加click
监听事件,如果依赖的state变量很多,这里面的开销就很大了。
那我们怎么避免这种情况下闭包导致的state变量的值不更新问题?个人做法是用到另一个Hook方法useReducer, 将click事件触发的行为剥离出来。
const INIT_STATE = { 'count': 1, 'domClick': false } const reducer = (state, action) => { switch(action.code) { case 'addCount': return {...state, 'count': state.count+ 1}; case 'domClick': return {...state, 'domClick': action.payload} } } function App() { const[count ,setCount] = useState(1); const [state, dispatch] = useReducer(reducer, INIT_STATE); if (state.domClick) { console.log(`${count}`) dispatch({code:'domClick', payload: false}) } useEffect(() => { domElement.addEventListener('click', () => { dispatch({code:'domClick', payload: true}) }) },[]) const onClick = () => { setCount(count++) } return (<div onClick={onClick}> {count} </div>); }
参考文章
https://javascript.info/closure
本文使用 mdnice 排版