React 事件优先级 Discrete 与 Continuous 映射

各位同学好,欢迎来到今天的“React 内部机制深潜”专场。

我是你们的领路人,一个在代码堆里摸爬滚打多年,依然对“为什么我的页面在滚动时会卡顿”这个问题耿耿于怀的前端老兵。

今天我们要聊的东西,听起来可能有点枯燥,甚至有点像是在研究“哲学”。但请相信我,如果你想成为一名真正的“React 专家”,而不是只会写 useStateuseEffect 的“CRUD 工程师”,你必须理解这个概念——事件优先级

特别是 Discrete(离散)Continuous(连续) 这两个家伙。

如果你们把 React 的更新机制比作一个繁忙的餐厅后厨,那么事件优先级就是服务员手中的点餐单。有的单子是“加急的(比如有人摔碎了盘子,老板让你马上修)”,有的是“慢吞吞的(比如有人问厕所在哪)”。如果你把慢吞吞的单子插队到加急单前面,厨房就会炸锅。

React 的并发模式,本质上就是在这个厨房里玩转时间切片的艺术。而今天,我们就来揭开这层神秘的面纱,看看这些优先级是怎么映射的,又是如何决定你的组件渲染速度的。

准备好了吗?系好安全带,我们要进去了。


第一章:单线程的诅咒与救赎

首先,我们得面对现实。JavaScript 是单线程的。

这意味着什么?意味着如果你在 onClick 里写了一个死循环,或者执行了一个耗时的 while(true),整个浏览器,包括你正在滚动的页面、正在播放的视频,都会瞬间冻结。就像你正在和女神/男神视频聊天,突然屏幕卡死,然后对方发来一个问号:“???”。

React 15 之前,就是这样的暴君。你点击一下,React 就会疯狂地把整个树渲染一遍,直到结束。如果你在渲染过程中又来了一个点击事件?对不起,排队。这就是 React 15 的“同步渲染”。

React 16 之后,我们引入了 Fiber 架构。Fiber 的核心思想就是可中断渲染。React 把渲染任务切成无数个小块,就像切披萨一样。渲染一会儿,停下来看看有没有更紧急的事情(比如点击事件),如果有,就插队先处理紧急的,处理完再回来切下一块披萨。

但是,谁来决定什么是“紧急的”,什么是“不紧急的”?

这就是我们今天的主角——事件优先级


第二章:三大阵营的划分

在 React 的世界里,事件被分成了三大派系:

  1. Discrete Events(离散事件):这是“急性子”。点击、键盘输入、提交表单。这些事情发生得突然,用户期望立即得到反馈。如果不马上处理,用户会以为页面坏了。
  2. Continuous Events(连续事件):这是“粘人精”。滚动、触摸移动、鼠标移动。这些事情发生得非常频繁,可能每秒几十次甚至上百次。如果每次都“急性子”地全量渲染,浏览器绝对会当场去世。
  3. Idle Events(空闲事件):这是“摸鱼党”。setTimeoutrequestAnimationFrameuseEffect。这些事情可以在浏览器不忙的时候做。

为了方便记忆,我们可以用以下类比:

  • Discrete(离散):就像你在过马路时突然看到一辆车冲过来。你必须马上跑,不能等。
  • Continuous(连续):就像你在听一首很长的歌。你可以一边听一边做别的事,但这首歌是连续不断的,你不能把歌切断了听。
  • Idle(空闲):就像你在下班后的摸鱼时间。老板(浏览器主线程)不叫你,你就在那儿慢慢干。

第三章:映射关系大揭秘

这是今天的重头戏。当你在浏览器里按下鼠标,或者滚动滚轮时,这个 DOM 事件是如何一步步变成 React 的调度优先级的?

React 内部维护了一个映射表。这个表就像是一个翻译官,把浏览器的原生事件翻译成 React 能听懂的语言。

1. Discrete Events(离散事件)

这些事件一旦触发,就会立即被标记为最高优先级。React 会打断当前正在进行的任何低优先级渲染,优先执行这些更新。

映射列表:

  • click
  • input (大部分情况)
  • change
  • keydown
  • keyup
  • submit
  • focus
  • blur

代码视角(模拟):

// 这是在 React 内部源码中的一个简化逻辑
function dispatchDiscreteEvent(type, listener, event) {
  // 1. 记录开始时间,用于性能分析
  const prevEventTime = currentEventTime;
  currentEventTime = performance.now();

  // 2. 设置当前事件优先级为 Discrete
  // 注意:这会触发 React 的中断机制
  runWithPriority(DiscreteEventPriority, () => {
    // 3. 执行事件处理函数
    listener(event);
  });

  // 4. 处理完之后,更新时间戳
  currentEventTime = prevEventTime;
}

为什么是 Discrete?
因为用户期望的是“交互即响应”。你点一下按钮,按钮马上变色。如果你点了半天没反应,用户的手指都会抽筋。

2. Continuous Events(连续事件)

这些事件是高频触发的。如果每次都让 React 全量渲染,那页面就是一帧一帧的“幻灯片”。

映射列表:

  • scroll
  • mouseenter
  • mouseleave
  • touchmove
  • animationstart
  • transitionend

代码视角:

function dispatchContinuousEvent(type, listener, event) {
  // Continuous 事件的优先级低于 Discrete,但高于 Idle
  runWithPriority(ContinuousEventPriority, () => {
    listener(event);
  });
}

为什么是 Continuous?
因为滚动是一个连续的过程。当你滚动页面时,React 不会立即把整个虚拟 DOM 树重新渲染一遍。它会把这些滚动事件收集起来,放在一个队列里,然后在浏览器的下一帧(requestAnimationFrame)到来时,批量处理这些更新。

这就像你在刷抖音,视频是连续播放的,你不能让视频卡顿一下再播放下一帧,那样看着难受。React 对滚动事件的处理,就是尽量保持这种流畅性。

3. Idle Events(空闲事件)

这些事件通常由开发者主动触发,或者系统在后台运行。

映射列表:

  • setTimeout (默认)
  • setInterval
  • requestAnimationFrame
  • useEffect
  • useLayoutEffect

代码视角:

function scheduleCallback(priorityLevel, callback, options) {
  // 如果是 Idle 级别,React 会在浏览器空闲的时候才执行
  // 甚至可能根本不执行,因为浏览器还有其他任务要做
  if (priorityLevel === IdleEventPriority) {
    // 这里会调用浏览器的 IdleCallback API
    return requestIdleCallback(callback, options);
  }
}

为什么是 Idle?
因为 useEffect 做的事情通常是副作用,比如发送网络请求、埋点统计。这些事情如果不阻塞当前的渲染,那当然是越晚越好。React 18 甚至引入了 useDeferredValue,专门用来把用户的输入事件(Discrete)转换成 Idle 优先级,让输入更流畅。


第四章:实战演练——为什么你的滚动条卡住了?

为了让大家更深刻地理解,我们来写一个“反面教材”。

假设你有一个列表,里面有 10000 条数据。你监听了一个 scroll 事件,在这个事件里,你直接修改了状态,导致整个列表重新渲染。

function BadComponent() {
  const [items, setItems] = useState(generateHugeList());
  const [filter, setFilter] = useState('');

  const handleScroll = (e) => {
    // 危险!这是 Continuous 事件
    // 但是我们在里面做了极其耗时的计算和状态更新
    console.log('Scrolled!');

    // 假设这里有个很复杂的过滤逻辑
    const filtered = items.filter(item => item.includes(filter));

    // 更新状态,触发重渲染
    setItems(filtered); 
  };

  return (
    <div onScroll={handleScroll} style={{ height: '500px', overflow: 'auto' }}>
      <ul>
        {items.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

发生了什么?

  1. 用户开始滚动。
  2. onScroll 触发。这是 Continuous 事件。
  3. React 把这个事件放入队列,等待下一帧处理。
  4. 下一帧来了,React 开始处理这个 setItems
  5. 但是!React 正在渲染页面的其他部分,或者正在处理用户的另一个点击事件。
  6. 因为 setItems 的优先级是 Continuous,它会打断其他 Continuous 事件,但可能被 Discrete 事件(点击)打断。
  7. 更糟糕的是,如果列表很大,重新渲染 10000 个 DOM 节点需要时间。这段时间里,用户还在滚动。
  8. React 收集了更多的滚动事件。
  9. 渲染还没完成,又来了新事件。React 只能再次中断,重新排队。
  10. 结果就是:页面卡死,滚动条不动,用户心态爆炸。

第五章:解药——React 18 的并发特性

既然知道了病因,那怎么治?

React 18 提供了两个神器:startTransitionuseDeferredValue。它们的核心思想就是:把高频的 Continuous 事件,降级处理。

1. startTransition

startTransition 允许你把一个状态更新标记为“低优先级”或“过渡性”的。

让我们修改上面的代码:

import { useState, startTransition } from 'react';

function GoodComponent() {
  const [items, setItems] = useState(generateHugeList());
  const [filter, setFilter] = useState('');
  const [isPending, setIsPending] = useState(false);

  const handleScroll = (e) => {
    // 滚动事件本身依然是 Continuous 的
    // 但我们在里面做了一个 startTransition
    setIsPending(true);

    startTransition(() => {
      // 这里的更新变成了 Transition Priority
      // 它的优先级低于普通的 Discrete 和 Continuous 事件
      setFilter(e.target.value);
    });
  };

  return (
    <div onScroll={handleScroll} style={{ height: '500px', overflow: 'auto' }}>
      <input onChange={handleScroll} placeholder="Type to filter..." />
      <div style={{ color: isPending ? 'red' : 'black' }}>
        {isPending ? 'Updating huge list...' : 'Ready'}
      </div>
      <ul>
        {items.map(item => <li key={item}>{item}</li>)}
      </ul>
    </div>
  );
}

原理分析:

当你滚动时,handleScroll 触发。setFilter 被包裹在 startTransition 里。
React 会把这次更新标记为 TransitionPriority

  • 当用户继续滚动(连续事件)时,React 会优先处理滚动事件本身,保证滚动流畅。
  • 当用户点击了一个按钮(离散事件)时,React 会立即中断滚动更新,优先处理按钮点击。
  • 只有在浏览器空闲的时候,React 才会慢慢去更新那个巨大的列表。

2. useDeferredValue

这个 API 更简单。它可以把一个状态值“延迟”一下。通常用于处理输入框。

import { useState, useDeferredValue } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  // deferredQuery 是 query 的“延迟版”
  const deferredQuery = useDeferredValue(query);
  const [results, setResults] = useState([]);

  useEffect(() => {
    // 模拟搜索 API
    const timer = setTimeout(() => {
      setResults(searchApi(deferredQuery));
    }, 100);
    return () => clearTimeout(timer);
  }, [deferredQuery]);

  return (
    <div>
      <input 
        value={query} 
        onChange={e => setQuery(e.target.value)} 
        placeholder="Search..."
      />
      {/* 这里渲染的是 deferredQuery 对应的结果 */}
      <SearchResults list={results} />
    </div>
  );
}

映射关系在这里体现得淋漓尽致:

  1. 用户输入 ‘a’。这是 Discrete 事件。setQuery('a')query 立即变成 ‘a’。输入框显示 ‘a’。
  2. React 看到输入框更新了,它准备渲染 <SearchResults list={results} />
  3. 但是,React 发现 deferredQuery 还是旧的值(比如 ‘b’)。React 会把这次渲染标记为低优先级。
  4. 此时,用户继续输入 ‘b’。这是新的 Discrete 事件。
  5. React 立即响应输入框的更新(显示 ‘ab’),因为这是 Discrete 事件。
  6. 当浏览器稍微空闲一点时,React 才会去处理 deferredQuery 的变化,触发搜索请求。

这就像是: 你的手(输入)比你的脑子(搜索)快。useDeferredValue 就是给脑子加了个缓冲区,让手能先快快地动,别等脑子。


第六章:深入 Scheduler——时间的魔术师

要真正理解这些优先级,我们不能只停留在 API 层面。我们要看看底层的 Scheduler 包。

React 源码里有一个独立的包叫 scheduler。它不依赖 React,可以在其他地方使用。它负责管理任务队列和优先级。

优先级层级:

Scheduler 里,优先级被量化了:

  1. ImmediatePriority (1) – 最高优先级,用于 click 等。
  2. UserBlockingPriority (2) – 用户阻塞级,用于 scroll 等。
  3. NormalPriority (3) – 普通。
  4. LowPriority (4) – 低优先级。
  5. IdlePriority (5) – 空闲优先级。

代码示例:模拟 Scheduler 的运行

虽然我们不能直接在浏览器里运行 React 源码,但我们可以用 JS 写一个简单的调度器来演示:

// 模拟任务队列
const taskQueue = [];

// 模拟调度函数
function schedule(priorityLevel, callback) {
  const task = {
    id: Math.random(),
    priority: priorityLevel,
    callback: callback
  };

  // 按优先级排序(数字越小优先级越高)
  taskQueue.push(task);
  taskQueue.sort((a, b) => a.priority - b.priority);

  // 模拟浏览器主线程执行
  if (taskQueue.length > 0) {
    const task = taskQueue.shift();
    console.log(`Executing task with priority: ${priorityLevel}`);
    task.callback();
  }
}

// 模拟用户操作
console.log("--- User starts scrolling ---");
schedule(2, () => console.log("Scroll event handled (UserBlocking)"));

setTimeout(() => {
  console.log("--- User clicks a button ---");
  // 离散事件优先级最高,会打断滚动
  schedule(1, () => console.log("Button click handled (Immediate)"));
}, 100);

setTimeout(() => {
  console.log("--- User takes a nap (Idle) ---");
  // 空闲事件,等滚动和点击都做完了才做
  schedule(5, () => console.log("Idle task handled (Idle)"));
}, 300);

输出结果:

--- User starts scrolling ---
Executing task with priority: 2
Scroll event handled (UserBlocking)
--- User clicks a button ---
Executing task with priority: 1
Button click handled (Immediate)
--- User takes a nap (Idle) ---
Executing task with priority: 5
Idle task handled (Idle)

看懂了吗?这就是 React 的灵魂。当用户点击时,React 会从队列里拿出最高优先级的任务(Immediate),哪怕它正在处理滚动任务(UserBlocking),也会立刻打断滚动,去处理点击。

这就是交互响应性的保证。


第七章:那些“坑”里的坑

作为资深专家,我必须提醒你们,理解了优先级,能帮你避开很多坑。这里有几个常见的“陷阱”。

陷阱一:在 useEffect 里做重计算

useEffect(() => {
  // 这里的逻辑通常会被标记为 Idle Priority
  // 如果你在 useEffect 里做了大量计算,可能会阻塞浏览器的空闲时间
  // 导致页面在用户没操作的时候反而卡顿
  const expensiveCalculation = heavyComputation();
  console.log(expensiveCalculation);
}, [dependency]);

为什么不好?
虽然 useEffect 不会阻塞渲染(它是异步的),但如果计算量太大,它会占用浏览器的空闲时间。如果用户此时正在滚动页面,浏览器的空闲时间被占用了,滚动就会卡顿。

解决方案:
把重计算放到 useMemo 里,或者使用 requestIdleCallback

陷阱二:不恰当地使用 setTimeout

setTimeout(() => {
  setCount(count + 1);
}, 1000);

这个 setTimeout 默认是 Idle Priority。这意味着,如果你在 1 秒内疯狂点击按钮(Discrete Priority),React 会优先处理所有的点击事件,把 setTimeout 里的更新挤到最后。

这有什么问题?
通常没问题,但如果你需要确保某个逻辑在点击之后立即执行,而不是排队等待,那你就得用 setTimeout(..., 0) 或者 queueMicrotask

陷阱三:混淆 useLayoutEffect 和 useEffect

这是一个经典的“性能陷阱”。

  • useEffect:在浏览器绘制之后运行。这是 Continuous 或 Idle 优先级(取决于调度)。
  • useLayoutEffect:在浏览器绘制之前运行。它会阻塞浏览器的下一次重绘。
function LayoutEffectDemo() {
  const [count, setCount] = useState(0);

  // 危险!这会阻塞浏览器
  useLayoutEffect(() => {
    if (count > 10) {
      document.body.style.backgroundColor = 'red';
    }
  }, [count]);

  return <button onClick={() => setCount(c => c + 1)}>Click me</button>;
}

当你点击按钮时,useLayoutEffect 会立即执行。它会阻塞浏览器把按钮画出来。用户看到的是按钮瞬间闪一下变红,然后才显示点击后的状态。这会造成视觉上的闪烁。

正确做法:
把这种影响布局的操作放到 useEffect 里。


第八章:进阶——自定义优先级

React 18 并没有直接暴露一个“自定义优先级”的 API 给普通用户。但是,通过 startTransition,我们已经掌握了自定义优先级的精髓。

如果你想手动控制某个任务,你可以使用 Scheduler 的 API(在 React 18 的源码里)。

// 这是一个非常高级的用法,通常不需要
import { unstable_runWithPriority as runWithPriority, 
         unstable_IdlePriority, 
         unstable_UserBlockingPriority } from 'scheduler';

function customPriorityExample() {
  // 假设你有一个任务,你想让它比普通事件低,但比空闲高
  runWithPriority(unstable_UserBlockingPriority, () => {
    // 执行你的任务
    doSomething();
  });
}

但在实际开发中,我们更常使用的是 startTransition。它就像是 React 给你提供的一把万能钥匙,让你能够把任何你想“降级”的更新都变成低优先级。


第九章:总结与展望

好了,同学们,我们的讲座接近尾声。

今天我们聊了什么?

我们聊了 React 如何通过事件优先级来管理浏览器的主线程。
我们了解了 Discrete(离散) 事件是急性子,必须马上处理。
我们了解了 Continuous(连续) 事件是粘人精,需要批量处理。
我们了解了 Idle(空闲) 事件是摸鱼党,能拖就拖。

我们看到了 映射关系click -> DiscreteEventPriorityscroll -> ContinuousEventPriority
我们学会了如何使用 startTransitionuseDeferredValue 来避免滚动卡顿,提升用户体验。

React 的并发模式,本质上就是一场关于时间优先级的精密舞蹈。React 就像一个经验丰富的指挥家,它知道在什么时候该让小提琴(UI 渲染)响起来,在什么时候该让大鼓(重计算)停下来,只为了一首和谐的交响曲。

最后,送给大家一句话:

永远不要让你的用户等待。如果用户在等待,那就是你的代码写得太慢了;如果用户在等待时还在卡顿,那就是你的优先级搞错了。

希望大家在未来的开发中,能像 React 内部那样,时刻关注任务的优先级,写出流畅、丝滑、像黄油一样顺滑的前端应用!

下课!下课!

(敲黑板的声音)


附录:完整代码示例集合

为了方便大家复习,我把刚才提到的几个核心概念封装成了几个完整的组件,你可以直接在项目中复制粘贴测试。

1. 滚动卡顿演示

import React, { useState } from 'react';

/**
 * 这是一个反面教材组件
 * 展示了如果在 scroll 事件中直接更新状态,会导致滚动卡顿
 */
export default function ScrollJankDemo() {
  const [items, setItems] = useState(generateItems(10000));
  const [scrollCount, setScrollCount] = useState(0);

  const handleScroll = () => {
    setScrollCount(prev => prev + 1);
    // 这里是性能杀手!
    // 每次 scroll 都会触发 10000 个 DOM 的重新渲染
    setItems(prev => prev.map(item => ({ ...item, id: item.id + 1 })));
  };

  return (
    <div style={{ border: '1px solid #ccc', height: '300px', overflow: 'auto' }}>
      <div style={{ height: '500px' }}>
        <h3>Scroll Jank Demo (Click me to scroll)</h3>
        <p>Scroll count: {scrollCount}</p>
        <ul>
          {items.map(item => (
            <li key={item.id}>{item.text}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

function generateItems(count) {
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    text: `Item ${i}`
  }));
}

2. 使用 useDeferredValue 的优化版

import React, { useState, useDeferredValue } from 'react';

/**
 * 这个组件展示了如何使用 useDeferredValue 优化搜索体验
 */
export default function SearchWithDebounce() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  const [results, setResults] = useState([]);

  // 这里是关键:只有 deferredQuery 变化时才触发搜索
  // 这意味着用户的输入不会阻塞搜索请求
  React.useEffect(() => {
    // 模拟异步搜索
    const timer = setTimeout(() => {
      console.log('Searching for:', deferredQuery);
      setResults(simulateSearch(deferredQuery));
    }, 300); // 300ms 延迟,模拟网络请求
    return () => clearTimeout(timer);
  }, [deferredQuery]);

  const handleChange = (e) => {
    // 直接更新 query,输入框响应会非常快
    setQuery(e.target.value);
  };

  return (
    <div>
      <input 
        type="text" 
        value={query} 
        onChange={handleChange} 
        placeholder="Type to search..."
      />
      <div style={{ marginTop: '20px' }}>
        <h4>Results:</h4>
        <ul>
          {results.map((item, index) => (
            <li key={index}>{item}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

function simulateSearch(query) {
  // 模拟返回结果
  return [
    `${query} - Result 1`,
    `${query} - Result 2`,
    `${query} - Result 3`
  ];
}

3. 优先级控制演示

import React, { useState, useEffect } from 'react';
import { unstable_runWithPriority, unstable_IdlePriority, unstable_Now } from 'scheduler';

/**
 * 这个组件演示了手动控制任务优先级
 */
export default function PriorityDemo() {
  const [log, setLog] = useState([]);

  const addLog = (message, priority) => {
    const time = unstable_Now();
    setLog(prev => [...prev, { time, message, priority }]);
  };

  const handleIdleTask = () => {
    // 这是一个 Idle Priority 任务
    unstable_runWithPriority(unstable_IdlePriority, () => {
      addLog("Running Idle Task", "Idle");
      // 模拟耗时操作
      const start = performance.now();
      while (performance.now() - start < 100) {} 
    });
  };

  const handleBlockingTask = () => {
    // 这是一个 UserBlocking Priority 任务
    unstable_runWithPriority(unstable_UserBlockingPriority, () => {
      addLog("Running Blocking Task", "Blocking");
      // 模拟耗时操作
      const start = performance.now();
      while (performance.now() - start < 100) {} 
    });
  };

  return (
    <div style={{ padding: '20px' }}>
      <h3>Task Priority Demo</h3>
      <p>Click the buttons to see when tasks are executed.</p>
      <div style={{ display: 'flex', gap: '10px' }}>
        <button onClick={handleBlockingTask}>Click Me (Blocking)</button>
        <button onClick={handleIdleTask}>Click Me (Idle)</button>
      </div>

      <div style={{ marginTop: '20px', fontFamily: 'monospace', fontSize: '12px' }}>
        <h4>Execution Log:</h4>
        <ul>
          {log.map((entry, i) => (
            <li key={i} style={{ color: entry.priority === 'Blocking' ? 'red' : 'green' }}>
              [{entry.time}] {entry.message} ({entry.priority})
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

希望这些代码能帮你更好地理解 React 的事件优先级机制!

发表回复

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