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相当于我们自己保存了一份

参考

相关推荐
Redstone Monstrosity7 分钟前
字节二面
前端·面试
东方翱翔14 分钟前
CSS的三种基本选择器
前端·css
Fan_web37 分钟前
JavaScript高级——闭包应用-自定义js模块
开发语言·前端·javascript·css·html
yanglamei196244 分钟前
基于GIKT深度知识追踪模型的习题推荐系统源代码+数据库+使用说明,后端采用flask,前端采用vue
前端·数据库·flask
千穹凌帝1 小时前
SpinalHDL之结构(二)
开发语言·前端·fpga开发
dot.Net安全矩阵1 小时前
.NET内网实战:通过命令行解密Web.config
前端·学习·安全·web安全·矩阵·.net
叫我:松哥1 小时前
基于Python flask的医院管理学院,医生能够增加/删除/修改/删除病人的数据信息,有可视化分析
javascript·后端·python·mysql·信息可视化·flask·bootstrap
Hellc0071 小时前
MacOS升级ruby版本
前端·macos·ruby
前端西瓜哥1 小时前
贝塞尔曲线算法:求贝塞尔曲线和直线的交点
前端·算法
又写了一天BUG1 小时前
npm install安装缓慢及npm更换源
前端·npm·node.js