落地:框架与性能优化,本质都是在与事件循环打交道
前五页的机制并非纸上谈兵——常用框架与各条性能建议,底层都是在选取事件循环里的某个时机。本页把它们串联起来:Vue 用 microtask 批量更新、React 用 MessageChannel 做时间切片、滚动用 rAF 而非 setTimeout、长任务需切碎并让出主线程以保住交互响应 (INP)。最后给出一张「需求与方案对照」速查表。
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 回调里读一次、写一次(赶在绘制前):
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) 量的就是这件事:从用户交互到下一次绘制的延迟——别让长任务堵在中间。