阅读 667

🆘 一次理解清楚,为什么使用 React useEffect 中使用 setInterval 获取的值不是最新的

Intro

这篇文章将通过一个使用 React Hook 常遇到的问题(stale state)入手,尝试理解 Hook 的内部运行逻辑。

废话不多说,直接看示例Sandbox

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";

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

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);
复制代码

你能看出示例代码中存在的问题吗?(如果一眼看出来了,那么继续阅读这篇文章可能不会给你带来收益。)这段代码实际运行起来的效果是,页面从 0 变为 1,之后就一直展示 1。不是直观理解那样,每隔一秒更新一次。

setCount 也可以接受一个 Function

如果之前有过 React 开发经验,这里的第一反应可能会是 setState 的异步调用,要获取最新的 state,最好是使用 setState(prevState => newState) 的方式来保证当前 setState 能生效。

确实,Hooks dispatch 方法也支持这种写法。上面的例子,只需要将 setCount(count + 1); 改写成 setCount(val => val + 1) 就可以如预期的那样运行。

就这?

为什么两个 count 不一致?

到这里只是让程序可以运行起来而已,出现理解分歧的原因是啥? 在同一个 JS 方法中,在不同的位置读取同一个变量,得到的结果不一致。

在下一个示例中添加打印 count,会很神奇的发现,每次重新 render,读取到的 count 都是最新的,而 setInterval 每次中都还是 0。而他们都出现在同一个作用域中,作用域只有一个 count,按道理每次都应该读到最新的 count 不是吗?

const [count, setCount] = useState(0);
console.log('render val:', count)

useEffect(() => {
    let id = setInterval(() => {
      console.log('interval val:', count)
      setCount(val => val + 1);
    }, 1000);
    return () => clearInterval(id);
}, []);
复制代码

要理清这个问题,就不得不把【什么是闭包?】扯出来。

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。

这个简单的例子中,哪里会产生闭包?useEffect 第一个参数,setInterval 的第一个参数。这两处位置,程序是分别创建了一个新的 Function 当做函数参数传递,由于闭包的存在,在函数真正被运行时才可以获取到外部的变量(count)。

由于我们给 useEffect,传递了第二个参数 [],表示这个 effect 没有任何外部依赖,只需要在第一次 render 时运行,无论这个组件被重新 render 多少次,setInterval 只会被注册一次。

并且 Function Component 每次 render 都是重新执行 Function (产生新的局部变量,闭包),第一次创建的闭包和第二次创建的闭包没有任何关系。

所以,当程序运行起来是,setInterval 内的闭包引用到的一直是最初的 count,而 useState 得到的是最新的 count。这是两处代码打印出来结果不一致的根本原因。

既然跟 useEffect 只执行了一次有关,那直接把 [] 去掉不就行了吗?

const [count, setCount] = useState(0);
console.log('render val:', count)

useEffect(() => {
    let id = setInterval(() => {
      console.log('interval val:', count)
      setCount(val => val + 1);
    }, 1000);
    return () => clearInterval(id);
});
复制代码

运行以上代码,确实 interval 中能读到最新的 count 了。

原理是这个 effect 现在每次重新 render 都再执行一次,产生新的闭包,引用到最新的 count。但这个方法能生效是因为触发重新 render 的动作恰巧只有 setCount,当出现多个触发 render 的动作时,会产生更多【奇怪】的结果。

每次都是重新渲染,为什么 useState 可以读到最新的 value

到这里,产生了另一个疑惑点,既然每次 render 都是新的,为什么 useState 可以获取到最新的值?

跟踪到 React renderWithHooks 源码处,可以发现 Function Component 在被渲染时确实就是当做普通方法调用

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderExpirationTime: ExpirationTime,
): any {
  // ...
  let children = Component(props, secondArg);
  //...
  return children;
}
复制代码

组件被调用时,会执行 useState 方法。从 react 源码上看,React 内部维护了一个 hook 的链表,链表表头存在 currentlyRenderingFiber.memoizedState,节点通过 next 链接。

useState 钩子相关的两处代码:

// 首次 render useState hook 时执行
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountWorkInProgressHook(); // 创建新的 hook 挂载到链表尾部
  hook.memoizedState = hook.baseState = initialState;
  // ...
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  // 返回缓存的 memoizedState (这里是 initialState)
  return [hook.memoizedState, dispatch];
}

// 更新 render useState hook 时执行
function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  // useState 的实现也是基于 reducer
  return updateReducer(basicStateReducer, (initialState: any));
}

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook(); // 获取缓存的 hook
  // ...
  const dispatch: Dispatch<A> = (queue.dispatch: any);
  // 返回缓存的 memoizedState
  return [hook.memoizedState, dispatch];
}
复制代码

React 源码在关于 Hook 首次执行和更新是分开处理的,但逻辑都是一样,获取或新建一个 hook,暴露给外部 memoizedState 和一个 dispatch 方法。dispatch 调用的时候就会取修改 memoizedState。这是为什么每次渲染 useState 可以读到最新的 Value的原因。

useRef

再回到原来的问题,要在 setInterval 中能读取到正确的 count,应该怎么做?

另一个钩子 useRef 代码在这里

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

useEffect(() => {
  // 及时更新 count 值
  countRef.current = count;  
});

console.log('render val:', count)

useEffect(() => {
    let id = setInterval(() => {
      // 不直接读取 count,而是 countRef.current
      console.log('interval val:', countRef.current)
      setCount(val => val + 1);
    }, 1000);
    return () => clearInterval(id);
}, []);
复制代码

借助 useRef,每次都把最新的值赋予 countRef.current = count;,闭包内原本获取 count 的位置,改成 countRef.current。这时候闭包引用的是一个 Object,当它被真正运行起来时就是通过对象的引用而不是一个基础数据类型的值。

打印结果:

useRef 内部又是怎么实现的?很简单,基本和 useState 一直,不同的是 useRef 直接在 hook 上缓存的是一个 Object,每次重新渲染得到的还是同一个 Object

function mountRef<T>(initialValue: T): {|current: T|} {
  const hook = mountWorkInProgressHook();
  const ref = { current: initialValue };

  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): {|current: T|} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}
复制代码

这个问题还有另一种常见场景,就是回调事件,例如在 ReactNative 中基于 PanResponder 封装一个手势处理的组件,在满足条件时触发 onChange 回调事件,稍不留神就会出现 onChange 不是最新的问题。

function SwipeToConfirm ({ onChange }) {
    const onChangeRef = useRef(onChange)
    useEffect(() => {
        onChangeRef.current = onChange
    })
    const panResponder = useRef(PanResponder.create({
      //...
      onPanResponderRelease: (evt, gestureState) => {
          // 一些逻辑处理,符合条件时执行 onChange
          onChangeRef.current()
      }
    })).current;

    return (
      <Animated.View
        {...panResponder.panHandlers}
      >
      </Animated.View>
    )
}
复制代码

🙆‍♂️,看到这里相信你对Hook有了更加深入得了解。

推荐阅读