3分钟深入了解React18中useState中的批量更新原理

332 阅读3分钟

react17中,对于在异步函数(例如promise, setTimeout)中调用多次setState,会触发多次更新,但是在react18中却没有这个问题。各位会不会感到好奇,带着这个问题,我们来看下面一个简单示例

react18 setState简化版

const scheduleMicrotask = window.queueMicrotask;
const callbackPriority = 1;

// 存储是否有相同优先级的更新
const root = {
  callbackPriority: null,
};

// 上一个更新
let lastupdate = null;
// 更新队列
let updateQueue = null;

// 状态
const state = {
  count: 1,
};

// 模拟setState
function setState(payload) {
  const update = {
    payload,
    next: null,
  };
  // 将更新组成一个链表
  if (updateQueue === null) {
    updateQueue = update;
    lastupdate = update;
  } else {
    lastupdate.next = update;
    lastupdate = update;
  }
  // 调度更新
  scheduleUpdate();
}

function scheduleUpdate() {
  if (root.callbackPriority !== null) {
    return;
  }
  // 存储当前更新的优先级,
  root.callbackPriority = callbackPriority;
  scheduleMicrotask(() => {
    updateRender();
  });
}

function updateRender() {
  let newCount = state.count;
  let update = updateQueue;
  // 去取出更新
  while (update) {
    newCount = update.payload;
    update = update.next;
  }
  state.count = newCount;
  // 重置
  root.callbackPriority = null;
  lastupdate = null;
  updateQueue = null;
  
  // 渲染dom
  renderToDOM();
}

const box = document.querySelector('#box');
const btn = document.querySelector('#btn');

function renderToDOM() {
  console.log('我更新了');
  box.innerHTML = state.count;
}

const onClick = () => {
  setState(2);
  setState(5);
  setState(10);
}

btn.addEventListener('click', onClick);

renderToDOM();

来看下效果 Kapture 2024-09-19 at 19.30.18.gif 可以看到,当我们点击"提交按钮时",只打印了一次“更新了”。

实现思路

这里利用workloop时间循环的机制

  1. 第一次调用setState的时候,产生一个update,构建update链表(模拟react中的机制),然后在调用scheduleMicrotask产生一个微任务。这个微任务需要再执行完onClick事件后执行,此时更新聊表为
{
    paylaod: 2,
    next: null
}
  1. 第二次调用setState的时候,产生一个新update,在上一个update后追加
{
   paylaod: 2,
   next: {
       payload: 5,
       next: null
   }
}
  1. 第三次调用setState的时候,同第二步
{
   paylaod: 2,
   next: {
       payload: 5,
       next: {
           payload: 10,
           next: 5
       }
   }
}

当执行完onClick后,浏览器会从微任务队列中取出一个任务,放入执行栈,这个时候执行updateRender,视图成功刷新

以上就是一个react setState的最简单实现


react中, setState的实现复杂很多,分为同步任务(Sync lane)和普通任务(default lane),当我们在点击一个按钮的时候,同步调用state的时候就是同步任务,这个时候更新是一个微任务更新(会尽可能早的执行完更新,这个是react的一个优化,react任务鼠标等时间对于视图的更新是很急切的),而在包裹setTimeoutpromise的setState,则更新lanedefault lane 此时是一个宏任务更新。

急切更新

下面一个例子中,当点击按钮时会产生一个Sync lane的更新如下

import React, { useState } from 'react';

function App () {
    const [count, setCount] = useState(0)
    console.log('render')
    const onClick = () => {
        setCount((prev) => prev + 1)
        setCount((prev) => prev + 1)
        setCount((prev) => prev + 1)
    }
    return (
        <div>
            <div>{count}</div>
            <button onClick={onClick}>Increment</button>
        </div>
    );
}

export default App;

源码我这就补贴了,感觉看起来很枯燥,这里我直接上图

image.png

在控制台中,我们会发现,render只打印了一次。

这个时候有的同学会说:react17也只打印一次。 的确是,这里确实表现是一样的,但是接下来的代码就有所区别了

普通更新


import React, { useState } from 'react';

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

    const onClick = () => {
+        setTimeout(() => {
            setCount((prev) => prev + 1)
            setCount((prev) => prev + 1)
            setCount((prev) => prev + 1)
+        }, 0)
    }
    return (
        <div>
            <div>{count}</div>
            <button onClick={onClick}>Increment</button>
        </div>
    );
}

export default App;

运行上面的代码,你会发现,在react17中,会打印三次,关于为何三次,我看了下源码是因为setTimeout等异步函数,会跳出了批处理的更新范围,导致后面每次调用都会触发重新渲染

注意:react 17中需使用传统的 ReactDOM.render的形式渲染,如果使用createRoot的形式,表现跟react18一致

react18中,没有采用状态标识是否批量更新,而是仍然采用任务调度的形式,对于异步任务里的 setState, 将会产生一个 default lane的更新,该更新被react任务不是很紧急的更新,会采用宏任务来调度

不同的地方为粉色区域,基本流程和上面同步差不多,知识任务的优先级不同

image.png