React Hooks 中的闭包问题

7,110 阅读7分钟

前言

今天中午在领完盒饭,吃饭的时候,正吃着深海鳕鱼片,蘸上番茄酱,那美味,简直无以言表。突然产品急匆匆的跑过来说:“今天需求能上线吧?”我忽然虎躯一震,想到自己遇到个问题迟迟找不到原因,怯怯的回答道:“能...能吧...”,产品听到‘能’这个字便哼着小曲扬长而去,留下我独自一人,面对着已经变味的深海鳕鱼片...一遍又一遍的想着问题该如何解决...

一、从JS中的闭包说起

JS的闭包本质上源自两点,词法作用域和函数当前值传递。

闭包的形成很简单,就是在函数执行完毕后,返回函数,或者将函数得以保留下来,即形成闭包。

关于词法作用域相关的知识点,可以查阅《你不知道的JavaScript》找到答案。

React Hooks中的闭包和我们在JS中见到的闭包并无不同。

定义一个工厂函数createIncrement(i),返回一个increment函数。每次调用increment函数时,内部计数器的值都会增加i

function createIncrement(i) {
    let value = 0
    function increment() {
        value += i
        console.log(value)
    }
    return increment
}
const inc = createIncrement(10)
inc() // 10
inc() // 20

createIncrement(10) 返回一个增量函数,该函数赋值给inc变量。当调用inc()时,value 变量加10。

第一次调用inc()返回10,第二次调用返回20,依此类推。

调用inc()时不带参数,JS 仍然可以获取到当前 valuei 的增量,来看看它是如何工作的。

原理就在 createIncrement() 中。当在函数上返回一个函数时,就会有闭包产生。闭包捕获了词法作用域中的变量 value i

词法作用域是定义闭包的外部作用域。在本例中,increment() 的词法作用域是createIncrement()的作用域,其中包含变量 valuei

无论在何处调用 inc(),甚至在 createIncrement() 的作用域之外,它都可以访问 valuei

闭包是一个可以从其词法作用域记住和修改变量的函数,不管执行的作用域是什么。

二、React Hooks中的闭包

通过简化状态重用和副作用管理,Hooks 取代了基于类的组件。此外,咱们可以将重复的逻辑提取到自定义 Hook 中,以便在应用程序之间重用。Hooks 严重依赖于 JS 闭包,但是闭包有时很棘手。

当咱们使用一个有多种副作用和状态管理的 React 组件时,可能会遇到的一个问题是过时的闭包,这可能很难解决。

三、过时的闭包

工厂函数createIncrement(i)返回一个increment函数。increment 函数对value增加i ,并返回一个记录当前value的函数

function createIncrement(i) {
    let value = 0
    function increment() {
        value += i
        console.log(value)
        const message = `Current value is ${value}`
        return function logValue() { // setState相当于logValue函数
            console.log(message)
        }
    }
    return increment
}
const inc = createIncrement(10)
const log = inc() // 10,将当前的value值固定
inc() // 20
inc() // 30

log() // "Current value is 10" 未能正确打印30

在这里还要提一下useRef,为什么当你使用let声明的useRef的时候不会遇到这个问题,而用let声明的变量的时候却会遇到这个问题,这是因为useRef其实并不是一个基础类型变量,而是一个对象,每次在修改值的时候,你实际修改的是对象的值,而对象是以指针的方式进行引用的,因此不管在任何地方取值都能获取到最新的值!

function createIncrement(i) {
    let value = 0
    function increment() {
        value += i
        console.log(value)
        const message = `Current value is ${value}`
        return function logValue() { // setState相当于logValue函数
            console.log(message)
        }
    }
    return increment
}
const inc = createIncrement(1) // i被固定为1,输入几就被固定为几
inc() // 1
const log = inc() // 2
inc() // 3

log() // "Current value is 2" 未能正确打印3

过时的闭包捕获具有过时值的变量

四、修复过时闭包的问题

(1) 使用新的闭包

解决过时闭包的第一种方法是找到捕获最新变量的闭包。

找到捕获了最新message变量的闭包,就是从最后一次调用inc()返回的闭包。

const inc = createIncrement(1)
inc() // 1
inc() // 2
const latestLog = inc()
latestLog() // "Current value is 3"

以上就是React Hook处理闭包新鲜度的方法了。

Hooks实现假设在组件重新渲染之前,最为Hook回调提供的最新闭包(例如useEffect(callback))已经从组件的函数作用域捕获了最新的变量。也就是说在useEffect的第二个参数[]加入监听变化的值,在每次变化时,执行function,获取最新的闭包。

(2) 关闭已更改的变量

第二种方法是让logValue()直接使用 value

让我们移动行 const message = ...;logValue() 函数体中:

function createIncrementFixed(i) {
  let value = 0;
  function increment() {
    value += i;
    console.log(value);
    return function logValue() {
      const message = `Current value is ${value}`;
      console.log(message);
    };
  }
  
  return increment;
}

const inc = createIncrementFixed(1);
const log = inc(); // 打印 1
inc();             // 打印 2
inc();             // 打印 3
// 正常工作
log();             // 打印 "Current value is 3"

logValue()关闭createIncrementFixed()作用域内的value变量。log()现在打印正确的消息。

五、Hook中过时的闭包

useEffect()

在使用useEffect Hook时出现闭包的常见情况。

在组件WatchCount中,useEffect每秒打印count的值。

function WatchCount() {
    const [count, setCount] = useState(0)
    useEffect(function() {
        setInterval(function log() {
            console.log(`Count is: ${count}`)
        }, 2000)
    }, [])
    
    return (
      <div>
      {count}
      <button onClick={() => setCount(count + 1)}> 加1 </button>
      </div>
    )
}

点击几次加1按钮,我们从控制台看,每2秒打印的为Count is: 0

在第一渲染时,log()中闭包捕获count变量的值0。过后,即使count增加,log()中使用的仍然是初始化的值0log()中的闭包是一个过时的闭包。

解决方法:让useEffect()知道log()中的闭包依赖于count:

function WatchCount() {
  const [count, setCount] = useState(0);

  useEffect(function() {
    const id = setInterval(function log() {
      console.log(`Count is: ${count}`);
    }, 2000);
    return function() {
      clearInterval(id);
    }
  }, [count]); // 看这里,这行是重点,count变化后重新渲染useEffect

  return (
    <div>
      {count}
      <button onClick={() => setCount(count + 1) }>
        Increase
      </button>
    </div>
  );
}

设置依赖项后,一旦count更改,useEffect()就更新闭包。

正确管理 Hook 依赖关系是解决过时闭包问题的关键。推荐安装 eslint-plugin-react-hooks,它可以帮助咱们检测被遗忘的依赖项。

useState()

组件DelayedCount有 2 个按钮

  • 点击按键 “Increase async” 在异步模式下以1秒的延迟递增计数器
  • 在同步模式下,点击按键 “Increase sync” 会立即增加计数器
function DelayedCount() {
  const [count, setCount] = useState(0);

  function handleClickAsync() {
    setTimeout(function delay() {
      setCount(count + 1);
    }, 1000);
  }

  function handleClickSync() {
    
    setCount(count + 1);
  }

  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>Increase async</button>
      <button onClick={handleClickSync}>Increase sync</button>
    </div>
  )
}

点击 “Increase async” 按键然后立即点击 “Increase sync” 按钮,count 只更新到 1

这是因为 delay() 是一个过时的闭包。

来看看这个过程发生了什么:

初始渲染:count 值为 0。 点击 'Increase async' 按钮。delay() 闭包捕获 count 的值 0setTimeout() 1 秒后调用 delay()。 点击 “Increase async” 按键。handleClickSync() 调用 setCount(0 + 1) count 的值设置为 1,组件重新渲染。 1 秒之后,setTimeout() 执行 delay() 函数。但是 delay() 中闭包保存 count 的值是初始渲染的值 0,所以调用 setState(0 + 1),结果count保持为 1。

delay() 是一个过时的闭包,它使用在初始渲染期间捕获的过时的 count 变量。

为了解决这个问题,可以使用函数方法来更新 count 状态:

function DelayedCount() {
  const [count, setCount] = useState(0);

  function handleClickAsync() {
    setTimeout(function delay() {
      setCount(count => count + 1); // 这行是重点
    }, 1000);
  }

  function handleClickSync() {
    setCount(count + 1);
  }

  return (
    <div>
      {count}
      <button onClick={handleClickAsync}>Increase async</button>
      <button onClick={handleClickSync}>Increase sync</button>
    </div>
  );
}

现在 setCount(count => count + 1) 更新了 delay() 中的 count 状态。React 确保将最新状态值作为参数提供给更新状态函数,过时的闭包的问题就解决了。

useLayoutEffect()

useLayoutEffect 可以看作是 useEffect 的同步版本。

useLayoutEffect其函数签名与 useEffect 相同,但它会在所有的DOM变更之后同步调用effect。可以使用它来读取DOM布局并同步触发重渲染。在浏览器执行绘制之前,useLayoutEffect内部的更新计划将被同步刷新。

因此,要尽可能使用标准的 useEffect 以避免阻塞视觉更新。因为 useLayoutEffect 是同步的,如果我们要在 useLayoutEffect 调用状态更新,或者执行一些非常耗时的计算,可能会导致 React 运行时间过长,阻塞了浏览器的渲染,导致一些卡顿的问题

如果你正在将代码从 class 组件迁移到使用 Hook 的函数组件,则需要注意 useLayoutEffectcomponentDidMountcomponentDidUpdate 的调用阶段是一样的。但是,我们推荐你一开始先用 useEffect,只有当它出问题的时候再尝试使用 useLayoutEffect

如果你使用服务端渲染,请记住,无论 useLayoutEffect 还是 useEffect 都无法在 Javascript 代码加载完成之前执行。这就是为什么在服务端渲染组件中引入 useLayoutEffect 代码时会触发 React 告警。解决这个问题,需要将代码逻辑移至 useEffect 中(如果首次渲染不需要这段逻辑的情况下),或是将该组件延迟到客户端渲染完成后再显示(如果直到 useLayoutEffect 执行之前 HTML 都显示错乱的情况下)。

若要从服务端渲染的 HTML 中排除依赖布局 effect 的组件,可以通过使用 showChild && <Child /> 进行条件渲染,并使用 useEffect(() => { setShowChild(true); }, []) 延迟展示组件。这样,在客户端渲染完成之前,UI 就不会像之前那样显示错乱了。

总结

闭包是一个函数,它从定义变量的地方(或其词法范围)捕获变量。

当闭包捕获过时的变量时,就会出现过时闭包的问题。

解决闭包的有效方法

  1. 正确设置 React Hook 的依赖项
  2. 对于过时的状态,使用函数方式更新状态