探究React 18 的批量更新

1,938 阅读5分钟

批量更新是将一到多个更新流程合并成一个,是一种常见的运行时优化策略;本文主要根据两个demo的代码运行表现来分析批量更新React18中的实现(主要是并发模式下)。整体上来说,React实现批量更新针对不同的优先级任务分别使用了微任务和宏任务来实现。

demo

首先看一下下面两段代码:

片段一

class App extends React.Component {
  constructor() {
    super();
    this.state = { count: 0 };
  }
  syncUpdate = () => {
      this.setState({
        count: this.state.count + 1,
      });
      console.log(this.state.count);
  }
  render() {
    return (
      <div
        onClick={() => {
          this.syncUpdate();
          this.syncUpdate();
          this.syncUpdate();
        }}
      >
        {this.state.count}
      </div>
    );
  }
}

const root = ReactDom.createRoot(document.querySelector("#root"));

root.render(<App />);

点击div;输出: 0, 0, 0; 页面展示1;

片段二:

class App extends React.Component {
  constructor() {
    super();
    this.state = { count: 0 };
  }
  asyncUpdate = () => {
    queueMicrotask(() => {
      this.setState({ count: this.state.count + 1 });
      console.log({ count: this.state.count });
    });
  };
  render() {
    return (
      <div
        onClick={() => {
          this.asyncUpdate();
          this.asyncUpdate();
          this.asyncUpdate();
        }}
      >
        {this.state.count}
      </div>
    );
  }
}

const root = ReactDom.createRoot(document.querySelector("#root"));

root.render(<App />);

点击div;输出: 0, 0, 0; 页面展示1;

可以看到,在并发模式下,跟Legancy模式下的批量更新是有所区别的(lagancy模式下片段二输出为1,2,3 页面显示3;);

原理探究

下面就debugger下React源码看发生了什么:

首先看demo1:

我们是在事件中触发的,所以要看整个流程脱离不了React中的事件处理, 总的来说,React中的事件代理包括事件代理的注册和事件触发后的回调收集与触发两个逻辑片段;

在createRoot调用过程里执行代理元素的事件注册(对没有冒泡的事件和可冒泡的事件的处理,对scroll等的passive的优化, 不同的事件产生不同的优先级,根据不同的优先级生成不同的listener);调用栈如下图:

image.png

在click里触发代理元素的事件回调,从事件触发的元素最近的所属fiber一直向上查找收集对应的onClick属性,这一段逻辑的调用栈如下图:

image.png

收集完了事件,开始执行收集的回调,click 内部的每个setState都会生成一个Update对象,存储在fiber的updateQueue字段中,然后开始ensureRootSchedule;

Tips: Update里面更新的Lane(React 中的优先级)的获取的是事件触发时不同事件绑定设置的对应的优先级:

// click 等事件的listener;
try {
    setCurrentUpdatePriority(DiscreteEventPriority); // 这个是Schedule的优先级,React的Lane与它有转换关系
    dispatchEvent(domEventName, eventSystemFlags, container, nativeEvent);
} finally {
    setCurrentUpdatePriority(previousPriority);
    ReactCurrentBatchConfig.transition = prevTransition;
}

调用栈如图(红框是一个setState):

image.png

触发了三次setState, 也就有三次ensureRootIsScheduled调用,可以看一下它内部的一段代码:

function ensureRootIsScheduled ()  {
    // ... 代码省略
    if (!didScheduleMicrotask) { // 模块内全局变量,初始false;
      didScheduleMicrotask = true;
      scheduleImmediateTask(processRootScheduleInMicrotask);
    }
    // ... 代码省略
}
function processRootScheduleInMicrotask() {
    didScheduleMicrotask = false;
    // ... 代码省略
    flushSyncWorkOnAllRoots();
}

整体就是把diff流程放在了微任务队列里,在下一个微任务周期内执行更新;代码产生了三个update,存在fiber上,不能搞混。

fiber.shared.pending的Update结构如下:

image.png

接着在flushSyncWorkAcrossRoots_impl上打个断点;跳过click回调;

首先发现 batchUpdates 的finally会触发flushSyncWorkOnLegacyRootsOnly, 但是flushSyncWorkAcrossRoots_impl会跳过diff过程; image.png

export function batchedUpdates<A, R>(fn: A => R, a: A): R {
  const prevExecutionContext = executionContext;
  executionContext |= BatchedContext;
  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;
    // If there were legacy sync updates, flush them at the end of the outer
    // most batchedUpdates-like method.
    if (
      executionContext === NoContext &&
      // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
      !(__DEV__ && ReactCurrentActQueue.isBatchingLegacy)
    ) {
      resetRenderTimer();
      flushSyncWorkOnLegacyRootsOnly();
    }
  }
}
export function flushSyncWorkOnLegacyRootsOnly() {
  // This is allowed to be called synchronously, but the caller should check
  // the execution context first.
  flushSyncWorkAcrossRoots_impl(true);
}
function flushSyncWorkAcrossRoots_impl (onlyLegacy) {
    // ... 省略代码
    if (onlyLegacy && root.tag !== LegacyRoot) { // 因为这个跳过
       // Skip non-legacy roots.
    }
    // ... 省略代码
}

跳过这个调用,会再次进入flushSyncWorkAcrossRoots_impl的调用,最终在getStateFromUpdate里面产生新的State;

此阶段调用栈如下: image.png

根据Update生成新的State的代码逻辑如下:

case UpdateState: {
  const payload = update.payload;
  let partialState;
  if (typeof payload === 'function') {
    // Updater function
    if (__DEV__) {
      enterDisallowedContextReadInDEV();
    }
    partialState = payload.call(instance, prevState, nextProps);
    if (__DEV__) {
      if (
        debugRenderPhaseSideEffectsForStrictMode &&
        workInProgress.mode & StrictLegacyMode
      ) {
        setIsStrictModeForDevtools(true);
        try {
          payload.call(instance, prevState, nextProps);
        } finally {
          setIsStrictModeForDevtools(false);
        }
      }
      exitDisallowedContextReadInDEV();
    }
  } else {
    // Partial state object
    partialState = payload;
  }
  if (partialState === null || partialState === undefined) {
    // Null and undefined are treated as no-ops.
    return prevState;
  }
  // Merge the partial state and the previous state.
  return assign({}, prevState, partialState);
}

赋值新的state;

image.png

到此时,拿到的this.state才是最新的值;

片段二

debugger完了片段一,那么片段二的输出就不难理解了;

执行click完后在微任务队列里放入了三个任务,然后第一个任务执行setState的时候React压入了第四个微任务,所以这三个里面打印的都是以前的State;打印在第四个微任务前执行的;

因为setState执行后只是将payload:{count:1}的Update放在了fiber中,只有执行了diff,才会将Update更新到class组件内的State,所以,setState后面的console拿到的还是以前的值,也就是在18里,setState是真正的异步了;

下面小改动一下代码,将异步任务由微任务改为宏任务;

asyncUpdate = () => {
    setTimeout(() => {
      this.setState({ count: this.state.count + 1 });
      console.log({ count: this.state.count });
    });
};

根据微任务与宏任务的执行顺存,这样第二个asyncUpdate的count肯定是要输出1了吧,发现并没有,那么是什么原因呢?

还记得上面click事件会设置任务优先级,然后在finally里重置上一个优先级;那么在setTimeout里执行的时候,这个更新的优先级是上一个优先级,也就是默认的优先级;

我们看微任务调度的函数processRootScheduleInMicrotask里面的一段代码,只有包含同步优先级,才会将mightHavePendingSyncWork设置为true,默认为false;

if (includesSyncLane(nextLanes)) {
    mightHavePendingSyncWork = true;
}

再看flushSyncWorkAcrossRoots_impl,如果是false,就直接退出了,不再执行performSyncWorkOnRoot;

这种情况的调用栈如下:

image.png

结合Chrome的preformance面板,可以看出这里的更新是在一个宏任务中执行;使用的是Schedule模块来提供调度;

image.png

具体逻辑也是在processRootScheduleInMicrotask这个函数中,它会在当前root不包含syncLane的情况下开启宏任务的调度;

因为执行this.asyncUpdate会压入三个宏任务,第一个执行触发setState后产生的微任务回调总再次产生一个宏任务,所以asyncUpdate里面还是会打印原来的count:0;

下面是模拟该过程的代码:

let syncLane = false;

class TestObj {
  innerState = { count: 0 };
  get state() {
    return this.innerState;
  }
  set state(val) {
    this.innerState = val;
    console.log({ val });
  }
  pending = new CircleLink();
  setState = (payload) => {
    const update = new Update(payload);
    this.pending.add(update);
    scheduleUpdate(this);
  };
  asyncHandle = () => {
    setTimeout(() => {
      this.setState({
        count: this.state.count + 1,
      });
    });
  };
  syncHandle = () => {
    this.setState({
      count: this.state.count + 1,
    });
  };
  // 相当于内部的click绑定
  eventCallFn = () => {
    // this.syncHandle();
    // this.syncHandle();
    // this.syncHandle();
    this.asyncHandle();
    this.asyncHandle();
    this.asyncHandle();
  };
}

class Update {
  constructor(data) {
    this.payload = data;
    this.next = null;
  }
}

class CircleLink {
  tail = null;
  constructor() {
    this.tail = null;
  }
  add(update) {
    if (this.tail === null) {
      update.next = update;
      this.tail = update;
    } else {
      update.next = this.tail.next;
      this.tail.next = update;
      this.tail = update;
    }
  }
}

function eventWrapper(trigger) {
  try {
    syncLane = true;
    trigger.eventCallFn();
  } finally {
    syncLane = false;
  }
}

function scheduleUpdate(optObj) {
  let flag = false;
  if (!flag) {
    flag = true;
    queueMicrotask(updateHandle.bind(null, optObj));
  }
}

function updateHandle(optObj) {
  if (!syncLane) {
    asyncHandle(optObj);
  }
  syncHandle(optObj);
}

function asyncHandle(optObj) {
  setTimeout(() => {
    calcNewStateAndAssign(optObj);
  }, 4);
}

function syncHandle(optObj) {
  if (!syncLane) {
    return;
  }
  calcNewStateAndAssign(optObj);
}

function calcNewStateAndAssign(obj) {
  const { pending, state } = obj;
  if (pending.tail === null) {
    return;
  }
  let pendingQueue = pending.tail;
  pending.tail = null;
  let lastUpdate = pendingQueue;
  let curUpdate = pendingQueue.next;
  let newState = state;
  do {
    let { payload } = curUpdate;
    newState = Object.assign({}, state, payload);
    curUpdate = curUpdate.next;
  } while (lastUpdate.next !== curUpdate);
  obj.state = newState;
}

const testObj = new TestObj();

eventWrapper(testObj);