理解 React Lanes:位掩码如何编码更新优先级
了解 React 如何利用位掩码将多个更新优先级编码为单个整数,以及为何在调度时选择位运算而非简单的布尔标志。
终究不是布尔值
在上一部分中,你看到lanes和childLanes的表现就像简单的标志位。对于Counter而言,其对应光纤上的某个字段会从空状态变为占用状态,其上层的所有父光纤也会发生同样的变化,而遍历这棵结构时正是依靠这些状态变化来判断哪些分支值得进入。在那种解释中,其他因素都不重要:一个字段要么为空,要么不为空。
但这样的描述并未说明这些字段实际上包含什么内容。
{
type: Counter,
memoizedState: { count: 1 },
lanes: 16,
}
lanes存储的是数字,而非true或false——实际上是存储在光纤上的整数。**如果React只需要知道“是否有待处理的任务,是或否”,那么普通的布尔值就完全足够了。**但React却选择了其他方式。
那么,为什么数字会成为标记“该纤维有待处理任务”的自然选择呢?
位掩码究竟是什么
React 提供了一组专门为这一目的而设计的常量,将它们对应起来就能立即找到答案。
SyncLane = 1 = 00000001
InputContinuousLane = 4 = 00000100
DefaultLane = 16 = 00010000
TransitionLane = 64 = 01000000
这些常量以二进制形式表示,每个常量恰好包含一个 1,而且每个常量中的这个 1 所处的位置都不同。SyncLane 位于第一个位置,而 DefaultLane 位于第五个位置。它们永远不会出现在相同的位置上。这种结构就被称为位掩码——其数值本身并无实际意义,而是充当了一排独立的开关,每个位对应一个开关,所有开关都存储在同一个字段中。
Counter的fiber显示lanes: 16时,并不意味着“这次更新相当于十六个单位的某种价值”。它的意思是属于DefaultLane的位被置为1,而其他所有位都保持为0。这个数字只是用作标志位的存储容器,而非需要被测量的数量。
当某根fiber需要同时跟踪多个这样的标志位时会发生什么?
通过单次操作合并两个通道
想象一下,有一根fiber在同一时刻要处理两种不同的待处理任务——一项常规更新,以及另一项被刻意设置为较低优先级的更新。React会通过按位或操作来合并这两个位掩码:该操作会逐位对比两个数值,只要任一源数值中的对应位为1,结果中的该位就会设为1。
00010000 (16, DefaultLane)
| 01000000 (64, TransitionLane)
-----------
01010000 (80)
DefaultLane和TransitionLane各自拥有其他车道不会触及的位位置,将它们进行或运算时,两个1都会保留在结果数值中,彼此不会相互抵消。最终得到的80就是lanes的新值,若将其转换回二进制形式,就会发现两个原始位都完好地保留着,与原来完全一致。
单独看数字80——它究竟揭示了什么?
为何单独看80也无法获取任何信息
80 这个数值本身并不能透露任何信息。读取这类值时不能像普通数字那样直接查看,而需要通过位与运算逐位检查,只有当两个比较数对应位置均为 1 时,该位置才会保留 1。
01010000 (80)
& 00010000 (16, DefaultLane)
-----------
00010000 (non-zero → yes, DefaultLane's bit is set)
01010000 (80)
& 00000001 (1, SyncLane)
-----------
00000000 (zero → no, SyncLane's bit is not set)
人们从不单独查看某个通道的值。而是针对特定通道提出具体问题,得到的回答要么是零,要么是非零值。这正是之前介绍的树遍历逻辑在后台的实际运作方式:每次它检查某条纤维的 childLanes 以判断是否值得继续深入该分支时,都会对当前正在搜索的通道执行同样的位与运算检查。
那么回到核心问题:为何要将其表示为数字,而非普通的布尔标志呢?
为何不直接使用布尔值?
布尔值只能回答一种类型的问题:是否有未完成的事项,即“是”或“否”。它无法表达“有一个常规更新在等待处理,同时还有一个优先级较低的更新也在等待”,因为布尔值仅能承载一位信息。React实际上需要在同一时间跟踪某个线程上多类不同的待处理任务,并且能够检测其中任意一类任务,而不影响其他任务的状态。
位掩码可以同时满足这两项要求。由于每个标志都占用独立的比特位置,因此一个数值字段就可以并行存储多个标志。而检测某个标志或组合多个标志都只是简单的CPU操作,即与运算或或运算,无需遍历数据结构,也无需为每种可能的优先级单独设置布尔变量。这正是将lanes存储为整数而非true或false的全部原因:它并非用于记录是否有任务存在,而是几乎无需成本地同时记录存在哪些类型的任务。
目前仍未解释的是,为何更新一开始就需要被视为较低优先级。
为何更新需要优先级
设想一个作为受控组件构建的搜索输入框,每次按键都会重新过滤一万条记录,而过滤操作就在组件的函数体内完成。
function SearchableList({ items }) {
const [query, setQuery] = useState("");
function handleChange(e) {
setQuery(e.target.value);
}
const filteredItems = items.filter(item => item.includes(query));
return (
<div>
<input value={query} onChange={handleChange} />
<ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
</div>
);
}
输入一个字符后,handleChange会被触发,进而调用setQuery,这会安排对SearchableList进行一次重新渲染。当 React 最终处理这次重新渲染时,它会以一个连续不断的同步调用方式从头到尾执行SearchableList函数。在这段单一调用过程中,.filter()必须遍历全部一万条记录,函数才能返回新的 JSX 结果。
正在运行的函数调用在其自身执行过程中无法被中断。fiber所提供的可中断性是在不同fiber之间起作用,而非在仍在执行的函数的逐行执行过程中。因此,在包含过滤操作在内的那个函数调用完成之前,React无法绘制更新后的输入值、保存任何数据或将像素显示在屏幕上。你输入的下一组按键会被排队等待,这并非因为有两个独立的更新在竞争,而是因为这两项操作都被包含在同一个函数调用的输出结果中。
解决办法是将此过程拆分为两个真正独立的更新,每个更新都可以拥有自己的优先级。
function SearchableList({ items }) {
const [query, setQuery] = useState("");
const [deferredQuery, setDeferredQuery] = useState("");
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
setQuery(value);
startTransition(() => {
setDeferredQuery(value);
});
}
const filteredItems = items.filter(item => item.includes(deferredQuery));
return (
<div>
<input value={query} onChange={handleChange} />
<ul>{filteredItems.map(item => <li key={item}>{item}</li>)}</ul>
</div>
);
}
setQuery 和 setDeferredQuery 现在作为两个独立的调用存在,每个调用都会触发各自的更新,并且都有各自的处理通道。由 setQuery 引发的渲染成本很低,因为不需要进行任何过滤操作,因此能快速完成,输入框也能持续响应输入。而由 setDeferredQuery 引发的渲染则会实际执行 .filter() 操作,由于它被封装在用于启用这种延迟更新模式的 startTransition 中,因此会携带 TransitionLane。如果在那个耗时的过滤渲染仍在进行的期间又输入了其他字符,React 会检查各个处理通道,找到正在等待的优先级更高的更新,然后暂停当前未完成的低优先级任务,先处理新的按键输入。这一机制之所以可行,是因为现在的这两个更新是独立跟踪的。
此时一个合理的问题是:是否要求开发者逐个为这些任务手动设置优先级值。
优先级主要是自动确定的
大多数情况下并非如此。React通常会根据最初触发更新的原因来选择处理路径,而非由开发者刻意选定。这一决策发生在React的事件系统中,该系统能够在setState执行的确切时刻识别出正在处理的浏览器事件类型。
大致从最紧急到最不紧急的顺序如下:
Discrete events (clicks, key presses, form submissions)
Continuous events (scroll, drag, pointer-move)
Default (timeouts, resolved promises, no special context)
Transition (explicitly deprioritized via startTransition)
Idle (only when nothing else is pending)
在点击处理函数中调用setState会使其自动处于该优先级列表的顶端。而在滚动处理函数中调用则会使它的优先级降低一层,因为将每次由滚动触发的更新都视为最高优先级,甚至可能每秒发生数十次,这本身就会导致性能问题。由已解决的承诺或超时触发的更新,由于没有可参考的上下文,会默认被归类到DefaultLane。开发者真正能够手动控制的只有startTransition,而且它的作用范围很有限:要么更新按正常流程执行,React会像处理其他所有更新一样根据上下文推断其优先级;要么该更新会被包裹起来,无论实际是由什么触发的,都会被强制放入TransitionLane。useDeferredValue本质上就是基于同一机制构建的一个轻量级封装。没有人会去设置
fiber.lanes = 64,而且根本没必要另作处理。 在结束讨论之前还需提醒一点:React的实际代码库中定义了大约三十个不同的通道常量,而非仅四个。其中许多存在纯粹是为了内部记录用途,并非真正代表新的优先级层级,其作用是独立跟踪多个转换过程,而非将它们合并到一个无差别的统一类别中。上述的五个层级才是值得作为概念模型牢记的。其余的都是建立在您已理解的架构之上的实现细节。
四部分,一种机制
综合来看,以下是从头到尾重新渲染时实际发生的过程。状态存储在纤维中,因为纤维是能够跨越单次渲染过程而存在的对象,这与组件函数不同——组件函数在每次调用之间都会忘记所有内容,元素则会被丢弃并每次重新创建。一个纤维表现为两个相互关联的版本:当前版本和正在处理中的版本,它们通过alternate指针连接在一起,因为只有将前后状态快照并排展示,才能进行任何比较。调用setState时永远不会直接修改任何一个纤维。相反,它只是将一条消息放入队列中,推迟实际的操作,而一组标记好的路径则让后续遍历树结构时能够知道哪些分支值得深入处理。而那个标记从来都不是简单的……
这有四种不同的思路,但最终都归结为同一种机制。只有在你理解了前面的那个之后,才能完全明白这一个。如果单独来看,这些概念中的任何一个都会变成一个可以记忆的事实,却缺少支撑它的实际推理过程。