
前阵子做一次列表页的性能复盘,我们盯着 INP 那条红线发愁:点一下筛选按钮,要等小半秒界面才有反应。代码翻下去,元凶是一段几千条数据的同步处理——它把主线程整个占住了,用户那一下点击只能排队干等。
老办法很多人都会:把这段长任务切开,中间让一让,给点击事件留口子。最常见的写法就是 setTimeout(fn, 0)。当时我也是这么改的,卡顿确实缓解了,但总觉得"让"得不够干脆——有时候点击响应回来了,可我那段计算的后半截,迟迟轮不上。
后来换成 scheduler.yield(),同样是"让一让",体感却明显不一样:点击立刻有反馈,而我的计算后半段又几乎马上接着跑完。差别到底在哪?就在四个字——优先级。这篇咱们就顺着优先级这条线,把它讲透。
怕有同学一上来就被绕晕,咱先把最基础的讲清楚,看懂这段再往下读。
它是浏览器新出的一个内置函数,你不用装任何库,直接写 await scheduler.yield() 就能用(Chrome / Edge / Firefox 已支持)。
它解决的是"页面卡死"这个老大难问题。 浏览器跑 JS 是"单线程"的——同一时间只能干一件事。如果你有一段计算特别重(比如处理上万条数据),它一口气跑起来就会把这条唯一的线程整个占住,这期间用户点按钮、滚页面,浏览器全都顾不上,表现出来就是"页面卡死了、点啥都没反应"。
scheduler.yield() 的作用,就是让你能在这段长计算的中途主动喊一声"我先停一下",把控制权交还给浏览器,让它赶紧去响应那些等着的用户操作;等浏览器忙完,再回来接着跑你没跑完的部分。
没有 yield:一段长计算一口气霸占主线程
[━━━━━━━━ 长计算(800ms) ━━━━━━━━] ← 这期间页面完全卡死
用户点击只能干等
用 yield:中途让一让,把缝隙留给用户
[━ 计算 ━] 让 [点击响应] [━ 计算 ━] 让 [画一帧] [━ 计算 ━]
↑ scheduler.yield() 就插在这些"让"的位置
你可能会问:"这不就是 setTimeout(fn, 0) 干的事吗?以前不也能切?" 能,但 scheduler.yield() 切得更聪明——区别就在它让完之后能保住自己的优先级,而 setTimeout 不能。这正是下面要重点讲的。
浏览器主线程是单线程的,同一时间只能干一件事。你写的一段长 JS 在跑的时候,用户的点击、浏览器的渲染,全都得排在它后面。所谓"切长任务",本质是主动把一整块活儿剁成几段,每段之间插一个"喘息点",让浏览器趁这个缝隙去处理那些等着的高优先级活儿(比如响应点击、画一帧)。
setTimeout(fn, 0) 也能制造这个喘息点。问题不在于它让不让,而在于——让完之后,你的后半段排在了队伍的什么位置。
这就要看浏览器任务队列里那套隐性的优先级规则了。同样是"待办任务",它们的地位并不平等:
浏览器任务优先级(从高到低,简化版)
用户交互 ──→ 点击 / 输入 / 键盘
(最该优先,不能让用户等)
│
▼
渲染相关 ──→ 样式计算 / 布局 / 绘制
│
▼
普通宏任务 ──→ setTimeout 回调 ★ 你的后半段在这
│
▼
空闲回调 ──→ requestIdleCallback(有空才跑)
看出门道了吗?当你用 setTimeout 把后半段甩出去,它就掉到了"普通宏任务"这一层。只要队列里还有别的 setTimeout、别的宏任务排在前面,你的后半段就得乖乖等它们跑完。这就是我当初那种"让得不够干脆"的感觉来源:不是没让,是让完之后自己被挤到后面去了。
用银行排队来理解会很直观。 你在银行办业务办到一半,想去趟洗手间(让出主线程)。setTimeout 的做法相当于:你直接撕掉手里的号,回来重新取一张新号——前面新来的人全排你前头了,你只能从队尾重新等。这一让,代价是把之前排的队全作废了。
setTimeout 的"让":撕号重排
你办到一半 → 去洗手间 → 撕掉旧号
│
▼
回来重新取号:A057
┌────────────────────────────────┐
│ A053 A054 A055 A056 │ A057(你) │
│ ← 这些都排在你前面 ← │ 你在最后 │
└────────────────────────────────┘
结果:白等一轮
scheduler.yield() 的核心设计,一句话概括就是:它让出执行权给浏览器处理高优先级的活儿,但你那段任务的后半段,优先级被原样保留下来。等高优先级的活儿处理完,轮到普通任务时,你的后半段会排在"同级队伍的最前面",而不是被丢到队尾。
还是银行那个例子。scheduler.yield() 相当于一家有"叫号保留"机制的银行:你去洗手间前跟柜员说一声,号还给你留着。期间柜员会先处理那些插队的加急业务(高优先级的点击、渲染),但你一回来,马上就轮到你,不用从队尾重排。
scheduler.yield() 的"让":号留着
你办到一半 → 去洗手间 → 号(A053)保留
│
柜员先处理加急 ────┤(点击/渲染插进来先做)
│
▼
你回来:还是 A053,排在普通队伍最前面
┌────────────────────────────────┐
│ A053(你) │ A054 A055 A056 ... │
│ 立刻轮到 │ 其他人在你后面 │
└────────────────────────────────┘
结果:既让了路,又没白等
对比一下这两段几乎一样的代码,差别全在最后那个"让"字怎么写:
// 写法一:setTimeout 切分 —— 让完掉队尾
function handleHeavyWork() {
processChunk1();
setTimeout(processChunk2, 0); // 后半段沦为普通宏任务,前面有谁就等谁
}
// 写法二:scheduler.yield() 切分 —— 让完保位置
async function handleHeavyWork() {
processChunk1();
await scheduler.yield(); // 让出主线程,但后半段优先级不变
processChunk2(); // 高优先级处理完,立刻轮到我
}
两段逻辑上做的是同一件事:处理完前半段,喘口气,再处理后半段。但写法二里,processChunk2 既给点击事件让了路,又不会被其他普通任务插队——这正是"响应快、收尾也快"的来源。
把两种写法的主线程时间轴摆在一起,差别一眼就看出来(假设让出那一刻,队列里正好还堆着别人的两个 setTimeout):
setTimeout 切分:后半段被别人插到前面
─────────────────────────────────────────────
前半段 │ 处理点击 │ 别人任务A │ 别人任务B │ 后半段
让出↑ ↑你在这才轮到
(拖了好久)
scheduler.yield() 切分:让完立刻轮到你
─────────────────────────────────────────────
前半段 │ 处理点击 │ 后半段 │ 别人任务A │ 别人任务B
让出↑ ↑你紧接着就跑完
(别人被排到你后面)
它能做到这点,靠的是返回一个 Promise。await 之后的后半段,会被调度成一个带着原优先级的任务重新入队——注意,这跟"微任务"不是一回事:它确实把执行权真正让给了浏览器(渲染、输入这些该插队的都能插进来),只是当轮到普通任务时,它凭借保留下来的优先级排在了同级队伍的最前面。而 setTimeout 的回调,既没这个优先级,又得等下一轮,自然就慢半拍。
把它放回我们最开始那个筛选按钮的场景,写法会非常自然:
TypeScript 版:
async function onFilterClick(): Promise<void> {
// 1. 先给用户一个立刻能看见的反馈
showLoadingSpinner();
// 2. 让出主线程,让这次点击的视觉反馈先画出来
await scheduler.yield();
// 3. 再跑那段重计算,此时优先级仍被保留,不会被别的任务插队
const result = runHeavyFilter(rawList);
renderList(result);
}
JavaScript 版(逻辑完全一致,去掉类型标注即可):
async function onFilterClick() {
showLoadingSpinner();
await scheduler.yield();
const result = runHeavyFilter(rawList);
renderList(result);
}
用户的体验是:手指刚离开按钮,加载动画立刻出现(因为渲染被让出去先做了),紧接着结果几乎无缝刷出来(因为重计算保住了优先级,没被别人挤掉)。这种"跟手"的感觉,就是 INP 想衡量的东西。
这种"大循环卡主线程"的场景,平时其实到处都是,比如:
这些场景的共同点是:一个停不下来的大循环。处理思路也一样——在循环里按时间片定期 yield,既不卡顿又不拖慢整体:
async function processInChunks(items) {
let lastYield = performance.now();
for (const item of items) {
handle(item);
// 每跑够 50ms 就让一让,把交互的口子留出来
if (performance.now() - lastYield > 50) {
await scheduler.yield();
lastYield = performance.now();
}
}
}
画成图,就是把"一口气跑完的长条"剁成几个 50ms 的小段,每段之间留个缝给用户:
不切:一口气跑 800ms ── 全程卡死,点啥都没反应
████████████████████████████████ ✗
切片:每 50ms 让一让 ── 缝里能响应点击、画一帧
████ ┊ ████ ┊ ████ ┊ ████ ┊ ████ ✓
让 让 让 让
(这些缝隙就是用户"点得动"的瞬间)
如果整段活儿本身就不急,可以用 scheduler.postTask() 把它标成低优先级丢到后台,而 scheduler.yield() 在它内部依然好使:
async function backgroundJob() {
part1();
await scheduler.yield(); // 后半段排在其他 background 任务之前,但仍让位给高优先级
part2();
}
scheduler.postTask(backgroundJob, { priority: "background" });
这里的"相对优先级"很关键:后半段会排在其他 background 任务前面,但依旧让位给点击、渲染这些高优先级的活儿。整段任务对外是"背景级",对内仍保持着自己的次序。
讲了这么多好处,也得泼盆冷水。截至 2026 年中,scheduler.yield() 在 Chrome、Edge、Firefox 都已支持(Firefox 是 2025 年 8 月跟上的),但 Safari 至今还没实现,所以它还没进入 Baseline("广泛可用")行列。这意味着你不能裸用,得做兜底:
// 简易兜底:没有 scheduler.yield 就退回 setTimeout
function yieldToMain() {
if (typeof scheduler !== "undefined" && scheduler.yield) {
return scheduler.yield();
}
return new Promise(resolve => setTimeout(resolve, 0));
}
更省心的做法是上官方的 scheduler-polyfill,但要心里有数:polyfill 模拟不了"优先级继承"那部分能力——在不支持的浏览器里,它退化成的还是 setTimeout 那套"让完掉队尾"的行为。换句话说,Safari 用户享受不到"保位置"的红利,但至少不会报错、不会卡死。
另外,切任务不是越碎越好。每次 yield 都有调度开销,如果你的任务本来就只有十几毫秒,硬切反而得不偿失。一般以 单段不超过 50ms 作为参考线——这也是浏览器判定"长任务"的门槛。
把长任务切开这件事,大家其实都在做;真正容易被忽略的是"切完之后,后半段排到哪去了"。setTimeout 让你掉到队尾,scheduler.yield() 让你既让了路又守住了位置——同样一行"让一让",体感天差地别。下次再写那种会卡主线程的重计算时,不妨先问自己一句:这地方,是不是该让它"让权不让位"?
聊聊你的情况👇
你项目里的长任务,现在还是用 setTimeout(fn, 0) 在切吗?有没有遇到过那种"明明让了,响应还是慢半拍"的怪事?
评论区说说你的选择和踩过的坑,点赞最高的那个真实场景,我下期专门拆一篇优化实战 👀