React 中的并发渲染

浏览量:949

引子

React 在 3 月 29 日发布了 v18,作为沉寂已久的大版本更新,react 18 起着面向未来的重大任务,并发渲染也正式来到了 production 环境。

作为最重要的更新之一以及面向「未来」的特性,它是什么,又对开发或者产品又什么影响?让我们一起来看看。

并发渲染的前世今生

并发渲染的这一概念并不是刚刚提出的,了解 react 的开发者应该都很清楚,在 React 16 时已经提出了这一概念(2018 React Conf

同时在 16.x 的小更新中已经包含了并发模式(Concurrent mode),允许开发者有办法使用这一新特性,而它仅仅比 hooks 晚了几个月(来自于building-great-user-experiences-with-concurrent-mode-and-suspense)。

并发渲染是什么

让我们从浏览器的渲染进程开始聊起。

浏览器的渲染进程是多线程的,通常有常驻的 GUI 渲染线程、JS 引擎线程、事件触发线程、定时器线程等。

其中,浏览器的渲染线程与 JS 引擎线程是互斥关系,想想就很容易明白,如果它们两个是可以同时运行的,那么渲染进程正在绘制元素数据时让 JS 操作 DOM,最终渲染的数据以哪一份为准呢?

而事件触发线程与定时器线程中的待处理任务都需要在 JS 线程空闲时才能进行处理。

由上面两条规则可以看出,当 JS 线程处理任务造成阻塞时,会造成整个页面的阻塞,反映到用户眼中,就是卡顿。

而作为对 JS 线程强依赖的库,React 的所有计算及操作都会在 JS 引擎上执行。当计算量较大或者某些操作引起渲染阻塞时,JS 引擎无法有空闲去处理那些需要及时响应的工作,比如事件的处理。

这种无响应对于用户体验来说是致命的。

React 想要解决的就是这一点,所以带来了并发渲染。

JS 引擎无法实现实际意义上的并发,所以 React 退而求其次,它让任务是可中断、可插队、可恢复的,这样可以在尽量保证 JS 线程不被长时间占用,不让任务一直执行下去,以让其优先响应更高优先级的任务(如用户交互、动画等)。

而作为构建界面的 JS 库,大部分卡顿的来源都是在数据到节点的「渲染」环节。需要注意的是,渲染并不是指的浏览器渲染进程的对节点渲染,React 作为 state=>UI 的映射模式,再怎么火爆或者流行也无法影响到浏览器的架构。并发渲染中的「渲染」指的是 React 中 diff 算法更新 VDOM(Virtual Dom) 的工作,而不是 VDOM 实际映射为 DOM 节点的工作(如果映射 DOM 节点也可以中断和回退,那页面不就乱了嘛)。

说了这么多,简单来说并发渲染就是 React 全新的更新 VDOM 的机制,而它的目的只有一个,改善用户体验,尽可能的避免卡顿或无响应的情况。

原理的简单探寻

明白了并发渲染是什么且它做了什么,我们就可以思考它是如何做的。

控制 JS 执行时间而不阻塞其他任务这个需求很容易就会想到 requestIdleCallback API,requestIdleCallback 能够充分利用帧与帧之间的空闲时间来执行 JS,甚至还能设置超时时间防止任务一直未被执行。

看起来似乎浏览器可以处理好一切,我们可以直接使用,但 requestIdleCallback 有两个致命的问题:

  1. 兼容性太差
  2. requestIdleCallback 实际执行在浏览器进行渲染之后,如果再进行 DOM 变更,会重新触发渲染。

所以 React 团队使用了 requestAnimationFrame 来实现时间分片,通过刷新频率来获取一帧内 JS 可执行的最大时间。

Fiber 树的更新流程分为两个阶段:
a. Render,也就是对 VDOM 的计算
b. commit,也就将是 VDOM 的更新应用于真实 DOM 节点上。

其中 render 阶段是纯粹的 JS 计算,且不会产生副作用,React 所中断的任务就在这里;同时,React 引入了调度系统 – Scheduler, 来根据任务的优先级完成任务的调度。碍于篇幅,这里就不过多的深入原理了。

让我们一起来看一看如何使用并发渲染这一特性以及实际效果。

怎么使用

启用并发模式 – react 16.x/ 17 – Concurrent Mode

在 react 16.x/ 17 中需要开启并发模式才能使用并发渲染,需要安装 react, react-dom 为 experimental 版本:

npm install react@experimental react-dom@experimental。

使用并发模式:
并发模式是对 react 工作方式的完全更改,所以需要改变入口 API, 使用 createRoot 方法替换 render 方法。

import React from 'react';
import { createRoot } from 'react-dom';

createRoot(document.getElementById('root')).render(<App/>);

直接使用 – react 18 – Concurrent Features

React 18 中放弃了 需要开启的「模式」,统一使用 ReactDOM.createRoot 来创建应用。

当使用并发特性时,React 会自动的让 Fiber 采用并发渲染。

代码中使用并发特性

useTransition

useTransition hooks 返回一个标识 state 更新状态的值与一个 function,function 提供一个回调函数,让状态更新在回调中进行。

回调中的状态更新,都会以时间切片的方式来进行,同时它的渲染优先级会被降低,即它会等待其他渲染完成后才进行更新,有点类似给渲染数据手动添加的节流或防抖。

但它不会等待固定的时间去执行,而是会在线程空闲时再去执行,效果会更好些。

效果可以参考这个例子

当数据量较大时,使用 useTransition 明显感觉到字符 highlight 的速度要慢些,同时输入可以流畅进行;而不使用 useTransition 时,highlight 会造成非常明显的卡顿。

useDeferredValue

useDeferredValue 的作用 useTransition 接近,但它不关心数据来源。

useDeferredValue 接受一个值然后返回一个新值,当使用这个新值作为渲染数据时,效果与使用 useTransition 相同。

需要注意的是,组件仅仅是延迟了更新,如需缓存还需要搭配 React.memo 使用。

Suspense

Suspense 应该是并发渲染的副作用所带来的更新。

它与并发渲染的核心功能无关,它可以让组件在渲染前等待一段时间,同时在此之前加载一个占位符组件。它可以将组件读取数据与加载状态进行分离,组件逻辑只需要关心有数据的渲染结果即可。

可以参考官方例子来看一下效果:

留下评论