事情是这样的。后台系统里有个「导出报表」按钮,点击之后前端要把从接口拿回来的一万两千条订单记录全部渲染到表格里,然后让用户勾选,再触发下载。
上线之后收到一堆反馈:点按钮,页面白一下,光标变沙漏,滚动条拖不动,输入框里打字延迟两秒才出来。有人还以为是电脑卡了,直接重启了 Chrome。
一查 Performance 面板,真凶很明显——主线程被一个 1.8 秒的 Task 占满了。整个渲染过程里每一行数据的 DOM 操作都是同步的,一万两千次循环从头跑到尾,中途谁都不许插队。这就是典型的「长任务」。
为什么 setTimeout 分片不够用了
解决长任务的老办法,很多人第一反应是把循环拆成几段,每段中间用 setTimeout(fn, 0) 或者 MessageChannel 让出去一下。
function renderInChunks(items, chunkSize = 200) {
let index = 0;
function step() {
const end = Math.min(index + chunkSize, items.length);
for (; index < end; index++) {
appendRow(items[index]);
}
if (index < items.length) {
setTimeout(step, 0);
}
}
step();
}
这招能用,但有三个问题始终绕不开。
一是优先级丢了。切成小段之后,这些小块和用户点击事件、输入事件被放在了同一优先级队列里抢时间。用户打字时本来最该优先处理的输入事件,可能被排在好几个渲染小块后面。表现就是打字还是有延迟,只是从「延迟两秒」变成「延迟三下」。
二是每次让出都有最小延迟。根据 HTML 规范,嵌套超过 4 层的 setTimeout 会被强制加上最小延迟。虽然这个最小延迟只有几毫秒,但 12000 条数据切成 60 段,每段丢几毫秒,累计也就上去了。而且这不是处理器忙不忙的问题,是规范强制要求的,你控制不了。
三是代码读起来累。递归调用、索引管理、边界判断,一份「渲染列表」的简单需求被写成了状态机。
Scheduler API 是什么
浏览器里其实一直有个 scheduler 对象,只是之前只有 scheduler.postTask() 一个方法。新提案给它加了两个关键能力:任务优先级和在任务中途主动让出。
先看 postTask:
scheduler.postTask(() => {
console.log('这个任务优先级是 user-visible');
}, { priority: 'user-visible' });
它支持三个优先级,从高到低:
user-blocking:用户直接感知的操作,比如点击响应、输入回显;user-visible:用户能看见但不影响交互的操作,比如列表渲染的后续部分;background:用户基本看不见的,比如日志上报、缓存预热。
默认值是 user-visible。这套优先级和浏览器的输入处理、渲染信号在同一个体系里,所以高优先级的任务真的能插队,而不是像 setTimeout 那样傻等。
然后是主角 scheduler.yield()。它返回一个 Promise,await 它的时候,当前任务会暂停,主线程让出去处理其他事,等轮到本任务恢复时继续往下执行。
async function doWork() {
// ...前半段工作
await scheduler.yield();
// ...后半段工作,此时主线程已经有机会处理其他任务了
}
注意关键词是「让出去」,不是「结束」。这是它和 setTimeout 分片最重要的区别——恢复执行时,同一个任务的优先级会延续,而不是降到最低。这意味着你的分片任务不会被无穷无尽的小任务饿死,能正常往下推进。
实战:把 1.8 秒的卡顿拆成每帧 16 毫秒
回到开头那个场景。原来的渲染逻辑大概是这样的:
function renderAll(items) {
const tbody = document.querySelector('#orders tbody');
const fragment = document.createDocumentFragment();
for (const item of items) {
const row = document.createElement('tr');
row.innerHTML = `
<td><input type="checkbox" data-id="${item.id}"></td>
<td>${item.orderNo}</td>
<td>${item.customer}</td>
<td>${item.amount}</td>
<td>${item.status}</td>
`;
fragment.appendChild(row);
}
tbody.appendChild(fragment);
}
用 scheduler.yield 改造之后:
const RENDER_BUDGET = 12; // 每帧最多占用 12 毫秒,留出时间给浏览器
async function renderAllYielding(items) {
const tbody = document.querySelector('#orders tbody');
let index = 0;
while (index < items.length) {
const start = performance.now();
const fragment = document.createDocumentFragment();
// 在时间预算内尽可能多渲染几行
while (index < items.length && performance.now() - start < RENDER_BUDGET) {
fragment.appendChild(buildRow(items[index]));
index++;
}
tbody.appendChild(fragment);
// 还有剩余数据才让出,全部渲染完就不必让了
if (index < items.length) {
await scheduler.yield();
}
}
}
核心思路是「按时间预算分片」而不是「按固定条数分片」。固定条数分片有个问题:如果某些行的内容特别长(比如客户名带一长串备注),那一块就可能超时。按时间预算看比较靠谱:每块跑满 12 毫秒就停,让出去给浏览器,下一帧再接着干。
12 这个数字不是随便拍的。浏览器一帧通常有 16.7 毫秒的预算,扣掉样式计算、布局、绘制和合成的时间,留给脚本的安全区间大概在 10 到 12 毫秒之间。设得太满,会出现掉帧;设得太保守,渲染总时长会拉长。
用优先级让交互事件插到前面
光让出还不够。页面里还可能有其他事情要处理——用户滚动、点击某个已经渲染出来的行、在搜索框输入内容。这些操作应该比「继续渲染后面的行」更重要。
给渲染任务设个低一点的优先级:
function startRender(items) {
let cancelled = false;
scheduler.postTask(async () => {
const tbody = document.querySelector('#orders tbody');
let index = 0;
while (index < items.length && !cancelled) {
const start = performance.now();
const fragment = document.createDocumentFragment();
while (index < items.length && performance.now() - start < RENDER_BUDGET) {
fragment.appendChild(buildRow(items[index]));
index++;
}
tbody.appendChild(fragment);
if (index < items.length) {
await scheduler.yield();
}
}
}, { priority: 'background' });
return () => { cancelled = true; };
}
注意这里有个坑。scheduler.yield() 恢复执行时,任务优先级会延续父任务的优先级——也就是 background。这恰好是我们要的:整个渲染任务全程都是低优先级,用户点击的 user-blocking 事件永远能插到前面。
演示一下效果。用 Chrome Performance 面板录一段,同时执行渲染和模拟用户点击,改造前的长任务是一个 1.8 秒的红色块,改造后变成了一串 12 毫秒左右的小块,点击事件的响应延迟从 1800 毫秒降到 20 毫秒以内。
和这几个东西的对比
对比 setTimeout(0):setTimeout 的分片会被降优先级,恢复后不一定能顺序推进;scheduler.yield 保持任务自身的优先级,进度可控。
对比 requestIdleCallback:requestIdleCallback 只在浏览器空闲时调用,用户交互频繁时可能一直不触发,任务就卡住了。而且它没法设置优先级,只能等浏览器赏脸。scheduler.yield 是主动让出、尽快恢复,可控性高得多。
对比 Web Worker:Worker 适合纯计算(比如排序、加密、格式转换),但 DOM 操作必须回到主线程,数据还得序列化一次。渲染一万行这种活儿本来就要在主线程做,Worker 帮不上忙。如果是复杂的数据预处理加简单渲染,Worker + scheduler.yield 组合效果最好。
几个细节,写的时候容易踩
一、不是所有地方都该 yield。让出一次是有成本的——调度器要切上下文、重排微任务队列。一个任务总共才跑 3 毫秒,让出两次反而更慢。判断标准:脚本执行时间超过 50 毫秒的,拆;不到 50 毫秒的,别折腾。
二、yield 不能中断同步代码。它是 await,只有 await 出现的位置才是让出点。如果你把一万行渲染塞在一个不会 yield 的循环里,还没跑到 yield 的那句,主线程照样锁死。
三、await 之后的代码不保证立刻执行。中间可能插入其他高优先级任务,等待时间不确定。如果分片逻辑里维护了某个状态(比如当前渲染到第几个),不能假设前后连续,得每次从状态里读。
四、取消要自己做。Scheduler API 本身不提供取消机制。上面那个 cancelled 变量是手写的,任务真正被取消的时机是「下一次 yield 后,检查到 cancelled 为 true 时退出」。所以任务不能长得无法切分,每一小块之间必须有一个可控的出口。
兼容性怎么办
截至现在,scheduler.yield 已经在 Chrome 129+ 上线,Safari 和 Firefox 还在实现中。生产环境用的话,一定要写 fallback:
function yieldToMain() {
if (typeof scheduler !== 'undefined' && typeof scheduler.yield === 'function') {
return scheduler.yield();
}
if (typeof scheduler !== 'undefined' && typeof scheduler.postTask === 'function') {
return new Promise(resolve => scheduler.postTask(resolve, { priority: 'user-visible' }));
}
// 兜底:用 MessageChannel 让出,比 setTimeout 少一层最小延迟
return new Promise(resolve => {
const channel = new MessageChannel();
channel.port1.onmessage = () => resolve();
channel.port2.postMessage(null);
});
}
调用处改成 await yieldToMain(),业务代码不用动。支持 scheduler.yield 的浏览器用原生实现,不支持的走 postTask,再不行退到 MessageChannel。
关于 fallback 要有一个清醒的认知:这些替代方案都无法保留任务的原优先级。也就是说在不支持 scheduler.yield 的浏览器里,你的 background 分片任务恢复后可能会以默认优先级运行,多少会影响优先级设计的预期效果。所以优先级分层这类精细控制,最好当成渐进增强来用,而不是硬性依赖。
什么场景该用,什么场景别硬套
值得用的场景:
- 批量渲染大量 DOM 节点(列表、表格、树);
- 大量数据的本地计算(格式化、统计、聚合);
- 初始化阶段的资源预热,比如提前加载和挂载若干模块;
- 任何单次执行超过 50 毫秒的同步任务。
不值得用的场景:
- 本来就只有几十条数据的循环,改成 yield 反而更慢;
- 纯数据处理、和 DOM 无关的,先用 Web Worker 试试,通常比 yield 更省心;
- 动画驱动逻辑,应该用
requestAnimationFrame——它和渲染节奏绑定,比scheduler.yield更贴合动画场景。
收尾
主线程阻塞这件事,长期以来的解决方案都不太优雅——要么牺牲优先级,要么牺牲代码可读性。Scheduler API 把「让出主线程」这件事做成了一等公民,让分片逻辑看起来就像是普通的 async/await 代码。
改造的时候不用一刀切。先把 Performance 面板打开,找到那些超过 50 毫秒的长任务,一个一个改。改完再录一段对比,肉眼可见的变化是最有说服力的。
上面那个导出报表的页面,改造完之后用户的反馈是「感觉流畅多了」。这就是对的评价。

