好,各位同学,各位未来的 React 架构师,还有那些头发还在的发量惊人的前端大佬们,大家晚上好!
欢迎来到今天的“React 内部宇宙探索”讲座。今天的主题有点硬核,有点枯燥,甚至有点让人想打瞌睡,但请相信我,只要你听完,下次你在写代码时看到 onScroll 或 onClick,你就不再是一个只会调用的调包侠,你将是一个拥有上帝视角的“调度大师”。
今天的课题是:React 事件优先级映射协议:源码分析点击(Click)与输入(Input)事件如何被映射至 DiscreteLane 与 ContinuousLane。
把这三个词连起来读一遍,是不是觉得脑子里像塞进了一团乱麻?别怕,我们把它拆开揉碎了喂给你吃。
第一部分:从“喧哗”到“秩序”——为什么要引入优先级?
想象一下,你正在一个繁忙的十字路口当交警。突然,左边的红绿灯坏了,右边的红绿灯坏了,路中间一辆车撞上了,后面堵了两公里,同时广播还在播放:“注意!注意!前方发生交通事故!”
这时候,如果你发现有一个路人在大喊:“哎呀,我的袜子掉了!”你会停下来去帮他找袜子吗?显然不会。你会先处理车祸,或者至少先保证车流不瘫痪。
在 React 中,这就是“事件优先级”存在的意义。
React 不是一个单纯处理 DOM 的库,它是一个调度器。当你触发一个点击事件时,这不仅仅是一个 JS 函数调用,它是一个巨大的系统调用。React 需要决定:“哦,用户点了一下,我现在应该立即渲染吗?还是等等浏览器渲染完这帧再说?”
这里有两个极端的场景:
- Discrete(离散事件): 比如点击。这是用户明确、果断的意图。就像那个喊“袜子掉了”的路人。它的特点是瞬间爆发,不需要连续流。如果你点击提交按钮,React 必须立刻停止一切工作,把数据存进去,把“提交成功”的 Toast 弹出来。如果在渲染一个复杂的列表时用户点了一下,React 必须打断渲染,优先响应点击。
- Continuous(连续事件): 比如滚动。这是用户长时间的操作。这就像交通堵塞,它是一个连续不断的流。如果你在拖动一个滑块时,React 偶尔卡顿一下,用户感觉会很糟糕,会觉得“这页面坏了”。所以,滚动事件必须被“柔和”地处理,它们不能随便打断正在进行的渲染,但也不能被完全无视。
好,接下来我们直接上手源码,看看 React 是怎么把这两个截然不同的“野孩子”关进不同的“车道”里的。
第二部分:源码解剖——寻找调度员的地图
在 React 的源码仓库里,我们要去的地方有两个:
packages/scheduler/src/SchedulerPriorities.js:这里是调度员的“职级表”。packages/react-dom/events/DOMEventComponent.js:这里是拦截 DOM 事件的“看门人”。
1. 调度员的职级表
打开 SchedulerPriorities.js,你会看到几行看起来像是在数数的代码。React 使用了一组常数来定义优先级,从最高到最低排列如下:
// 极其紧急,甚至比用户点击还高(比如键盘打字中断渲染)
export const ImmediatePriority = 1;
// 用户阻塞优先级。这是点击、输入这些事件归属的阵营。
export const UserBlockingPriority = 2;
// 普通优先级,比如 React 组件首次渲染。
export const NormalPriority = 3;
// 低优先级,比如 `console.log` 或者非关键的后台任务。
export const LowPriority = 4;
// 没人管优先级了,放哪里都行。
export const IdlePriority = 5;
我们的任务就是搞清楚:点击属于 ImmediatePriority 还是 UserBlockingPriority?
2. 看门人的判决书
打开 packages/react-dom/events/DOMEventComponent.js,在文件的深处,你会找到 getEventPriority 这个函数。这是所有事件被 React 捕获后的第一道“体检”。
看这段代码(源码微调版,方便阅读):
function getEventPriority(domEventName) {
switch (domEventName) {
case 'click':
case 'keydown':
case 'focus':
case 'submit':
case 'input':
return EventPriority.ImmediatePriority; // 立即执行!
case 'scroll':
case 'mouseenter':
case 'mousemove':
case 'mouseout':
case 'mouseover':
case 'touchmove':
return EventPriority.UserBlockingPriority; // 用户阻塞,打断,但优先级低一点。
default:
return EventPriority.NormalPriority;
}
}
哇!真相大白!
React 根据事件名称的“长相”,直接判决了它们的命运:
- 点击 (
click)、输入 (input)、聚焦 (focus):被判为 ImmediatePriority。 - 滚动 (
scroll)、鼠标移动 (mousemove):被判为 UserBlockingPriority。
但是!题目要求我们讲 DiscreteLane 和 ContinuousLane。不要急,这只是翻译问题。在 Scheduler 的内部,ImmediatePriority 对应的就是 Discrete 优先级(通常映射为可中断的 Discrete Lane),而 UserBlockingPriority 对应的是 Continuous 优先级(通常映射为不可中断或延迟处理的 Continuous Lane)。
第三部分:深挖 DiscreteLane——点击的“原子性”
为什么点击是 Discrete(离散)的?
让我们回到调度器的逻辑。在 SchedulerHostConfig.js 中,React 会根据优先级决定是使用 requestAnimationFrame(RAF)还是 requestIdleCallback(RIC)。
Discrete 事件(点击) 喜欢用 requestAnimationFrame,而且要求同步执行。
想象一下,你正在渲染一个包含 5000 个节点的长列表。React 的渲染机制是分片渲染的,它一帧一帧地画。突然,你点击了一下“保存”按钮。
此时,React 的工作流是这样的:
- 捕捉: React 拦截了
click事件。 - 判决:
getEventPriority返回ImmediatePriority。 - 调度: 调度器收到
ImmediatePriority,决定立即中断当前正在进行的渲染任务。 - 执行: React 立即运行事件处理器(你的
onClick),更新状态。 - 重启: React 重新开始渲染流程,这次必须把刚才的点击结果画出来。
这就是 DiscreteLane 的本质。它是一个高优先级的“刺头”。它允许调度器在渲染的中途把任务切走。
代码示例:模拟 Discrete 事件的打断
虽然我们不能直接在浏览器里测试 Scheduler,但我们可以通过 React 的文档和原理模拟出这种行为。通常,为了模拟高优先级中断,我们会看到 useEffect 的 cleanup 函数被频繁触发。
import { useEffect, useState } from 'react';
const DiscreteDemo = () => {
const [count, setCount] = useState(0);
const [log, setLog] = useState([]);
useEffect(() => {
// 这是一个模拟的长耗时任务
const id = setInterval(() => {
console.log('正在渲染数据...');
// 这里我们没有去 setCount,只是模拟渲染
}, 100);
// 清理函数
return () => {
clearInterval(id);
// 如果你点击的频率够快,你会发现这里的清理函数被疯狂调用
// 这意味着 React 在执行其他任务(比如点击回调)时,觉得原来的渲染已经过时了,所以把它“杀”掉了
console.log('渲染任务被中断,重新渲染...');
};
}, []);
const handleClick = () => {
// 这是一个高优先级事件
console.log('点击发生!');
setCount(prev => prev + 1);
};
return (
<div>
<h1>Count: {count}</h1>
<button onClick={handleClick}>点我(Discrete 事件)</button>
</div>
);
};
在这个例子中,setCount 是一个 Discrete 操作。如果你的渲染任务正在跑,点击一来,React 会把正在跑的那个渲染任务扔进垃圾桶,立马执行 setCount 的更新。
第四部分:深挖 ContinuousLane——滚动的“流畅性”
现在,让我们看看 Continuous 事件(滚动)。
滚动是 React 代码中优先级最低的一类事件。为什么?因为频率太高,颗粒度太细。
如果你滚动页面时,React 每动一下鼠标都要中断渲染去处理滚动事件,那你的浏览器性能会瞬间崩盘,页面会变成 PPT。而且,对于用户来说,滚动的流畅度比点击反馈的及时性更重要。
Continuous 事件(滚动)通常被标记为 UserBlockingPriority。它们的行为截然不同:
- 不可中断: 滚动事件通常不能打断正在进行的渲染任务。如果 React 正在渲染一帧,你滚动了鼠标,React 不会停止渲染去处理滚动,它会等到当前帧渲染完毕。
- 延迟处理: 滚动事件会被推入一个队列。React 会在当前帧渲染完之后,利用浏览器的
requestIdleCallback(空闲回调)来处理这些滚动事件。
代码示例:Continuous 事件的延迟
如果你在开发中遇到滚动极其卡顿的情况,通常是因为你在滚动事件里写了大量的同步逻辑。
import { useEffect, useState } from 'react';
const ContinuousDemo = () => {
const [scrollY, setScrollY] = useState(0);
// 模拟一个耗时的计算
const heavyComputation = () => {
let sum = 0;
for (let i = 0; i < 10000000; i++) {
sum += i;
}
return sum;
};
const handleScroll = (e) => {
// 警告!这里是一个 Continuous 事件
// 如果你在滚动时执行这段代码,会极其卡顿
const result = heavyComputation();
setScrollY(e.currentTarget.scrollTop);
console.log('滚动位置:', e.currentTarget.scrollTop);
};
return (
<div style={{ height: '200vh', overflowY: 'scroll', border: '1px solid red' }}>
<h1 style={{ position: 'sticky', top: '0' }}>滚来滚去 (Continuous)</h1>
<div style={{ height: '100px' }}>...</div>
<button
onClick={() => console.log('点击了一下')}
style={{ position: 'sticky', top: '50px' }}
>
点击我
</button>
<div style={{ height: '100px' }}>...</div>
<div style={{ height: '100px' }}>...</div>
</div>
);
};
在这个代码里,点击按钮(Discrete)会立刻让你看到日志,而滚动页面(Continuous),除非你的浏览器卡死,否则你几乎感觉不到 React 在处理它。它被“推迟”了,甚至被“丢弃”了(如果一帧内滚动太快)。
第五部分:Lanes 的魔法——从优先级到具体的位
好了,我们知道了点击是高优先级,滚动是低优先级。但题目问的是 Lanes。Lanes 是什么?它是 React 18 引入的更细粒度的调度模型。
React 以前用一个简单的数字代表优先级,现在它用 32 位整数来代表“车道”。
- DiscreteLane:映射的是
ImmediatePriority。 - ContinuousLane:映射的是
UserBlockingPriority。
让我们看看源码中它们是如何通过位运算结合在一起的。
在 Scheduler.js 中,优先级被映射为 Lanes:
const NoLanes = 0b00000000000000000000000000000000; // 0
const DiscreteEventPriority = 0b00000000000000000000000000000001; // 1 << 0
const ContinuousEventPriority = 0b00000000000000000000000000000010; // 1 << 1
关键点来了:
- DiscreteLane (1) 是一个可中断的 Lane。当 React 收到一个 Discrete 事件(点击),它会设置一个标志位,告诉调度器:“嘿,这有个高优先级任务,一旦有这个位被设置,马上给我停下当前的工作!”
- ContinuousLane (2) 是一个不可中断的 Lane。当 React 收到滚动事件,它会把这个 Lane 标记为“待办”,然后告诉调度器:“这事儿也挺急,但能不能等渲染完这帧再说?”
这就是为什么 React 18 的并发模型能玩得转。因为有了 Lanes,React 不仅仅知道“这是个高优先级任务”,它还知道“这是个什么类型的高优先级任务”。
第六部分:事件重放——Continuous 事件的悲剧
这里有一个非常有趣且反直觉的源码机制,叫做 Event Replay(事件重放)。
因为 Continuous 事件(如滚动)是低优先级的,它们被推到了队列的后面。而 Discrete 事件(点击)是高优先级的,它们可以随时插队。
但是,React 在渲染过程中,可能会因为高优先级任务的打断而挂起或重启。这时候,那些已经在队列里的 Continuous 事件怎么办?它们还没处理呢!
React 的调度器会做一个非常“悲壮”的操作:重放。
如果 React 在处理一个点击事件时,发现之前挂起的滚动事件还没处理,它会在处理完点击后,再次触发滚动事件的监听器。
看下面这段伪源码逻辑:
// 源码简化版:dispatchContinuousEvent
function dispatchContinuousEvent(event, listener) {
// 1. 先把事件推入队列,标记为 ContinuousLane
// queue.push({ lane: ContinuousLane, event, listener });
// 2. 告诉调度器,有一个 Continuous 事件来了
// scheduleWork(ContinuousLane);
// 3. 调度器检查:哎呀,我正在渲染一个 Discrete 事件(比如点击)!
// Discrete 优先级高于 Continuous!
// 4. 调度器决定:先把 Discrete 渲染完,渲染完再回来处理 Continuous。
// 5. 当 Discrete 渲染完毕,调度器回到原点,发现还有 Continuous 事件在排队。
// 6. 调度器再次检查:现在没有高优先级任务了,处理滚动!
// 这里就会触发 listener(event) 的重放。
}
这就是为什么你在开发时,有时候 onScroll 的逻辑会执行两次,或者比实际滚动慢一点点——因为 React 把它“排队”了,甚至为了处理高优先级的点击,不得不回头重新“播放”一遍滚动事件。
第七部分:Input 事件的微妙之处
回到题目,我们提到了 Input(输入) 事件。
在 DOMEventComponent.js 的源码中,input 事件通常被归类为 ImmediatePriority。这很有意思。
为什么输入是 Discrete(高优先级)的?明明打字是连续的啊?
这是为了“原子性”体验。
虽然打字是连续的,但每一次按键都是一个离散的、不可分割的操作。
- 如果你在输入框打字,React 必须立刻把字显示出来。如果你按了 A 键,屏幕上却延迟了 500ms 才出来 A,用户会疯掉。
- 如果是滚动,你可以忍受 50ms 的延迟,因为滚动是一个平滑的视觉流。
- 但输入,必须像打字机一样,一下就是一个字符,没有任何缓冲。
所以,input 事件虽然也产生很多(Continuous 的量级),但它被赋予了 Discrete 的格。这保证了打字的跟手性。
第八部分:实战演练——如何自己实现一个“调度器”
为了彻底理解这些,我们来写点代码。不依赖 React,只依赖浏览器原生的 API 来模拟一下这个映射。
// 模拟 Scheduler
const Schedulers = {
taskQueue: [],
currentPriority: 0,
// 定义优先级
PRIORITY: {
IMMEDIATE: 1, // 点击、输入
USER_BLOCKING: 2, // 滚动
IDLE: 5 // 低优先级
},
// 添加任务
schedule(task, priority) {
this.taskQueue.push({ task, priority });
// 排序,优先级高的排前面
this.taskQueue.sort((a, b) => b.priority - a.priority);
this.runNextTask();
},
runNextTask() {
if (this.taskQueue.length === 0) return;
const currentTask = this.taskQueue[0];
// 模拟当前正在执行的任务优先级
const runningPriority = this.currentPriority;
// 逻辑判断:
// 1. 如果新任务是 IMMEDIATE (1),它永远比正在跑的任务高,直接打断,切换任务。
// 2. 如果新任务是 USER_BLOCKING (2),如果正在跑的是 IDLE (5),则打断。
// 3. 如果新任务是 USER_BLOCKING (2),但正在跑的是 IMMEDIATE (1),则等待,不打断。
if (currentTask.priority === this.PRIORITY.IMMEDIATE ||
(currentTask.priority === this.PRIORITY.USER_BLOCKING && runningPriority === this.PRIORITY.IDLE)) {
// 执行任务
console.log(`[调度器] 执行优先级 ${currentTask.priority} 的任务: ${currentTask.task.name}`);
this.taskQueue.shift();
this.currentPriority = currentTask.priority;
// 模拟任务耗时
setTimeout(() => {
this.runNextTask();
}, 100);
} else {
// 任务被挂起,等待下一次调用 runNextTask
}
}
};
// 模拟点击
function handleClick() {
console.log("触发点击事件!");
Schedulers.schedule({
name: "渲染点击后的UI更新",
priority: Schedulers.PRIORITY.IMMEDIATE
}, Schedulers.PRIORITY.IMMEDIATE);
}
// 模拟滚动
function handleScroll() {
console.log("触发滚动事件!");
Schedulers.schedule({
name: "处理滚动逻辑",
priority: Schedulers.PRIORITY.USER_BLOCKING
}, Schedulers.PRIORITY.USER_BLOCKING);
}
// 模拟后台任务
function doIdleWork() {
console.log("开始执行空闲任务...");
Schedulers.schedule({
name: "渲染大型列表",
priority: Schedulers.PRIORITY.IDLE
}, Schedulers.PRIORITY.IDLE);
}
// 模拟输入
function handleInput() {
console.log("触发输入事件!");
Schedulers.schedule({
name: "更新输入框状态",
priority: Schedulers.PRIORITY.IMMEDIATE
}, Schedulers.PRIORITY.IMMEDIATE);
}
// 场景模拟
console.log("--- 开始场景模拟 ---");
doIdleWork(); // [调度器] 执行优先级 5 的任务: 渲染大型列表
// 此时如果滚动,会被打断吗?
handleScroll(); // 滚动无法打断 IDLE 任务
setTimeout(() => {
console.log("n--- 等待一帧后 ---");
handleClick(); // 点击能打断 IDLE 任务吗?能!
}, 200);
// 最终执行顺序大概是:渲染列表 -> (被点击打断) -> 更新UI -> (滚动被排队) -> 处理滚动
这段代码虽然简单,但它完美复刻了 React 的核心逻辑:ImmediatePriority(点击/输入)拥有最高的打断权,它可以随时把 ContinuousPriority(滚动)从 CPU 上挤下去。
第九部分:总结——事件处理的“分诊室”
好了,同学们,我们的源码之旅即将结束。让我们回顾一下今天这个“分诊室”的故事:
- 点击:这是一个急性子的病人。它一进门,护士(调度器)就得放下手里的别的事(比如渲染背景),先给它看病。这就是 DiscreteLane。它要求同步、即时、不可阻挡。
- 输入:也是一个急性子,理由同上。虽然它来了很多次,但每次都必须立即响应,不能有延迟。这也是 DiscreteLane。
- 滚动:这是一个慢性子的病人。它虽然也急,但只要医生(React)手里有更急的活,它就得在走廊里等。等医生忙完了,再回头处理它。这就是 ContinuousLane。它允许延迟,允许排队,甚至允许被重放。
React 通过 getEventPriority 将原生事件映射到 ImmediatePriority 和 UserBlockingPriority,再通过 Lane 模型将这些优先级变成可运算的位,最后由 Scheduler 这个不知疲倦的管家决定到底谁先上机。
最后的建议:
下次你在写代码时,如果遇到性能问题,不要盲目地用 useEffect 或者复杂的逻辑去“优化”点击事件。因为 React 已经帮我们做了最完美的优化:点击事件,永远比你写的其他逻辑都快。
但如果你在处理滚动,请务必小心。因为滚动事件在 React 眼里是“弱势群体”,如果排队的任务太多,它可能永远等不到被处理。这就是为什么我们在处理滚动时,通常只会保存 e.scrollTop 的值,在 useEffect 里去读取,而不是直接在事件回调里做重计算。
希望今天的讲座能让你对 React 的并发模型有一个全新的、充满物理色彩的认知!下课!