Web 平台 API / 事件循环 · 任务、microtask,与那一圈的所有时机 / 落地:框架与性能优化,本质都是在与事件循环打交道 待审核 6 / 6
applications · 框架与工程里的事件循环

落地:框架与性能优化,本质都是在与事件循环打交道

前五页的机制并非纸上谈兵——常用框架与各条性能建议,底层都是在选取事件循环里的某个时机。本页把它们串联起来:Vue 用 microtask 批量更新React 用 MessageChannel 做时间切片滚动用 rAF 而非 setTimeout长任务需切碎并让出主线程以保住交互响应 (INP)。最后给出一张「需求与方案对照」速查表。

1 · 长任务会冻住页面——亲手感受切片

一段耗时约 800ms 的工作,放在主线程上一次性执行完,会阻塞渲染和点击;切成小块、块间让出主线程,页面便能持续响应。下面左侧的转盘由 rAF 驱动(理应持续转动),旁边的按钮统计可点击的次数——对比两种模式下的差别:

图 1-1 · 800ms 工作在一次性执行与切片让出两种模式下的对照:rAF 转盘是否持续转动、按钮的可点击次数与卡顿帧数。

注 · 退路取 MessageChannel 而非 setTimeout(r, 0):切片是连环嵌套,从第六跳起每跳被钳到 4ms,切 250 次即平白多耗约 1 秒;MessageChannel 不受这道下限约束。这也是 React Scheduler 当年的选择。

2 · 框架里的事件循环

Vue 的 nextTick = 一个 microtask

Vue 修改数据不会同步更新 DOM,而是把更新放入队列,用一个 microtask (Promise.then) 在当前同步代码执行完毕后一次性 flush。因此同一个 tick 里修改 100 次,只渲染 1 次(批处理,正是第 3 页的 microtask 结算)。await nextTick() 即「等待这个 microtask 执行完毕、DOM 已更新」。

React Scheduler = MessageChannel + 时间切片

React (Fiber) 把渲染拆成小单元,每执行约 5ms 就检查是否应当让出;让出靠 MessageChannel 排一个 macrotask,把控制权交还给浏览器去处理输入和绘制,下一个 macrotask 再继续渲染。这使得大组件树更新时页面不会卡死——用的全是第 2 页讲的「不被限流的即时 macrotask」。新版还在向 scheduler.postTask / isInputPending 迁移。

3 · 滚动与动画的正确工具:rAF

scroll / resize / mousemove 触发极为频繁,直接在事件里改样式会一帧修改多次、做无用功。正确做法是用 rAF 节流到每帧最多一次:事件里只记下最新值,在 rAF 回调里读一次、写一次(赶在绘制前):

图 3-1 · 用 rAF 把高频 scroll 事件节流到每帧一次的写法对照。

4 · 需求与方案对照

需求 方案 理由
同步代码后、渲染前插入处理 (批量 DOM、读最新状态) queueMicrotask / Promise.then microtask 紧贴当前任务尾,早于渲染
把工作推迟到「稍后」,让出主线程 setTimeout / MessageChannel macrotask,本轮 microtask+渲染之后
做动画、改样式不撕裂 requestAnimationFrame 绘制前执行,对齐刷新率
节流高频事件 (scroll/resize) requestAnimationFrame 每帧最多一次,避免无用功
不紧急的后台任务 (预取、日志) requestIdleCallback / postTask(background) 绘制后趁空闲执行,不抢占交互
长时间计算不冻结 UI 切片 + scheduler.yield / MessageChannel 块间让出,保住输入响应
多个任务需分优先级 / 可取消 scheduler.postTask 三档优先级 + AbortController

注 · 一根主线贯穿全系列:主线程只有一个,事件循环按「macrotask → microtask 清空 → 渲染 (rAF) → 空闲 (rIC)」转圈。所有关于卡顿与响应是否及时的问题,本质都是代码被放在了这一圈的哪个时机是否按时让出主线程。现代指标 INP (Interaction to Next Paint) 量的就是这件事:从用户交互到下一次绘制的延迟——别让长任务堵在中间。