一、 Render API
众所周知,react17 提供了三种入口模式
- legacy 模式: ReactDOM.render(, rootNode)。没有开启新功能,这是 react17 采用的默认模式。
- blocking 模式: ReactDOM.createBlockingRoot(rootNode).render()。作为迁移到 concurrent 模式的过渡模式。
- concurrent 模式: ReactDOM.createRoot(rootNode).render()。这个模式开启了所有的新功能。 react18 正式迁移到了 concurrent 模式,同时,用户也可以继续使用 react17 下的旧 API(但是会有警告提示)。
// React 17
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
const root = document.getElementById("root")!;
ReactDOM.render(<App />, root);
// React 18
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
const root = document.getElementById("root")!;
ReactDOM.createRoot(root).render(<App />);
二、 Automatic Batching
批处理是 react 将多个状态更新分组到一个渲染中以获得更好的性能。react18 之前只能在 react 事件处理程序中批处理更新。默认情况下,Promise、setTimeout、本机事件处理程序或任何其他事件内部的更新不会在 React 中批处理。使用自动批处理,这些更新将自动批处理:
//示例一:react17会render三次,react18只需要render两次,setTimeout内部批量更新
const handleClick = async () => {
setTimeout(() => {
setC1((c) => c + 1);
setC1((c) => c + 1);
}, 0);
setC2((c) => c + 1);
};
//示例二:react18中也需要render两次
const handleClick = async () => {
await setC1((c) => c + 1); //提升到同步优先级,类似flushSync
setC2((c) => c + 1);
};
这里做了两个 demo,大家可以进去试试看看,根据打印次数判断渲染次数。
react17-demo:react17-demo - CodeSandbox
react18-demo:react18-demo - CodeSandbox
那么,如果我不想要批处理呢?
flushSync
官方提供了一个 API flushSync
用于退出批处理
function handleClick() {
flushSync(() => {
setC1((c) => c + 1);
});
setC2((c) => c + 1);
}
flushSync
会以函数为作用域,函数内部的多个 setState
仍然为批量更新,这样可以精准控制哪些不需要的批量。
实现
自动批处理的实现在 React18 中是基于优先级的,用lane来进行优先级的控制。先简单介绍一下 lane。
lane
是一个表示 priority 的一个东西,它通过二进制位来表示。优先级最高的SyncLane
为 1,其次为 2、4、8 等等,所有 lane 的定义可参考源码。
react 通过 lanes
表达批量更新。lanes
是一个整数,该整数所有二进制位为 1 对应的优先级任务都将被执行。例如 lanes 为 17 (10001)时,表示将异步并行更新SyncLane
(值为 1)和DefaultLane
(值为 16)的任务。
个人理解:lane 用来弥补 expirationTime 的缺陷,它首先说明这个任务 是个什么任务(确定优先级,确定 lane 值) ,其次说明哪些任务应该被 batching 到一起做(lane 相同即 batching 到一起做) 。然后通过 lanes 确定哪些并行更新。
下面结合源码看一下批量更新的实现,该方法是 18 中每一次更新调度的必经之路,批处理的实现的核心在于当相同优先级的更新发生时,并不会生成新的任务,而是复用上一次的任务,从而实现合并。 👉为了便于理解,对源码做了一定程度的简化,下同
function ensureRootIsScheduled(root, currentTime) {
......
// Determine the next lanes to work on, and their priority.
var nextLanes = getNextLanes(root, root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes);
// This returns the priority level computed during the `getNextLanes` call.
var newCallbackPriority = returnNextLanesPriority();
// Check if there's an existing task. We may be able to reuse it.
if (existingCallbackNode !== null) {
var existingCallbackPriority = root.callbackPriority;
// 👇以下判断是关键逻辑
if (existingCallbackPriority === newCallbackPriority) {
// The priority hasn't changed. We can reuse the existing task. Exit.
return ;
}
// The priority changed. Cancel the existing callback. We'll schedule a new
// one below.
cancelCallback(existingCallbackNode);
}
// Schedule a new callback.
var newCallbackNode;
......
root.callbackPriority = newCallbackPriority;
root.callbackNode = newCallbackNode;
} // This is the entry point for every concurrent task, i.e. anything that
// goes through Scheduler.
再看看FlushSync
的实现:
export function flushSync(fn) {
try {
// DiscreteEventPriority === SyncLane
setCurrentUpdatePriority(DiscreteEventPriority);
fn && fn();
} finally {
setCurrentUpdatePriority(previousPriority);
}
}
其实是将内部更新的优先级强制指定为SyncLane
,即指定为同步优先级,具体效果就是每一次更新时都会同步的执行渲染。
三、Transitions
过渡是 React 18 中的一个新概念,用于区分紧急和非紧急更新。
紧急更新反映了直接交互,例如键入、单击、按下等。
非紧急(过渡)更新将 UI 从一个视图转换到另一个视图。
打字、点击或按下等紧急更新需要立即响应,以符合我们对物理对象行为方式的直觉。否则用户会觉得“不对劲”。但是,过渡是不同的,因为用户不希望在屏幕上看到每个中间值。
下面我们来看一个例子:当滑块滑动时,下方的图表会一起更新,然而图表更新是一个 CPU 密集型操作,比较耗时。由于阻塞了渲染导致页面失去响应,用户能够非常明显的感受到卡顿。
实际上,当我们拖动滑块的时候,需要做两次更新:
// Urgent: Show what was typed
setSliderValue(input);
// Not urgent: Show the results
setGraphValue(input);
startTransition
包装在 startTransition 中的更新被视为非紧急更新,如果出现更紧急的更新(如点击或按键),则会中断。
import { startTransition } from "react";
// Urgent
setSliderValue(input);
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results
setGraphValue(input);
});
使用后效果:
应该可以明显的感受到,虽然图表的更新还是会有些延迟,但是整体的用户体验相对之前是非常好的。
useTransition
一般情况下,我们可能需要通知用户后台正在工作。为此提供了一个带有 isPending
转换标志的 useTransition
,React
将在状态转换期间提供视觉反馈,并在转换发生时保持浏览器响应。
import { useTransition } from "react";
const [isPending, startTransition] = useTransition();
return isPending && <Spin />;
useDeferredValue
返回一个延迟响应的值,可以让一个state
延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValue
和 useTransition
一样,都是标记了一次非紧急更新。
import { useDeferredValue } from 'react';
const deferredValue = useDeferredValue(value);
useDeferredValue
与 useTransition
其实挺相似的:
相同:
useDeferredValue
本质上和内部实现与useTransition
一样都是标记成了非紧急更新任务。不同:
useTransition
是把更新任务变成了延迟更新任务,而useDeferredValue
是产生一个新的值,这个值作为延时状态。
同 debounce
的区别:
debounce
即 setTimeout
总是会有一个固定的延迟,而 useDeferredValue
的值只会在渲染耗费的时间下滞后,在性能好的机器上,延迟会变少,反之则变长。
实现
// startTransition
function startTransition(setPending, callback, options) {
......
setPending(true);
var prevTransition = ReactCurrentBatchConfig$2.transition;
// start transition
ReactCurrentBatchConfig$ 2. transition = {};
var currentTransition = ReactCurrentBatchConfig$2.transition;
try {
setPending(false);
callback();
} finally {
// Recovery
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig$2.transition = prevTransition;
}
}
}
export function requestUpdateLane(fiber: Fiber) {
// ...
// requestCurrentTransition => ReactCurrentBatchConfig.transition
const isTransition = requestCurrentTransition() !== null ;
if (isTransition) {
return claimNextTransitionLane();
}
// ...
}
startTransition
内部时会使用一个全局变量ReactCurrentBatchConfig$2.transition
作为是否开启transition
的开关,后边的setPending(false)
和callback()
在触发dispatchAction()
的时候会调用requestUpdateLane
,requestUpdateLane
返回的是isTransition
任务。优先级下降,因此执行时间要比普通更新晚,同时即使更新发生时,也可以被高优先级的更新打断,从而不阻塞用户渲染。
// useTransition
function mountTransition() {
var _mountState = mountState(false),
isPending = _mountState[0],
setPending = _mountState[1]; // The `start` method never changes.
var start = startTransition.bind(null, setPending);
var hook = mountWorkInProgressHook();
hook.memoizedState = start;
return [isPending, start];
}
useTransition
的核心其实就是通过useState
维护了一个pending
,然后将setPending
作为参数传递给startTransition
。
// useDeferredValue
function updateDeferredValue(value) {
var hook = updateWorkInProgressHook();
var resolvedCurrentHook = currentHook;
var prevValue = resolvedCurrentHook.memoizedState;
return updateDeferredValueImpl(hook, prevValue, value);
}
function updateDeferredValueImpl(hook, prevValue, value) {
var shouldDeferValue = !includesOnlyNonUrgentLanes(renderLanes);
if (shouldDeferValue) {
// This is an urgent update. If the value has changed, keep using the
// previous value and spawn a deferred render to update it later.
if (!objectIs(value, prevValue)) {
var deferredLane = claimNextTransitionLane();
currentlyRenderingFiber$1.lanes = mergeLanes(
currentlyRenderingFiber$1.lanes,
deferredLane
);
markSkippedUpdateLanes(deferredLane);
hook.baseState = true;
}
// Reuse the previous value
return prevValue;
} else {
// This is not an urgent update, so we can use the latest value
if (hook.baseState) {
// Flip this back to false.
hook.baseState = false;
markWorkInProgressReceivedUpdate();
}
hook.memoizedState = value;
return value;
}
}
useDeferredValue
的实现首先是判断当前更新的优先级,如果是一个紧急更新则直接返回prevValue
,并且在当前fiber
中标记一个transition
更新。当非紧急更新发生时,直接返回最新的值。
四、useId
useId
是一个新的 hook,用于在客户端和服务器上生成唯一 ID,同时避免 hydration mismatches。
我们首先介绍一下 SSR 的流程:
在服务端,我们会将 React 组件渲染成为一个字符串,这个过程叫做脱水「 dehydrate 」。字符串以 html 的形式传送给客户端,作为首屏直出的内容。到了客户端之后,React 还需要对该组件重新激活,用于参与新的渲染更新等过程中,这个过程叫做「 hydrate 」。
当我们在使用 React 进行服务端渲染(SSR)时就会遇到一个问题:如果当前组件已经在服务端渲染过了,但是在客户端我们并没有什么手段知道这个事情,于是客户端还会重新再渲染一次,这样就造成了冗余的渲染。
因此,react18 提出了 useId 这个 hook 来解决这个问题,它使用组件的树状结构(在客户端和服务端都绝对稳定)来生成 id。
实现
function useId() {
var task = currentlyRenderingTask;
// 获取组件结构以生成ID
var treeId = getTreeId(task.treeContext);
var responseState = currentResponseState;
if (responseState === null) {
throw new Error(
"Invalid hook call. Hooks can only be called inside of the body of a function component."
);
}
var localId = localIdCounter++;
return makeId(responseState, treeId, localId);
}
五、Suspense
SSR
React 18 在服务器上添加了对 Suspense 的支持,并使用并发渲染特性扩展了它的功能。
流式 HTML 让你尽早开始发送 HTML,流式 HTML 的额外内容与
<script>
标签一起放在正确的地方。选择性 hydration 让你在 HTML 和 JavaScript 代码完全下载之前,尽早开始为你的应用程序进行 hydration。它还优先为用户正在互动的部分进行 hydration,创造一种即时 hydration 的错觉。
想更加深入了解原理可参考大佬的文章:React 18 中新的 Suspense SSR 架构 - 掘金
transition
function handleClick() {
setTab("comments");
}
<Suspense fallback={<Spinner />}>
{tab === "photos" ? <Photos /> : <Comments />}
</Suspense>;
在这个示例中,如果tab
从 'photos'
设置为 'comments'
,但Comments
暂停,用户将看到一个 Spinner 。因为用户不想再看到Photos
,Comments
还没有准备好渲染任何东西,而 React 需要保持用户体验一致,所以它只能显示Spinner
上面的内容。
但是,有时这种用户体验并不理想(此时用户无法进行交互)。有时在准备新 UI 时显示“旧” UI 会更好。你可以结合useTransition
让 React
做到这一点:
const [isPending, startTransition] = useTransition();
function handleClick() {
startTransition(() => {
setTab("comments");
});
}
<Suspense fallback={<Spinner />}>
<div style={{ opacity: isPending ? 0.8 : 1 }}>
{tab === "photos" ? <Photos /> : <Comments />}
</div>
</Suspense>;
在这个示例中,我们可以使用isPending
它来向用户反映正在发生的事情。UI 保持完全交互——例如,用户可以根据需要切换回'photos'
选项卡。
可以在 demo 中试试看:codesandbox.io/s/react18-s…
六、useSyncExternalStore
useSyncExternalStore
是由 useMutableSource
改变而来,主要用来解决外部数据 tearing(撕裂)问题。
useSyncExternalStore
旨在供库使用,而不是应用程序代码。
tearing
Screen tearing is a visual artifact in video display where a display device shows information from multiple frames in a single screen draw - wiki
简单的说,就是在屏幕上看到了同一个物体的不同帧的影像,画面仿佛是“撕裂的”,对应的 react 中,指使用了过去版本的状态进行画面渲染引起的 UI 不一致或者崩溃。
引入并发渲染后,渲染是可能被更高优先级的任务中断,这也使得 tearing 成为可能。 但 React 本身对于 state 的更新做了很多的工作来避免这个问题,但是如果我们的依赖了外部的状态,比如 redux,它在控制状态时可能并非直接使用的 React 的 state,而是自己在外部维护了一个 store 对象,脱离了 React 的管理,也就无法依靠 React 自动解决撕裂问题。因此 React 对外提供了这样一个 API。
通过这个 demo 能更好的了解什么是撕裂:useSyncExternalStore demo - CodeSandbox
七、useInsertionEffect
useInsertionEffect
应该仅限于 css-in-js 库作者。
useInsertionEffect
是一个新的钩子,它允许 CSS-in-JS 库解决在渲染中注入样式的性能问题。 这个 Hooks 执行时机在 DOM
生成之后,useLayoutEffect
之前,它的工作原理大致和 useLayoutEffect
相同,只是此时无法访问 DOM
节点的引用,一般用于提前注入 <style>
脚本。
八、一点实践
Table 一页展示 2224 条数据
未使用 react18 的 transition 特性
可以从执行堆栈图看到,由于同时渲染的组件过多,JS 执行时间为5.72s(js 代码阻塞时长)
使用 react18 的 transition 特性(useTransiton or useDeferredValue)
// useTransiton
const [isPending, startTransition] = useTransition();
const getList = async () => {
const res: IRes = await request.get({...});
const list = res?.Response?.Data;
startTransition( () => {
setList(list as IDetail[]);
});
};
//useDeferredValue
const deferredList = useDeferredValue(list);
// 以上两种方式二选一
可以看到原来的一个长任务,被拆分成了许多5ms左右的短任务(时间分片)和一个长任务,这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性,最终还是有一个2.27s的长任务。
时间分片:它的本质就是将长任务分割为一个个执行时间很短的任务,然后再一个个地执行。
这里有一个疑问:为什么最后还是有一个长任务?
这是为了防止某次更新由于优先级过低,一直无法执行,React 有个「过期机制」:每个更新都有个过期时间,如果在过期时间内都没有执行,那么他就会过期。 过期后的更新会同步执行(也就是说他的优先级变得和 SyncLane 一样),表现为最后一个长任务。
总结
盘点了一下 react18 的新特性以及简单剖析了一下各自的源码实现,如有错误,欢迎大家指正 😃
参考文档
作者:大 Y 回家吃饭 链接:https://juejin.cn/post/7118671121251057700 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。