概述
React 团队2022年3月发布了 React 18 版本。这个版本主要是增强 React 应用程序的
并发渲染
能力,和对现有功能开箱即用的改进 , 使得我们可以以 React 以前不允许的方式改善用户体验。
介绍总览
- 理解并发
- 并发功能
new Suspense Feature
:Suspense支持SSR,让应用更快的加载和交互
startTransition:
状态转换期间保持 UI 响应
useDeferredValue:
推迟更新屏幕上不太重要的部分
- 开箱即用的改进:
- automatic batching(React 18 默认所有的更新都是自动合并且异步处理)
- 严格模式
- StrictMode
- 如何升级
理解并发
React18最重要的一个更新就是并发,新特性如Suspense
、useTransition
、useDeferredValue
的内部原理都是基于并发的,未来将基于并发特性支持更多功能,由此可见理解并发是至关重要的。那什么是并发?React引入并发是为解决什么问题?是如何实现的?
1、什么是并发
- 名词解释:参考Dan的 phone calls example
No concurrency (一个时间段内,只能有一个通话) | Concurrency(一个时间段内,同时存在多个通话) | Parallel (一个时间段内,同时执行多个通话) |
---|---|---|
和Alice通话到一半,此时Bob来电,你助理可帮你处理,说明你支持并行 |
并发的关键是具备处理多个任务的能力,但不是在同一时刻处理,而是交替处理多个任务。
React中的并发是指多个Task跑在同一个主线程(JS主线程)中,只不过每个Task都可以不断在“运行”和“暂停”两种状态之间切换。这其实跟CPU的执行原理一样,这就叫时间分片(Time Slicing)
2、React引入并发是为解决什么问题?
- React中存在一个问题,在React 18之前,除react 事件中的state 更新外,其他state的更新都是同步的,一旦开始更新,就不能中断、停止和丢弃这次更新,这是一个阻塞线程的过程,意味着处理更新只能是一个一个地做。
- v17: in previous versions of React — in a single, uninterrupted, synchronous transaction. With synchronous rendering, once an update starts rendering, nothing can interrupt it until the user can see the result on screen.
- v18:In a concurrent render, React may start rendering an update, pause in the middle, then continue later. It may even abandon an in-progress render altogether。
譬如,当触发一次setState更新,且此次更新需要耗时很长,是一个long task,假设200ms,也就是说这200ms内页面都是卡死的,此时用户点交互操作是无法及时响应。(github.com/w3c/longtas… 根据W3C性能小组的介绍,超过50ms的任务就是长任务)
- 如何解决无法及时响应用户交互行为的问题呢?React给出解决方案:并发。即执行更新事件「低优先级」过程中,出现用户交互行为「高优先级」,中断更新,主动让出JS主线程处理,完成响应后继续执行更新。
目标:改善用户体检,动画和交互能够丝滑般流畅😄
3、如何实现
- 浏览器的一帧里包含了哪些过程?
如图所示一帧中包含了6个步骤:处理用户的交互-> JS 解析执行-> 帧开始(窗口尺寸变更,页面滚动等的处理)->执行 requestAnimationFrame(rAF)->布局->绘制。
以动画渲染为例,如果每秒渲染 60 个新帧,用户就认为动画很流畅,故浏览器将新帧绘制到屏幕所需的时间只有16毫秒(1s / 60 = 16.67ms,其中浏览器需要大约 6 毫秒将新帧绘制到屏幕,故只剩10毫秒来生成一个帧,如果超过10毫秒来生成帧,即输出速度变慢,则会出现页面卡顿)
上图JS主线程运行会产生堆和执行栈,根据浏览器事件循环机制,异步代码会放在回调队列中,执行完栈中的同步代码,才会执行回调队列中的任务队列。回调队列中有宏任务和微任务,一帧里可以按队列先进先出,执行多个宏任务 (下一个宏任务执行前,会把前一个宏任务产生的微任务队列先执行)。宏任务会被浏览器自动调控。比如浏览器如果觉得宏任务执行时间太久,它会将下一个宏任务分配到下一帧中,避免掉帧。并不是每轮event loop都会执行UI Render,浏览器会判断渲染是否会有获益,只有必要时才更新视图,并尽量保证60Hz刷新频率,
浏览器渲染进程是多线程的( 主要包含GUI渲染线程、JS引擎线程、事件线程、定时器触发线程、HTTP 请求线程),JS线程和GUI渲染线程是互斥的,当页面出现用户交互事件,如点击,滚动、触摸时,事件线程会将事件添加到回调队列末尾,等待JS线程空闲后执行。如果长时间执行JS,会导致GUI渲染线程长时间挂起和触发事件不能被响应。(GUI渲染线程: 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等)
所以想要不卡顿,需要控制JS执行时间,不影响后续的Layout和Paint,React中通过将JS执行拆分成时间片(Time Slicing)来执行。比如React 更新需要耗时200ms,可以拆分成40个5毫秒的更新分配在浏览器帧里,如果一帧里有多余时间就执行,没有多余时间就推到下一帧继续执行。不会阻塞每一帧里的其他事件如点击,渲染等,也就可以达到用户可交互的目的。
- 如何拆解长任务?
可以通过requestIdleCallback、setTimeout、MessageChannel等方式将同步代码块,拆分成多个异步代码块。
- 理解requestIdleCallback
如果一帧中还有剩余时间,则会执行requestIdleCallback回调,充分利用帧与帧之间的空闲时间来执行JS,主要用于处理一些耗时但不是那么重要的工作。由于requestIdleCallback是在渲染后执行的,所以适合做JS运算,如果再进行DOM的变更,会导致重新触发layout和paint,使得帧时间不可控。另外requestIdleCallback存在兼容问题和触发频率不稳定问题,故React中并没有使用该方式
// requestIdleCallback 简单示例
const tasks=Array(5).fill(1)
// 浏览器执行线程空闲时间调用 myWork,超过 2000ms 后立即必须执行
requestIdleCallback(myWork, { timeout: 2000 });
function myWork(deadline) {
// 如果有剩余时间,或者任务已经超时,并且存在任务就需要执行,deadline.timeRemaining最长时间是 50ms
while ((deadline.timeRemaining() > 0 || deadline.didTimeout)
&& tasks.length > 0) {
tasks.shift()
console.log('working')
}
// 当前存在任务,再次调用 requestIdleCallback,会在空闲时间执行 myWork
if (tasks.length > 0) {
requestIdleCallback(myWork, { timeout: 2000 });
}
}
- 理解MessageChannel
该API可以创建一个新的消息通道,并通过它的两个port 属性发送数据。 MessageChannel在浏览器事件循环中属于宏任务。(MessageChannel可直接理解成setTimeout,只不过它性能更好,setTimeout有4ms的gap),React内部基于MessageChannel
来实现时间片,位于Scheduler库中)
// MessageChannel 简单示例
const channel = new MessageChannel();
const {port1 , port2}=channel
port1.onmessage = function(event) {
console.log("port1收到来自port2的数据:" + event.data);
}
port2.onmessage = function(event) {
console.log("port2收到来自port1的数据:" + event.data);
}
port1.postMessage("发送给port2");
port2.postMessage("发送给port1");
- Scheduler库内部具体实现细节
scheduler内部实现涉及 react-dom、react-reconciler,关系如下所示。主要涉及两大循环: 构建fiber树循环以及任务调度循环。 (更详解参考:7kms.github.io/react-illus…)
scheduler调度过程:
1、每次更新执行都会在scheduler中注册一个task,加入到调度任务队列(taskQueue)中。(task中存有任务的优先级、回调函数、过期时间等)
2、请求回调,通过MessageChannel
发消息的方式触发performWorkUntilDeadline
函数, 最后执行回调scheduledHostCallback
,scheduledHostCallback
中实际执行的是workLoop
,workLoop
的作用是循环消费任务队列。如果超时(5ms),停止执行,让出主线程。如果单个task.callback执行时间就很长(假设 200ms). 就需要task.callback 自己能够检测是否超时, 所以在 fiber 树构造过程中, 每构造完成一个单元, 都会检测一次超时, 如遇超时就退出fiber树构造循环(位于workLoopConcurrent函数中), 等待下一次调度。
MessageChannel
在浏览器事件循环中属于宏任务
, 所以调度中心永远是异步执行
回调函数。
// 接收 MessageChannel 消息
const performWorkUntilDeadline = () => {
if (scheduledHostCallback !== null) {
const currentTime = getCurrentTime(); // 获取当前时间
const hasTimeRemaining = true;
let hasMoreWork = true;
try {
// 执行回调, 返回是否有还有剩余任务
hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
} finally {
if (hasMoreWork) {
schedulePerformWorkUntilDeadline(); // 有剩余任务或执行scheduledHostCallback失败, 发起新的调度
} else {
isMessageLoopRunning = false;
scheduledHostCallback = null;
}
}
} else {
isMessageLoopRunning = false;
}
};
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// DOM and Worker environments.
// We prefer MessageChannel because of the 4ms setTimeout clamping.
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
// 请求回调, 此处的callback即包含了workLoopConcurrent函数:循环更新fiber节点
function requestHostCallback(callback) {
scheduledHostCallback = callback;
if (!isMessageLoopRunning) {
isMessageLoopRunning = true;
schedulePerformWorkUntilDeadline();
}
}
// 取消回调
cancelHostCallback = function() {
scheduledHostCallback = null;
};
// 循环消费任务队列
function workLoop(hasTimeRemaining: boolean, initialTime: number) {
let currentTime = initialTime;// 保存当前时间, 用于判断任务是否过期
currentTask = peek(taskQueue);// 获取队列中的第一个任务
while (
currentTask !== null
) {
if (
currentTask.expirationTime > currentTime &&
(!hasTimeRemaining || shouldYieldToHost())
) {
// 虽然currentTask没有过期, 但是执行时间超过了限制(毕竟只有5ms, shouldYieldToHost()返回true). 停止继续执行, 让出主线程
break;
}
const callback = currentTask.callback;
if (typeof callback === 'function') {
currentTask.callback = null;
currentPriorityLevel = currentTask.priorityLevel;
const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
// 执行回调
const continuationCallback = callback(didUserCallbackTimeout);
currentTime = getCurrentTime();
// 回调完成, 判断是否还有连续(派生)回调
if (typeof continuationCallback === 'function') {
// 产生了连续回调(如fiber树太大, 出现了中断渲染), 保留currentTask
currentTask.callback = continuationCallback;
// 返回true, 等待下次调度
return true;
} else {
if (currentTask === peek(taskQueue)) {
// 把currentTask移出队列
pop(taskQueue);
}
}
} else {
// 如果任务被取消(这时currentTask.callback = null), 将其移出队列
pop(taskQueue);
}
// 更新currentTask
currentTask = peek(taskQueue);
}
/ 如果task队列没有清空, 返回true. 等待调度中心下一次回调
if (currentTask !== null) {
return true;
} else {
return false; // task队列已经清空, 返回false.
}
}
workLoopConcurrent
函数:循环构建fiber节点
// 循环更新fiber节点
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
更新时,在Concurrent
模式下,低优先级更新会进入到workLoopConcurrent
函数,该函数会遍历每个Fiber节点,并对每个fiber节点执行performUnitOfWork
处理,每次执行前会进行shouldYield()
判断。也就是图中3位置。所以每个fiber节点的处理工作是一个最小单元,并发涉及的中断和暂停只能发生在每个fiber节点开始之前(beginWork),或者处理之后(completeWork)。也就是说中断是在render阶段,并非在commit阶段。图4 commitRoot
主要逻辑是处理副作用, 将最新的 fiber 树结构反映到 DOM 上.
中断之后如何从之前的工作中恢复?得益于fiber结构,并通过一个workInProgress的全局变量来保存最近一次的fiber节点。从中断恢复的时候读取这个全局变量,并以这个变量为起点继续完成fiber树的构建。
- 判断是否超时:
shouldYield()
函数
let frameInterval = 5;
function shouldYieldToHost(): boolean {
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// The main thread has only been blocked for a really short amount of time;
// smaller than a single frame. Don't yield yet.
return false;
}
shouldYield()
方法会去判断更新时间是否超过5ms,如果超过让出主线程。
React实现并发,内部逻辑是很复杂的,介绍中只是其中很小一部分,中断后如果确保渲染的准确性,还涉及优先级管理+React双缓存策略。感兴趣的同学可以自行了解。
4、缺点
- Concurrent 对外部数据的影响
-
- Concurrent 是 React 内部的渲染机制,当涉及到外部数据,则会出现内容撕裂(不一致)的问题,而状态管理是典型的外部数据场景。
-
-
- 解决方案:react提供了useSyncExternalStore来返回 immutable 的数据
-
5、总结
- React的并发是通过时间切片的方式实现,使用异步将主线程上的Long Task拆分成5ms的宏任务进行执行,利用浏览器自动调控,将宏任务分配至每一帧中执行,从而不阻塞主线程。意味UI可以立即响应用户的输入,即使它正处于一个大型的渲染任务中,也能有一个流畅的用户体验。
- 并发本身并不是一个功能。它是一种新的幕后机制, 它的价值在于它所解锁的并发功能。
- React 18 最重要的更新是渲染模式的变更— ****渲染可中断
并发功能
Suspense
愿景:Suspense fallback 可以处理任何异步操作(加载代码、数据、图像等)。
- v16/17
1.1 使用场景一:Suspense主要结合React.lazy进行代码分割,如下所示:
通过react.lazy动态加载组件
1.2 使用场景二:指定fallback
属性,用于将读取数据
和指定加载状态
分离(如:组件未ready前,声明式地为组件指定加载态)
Suspense的实现原理通过Suspense所包裹的子组件内部throw一个promise出来,然后被Suspense的componentDidCatch捕获到,在其内部处理这个promise,处于pedding时显示fallback, 加载完成后通过promise.then重新触发更新,显示子组件。 (github.com/demos-platf…)
- v18,未改变Suspense API 本身,改进了其语义和新增了一些新特性 详细介绍
2.1 Behavior change: Committed trees are always consistent
2.2 悬停组件的父和兄弟组件渲染会被废弃,只有等悬停组件加载完成后,统一commit,再执行相关副作用。
2.3 New feature: Server-side rendering support with streaming
2.4 支持SSR 下的懒加载支持
2.5 更快的呈现内容和更快的可交互 github.com/reactwg/rea… Demo
SSR: fetch data (server) → render to HTML (server) → load code (client) → hydrate (client)
v18之前,SSR需要等页面生成好完整html后,返回给客服端,如果其中一个组件A在服务端请求数据时间很长将会阻塞整个页面的生成。在v18中,使用Suspense包裹组件A,页面中的A组件可以使用一个占位,如loading,不等待A组件生成,将带有loading的html发送给客户端,等A组件请求完数据和加载完成后,再发送客户端替换loading,并会内联一段js使其放在正确位置。同时A组件也不会阻塞页面的hydrate,意味着可以更快的可交互。
- New feature: Using transitions to avoid hiding existing content
3.1 状态转换期间保持 UI 响应。如下示例中,当切换tab为comments时,Comments组件被挂起,展示Spinner组件,这对于用户来说是合理的,但有时候,当新UI正在准备时,展示旧UI 比展示Spinner组件是更合理的,我们就可以结合startTransition来实现,
function handleClick() {
setTab('comments');
}
<Suspense fallback={<Spinner />}>
{tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
===============结合startTransition===============
function handleClick() {
startTransition(() => { //startTransition 的作用是将切换操作标记为为非紧急更新,React会预留时间保持旧UI,等新UI准备好后,再切换至新UI
setTab('comments');
});
}
<Suspense fallback={<Spinner />}>
{tab === 'photos' ? <Photos /> : <Comments />}
</Suspense>
===============结合useTransition===============
const [isPending, startTransition] = useTransition(); //useTransition比startTransition多提供了一个isPending,用于感知是否处于transition状态中
function handleClick() {
startTransition(() => {
setTab('comments');
});
}
<Suspense fallback={<Spinner />}>
<div style={{ opacity: isPending ? 0.8 : 1 }}>
{tab === 'photos' ? <Photos /> : <Comments />}
</div>
</Suspense>
- Behavior change: Layout effects re-run when content reappears
- Suspense内部组件在隐藏和展示切换中会多次执行layout effect。layout effect中可能存在跟布局相关的逻辑,展示和隐藏的过程中涉及布局变动,为了保证渲染的一致性,再次展示组件时需要再次执行layout effect。
- 缺点:对存量代码,要确保Suspense内部组件中的layout effects在多次执行的情况下都能正确渲染。——可以开启严格模式,提早发现问题。
- 补充: useEffect是异步执行,执行时机是页面渲染完成后,useLayoutEffect是同步执行的,是在浏览器把内容真正渲染到界面之前,和componentDidMount等价
Transition
A transition is a new concept in React to distinguish between urgent and non-urgent updates.
非紧急更新可以称为 transitions,startTransition
是 React 18 新增加的一个 API,它可以让你区分 非紧急
的状态更新
举个常见例子:搜索框检索商品场景,对于用户来说,输入框内容展示是紧急状态更新,而查询结果是非紧急状态更新,
import { startTransition } from 'react';
// urgent: 输入内容
setInputValue(input);
// non-urgent: 结果展示
startTransition(() => {
setSearchQuery(input);
});
// React 还给我们提供了一个带有 isPending 过渡标志的 Hook
import { useTransition } from 'react' ;
const [ isPending , startTransition ] = useTransition ( ) ;
- startTransition executes immediately, unlike setTimeout.
- setTimeout has a guaranteed delay, whereas startTransition's delay depends on the speed of the device, and other urgent renders.
- startTransition updates can be interrupted unlike setTimeout and won't freeze the page.
- React can track the pending state for you when marked with startTransition.
useDeferredValue: 是对输入值进行defer渲染,和useTransition 都是降低状态更新的优先级。具体使用详见:beta.reactjs.org/reference/r…
批处理
Batching is when React groups multiple state updates into a single re-render for better performance
React 有一道经典面试题,setState
到底是同步的还是异步的?
- React 17-> we only batched updates inside React event handlers. Updates inside of promises, setTimeout, native event handlers, or any other event were not batched in React by default.
- React 18-> Automatic Batching
- 实现的本质: React will wait for a micro-task to finish before re-rendering.
- 升级18后,Automatic Batching是开箱即用的功能,可以通过
ReactDOM.flushSync()
退出批处理
//v17 re-rendered once at the end
const handleClick = () => {
setIsFetching(false);
setError(null);
setFormStatus('success')
}
//v17 Total 3 re-renders
//v18 re-rendered once at the end
fetch('/api').then( () => {
setIsFetching(false);
setError(null);
setFormStatus('success')
});
严格模式
New Strict Mode Behaviors- reusable state
- 严格模式在React 16.3就已经发布,当时是为了给实验中的并发渲染做准备,识别出可能有问题的代码。React 18 中,StrictMode 增加了对 Strict Effects 的支持。即开启严格模式,React会默认给加载的组件执行两次effects (
mount
->unmount
->mount
) , 同其他严格模式下的行为一样,该行为只会在开发模式下执行,生产环境仍执行一次。
import React from 'react';
function ExampleApplication() {
return (
<div>
<Header />
// 用React.StrictMode包裹组件,将对包裹的组件开启严格模式
<React.StrictMode>
<div>
<ComponentOne />
<ComponentTwo />
</div>
</React.StrictMode>
<Footer />
</div>
);
}
为什么要添加reusable state?因为react中提供的一些特性中需要满足组件多次"mounted" and "unmounted" 。比如Fast Refresh,该功能默认在next、 Create React App、 RN中开启,每次保存文件,effects将会re-run。以及即将支持的“Offscreen” API也需要满足这一条件。
- “Offscreen” API:
-
- allow React to preserve state by hiding components instead of unmounting them.To do this React will call the same lifecycle hooks as it does when unmounting– but it will also preserve the state of both React components and DOM elements.
-
- 目前我们想要保留state,只能进行状态提升到父元素或利用redux等store库存储。无法做到卸载的同时保留state。
-
- 能更好地支持tab场景和虚拟列表场景
开启严格模式将会识别出如下问题 : reactjs.org/docs/strict…
- 识别不安全的生命周期,如
componentWillMount、componentWillReceiveProps、componentWillUpdate
- 识别string ref 的使用
<input type='text' ref='titleRef'/>
,需改为Callback Refs
或React.createRef
方式
- 识别已废弃的findDOMNode ,可使用ref解决
- 识别非预期的副作用
- 多次执行Render phase lifecycles
- 幂等性:对于同一个系统,在同样条件下,一次请求和重复多次请求对资源的影响是一致的,就称该操作为幂等的。
- 识别legacy context API
- 确保state可重复使用
如何升级
- 升级react和react-dom至v18版本,绝大多数情况下,老代码依然可以在 React 18 中正确运行。
- shipping React 17 inside React 18 as
ReactDOM.render
and adding the React 18 behaviors only toReactDOM.createRoot
2、如果想要使用新的并发特性(Concurrent Feature),需要使用新的 createRoot API 。
- render->createRoot,createRoot是开启“并发模式”的开关
- 开启后,”并发模式“只会在使用并发特性的地方进行并发渲染,所以说 "there is no concurrent mode, there are only concurrent features".
3、 开启严格模式—— React.StrickMode组件包裹组件开启严格模式
4、修复页面warning
参考文档
www.freecodecamp.org/news/react-…
zhuanlan.zhihu.com/p/503521680