React Hooks 中的闭包问题

React Hooks 中的闭包问题

React 自从引入 hooks,虽然解决了类组件的一些弊端,但是也引入了一些问题,比如闭包问题。

闭包问题

先看一个例子

import React, { useState, useEffect } from "react";

export default () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setInterval(() => {
      console.log("当前值:", count);
    }, 1000);
  }, []);

  return (
    <>
      count: {count}
      <br />
      <button onClick={() => setCount((val) => val + 1)}>增加 1</button>
    </>
  );
};

当增加按钮的时,发现当前值打印始终都是 0,没有发生变化。这就是 React 的闭包问题。

闭包产生的原因

为了维护函数组件的state,React 用链表的方式来存储函数组件里面的 hooks,并为每一个 hooks 创建了一个对象。hooks 函数执行的顺序是不变的,就可以根据这个链表拿到当前 hooks 对应的 Hook 对象,函数式组件就是这样拥有了state的能力

//hooks对象
type Hook = {
  memoizedState: any,//存储上一次组件更新后的state
  baseState: any,
  baseUpdate: Update<any, any> | null,
  queue: UpdateQueue<any, any> | null,
  next: Hook | null,//指向下一个 hook 对象
};

回到上面的示例

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

  useEffect(() => {
    setInterval(() => {
      console.log("当前值:", count);
    }, 1000);
  }, []);

React Hooks 中的闭包问题_第1张图片

代码第一次执行:执行 useState,count 为 0。执行 useEffect,执行其回调中的逻辑,启动定时器,每隔 1s 输出 当前值: 0

点击增加按钮:当state更新时, 链表从头开始重新渲染,,useState 将 Hook 对象 上保存的状态置为 1, 那么此时 count 也为 1 了。执行 useEffect,其依赖项为空,不执行回调函数。但是之前的回调函数还是在的,它还是会每隔 1s 打印count,但这里的 count 还是之前第一次执行时候的 count 值,该count值定时器的回调函数里面被引用了,就形成了闭包一直被保存。
闭包产生的原因:就是当前hooks中没有获取到最新的state

如何解决

方法一: 清除重建

给 useEffect 设置依赖项,重新执行函数,设置新的定时器,拿到最新值。

import React, { useState, useEffect } from "react";

export default () => {
  const [count, setCount] = useState(0);
  //声明一个定时器
  const timer = useRef(null);
  
  useEffect(() => {
    //定时器期间,有新操作时,清空旧定时器,重设新定时器
      
    //定时器存在 则清空
    if (timer.current) {
      clearInterval(timer.current);
    }
    //重新设定时器,保证打印的永远是最新的值
    timer.current = setInterval(() => {
      console.log("当前值:", count);
    }, 1000);
  }, [count]);

  return (
    <>
      count: {count}
      <br />
      <button onClick={() => setCount((val) => val + 1)}>增加 1</button>
    </>
  );
};

方法二: 使用 useRef

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。

useRef 创建的是一个普通JS对象,而且会在每次渲染时返回同一个 ref 对象,当我们变化它的 current 属性的时候,操作的都是同一个对象,所以定时器中能够读到最新的值。

import React, { useState, useEffect } from "react";

export default () => {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    setInterval(() => {
      console.log("当前值:", latestCount.current);
    }, 1000);
  }, []);

  return (
    <div>
      count: {count}
      <br />
      <button
        onClick={() => {
          setCount((val) => val + 1);
          latestCount.current += 1;
        }}
      >
        增加 1
      </button>
    </div>
  );

方法三: ahooks中的useLatest

基于上述的第二种解决方案,useLatest 这个 hook 随之诞生。它返回当前最新值的 Hook,可以避免闭包问题。实现原理很简单,就是使用 useRef 包一层:

import { useRef } from 'react';
// 通过 useRef,保持每次获取到的都是最新的值
function useLatest<T>(value: T) {
  const ref = useRef(value);
  ref.current = value;

  return ref;
}

export default useLatest;

代码实现

import React, { useState, useEffect } from "react";
import { useLatest } from 'ahooks';

export default () => {
  const [count, setCount] = useState(0);
  const latestCountRef = useLatest(count);

  useEffect(() => {
   const interval =  setInterval(() => {
      console.log("当前值:", latestCountRef.current);
    }, 1000);
   return () => clearInterval(interval);
  }, []);

  return (
    <>
      count: {count}
      <br />
      <button onClick={() => setCount((val) => val + 1)}>增加 1</button>
    </>
  );
};

方法四:ahooks中的 useMemoizedFn

React 中另一个闭包场景,是基于 useCallback 的。

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

const callbackFn = useCallback(() => {
  console.log(`Current count is ${count}`);
}, []);

以上不管,我们的 count 的值变化成多少,执行 callbackFn 打印出来的 count 的值始终都是 0。这个是因为回调函数被 useCallback 缓存,形成闭包,从而形成闭包陷阱。

那我们怎么解决这个问题呢?官方提出了 useEvent-- 相当于useCallback的升级版,但该题案没有通过,后续官方还会给出新的解决方法。useEvent保持函数引用不变与访问到最新状态。使用它之后,上面的例子就变成了。

const callbackFn = useEvent(() => {
  console.log(`Current count is ${count}`);
});

在 ahooks 中已经实现了类似的功能,useMemoizedFn 是持久化 function 的 Hook,理论上,可以使用 useMemoizedFn 完全代替 useCallback。使用 useMemoizedFn,可以省略第二个参数 deps,同时保证函数地址永远不会变化

const memoizedFn = useMemoizedFn(() => {
  console.log(`Current count is ${count}`);
});

我们来看下它的源码,可以看到其还是通过 useRef 保持 function 引用地址不变,并且每次执行都可以拿到最新的 state 值。

function useMemoizedFn<T extends noop>(fn: T) {
  // 通过 useRef 保持其引用地址不变,并且值能够保持值最新
  const fnRef = useRef<T>(fn);
  fnRef.current = useMemo(() => fn, [fn]);
  // 通过 useRef 保持其引用地址不变,并且值能够保持值最新
  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    // 返回的持久化函数,调用该函数的时候,调用原始的函数
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }

  return memoizedFn.current as T;
}

参考

ahooks 是怎么解决 React 的闭包问题的?

你可能感兴趣的:(开发中问题总结,react.js,javascript,前端)