react18 useTransition尝试及原理

1,272 阅读3分钟

前言

  • 假设有如下代码,input接收用户输入的数量num同时渲染出num个li image.png
import React, { useState, useTransition } from "react";
import ReactDOM from "react-dom/client";
function App() {
  const [num, setNum] = useState(0);
  const handleInputChange = ({ target: { value } }) => {
    const foramtVal = parseInt(value) || 0;
    setNum(foramtVal);
  };
  return (
    <>
      <input onChange={handleInputChange} value={num}></input>
      <ul>
        {Array(num)
          .fill(0)
          .map((_, i) => (
            <li key={i}>{num - i}</li>
          ))}
      </ul>
    </>
  );
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

1 不使用useTransition

  • 直接调用setState时react采用Legacy模式来协调fiber tree,最终会调用renderRootSync进入workLoopSync,整个过程是同步不可中断的循环
function workLoopSync() {
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}
  • 当不使用useTransition时输入43210发现,当输入到0时出现明显的卡顿,input输入框响应从4321到43210等了约3秒时间;

    不使用useTransition.gif

  • 浏览器在一帧(一般是16.6ms)中要完成下面的任务,因为JS可以操作DOM,所以GUI渲染线程JS线程是互斥的,如果一个task执行js时间过长,那么就没有时间留给浏览器做渲染工作,就会导致掉帧;

一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback

image.png

  • 这时,整个调用栈都在一个task中,消耗了约3秒,从performance分析发现js处理占用了约2.68s,明显大于一帧的16.6毫秒,这时候如果有用户输入,点击等操作时都不能及时响应,会出现页面掉帧卡顿

image.png

2 使用useTransition

  • 改造一下代码,区分出列表渲染为低优先级更新;
import React, { useState, useTransition } from "react";
import ReactDOM from "react-dom/client";
function App() {
  const [num, setNum] = useState(0);
  const [length, setLength] = useState(0);
  const [isPending, startTransition] = useTransition();
  const handleInputChange = ({ target: { value } }) => {
    const foramtVal = parseInt(value) || 0;
    setNum(foramtVal);//紧急更新
    startTransition(() => setLength(foramtVal));//低优先级更新
  };
  return (
    <>
      <input onChange={handleInputChange} value={num}></input>
      <ul>
        {isPending
          ? "pending..."
          : Array(length)
              .fill(0)
              .map((_, i) => <li key={i}>{length - i}</li>)}
      </ul>
    </>
  );
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
  • 通过useTransition调用setState,react采用Concurrent模式来协调fiber tree,最终会调用renderRootConcurrent进入workLoopConcurrent,每次进入循环前都会调用shouldYield(判断执行时间是否大于frameInterval,默认是5ms)方法判断是否暂停,这样就实现了可中断;
function workLoopConcurrent() {
  // Perform work until Scheduler asks us to yield
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
  • 当使用useTransition时输入43210发现,输入框响应从4321到43210比刚才明显要及时,列表渲染经历了从pending到真实列表的过程;

    使用useTransition.gif

  • 通过performance面板发现render阶段的工作被拆分成了多个task,此时每个task的时间占用都较小(约5ms),浏览器有剩余时间可以响应input框中的紧急更新,所以可以看到输入框的变化比不使用useTransition要流畅很多;

image.png

3 react可中断更新原理之时间切片

  • react16后的fiber架构把每一个元素每一个组件分成了一个个fiber节点,每次调用performUnitOfWork都只处理一个fiber节点,由原来的递归不可中断更新变成了循环可中断更新,这里进入循环前都会调用shouldYield(判断执行时间是否大于frameInterval,默认是5ms)方法判断是否暂停,这样就实现了可中断;
function workLoopConcurrent() {
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}
  • 这样react在中断render之后,就把剩余时间的控制权交还给了浏览器,浏览器就有时间去做渲染工作了,减少了掉帧的可能性(如果一个fiber节点的处理时间非常长那也是会存在执行js时间过长导致掉帧的)
  • 上面shoulYield中断render后,Scheduler将需要被执行的回调函数作为MessageChannel的回调在下一个task中执行。如果当前宿主环境不支持MessageChannel,则使用setTimeout。背后实现原理需要学习Scheduler的lane模型及优先级调度等知识,等后续搞懂了再来更新啦~