Transition 是 react18 引入的新概念,用来区分紧急和非紧急的更新。
react 官方的 demo 如下:
import {startTransition} from 'react';
// Urgent: Show what was typed
setInputValue(input);
// Mark any state updates inside as transitions
startTransition(() => {
// Transition: Show the results
setSearchQuery(input);
});
有 2 个 API:
2 个 API 还有一个差别:当进行连续快速输入时,使用 startTransition 是无法触发类似 throttle 的效果的。
存在的问题:
存在的问题:
用 transition 机制的效果:
transition 相比前两种方案的优势:
我们看到页面首先进入了 pending 状态,然后才显示为 transition 更新后的结果。这里发生了 2 次 react 更新。但我们只写了一个 setState。
function App() {
const [value, setValue] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
const newVal = e.target.value;
startTransition(() => setValue(newVal));
};
return (
<div>
<input onChange={handleChange} />
<div className={isPending ? 'loading' : ''}>
{
Array(50000).fill("a").map((item, index) => {
return <div key={index}>{value}</div>;
})
}
</div>
</div>
);
}
我们看一下 useTransition 源码:
useTransition(): [boolean, (() => void) => void] {
currentHookNameInDev = 'useTransition';
mountHookTypesDev();
return mountTransition();
},
function mountTransition(): [boolean, (callback: () => void, options?: StartTransitionOptions) => void] {
const [isPending, setPending] = mountState(false);
// The `start` method never changes.
const start = startTransition.bind(null, setPending);
const hook = mountWorkInProgressHook();
hook.memoizedState = start;
return [isPending, start];
}
function startTransition(setPending, callback, options) {
const previousPriority = getCurrentUpdatePriority();
setCurrentUpdatePriority(
higherEventPriority(previousPriority, ContinuousEventPriority),
);
setPending(true);
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = {};
...
try {
setPending(false);
callback();
} finally {
setCurrentUpdatePriority(previousPriority);
ReactCurrentBatchConfig.transition = prevTransition;
...
}
}
当调用 startTransition 时,会先通过 setPending 将 isPending 改为 true,然后再通过 setPending 将 isPending 改为 false,并在 callback 中触发我们自己定义的更新。
这里有一个奇怪的地方,3 次 setState 并没有合并在一起,而是触发了 2 次 react 更新,setPending(true) 为 1 次,setPending(false) 和 callback() 为第二次。
这是因为
ReactCurrentBatchConfig.transition = {}
这句语句将更新的上下文变更为了 transition。使得 setPending(true) 和 后面的 2 次更新的上下文不同了。
为什么更新的上下文变化会影响 setState 的合并呢,下面简单展开讲一讲 setState 时 react 在干什么。
一次 react 更新,主核心的过程是 fiber tree 的协调(reconcile),协调的作用是找到 fiber tree 中发生变化的 fiber node,最小程度地对页面的 dom tree 结构进行调整。
在进行协调时,react 提供了两种模式:Legacy mode - 同步阻塞模式和 Concurrent mode - 并行模式。
这两种模式,区别在于 fiber tree 的协调过程是否可中断。 Legacy mode,协调过程不可中断;Concurrent mode,协调过程可中断。
Legacy mode:
Concurrent mode 的意义在于:
react 的调度机制是 workLoop 机制。伪代码实现如下:
let taskQueue = []; // 任务列表
let shouldTimeEnd = 5ms; // 一个时间片定义为 5ms
let channel = new MessageChannel(); // 创建一个 MessageChannel 实例
function workLoop() {
let beginTime = performance.now(); // 记录开始时间
while(true) { // 循环处理 taskQueue 中的任务
let currentTime = performance.now(); // 记录下一个任务开始时的时间
if (currentTime - beginTime >= shouldTimeEnd) break; // 时间片已经到期,结束任务处理
processTask(); // 时间片没有到期,继续处理任务
}
if (taskQueue.length) { // 时间片到期,通过调用 postMessage,请求下一个时间片
channel.port2.postMessage(null);
}
}
channel.port1.onmessage = workLoop; // 在下一个时间片内继续处理任务
workLoop();
workLoop 有 2 种,Legacy 模式下,是 workLoopSync;Concurrent 模式下,是 workLoopConcurrent。workLoopSync 中每个任务都要完成后才会释放主进程,workLoopConcurrent 中每个任务在时间片耗尽后会释放主进程等待下一个时间片继续执行任务。
workLoopSync 对应 Legacy 模式。如果是在 event、setTimeout、network request 的 callback 中触发更新,那么协调时会启动 workLoopSync。在协调过程中,需要对 fiber tree 做深度优先遍历,处理每一个 fiber node。workLoopSync 开始工作以后,要等到 stack 中收集的所有 fiber node 都处理完毕以后,才会结束工作,也就是 fiber tree 的协调过程不可中断。
workLoopConcurrent 对应 Concurrent 模式。如果更新与 Suspense、useTransition、OffScreen 相关,那么协调时会启动 workLoopConcurrent。 workLoopConcurrent 开始工作以后,每次协调 fiber node 时,都会判断当前时间片是否到期。如果时间片到期,会停止当前 workLoopConcurrent、workLoop,让出主线程,然后请求下一个时间片继续协调。
相关源码如下:
参考 前端进阶面试题详细解答
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
}
function workLoopConcurrent() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
react 有 3 套优先级机制:
React 事件优先级如下:
// 离散事件优先级,例如:点击事件,input输入等触发的更新任务,优先级最高
export const DiscreteEventPriority: EventPriority = SyncLane;
// 连续事件优先级,例如:滚动事件,拖动事件等,连续触发的事件
export const ContinuousEventPriority: EventPriority = InputContinuousLane;
// 默认事件优先级,例如:setTimeout触发的更新任务
export const DefaultEventPriority: EventPriority = DefaultLane;
// 闲置事件优先级,优先级最低
export const IdleEventPriority: EventPriority = IdleLane;
react 在内部定义了 5 种类型的调度(Scheduler)优先级:
5 种优先级的顺序为: ImmediatePriority > UserBlockingPriority > NormalPriority > LowPriority > IdlePriority。
react 内部定义了 31 条 lane,lane 可以理解为每个任务所处的赛道。用二进制表示,按优先级从低到高依次为:
lane 对应的位数越小,优先级最高。如 SyncLane 为 1,优先级最高; OffscreenLane 为 31, 优先级最低。
react 先将 lane 的优先级转换为 React 事件的优先级,然后再根据 React 事件的优先级转换为 Scheduler 的优先级。
当通过 startTransition 的方式触发更新时,更新对应的优先级等级为 NormalPriority。而在 NormalPriority 之上,还存在 ImmediatePriority 、UserBlockingPriority 这两种级别更高的更新。通常,高优先级的更新会优先级处理,这就使得尽管 transition 更新先触发,但并不会在第一时间处理,而是处于 pending - 等待状态。只有没有比 transition 更新优先级更高的更新存在时,它才会被处理。
Concurrent 模式下,如果在低优先级更新的协调过程中,有高优先级更新进来,那么高优先级更新会中断低优先级更新的协调过程。
每次拿到新的时间片以后,workLoopConcurrent 都会判断本次协调对应的优先级和上一次时间片到期中断的协调的优先级是否一样。如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;如果不一样,说明有更高优先级的更新进来,此时要清空之前已开始的协调过程,从根节点开始重新协调。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新。
调用 setState,并不会立即更新组件 state。state 的更新,其实是发生在 fiber tree 的协调过程中,这个过程如下:
上面 useTransition 的例子中,连续 3 次 setState,会生成 3 个 update 对象 - update1(setPending(true)),update2(setPending(false)),update3(callback 里的 setState 调用)。这三个 update 对象会按照创建的先后顺序依次添加到 updateQueue 中。
update 对象结构:
export function createUpdate(eventTime: number, lane: Lane): Update<*> {
const update: Update<*> = {
eventTime,
lane, // 这里为 update 绑定了优先级
tag: UpdateState,
payload: null,
callback: null,
next: null,
};
return update;
}
由于创建 update 对象的上下文不相同,导致 update 对象处理的时机不相同。第一次协调时,处理 update1;第二次协调时,处理 update2 和 update3。之所以这样,是因为不同的上下文,为 update 对象绑定了的不同的 lane。
lane 决定了 update 对象的处理时机。
所以如上,update1 被分配的 lane 为 InputContinuousLane,而 update2、update3 被分配的 lane 为 TransitionLane。为每个 update 生成 lane 的源码如下:
export function requestUpdateLane(fiber: Fiber): Lane {
...
const isTransition = requestCurrentTransition() !== NoTransition;
if (isTransition) {
if (currentEventTransitionLane === NoLane) {
// All transitions within the same event are assigned the same lane.
currentEventTransitionLane = claimNextTransitionLane();
}
return currentEventTransitionLane;
}
...
}
export function requestCurrentTransition(): Transition | null {
return ReactCurrentBatchConfig.transition;
}
至此,已经可以看到,update2 和 update3 被分配了较低的优先级,因此 3 次 setState 被分开成了 2 次更新。
了解了上面的原理,就可以来回答这几个问题了:
useTransition 为何能表现出 debounce 效果
高优先级更新会中断低优先级更新,优先处理。
startTransition 方法执行过程中, setPending(true) 触发的更新优先级较高,而 setPending(false)、callback 触发的更新优先级较低。当 callback 触发的更新进入协调阶段以后,由于协调过程可中断,并且用户一直在输入导致一直触发 setPending(true),使得 callback 触发的更新一直被中断,直到用户停止输入以后才能被完整处理。
useTransition 为何能表现出 throttle 效果
如果你一直输入,最多 5s,长列表必然会渲染,和 防抖 - throttle 效果一样。
这是因为为了防止低优先级更新一直被高优先级更新中断而得不到处理,react 为每种类型的更新定义了最迟必须处理时间 - timeout。如果在 timeout 时间内更新未被处理,那么更新的优先级就会被提升到最高 - ImmediatePriority,优先处理。
transition 更新的优先级为 NormalPriority,timeout 为 5000ms 即 5s。如果超过 5s, transition 更新还因为一直被高优先级更新中断而没有处理,它的优先级就会被提升为 ImmediatePriority,优先处理。这样就实现了 throttle 的效果。
useTransition 和 startTransition 区别
用户连续输入时,使用 useTransition 会出现 debounce 的效果,而直接使用 startTransition 则不会。
因为 startTransition 的源码:
function startTransition(scope) {
var prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = 1; // 修改更新上下文
try {
scope(); // 触发更新
} finally {
...
}
}
对比 useTransition 的 startTransition, 我们会发现 startTransition 中少了 setPending(true) 的过程。
使用 useTransition 时,transition 更新会一直被连续的 setPending(true) 中断,每次中断时都会被重置为未开始状态,导致 transition 更新只有在用户停止输入(或者超过 5s)时才能得到有效处理,也就出现了类似 debounce 的效果。
而直接使用 startTransition 时, 尽管协调过程会每隔 5ms 中断一次,但由于没有 setPending(true) 的中断, 连续的输入并不会重置 transition 更新。当 transition 更新结束协调时,自然而然地就会开始浏览器渲染过程,不会出现类似 debounce 的效果。
React ,它本身的思路是纯 JS 写法,这种方式非常灵活,但是,这也使它在编译时很难做太多的事情,像上面这样的编译时优化是很难实现的。所以,我们可以看到 React 几个大版本的的优化主要都在运行时。
进行运行时优化,关注的主要问题就是 CPU 和 IO。
Suspense 的作用,主要是 react 优化切换内容效果。而 Transition API 的最初提出,是为了配合 Suspense API 进行 IO 角度的优化。
useTransition 的前身是 withSuspenseConfig。Sebmarkbage 在 19 年五月份提的一个 PR 中引进了它。在 19 年 11 月更名为 useTransition。
Transition Hook 的作用是告诉 React,延迟更新 State 也没关系。
初版的 useTransition 的实现源码如下:
function updateTransition(
config: SuspenseConfig | void | null,
): [(() => void) => void, boolean] {
const [isPending, setPending] = updateState(false); // 相当于useState
const startTransition = updateCallback( // 相当于useCallback
callback => {
setPending(true); // 设置 pending 为 true
// 以低优先级调度执行
Scheduler.unstable_next(() => {
// ⚛️ 设置suspenseConfig
const previousConfig = ReactCurrentBatchConfig.suspense;
ReactCurrentBatchConfig.suspense = config === undefined ? null : config;
try {
// 还原 pending
setPending(false);
// 执行你的回调
callback();
} finally {
// ⚛️ 还原suspenseConfig
ReactCurrentBatchConfig.suspense = previousConfig;
}
});
},
[config, isPending],
);
return [startTransition, isPending];
}
划重点,虽然跟现在的版本有一些差别,但主要的思想依然是:以较低的优先级运行后 2 次 setState。
一路以来,主要的修改包含:在做兼容数据流状态库如 redux,修改优先级的实现方案。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。