跳到主要内容

一、 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 密集型操作,比较耗时。由于阻塞了渲染导致页面失去响应,用户能够非常明显的感受到卡顿。

p_.gif

实际上,当我们拖动滑块的时候,需要做两次更新:

// 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);
});

使用后效果:

222_.gif 应该可以明显的感受到,虽然图表的更新还是会有些延迟,但是整体的用户体验相对之前是非常好的。

useTransition

一般情况下,我们可能需要通知用户后台正在工作。为此提供了一个带有 isPending 转换标志的 useTransitionReact 将在状态转换期间提供视觉反馈,并在转换发生时保持浏览器响应。

import { useTransition } from "react";

const [isPending, startTransition] = useTransition();

return isPending && <Spin />;

useDeferredValue

返回一个延迟响应的值,可以让一个state 延迟生效,只有当前没有紧急更新时,该值才会变为最新值。useDeferredValueuseTransition 一样,都是标记了一次非紧急更新。

import { useDeferredValue } from 'react';

const deferredValue = useDeferredValue(value);

useDeferredValueuseTransition 其实挺相似的:

  • 相同:useDeferredValue 本质上和内部实现与 useTransition 一样都是标记成了非紧急更新任务。

  • 不同:useTransition 是把更新任务变成了延迟更新任务,而 useDeferredValue 是产生一个新的值,这个值作为延时状态。

debounce 的区别:

debouncesetTimeout 总是会有一个固定的延迟,而 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()的时候会调用requestUpdateLanerequestUpdateLane返回的是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 」。

20230301153259

当我们在使用 React 进行服务端渲染(SSR)时就会遇到一个问题:如果当前组件已经在服务端渲染过了,但是在客户端我们并没有什么手段知道这个事情,于是客户端还会重新再渲染一次,这样就造成了冗余的渲染。

因此,react18 提出了 useId 这个 hook 来解决这个问题,它使用组件的树状结构(在客户端和服务端都绝对稳定)来生成 id。

20230301153305

实现

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 的支持,并使用并发渲染特性扩展了它的功能。

  1. 流式 HTML 让你尽早开始发送 HTML,流式 HTML 的额外内容与 <script> 标签一起放在正确的地方。

  2. 选择性 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 。因为用户不想再看到PhotosComments还没有准备好渲染任何东西,而 React 需要保持用户体验一致,所以它只能显示Spinner上面的内容。

但是,有时这种用户体验并不理想(此时用户无法进行交互)。有时在准备新 UI 时显示“旧” UI 会更好。你可以结合useTransitionReact 做到这一点:

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 代码阻塞时长)

20230301153338

使用 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);
// 以上两种方式二选一

20230301153401 20230301153409

可以看到原来的一个长任务,被拆分成了许多5ms左右的短任务(时间分片)和一个长任务,这样浏览器就有剩余时间执行样式布局样式绘制,减少掉帧的可能性,最终还是有一个2.27s的长任务。

时间分片:它的本质就是将长任务分割为一个个执行时间很短的任务,然后再一个个地执行。

这里有一个疑问:为什么最后还是有一个长任务?

这是为了防止某次更新由于优先级过低,一直无法执行,React 有个「过期机制」:每个更新都有个过期时间,如果在过期时间内都没有执行,那么他就会过期。 过期后的更新会同步执行(也就是说他的优先级变得和 SyncLane 一样),表现为最后一个长任务。

总结

盘点了一下 react18 的新特性以及简单剖析了一下各自的源码实现,如有错误,欢迎大家指正 😃

参考文档

信息

作者:大 Y 回家吃饭 链接:https://juejin.cn/post/7118671121251057700 来源:稀土掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。