随心而记
1366 字
7 分钟
React setState 批处理机制引发的问题案例
2024-03-14
发布时间
分类
标签

React 的 useState 钩子用于在函数组件中添加状态。当你调用由 useState 返回的设置状态函数(通常命名为 setState)时,React 会安排一次组件的更新。但是,这些更新并不总是立即执行。React 有一个批处理(batching)机制,用于合并多个状态更新,从而减少不必要的重新渲染,提高应用性能。这个机制的工作原理和其对开发的影响是开发者需要理解的重要概念。

批处理机制的工作原理#

  1. 事件处理中的批处理:在 React 事件处理器内部触发的状态更新(如用户点击按钮时触发的事件)会自动批处理。这意味着如果你在一个事件处理函数中连续调用多次 setState,React 会将这些更新合并为一次更新,并只触发一次重新渲染。这种批处理发生在 React 控制的事件处理函数中,如 onClickonChange 等。
  2. 异步操作中的批处理:React 18 引入了自动批处理,这意味着即使在异步操作(如 setTimeoutPromise.then 或者任何不是 React 事件处理器触发的异步回调)中的状态更新也会被批处理。在 React 17 及之前版本,只有 React 控制的事件处理函数中的状态更新才会自动批处理,而异步操作中的状态更新不会被批处理。
  3. 手动批处理:如果你需要在不自动批处理的情况下强制批处理状态更新(例如,在 React 17 或更早版本的异步代码中),可以使用 ReactDOM.unstable_batchedUpdates() 函数。通过将更新封装在 unstable_batchedUpdates 的回调中,即使在异步操作中也可以实现批处理。

批处理的影响#

  • 性能提升:通过减少不必要的渲染次数,批处理机制可以显著提高应用的性能。
  • 状态更新的合并:在批处理期间,React 会合并状态更新。如果你多次更新同一个状态,React 会以最后一次更新为准。
  • 异步性质setState 调用是异步的,React 会在稍后某个时间点应用状态更新。因此,立即读取状态更新后的值可能不会反映最新的状态。

案例#

假设有一个页面,它有两个子组件,在页面初始化时需要请求接口,其中两个入参分别是这两个子组件通过事件抛出来的,伪代码如下

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

const ChildrenOne = (props) => {
  useEffect(() => {
    setTimeout(() => {
      props.onSendMsg('one');
    }, Math.random() * 3 * 1000);
  }, []);
  return <></>;
};
const ChildrenTwo = (props) => {
  useEffect(() => {
    setTimeout(() => {
      props.onSendMsg('two');
    }, Math.random() * 3 * 1000);
  }, []);
  return <></>;
};

export default () => {
  const [params, setParams] = useState({
    msg1: '',
    msg2: '',
  });
  const onSearch = () => {
    console.log(params);
  };
  const onEmitOne = (msg: string) => {
    setParams({
      ...params,
      msg1: msg,
    });
  };
  const onEmitTwo = (msg: string) => {
    setParams({
      ...params,
      msg2: msg,
    });
  };
  useEffect(() => {
    // 需要在 msg1 和 msg2 都 ready 之后再执行
    if (params.msg1 && params.msg2) {
      onSearch();
    }
  }, [params]);
  return (
    <>
      <ChildrenOne onSendMsg={onEmitOne} />
      <ChildrenTwo onSendMsg={onEmitTwo} />
    </>
  );
};

这时我们会发现onSearch 函数始终不会触发,这是因为setParams函数调用是异步的,而且每次调用都基于旧的params状态。在onEmitOneonEmitTwo函数中,params的值在函数开始执行时就被确定了,不会因为状态更新而改变。因此,当onEmitOneonEmitTwo函数几乎同时被调用时,它们获取到的params状态都是初始状态。这就导致了即使两个函数都调用了setParams,最后的结果也只会包含其中一个函数的更新。为了解决这个问题,你应该使用setParams的函数形式来更新状态,这样每次更新都会基于最新的状态进行。

为了解决这个问题,你可以对 setParams 使用函数形式的调用,这种形式的调用会包含最新的状态。如下代码所示:

const onEmitOne = (msg: string) => {
  setParams(prevParams => ({
    ...prevParams,
    msg1: msg,
  }));
};
const onEmitTwo = (msg: string) => {
  setParams(prevParams => ({
    ...prevParams,
    msg2: msg,
  }));
};

这样,无论 onEmitOneonEmitTwo 函数何时被调用,setParams 都会基于最新的 params 状态进行更新,从而确保 onSearch 函数可以被正确触发。

这时 onSearch 函数虽然能正常触发,但是在之后还存在其他入参的改变导致频繁的接口请求,而我这里的需求是只在页面初始化时进行。

这时我的解决方案是讲onEmitOneonEmitTwo 改造成异步函数,使用 Promise 将结果传递出去,这时在新创建一个getInitData 函数只在页面初始化时调用,这个函数的主要功能是拿到onEmitOneonEmitTwo 返回的两个参数,将参数传递给onSearch 函数,并执行。这时也需要将 onSearch 函数改造能能接收入参的形式,实现代码如下:

export default () => {
  const [params, setParams] = useState({
    msg1: '',
    msg2: '',
  });
  const onSearch = (_param?: { msg1: string; msg2: string }) => {
    const { msg1, msg2 } = _param ?? {};
    const param = {
      msg1: msg1 ?? params.msg1,
      msg2: msg2 ?? params.msg2,
    };
    setParams({
      ...params,
      ...param,
    });
    console.log(param);
  };
  const onEmitOne = async (msg: string): Promise<string> => {
    return new Promise((resolve) => {
      resolve(msg);
    });
  };
  const onEmitTwo = async (msg: string): Promise<string> => {
    return new Promise((resolve) => {
      resolve(msg);
    });
  };
  const getInitData = async () => {
    const res = await Promise.all([onEmitOne('one'), onEmitTwo('two')]);
    onSearch({
      msg1: res[0],
      msg2: res[1],
    });
  };
  useEffect(() => {
    getInitData();
  }, []);
  return <></>;
};

React setState 批处理机制引发的问题案例
https://www.promises.top/posts/front/case-studies-on-issues-caused-by-reacts-setstate-batching-mechanism/
作者
发布于
2024-03-14
许可协议
CC BY-NC-SA 4.0