谈谈React Fiber与分片

avatar
前端开发工程师 @bigo

file

本文首发于:github.com/bigo-fronte… 欢迎关注、转载。

谈谈React Fiber与分片

React的理念和Fiber的出现

从React的Doc上可以看到React的理念是:

React is, in our opinion, the premier way to build big, fast Web apps with JavaScript.

但是我们有时候一个很长很深的DOM列表(在没有做列表优化的前提下),setState创建更新后,React会进行对比创建前和创建后的节点(Reconcilation阶段),对比的过程是不可中断的, 由于网页的主线程不仅包含了js执行,样式计算, 还包含了渲染需要的重排重绘,也就是当Reconcilation(js执行任务)执行很久的时候,当前的任务在主线程占用时间过多,就会影响浏览器正常的重排/重绘,也会影响正常的用户交互(输入,点击,选择等等)。

举个比较极端的例子,我们有个很深的列表(1500层),而且变化频繁:

function App() {
  const [randomArray, setRandomArray] = useState(
    Array.from({ length: 1500 }, () => Math.random())
  );

  useEffect(() => {
     changeRandom()
  }, []);

  const changeRandom = () => {
    setRandomArray(randomSort(randomArray));
    cancelAnimationFrame(raf);
    raf = requestAnimationFrame(changeRandom);
  };

  const finalList = randomArray.reduce((acc, cur) => {
    acc = (
      <div key={cur} style={{ color: randomColor() }}>
        {cur} {acc}
      </div>
    );
    return acc;
  }, <div></div>);

  return (
    <div>
      <section>{finalList}</section>
    </div>
  );
}

performance面板看,也是changeRandom所触发的整个js执行任务占用了161ms

从事件循环看,changeRandom函数执行setState进入reconcilation阶段,但是由于列表层次太深,整个过程又是不可中断的,所以耗时多阻碍了其他任务包括键盘输出样式计算重排, 重绘等:

从浏览器的一帧来看,当上述的reconcilationtask阻塞了太久,导致正常刷新率情况下的每帧16.6ms下没有更新视图,造成掉帧的问题

所以基于React的理念,为了解决上述的问题,实现reconcilation过程可中断,当然包括其他比如Concurrent Mode的那些试验性的feature, 以及优先级调度相关的东西, React决定使用Fiber重写底层实现

Fiber的数据结构和Fiber树的构建

还是上述的代码,我们随便找一个渲染出来的DOM元素

右健审查
  |
在对应的DOM标签中右键store as global variable
  |
切换控制台到console
  |
输入temp1.__reactInternalInstance$mszvvg3x40p(后面会有智能提示)

即可看到当前节点对应的Fiber信息

列出主要的几个Fiber数据结构:

  1. tag: 表示Fiber节点的类型
export const FunctionComponent = 0; // FC对应的Fiber节点
export const ClassComponent = 1;
export const IndeterminateComponent = 2; // Before we know whether it is function or class
export const HostRoot = 3; // 根Fiber
export const HostPortal = 4; // A subtree. Could be an entry point to a different renderer.
export const HostComponent = 5; // DOM文档的节点对应Fiber 如div,section...
export const HostText = 6; // 文本节点
export const Fragment = 7;
export const Mode = 8;
export const ContextConsumer = 9;
export const ContextProvider = 10;
export const ForwardRef = 11;
export const Profiler = 12;
export const SuspenseComponent = 13;
export const MemoComponent = 14;
export const SimpleMemoComponent = 15;
export const LazyComponent = 16;
export const IncompleteClassComponent = 17;
export const DehydratedSuspenseComponent = 18;
export const EventComponent = 19;
export const EventTarget = 20;
export const SuspenseListComponent = 21;
  1. type: HostComponent则当前React元素的DOMElement类型, 如果是组件Fiber,则指向组件的类或者函数
  2. stateNode: 指向创建的真实的DOM对象
  3. return child 和 sibling:分别对应当前Fiber的父Fiber, 第一个子Fiber和兄弟Fiber节点
  4. alternate:双缓存使用
current.alternate = workInProgress
workInProgress.alternate = current

demo中构建的Fiber树是这样的:

React渲染的两个流程

  • render/reconcilation (可中断interruptible)
    • beginWork主要的作用是创建初始化和更新的当前Fiber节点的子Fiber节点,并且返回当前Fiber的第一个子节点去开始下一次performUnitOfWork
    • completeWork主要是创建Fiber.stateNode的过程,即根据beginWork生成的新Fiber调用document.createElement去创建DOM节点存储在Fiber.stateNode中,再在commit流程的时候去append到真实的DOM中
  • commit(不能中断,否则DOM可能变来变去,UI DOM不稳定);主要是将render阶段生成的stateNode commit到真实的DOM节点中

Scheduler: 调度模块,调度render/reconcilation阶段的任务,将任务分为5ms一个,可中断

开启Concurrent Mode以及分片

启动Fiber时间分片功能需要开启Concurrent Mode模式,也就是说我们平时开发中默认用的ReactDOM.render,虽然用了Fiber,但其实没有用到时间分片。

开启Concurrent Mode只需要两个步骤:

  1. 装上试验性的react和react-dom包
npm install react@experimental react-dom@experimental
  1. 使用ReactDOM.createRoot创建一个FiberRootrender替代ReactDOM.render
ReactDOM.createRoot(rootNode).render(<App />)

开启完毕。

当然除了我们平常用的ReactDOM.renderLegacy Mode以及Concurrent Mode, React还出了一个 Blocking Mode,其实就是拥有部分Concurrent Mode功能的一个中间版本,创建方式是:ReactDOM.createBlockingRoot(rootNode).render(<App />) 三种模式的对比: 可以看到在ConcurrentMode下,开始有了SuspenseList,可以控制Suspense组件的一个顺序,也支持了优先级渲染,中断预渲染等,还有一些新的hook, 比如用useTransition搭配Suspense可以用来做加载的优化,useDefferredValue来做一些state值的缓存,对于某些优先级不是很高但是又很耗时间的更新,可以不用立即更新,而是获取deffer延迟的state等等,但是这些还是还是在试用包里面,可能随时会改,所以就不细说,感兴趣的可以看下: Suspense for Data Fetching

开启分片后性能和用户体验对比(concurrent mode vs legacy mode)

我们回到最开始的demo, 我们对比下开启Concurrent Mode(也就是开启分片)前后的performance面板对比: 开启分片前: 可以看到主线程上的每次更新都是由changeRandom发起然后再进行reconcilation阶段和commit阶段,整个方法包含在一个Task里面:

开启分片后: 开启分片后,可以看到render/reconcilation阶段分成了很多个任务,有很多个Task都是5ms的任务

这时候我们加一个输入框,来测试是否用户体验提升很多,是否reconcilation分片真的这么强。

于是加个输入框,并且不要让输入框影响随机的div深列表,所以我们把List单独抽出来,并且用React.memo包起来,这样输入框的setState并不会引发List的重渲染:

外层:

function App() {
  const [filterText, setFilterText] = useState('');
  return (
    <div>
      <input
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
      />
      <button>按钮</button>
      <section>
        <List />
      </section>
    </div>
  );
}

随机List:

const List = React.memo(function List (props) {
  const [randomArray, setRandomArray] = useState(
    Array.from({ length: 1500 }, () => Math.random())
  );

  useEffect(() => {
    changeRandom()
 }, []);

 const changeRandom = () => {
   setRandomArray(randomSort(randomArray));
   cancelAnimationFrame(raf);
   raf = requestAnimationFrame(changeRandom);
 };

 const finalList = randomArray.reduce((acc, cur) => {
   acc = (
     <span key={Math.random()} style={{ color: randomColor() }}>
       {cur} {acc}
     </span>
   );
   return acc;
 }, <span></span>);
 return <div>{finalList}</div>
})

Legacy模式下,可以看到输入框的输入有点卡顿,主要是整个render/reconcilation任务占用了太多时间导致 然后我们开启Concurrent Mode,发现还是被阻塞了,还是会有一点卡,看performance发现主要是被commitlayout/paint两个流程卡住了,

当然当输入也没有刚好卡在render/reconcilation的分片当中,是不会被render/reconcilation本身阻塞的,所以可以总结: render/reconcilation的分片以及达到了效果,在分片的间隔时间已经可以去插入执行其他优先级更高的用户相应了。 但是,为了更好的演示分片带来的效果,我决定排除commit流程和Layout/Paint重排重绘带来的影响。

抛开Layout/Paint流程和commit流程来看分片带来的performance优化

  1. 抛开重排重绘带来的影响: 直接给List组件外层套一个style={{ display: 'none' }}, 这样render/reconcilation阶段完成之后就不会进行重排重绘了,但是生成的Fiber还是会生成,只不过最后commit到DOM上也不会渲染
<section style={{ display: 'none' }}>
  <List />
</section>

为了效果更明显,我直接拷贝多了两个List, 并且每个List增加到3000条,

const List = React.memo(function List (props) {
  const [randomArray, setRandomArray] = useState(
    Array.from({ length: 3000 }, () => Math.random())
  );

  useEffect(() => {
    changeRandom()
 }, []);

 const changeRandom = () => {
   setRandomArray(randomSort(randomArray));
   cancelAnimationFrame(raf);
   raf = requestAnimationFrame(changeRandom);
 };

 const finalList = randomArray.reduce((acc, cur) => {
   acc = (
     <span key={Math.random()} style={{ color: randomColor() }}>
       {cur} {acc}
     </span>
   );
   return acc;
 }, <span></span>);

 return <div>{finalList}{finalList1}{finalList2}</div>

})

这样, 在Legacy模式下, 我们看到performance里面,已经没有Layout/Paint这样的任务来阻碍我们的输入了,只剩下commit,现在加大List数量的情况下,render/reconcilation大概阻塞了500多ms, 很卡。

这时候再看Concurrent Mode,很流畅,commitRoot直接没有了(也算是Concurrent Mode一个优化吧,对于display:none来说,本身commit就没有必要)

总结及Concurrent Mode的其他Features

当然我们举了很夸张的例子(深节点,移除重排重绘)来单独看Concurrent Mode模式下对于render/reconcilation带来的优化效果。当然分片只是Fiber的一小部分功能,Fiber架构解锁了很多Concurrent Mode的新功能: <SuspenseList>, useTransition, useDeferredValue等等,当然这些暂时是试用性的。 在我们的demo中,可以使用useDeferredValue来做state的延迟,比如我们轮训获取到了实时的长列表,但是又不想阻塞输入框等用户的操作,我们可以将旧的stateuseDeferredValue暂时存起来,然后将旧版的state传给带memo的组件,这时候我们通过降低了列表的时效性来换取了用户交互体验的提升,而且我们原state永远是最新的,所以跟增大轮询时间又不太一样。 总之,Concurrent Mode解锁了很多新的功能,当然有些是试用性的,但是可以期待当Concurrent Mode正式使用的时候,新特性给性能和用户体验带来的提升。

欢迎大家留言讨论,祝工作顺利、生活愉快!

我是bigo前端,下期见。