🆘 一次理解清楚,为什么使用 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有了更加深入得了解。

推荐阅读

原文链接:juejin.im

上一篇:前端干货之随机图库 Lorem Picsum
下一篇:奇妙的 CSS MASK

相关推荐

  • 🌈 React 函数式组件优化

    1. React 性能优化思路 减少重新 render 的次数。 减少计算的量。主要是减少重复计算,对于函数式组件来说,每次 render 都会重新从头开始执行函数调用。

    1 个月前
  • 高频数据交换下Flutter与ReactNative的对比

    (标题图片来自网络,侵删) 后端使用go写的socketio服务模拟期货行情数据,每10ms推送10条行情数据 ReactNative已经尽力优化了。 Flutter由于没flutter-socket...

    2 年前
  • 高性能迷你React框架 anu1.3.0 发布

    anujs1.3.0是一款高性能React-like框架,是目前世界上对React16兼容最好的迷你库。 自React16起,相继推出createContext,createPortal, creat...

    3 年前
  • 高德地图 react-amap 实战

    react-amap 是基于 React 的高德地图组件。 1. 获取地图示例 react-amap 作为高德地图在 React 中的实现,实际使用中不可避免的需要通过地图对象调用各种方法,reac...

    1 年前
  • 高品质 React UI 组件

    A high quality UI Toolkit, A Component Library for React 16+. 💘 Installation npm install isui --s...

    3 年前
  • 骚操作!在react中使用vuex

    原文地址 前言 笔者最近在学习使用react,提到react就绕不过去redux。redux是一个状态管理架构,被广泛用于react项目中,但是redux并不是专为react而生,两者还需要react...

    2 年前
  • 项目文档说明:react + Ant Design 的 blog-react-admin

    前言 此 blog-react-admin 项目是基于 蚂蚁金服开源的 ant design pro 之上,用 react 全家桶 + Ant Design 的进行再次开发的,项目已经开源,项目地址...

    2 年前
  • 面试题:Hooks 与 React 生命周期的关系

    React 生命周期很多人都了解,但通常我们所了解的都是 单个组件 的生命周期,但针对 Hooks 组件、多个关联组件(父子组件和兄弟组件) 的生命周期又是怎么样的喃?你有思考和了解过吗,接下来我们将...

    1 年前
  • 面试官:请你在React中引入Vue3的@vue/reactivity,实现响应式。

    前言 React的状态管理是一个缤纷繁杂的大世界,光我知道的就不下数十种,其中有最出名immutable阵营的redux,有mutable阵营的mobx,react-easy-state,在hooks...

    9 个月前
  • 面试官我想做个Reacter(React路由)

    路由的基础用法回顾,源码study,文章首发于docs,求个star 依赖 路由依赖于 react-router ,但是一般 pc 路由使用 react-router-dom ,它依赖于 react-...

    8 个月前

官方社区

扫码加入 JavaScript 社区