React 函数式组件中存在的闭包过期问题(由浅入深理解闭包与闭包过期的产生)

概要

学习完本文,你可以掌握的知识:

  • 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) 的内部(隐藏)的关联对象

词法环境由两部分组成:

  1. 环境记录(Environment Record):一个对象,存储局部变量
  2. 对外部词法环境的引用,与外部代码相关联

词法环境的生成在代码执行过程中会不断改变(记住这句话,接着往下看)

变量声明 和 函数声明

对于变量的声明和函数的声明,他们在生成词法环境时状态的变化不同:

  1. 首先会扫描到所有的变量和函数
    • 变量会被初始化为uninitialized状态
    • 函数会立即被初始化,这也是为什么可以在函数声明前调用的原因(思考下这句话)
  2. 代码逐步执行
    • 初始化变量,**对于let const声明的变量,其会被初始化为<uninitialized>,而var声明的变量会被先初始化为undefined
      • 如果变量的值需要调用函数返回,则调用函数时对于let const声明的变量我们无法访问,而var声明的变量可以访问,且为undefined
      • 如果不需要调用函数,则正常赋值;
  3. 函数运行时,会自动创建一个新的词法环境存储这次调用所涉及的变量及参数

文字可能有些晦涩,下面将给出图示,相信大家一下就能看明白了 下图为开始执行脚本后,词法环境变化的过程:箭头为外部引用,全局词法环境无外部引用,所以指向null

  1. 扫描所有变量和函数

  2. 变量声明name,初始化为"Ys_OoO"

  3. 变量声明age,先初始化为了<uninitialized>,由于赋值由函数决定,需要进一步分析

  4. 执行函数,生成了新的词法环境,birthYear变量的声明与上述name相同,不过多分析。

  5. 函数执行完毕,返回值赋给了age,最终的到如下词法环境

对于上述分析,请格外注意下面的情况(这种代码不应该出现在你的任何项目中,仅用于区分):

js 复制代码
function getV() {
  console.log(a); // undefined 
  console.log(b); // Reference error
  return 1;
}
var a = getV();
let b = getV()

这段代码中,请留意 varlet 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相当于我们自己保存了一份

参考

相关推荐
寻觅~流光2 分钟前
封装---优化try..catch错误处理方式
开发语言·前端·javascript·typescript
csj505 分钟前
前端基础之《Vue(22)—安装MongoDB》
前端·vue
今天也在写bug7 分钟前
输入npm install后发生了什么
前端·npm·node.js
玲小珑32 分钟前
Next.js 教程系列(十六)Next.js 中的状态管理方案
前端·next.js
前端小巷子34 分钟前
web实现文件的断点续传
前端·javascript·面试
小磊哥er35 分钟前
【前端工程化】前端项目怎么做代码管理才好?
前端
jojo是只猫1 小时前
前端vue对接海康摄像头流程
前端·javascript·vue.js
10年前端老司机5 小时前
React无限级菜单:一个项目带你突破技术瓶颈
前端·javascript·react.js
阿芯爱编程9 小时前
2025前端面试题
前端·面试
前端小趴菜0510 小时前
React - createPortal
前端·vue.js·react.js