React 事件优先级映射协议:源码分析点击(Click)与输入(Input)事件如何被映射至 DiscreteLane 与 ContinuousLane

好,各位同学,各位未来的 React 架构师,还有那些头发还在的发量惊人的前端大佬们,大家晚上好!

欢迎来到今天的“React 内部宇宙探索”讲座。今天的主题有点硬核,有点枯燥,甚至有点让人想打瞌睡,但请相信我,只要你听完,下次你在写代码时看到 onScrollonClick,你就不再是一个只会调用的调包侠,你将是一个拥有上帝视角的“调度大师”。

今天的课题是:React 事件优先级映射协议:源码分析点击(Click)与输入(Input)事件如何被映射至 DiscreteLane 与 ContinuousLane。

把这三个词连起来读一遍,是不是觉得脑子里像塞进了一团乱麻?别怕,我们把它拆开揉碎了喂给你吃。

第一部分:从“喧哗”到“秩序”——为什么要引入优先级?

想象一下,你正在一个繁忙的十字路口当交警。突然,左边的红绿灯坏了,右边的红绿灯坏了,路中间一辆车撞上了,后面堵了两公里,同时广播还在播放:“注意!注意!前方发生交通事故!”

这时候,如果你发现有一个路人在大喊:“哎呀,我的袜子掉了!”你会停下来去帮他找袜子吗?显然不会。你会先处理车祸,或者至少先保证车流不瘫痪。

在 React 中,这就是“事件优先级”存在的意义。

React 不是一个单纯处理 DOM 的库,它是一个调度器。当你触发一个点击事件时,这不仅仅是一个 JS 函数调用,它是一个巨大的系统调用。React 需要决定:“哦,用户点了一下,我现在应该立即渲染吗?还是等等浏览器渲染完这帧再说?”

这里有两个极端的场景:

  1. Discrete(离散事件): 比如点击。这是用户明确、果断的意图。就像那个喊“袜子掉了”的路人。它的特点是瞬间爆发,不需要连续流。如果你点击提交按钮,React 必须立刻停止一切工作,把数据存进去,把“提交成功”的 Toast 弹出来。如果在渲染一个复杂的列表时用户点了一下,React 必须打断渲染,优先响应点击。
  2. Continuous(连续事件): 比如滚动。这是用户长时间的操作。这就像交通堵塞,它是一个连续不断的流。如果你在拖动一个滑块时,React 偶尔卡顿一下,用户感觉会很糟糕,会觉得“这页面坏了”。所以,滚动事件必须被“柔和”地处理,它们不能随便打断正在进行的渲染,但也不能被完全无视。

好,接下来我们直接上手源码,看看 React 是怎么把这两个截然不同的“野孩子”关进不同的“车道”里的。

第二部分:源码解剖——寻找调度员的地图

在 React 的源码仓库里,我们要去的地方有两个:

  1. packages/scheduler/src/SchedulerPriorities.js:这里是调度员的“职级表”。
  2. 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

但是!题目要求我们讲 DiscreteLaneContinuousLane。不要急,这只是翻译问题。在 Scheduler 的内部,ImmediatePriority 对应的就是 Discrete 优先级(通常映射为可中断的 Discrete Lane),而 UserBlockingPriority 对应的是 Continuous 优先级(通常映射为不可中断或延迟处理的 Continuous Lane)。

第三部分:深挖 DiscreteLane——点击的“原子性”

为什么点击是 Discrete(离散)的?

让我们回到调度器的逻辑。在 SchedulerHostConfig.js 中,React 会根据优先级决定是使用 requestAnimationFrame(RAF)还是 requestIdleCallback(RIC)。

Discrete 事件(点击) 喜欢用 requestAnimationFrame,而且要求同步执行。

想象一下,你正在渲染一个包含 5000 个节点的长列表。React 的渲染机制是分片渲染的,它一帧一帧地画。突然,你点击了一下“保存”按钮。

此时,React 的工作流是这样的:

  1. 捕捉: React 拦截了 click 事件。
  2. 判决: getEventPriority 返回 ImmediatePriority
  3. 调度: 调度器收到 ImmediatePriority,决定立即中断当前正在进行的渲染任务
  4. 执行: React 立即运行事件处理器(你的 onClick),更新状态。
  5. 重启: React 重新开始渲染流程,这次必须把刚才的点击结果画出来。

这就是 DiscreteLane 的本质。它是一个高优先级的“刺头”。它允许调度器在渲染的中途把任务切走。

代码示例:模拟 Discrete 事件的打断

虽然我们不能直接在浏览器里测试 Scheduler,但我们可以通过 React 的文档和原理模拟出这种行为。通常,为了模拟高优先级中断,我们会看到 useEffectcleanup 函数被频繁触发。

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。它们的行为截然不同:

  1. 不可中断: 滚动事件通常不能打断正在进行的渲染任务。如果 React 正在渲染一帧,你滚动了鼠标,React 不会停止渲染去处理滚动,它会等到当前帧渲染完毕。
  2. 延迟处理: 滚动事件会被推入一个队列。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 上挤下去。

第九部分:总结——事件处理的“分诊室”

好了,同学们,我们的源码之旅即将结束。让我们回顾一下今天这个“分诊室”的故事:

  1. 点击:这是一个急性子的病人。它一进门,护士(调度器)就得放下手里的别的事(比如渲染背景),先给它看病。这就是 DiscreteLane。它要求同步、即时、不可阻挡。
  2. 输入:也是一个急性子,理由同上。虽然它来了很多次,但每次都必须立即响应,不能有延迟。这也是 DiscreteLane
  3. 滚动:这是一个慢性子的病人。它虽然也急,但只要医生(React)手里有更急的活,它就得在走廊里等。等医生忙完了,再回头处理它。这就是 ContinuousLane。它允许延迟,允许排队,甚至允许被重放。

React 通过 getEventPriority 将原生事件映射到 ImmediatePriorityUserBlockingPriority,再通过 Lane 模型将这些优先级变成可运算的位,最后由 Scheduler 这个不知疲倦的管家决定到底谁先上机。

最后的建议:

下次你在写代码时,如果遇到性能问题,不要盲目地用 useEffect 或者复杂的逻辑去“优化”点击事件。因为 React 已经帮我们做了最完美的优化:点击事件,永远比你写的其他逻辑都快。

但如果你在处理滚动,请务必小心。因为滚动事件在 React 眼里是“弱势群体”,如果排队的任务太多,它可能永远等不到被处理。这就是为什么我们在处理滚动时,通常只会保存 e.scrollTop 的值,在 useEffect 里去读取,而不是直接在事件回调里做重计算。

希望今天的讲座能让你对 React 的并发模型有一个全新的、充满物理色彩的认知!下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注