likes
comments
collection
share

ahooks源码系列(一):React 闭包陷阱

作者站长头像
站长
· 阅读数 19

前言

最近在学习 ahooks 的源码,想学习别人的思路提高一下自己自定义 hook 的能力。然后我发现 ahooks 里面其实像 useLatestuseMemoizedFn 这两个 hook 是为了解决 React hook 自身带来的闭包问题,并且在 ahooks 的其他 hook 实现中,也会用到这两个 hook,所以第一篇就想谈谈我自己对 React 闭包陷阱的看法,也方便后续 ahooks 源码的学习

React 闭包出现的场景举例

我们先来举个例子:

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

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

  useEffect(() => {
    setInterval(() => {
      console.log('setInterval:', count);
    }, 1000);
  }, []);

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

export default App

上面的例子中,当点击 n 次按钮时,count 值会更新为 n,页面上展示的 count 也是 n,但是此时控制台 setInterval 打印的结果却是 0

ahooks源码系列(一):React 闭包陷阱

这显然就陷入了闭包的问题,那么闭包问题是如何产生的呢?

React 闭包产生的原因

首先,hooks 其实是以 单向链表 的形式存储在 Fiber 节点的 memoizedState 属性上,而每一个 hooks 对应一个 hook 节点对象,它的结构如下:

const hook = {
    memoizedState: null,  // 存储当前这个 hooks 的值,比如 useState 存储的就是 state,useEffect 存储的就是 callback
    baseState: null,
    baseQueue: null,
    queue: null, 
    next: null, // 指向下一个 hook 节点
  };

对于上面的例子,当 App 组件初始化时,React 内部会调用 mountWorkInProgressHook 函数,创建 hook 节点,组装成 hooks 单向链表

ahooks源码系列(一):React 闭包陷阱

然后当我们点击按钮, count 更新为 1 ,然后组件 rerender ,此时 React 内部会更新 hooks 链表,对于 useState 来说,它对应的 hook 节点会把记录的状态更新为 1,而对于 useEffect 来说,因为它的依赖是空数组,只在组件初始化时执行,所以此时可以理解为 useEffect 对应的 hook 节点不需要更新,直接复用旧的 useEffect 对应的 hook 节点

而前面的更新过程中,useEffect 旧的 hook 节点上保存的 callback 中, setInterval 里的回调函数对于 count 变量的使用还是 App 组件初始化时的那个 count,也就是 0,所以这里就形成了闭包的问题,setInterval 里面始终拿不到最新的 count

闭包的解决办法

上面的例子,解决闭包的话可以有两种形式

第一种就是给 useEffect 添加依赖项,然后记录当前计时器,每当组件重新渲染时,清除上一次计时器,然后记录新的计时器,引用最新的 count 值

const timer = useRef();

useEffect(() => {
  if (timer.current) {
    clearInterval(timer.current);
  }
  timer.current = setInterval(() => {
    console.log('setInterval:', count);
  }, 1000);
}, [count]);

控制台的输出:

ahooks源码系列(一):React 闭包陷阱

第二种就是使用 useRef 存储最新的 count

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

const App = () => {
  const [count, setCount] = useState(0);
  const lastCount = useRef(count);

  useEffect(() => {
    setInterval(() => {
      // useRef 在整个组件生命周期中不变
      console.log("setInterval:", lastCount.current);
    }, 1000);
  }, [count]);

  return (
    <div>
      count: {count}
      <br />
      <button
        onClick={() => {
          setCount((val) => val + 1);
          //点击加一时修改 lastCount.current 拿到最新的 count
          lastCount.current++;
        }}
      >
        点击加一
      </button>
    </div>
  );
};

export default App;

控制台的输出:

ahooks源码系列(一):React 闭包陷阱

但这样写有个问题,我每点一次按钮,定时器就会多一个,所以这种方式也不太好

ahooks 里面如何解决闭包的

前面我们也说了,ahooks 里面的 useLatestuseMemoizedFn 都可以解决闭包,我们来看看他们源码是如何实现的

useLatest

useLatest 的源码很简单,就是对 useRef 包了一层,返回的是 ref 对象

import { useRef } from 'react';

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

  return ref;
}

export default useLatest;

那为什么 useRef 可以解决闭包问题呢?

因为 useRef 返回的始终是同一个 ref 对象,这个 ref 对象在组件的整个生命周期中是不变的,也就是地址是没变的,既然地址没变,我通过 ref 对象的 current 属性存储最新的 state,就可以保证我每次通过 ref.current 拿到的值就是最新的呀

就像这样:

const me = { name: 'Jolyne' }
const b = me
b.name = 'LXQ'

console.log(me.name) // 'LXQ'

useMemoizedFn

在使用函数的时候,我们可能会使用到 useCallback 对函数进行缓存

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

上面的例子中,useCallback 的依赖是空数组,那么当 count 发生改变后,重新执行 callbackFn 时,会存在 count 已经更新,但是 callbackFn 输出的 count 依然是 0 的情况,这里就是涉及到闭包的问题

ahooks源码系列(一):React 闭包陷阱

useMemoizedFn 它解决的问题是:在保持函数地址不变的情况下,获取到最新的状态,它能解决闭包的问题

我们来看看它的源码

function useMemoizedFn<T extends noop>(fn: T) {
  // 每次拿到最新的 fn 值,并把它更新到 fnRef 中。这可以保证此 ref 能够持有最新的 fn 引用
  const fnRef = useRef<T>(fn);
  fnRef.current = useMemo(() => fn, [fn]);
  
  // 保证最后返回的函数引用是不变的-持久化函数
  const memoizedFn = useRef<PickFunction<T>>();
  if (!memoizedFn.current) {
    // 每次调用的时候,因为没有 useCallback 的 deps 特性,所以都能拿到最新的 state
    memoizedFn.current = function (this, ...args) {
      return fnRef.current.apply(this, args);
    };
  }

  return memoizedFn.current as T;
}

我们可以发现,useMemoizedFn 通过 useRef 保持 fn 引用地址不变,并且去除掉 useCallback deps 特性之后,每次执行都可以拿到最新的 state 值,保证函数调用的准确性和实时性。

最后

React 由于它的 函数组件的状态管理机制,导致了特定的闭包问题,而 ahooks 的目的之一就是去解决原生 React 的闭包问题,通过 useLatest 保证每次获取的都是最新的 state,通过 useMemoizedFn 持久化 fn 保证函数地址不变且能拿到最新的 state(不得不说,确实屌这些人)

还有就是在 ahooks 里面,输出函数都通过 useMemoizedFn 包裹,输入函数都用 useLatest 记录,来保证每次都拿到的是最新的函数,比如 useRequest 的第一个参数 service 就会通过 useLatest 来记录

以上就是我对 React 闭包的浅谈理解