react hooks闭包陷阱及解决方法

4,566 阅读6分钟

1 定时器回调的闭包问题

  • 下面这段代码会打印什么?
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
function App() {
  const [count, setCount] = useState(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  useEffect(() => {
    handleBtnClick();
    setTimeout(() => {
      console.log("setTimeout count", count);
    }, 1000);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

image.png

  • 因为useEffect的回调只执行了一次,这个回调函数作用域中的闭包永远引用着App函数第一次执行时创建的作用域;所以这里的setTimeout回调读取的count值是始终是闭包中的count值0;

image.png

  • 下面修改下代码,给useEffect增加count的依赖
function App() {
  const [count, setCount] = useState(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  useEffect(() => {
    if (count === 0) { //防止不停触发更新
      handleBtnClick();
    }
    setTimeout(() => {
      console.log("setTimeout count", count);
    }, 1000);
  }, [count]);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
  • 这里打印了两遍,因为useEffect的回调执行了两次创建了两个定时器,其中第二次已经可以获取到新的count值,因为第二次执行回调时App函数已经重新render并计算了新的count,这个回调函数执行时创建了新的作用域,里面的闭包引用了App函数重新render执行后的作用域;

image.png

2 事件监听绑定的处理函数闭包问题

function App() {
  const [count, setCount] = useState(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  const clickCb = () => {
    console.log("clickCb count", count);
  };
  useEffect(() => {
    if (count === 0) {
      handleBtnClick();
    }
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
  • 可以看见click事件的回调函数clickCb作用域中形成了闭包引用了App函数的作用域

image.png

  • 所以无论我们如何修改count值,这个事件处理函数读取到的永远是App组件首次render时创建的作用域

image.png

  • 下面修改下代码给useEffect增加依赖项
function App() {
  const [count, setCount] = useState(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  const clickCb = () => {
    console.log("clickCb count", count);
  };
  useEffect(() => {
    if (count === 0) {
      handleBtnClick();
    }
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, [count]);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
  • 发现此时可以打印预期的count值,因为这里每次count变化时useEffect的回调都会重新执行,移除旧的监听事件并重新绑定监听事件的处理函数,这个新的事件处理函数的作用域中的闭包是最新的App函数render后的作用域; image.png

3 从源码分析是谁在决定useEffect的回调是否需要重新执行

  • 函数fiber节点的memoizedState属性是一个链表,保存了这个函数组件上的所有hook;
  • 而每一个hook的memoizedState属性保存的值根据hook类型不同而不同,对于useEffect来说memoizedState是一个环状链表,保存的是对应函数组件的最后一个effect
//effect的数据结构
function pushEffect(tag, create, destroy, deps) {
  const effectEffect = {
    tag, //只有传入的是HookHasEffect才会在commit阶段执行这个effect中的回调
    create,
    destroy,
    deps,
    next: (null: any),
  };

image.png

  • 从下面流程图中可以看出,在updateEffect函数中会调用areHookInputsEqual(nextDeps, prevDeps)判断新旧dep是否相同(里面会调用Object.is做浅比较),如果相同则会提前return而不会标记HookHasEffect的tag,这样在commit阶段就不会执行这个effet image.png

  • 下图是commit阶段最后一个子阶段layout阶段的执行流程图 image.png

  • 对于useMemo和useCallback的闭包陷阱问题也是类似的;

4 解决办法

4.1 在依赖项数组中增加对应依赖项
  ...
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, [count]);//增加count的依赖
  ...
  • 但是这么做会导致每次count变化都会执行useEffect中的回调,移除旧的事件监听再重新建立监听事件绑定处理函数,这会导致不必要的开销也不符合正常的开发的思路(理论上我们只需要在组件mount时绑定一次监听事件,在组件销毁时移除对应事件监听即可),所以更佳的做法是使用useRef
4.2 useRef
4.2.1 使用useRef保存count值并手动同步更新
function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);          // + 
  const handleBtnClick = () => {
    const newCount = count + 1;        // + 
    setCount(newCount);                // + 
    countRef.current = newCount;       // + 这里是手动档更新countRef的值
  };
  const clickCb = () => {
    console.log("clickCb countRef.current", countRef.current);  // + 
  };
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
  • 这时候我们发现事件处理函数中读取的countRef.current可以获取到正确的count值;

image.png

4.2.2 使用useRef保存count值并使用useLayoutEffect自动同步更新
function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);
  const handleBtnClick = () => {
    setCount(count + 1);
  };
  const clickCb = () => {
    console.log("clickCb countRef.current", countRef.current);
  };
  useLayoutEffect(() => {       // +
    countRef.current = count;   // + 这里是每当count改变自动同步更新countRef的值
  }, [count]);                  // +
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
  • 发现我们可以在事件处理函数中读取到正确的count值,因为每次count改变useLayoutEffect的回调都自动帮我们同步更新了countRef的值;

image.png

  • 这里为什么用useLayoutEffect而不是useEffect,因为useLayoutEffect的回调函数是在commit阶段的最后一个子阶段layout阶段的commitLayoutEffects方法中被同步执行的,所以每次组件重新render后的commit阶段都能在click事件处理函数调用前更新好countRef的值;
  • 但是useEffect的回调是在commit阶段代码同步执行完后再异步调度的
if (!rootDoesHavePassiveEffects) {
    rootDoesHavePassiveEffects = true;
    scheduleCallback(NormalSchedulerPriority, () => {
      flushPassiveEffects();//在commit阶段完成后异步执行回调遍历pendingPassiveHookEffectsMount链表执行useEffect中注册的回调
      return null;
    });

  }
  • 我们尝试使用useEffect更新countRef的值,这样会导致我们在点击+1按钮时触发组件重新render,react帮我们异步调度useEffect的回调,然后也触发了click事件打印countRef的值,但是此时useEffect中的回调还没执行,所以这时候countRef还没更新,打印的还是0
  //useLayoutEffect(() => {      //改为useEffect尝试看看会发生什么
  useEffect(() => {
    countRef.current = count;
  }, [count]);

image.png

  • 但是当我们点击其余空白处再次触发click事件时发现打印了正确的countRef的值,因为这时候useEffect中回调已经被异步调度执行了,也就是已经更新了countRef的值

image.png

4.2.3 使用useRef保存count值并在函数组件顶层更新ref
  • 和放在useLayoutEffect回调中的区别是无论count值是否变化,只要函数组件render都会重新给countRef赋值
function App() {
  const [count, setCount] = useState(0);
  const countRef = useRef(null);
  countRef.current = count; // +
  const handleBtnClick = () => setCount(count + 1);
  const clickCb = () => {
    console.log("clickCb countRef.current", countRef.current);
  };
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));
4.2.4 封装自定义hook使用useRef保存count值并在函数组件顶层更新ref--> useLatest
  • 原理同4.2.3
export function useLatest(value) {
  const ref = useRef(value);
  ref.current = value;
  return ref;
}
function App() {
  const [count, setCount] = useState(0);
  const latestCountRef = useLatest(count);
  const handleBtnClick = () => setCount(count + 1);
  const clickCb = () => {
    console.log("clickCb latestCountRef", latestCountRef.current);
  };
  useEffect(() => {
    document.addEventListener("click", clickCb);
    return () => document.removeEventListener("click", clickCb);
  }, []);
  return (
    <>
      <button onClick={handleBtnClick}>+1</button>
      <span>count:{count}</span>
    </>
  );
}
ReactDOM.render(<App />, document.getElementById("root"));
4.3 为什么用useRef就可以?
  • useRef 返回一个包含current属性对象,这个对象在组件的整个生命周期内一直存在

  • 从源码中看到这个hook的数据结构很简单,就mount时创建一个ref对象并保存在hook.memoizedState中,当组件update重新调用useRef函数时直接返回这个对象 image.png

  • 这里的事件处理函数clickCb作用域链中引用了函数App的作用域形成了闭包,这个闭包中的变量对象始终是App函数首次执行时的作用域,所以可以从下图中看到count仍然是0,但是countRef对象中的current属性是1,因为这个闭包中countRef对象和函数App多次重新render时操作的countRef对象是同一个内存地址(useRef在hook.memoizedState保存着),所以是可以获取到预期的值的。

image.png

最后

  • 以上是我对react hooks闭包陷阱问题的理解,如果发现有误希望可以指正,大家一起交流学习哈;