jtwang7 / React-Note

React 学习笔记
8 stars 2 forks source link

React - Hooks 与 JS 闭包 #12

Open jtwang7 opened 3 years ago

jtwang7 commented 3 years ago

React - Hooks 与 JS 闭包

参考文章: react hooks 与闭包

前言

Hooks 中的许多特性与闭包密切相关,例如 useState 为何能在组件渲染后持有状态而不是重新定义,以及 useEffect 的”快照“等。 函数式组件在每次渲染时,会从上到下执行一遍,因为函数组件本身其实就是函数,以至于官方文档没有提及这点,但这点又十分重要,函数组件这一特性导致了每次重新渲染组件时,其内部的变量都会被重新声明。

闭包

由于函数组件每次渲染都会重新执行函数本身,若我们需要保持一个变量不被重新声明,则可以将其提取到函数组件外定义。这样函数组件内使用该变量时就会产生闭包,函数组件本身反复执行并不会影响到该外部变量。

import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts';

// chart 实例
let lgChart = null;
export default function LineGradient(props) {
  // 获取容器实例
  let ref = useRef(null);

  // 获取 chart 实例
  useEffect(() => {
    lgChart = echarts.init(ref.current)
  }, [])

  // do something with lgChart

  return (
    <div ref={ref}></div>
  )
}

以项目中创建 echarts 实例为例,由于实例需要挂载到已渲染的 DOM 节点上,因此实例必须放在 useEffect 内部创建。由于我们后续一直会用到 lgChart 这个变量,因此 lgChart 变量的声明必须放在 useEffect 作用域之外。若 lgChart 声明在 useEffect 外但在函数组件内,那么后续操作若触发了重渲染,chart 实例就会被重新声明,我们就需要重新创建 chart 实例。 为了避免反复创建 chart 实例,我们可以将 chart 实例声明置于函数组件外。此外,useRef,useMemo 等方法可以实现在函数组件内部持有变量。

注:函数组件外闭包,useRef,useMemo,useCallback 等方法一般用于持有”静态变量“,其主要目的是为了”性能优化“。关于”动态变量(状态)“的持有我们采用 useState 或 useReducer 的方法。 这里的”静态变量“指变量改变时不会被 React 探知,不会引发重渲染的变量,类似于类组件中的静态方法和静态变量。

useState 与 useReducer 都可以实现对”动态变量(状态)“的持久化,两者都提供了在状态发生改变时触发组件 render 的方法。

闭包引发的 "BUG"

尽管 Hooks 的持久化为函数式组件持有状态变量提供了方法。但也导致了一些问题,在 React 官网 FAQ 中提到的 为什么我会在我的函数中看到陈旧的 props 和 state ? 就是 React 闭包导致的。

参考官方例子:

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

  function handleAlertClick() {
    setInterval(() => {
      alert('You clicked on: ' + count);
    }, 3000);
  }

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
      <button onClick={handleAlertClick}>
        Show alert
      </button>
    </div>
  );
}

当点击 Show alert 时,handleAlertClick 就会执行,设置计时器并开始循环打印 count,此时 setInterval 内部的 count 引用了作用域外的变量 count,形成闭包,之后每隔 3s 打印 "You clicked on: 0"。若点击 Show alert 随后点击了 Click me,仍会每隔 3s 打印 "You clicked on: 0",尽管 setCount 触发了重渲染,但原先设置的 setInterval 中 count 仍持有的是上一个 count 的引用。

函数组件闭包的产生相当于对函数组件进行了一次“快照”。在函数组件成功渲染的那一刻,对函数组件进行一次“拍摄”,将函数组件内的变量都“定格”下来,后续的一些方法执行(如计时器,useEffect 内副作用执行等)都参照于本次“快照”。 之前我们提到,可以在 useEffect 中获取最新的 state 原因也是基于函数组件在渲染后产生闭包这一特点。setState 会在组件 render 前统一更新 state,组件更新完毕后会进行一次“快照”,此时产生的闭包就持有对更新后 state 的引用。

个人体会:渲染函数组件的整个过程,可以以 render 为界分为两个部分:“准备阶段” & “应用阶段”,准备阶段主要用于变量或方法的声明(同步执行)或变量的更新(setState 异步执行),应用阶段主要用于操纵变量与执行方法(useEffect)。

代码解析

我们用上述“快照”思想来判断下述例子的执行结果:

export default function App() {
  const [loading, setLoading] = useState(false);

  const foo = () => {
    console.log("is loading ? ", loading);
  };

  const bar = callback => {
    setTimeout(() => {
      setLoading(true);
      callback();
    }, 5000);
  };

  const runner = () => {
    foo();
    bar(runner);
  };

  useEffect(() => {
    console.log("run");
    runner();
  }, []);

  return (
    <div className="App">
      <h1>Is it loading ?  {loading ? 'true' : 'false'}</h1>
    </div>
  );
}

整体代码流程如下:

  1. 执行函数组件,声明变量(foo, bar, ...),返回 React Element;
  2. 按照“快照”的思想,组件渲染完成后,当前状态被定格为“照片”。(这张照片就是闭包的“形象化表达”,实际闭包是在函数上下文执行阶段产生的)

    闭包持有的是变量的值,而非对该变量的引用,这意味着即使后续变量更改,闭包仍持有更改前的变量状态。

  3. 执行 useEffect: 3.1 打印 run 3.2 执行 runner,执行 foo 3.3 打印 "is loading ? false",此时 loading 持有闭包内 loading 变量的引用。 3.4 执行 bar(runner),此时 runner 持有闭包内 runner 变量的引用。bar 接收 runner 并在 5s 后执行该函数,期间修改了 loading 触发重渲染,但由于 useEffect 只在创建时执行,因此第二次渲染并没有执行额外的副作用。 3.5 5s 后打印 "is loading ? false"。尽管 loading 值被修改,但在回调函数 runner 中 foo 始终持有旧的 loading 变量引用。
jtwang7 commented 2 years ago

请谨慎使用闭包! 在 React 中要针对不同场景,决定使用 useRef 还是闭包来保存变量。 使用规则请参考:React 填坑记录 - 相同组件复用时状态无法独立存储的问题