别跟渲染抢车道:参考telegram-tt 用 requestIdleCallback 让页面丝滑提速
本文技术方案来源于 github.com/Ajaxy/teleg... 的
src/util/schedulers.ts
你有没有遇到过这种情况:页面看起来功能都正常,但就是感觉有点卡顿?特别是那种大型 Web 应用,经常会有各种任务在后台跑,什么数据计算、日志记录、缓存维护等等。这些任务一多,就会跟页面的渲染"抢车道",导致掉帧、卡顿。
核心问题:在复杂的 Web 应用里,"什么时候执行任务"和"执行什么任务"同样重要。恰当的调度策略可以显著降低主线程压力、避免掉帧、提升交互流畅度。
今天我就结合 telegram-tt 项目中的 src/util/schedulers.ts 实现,来给大家拆解一下两类轻量调度器:onIdle 和 onTickEnd。我们会从底层 API(requestIdleCallback)开始讲起,然后看看实战场景。
1. 为什么需要轻量调度器?
想象一下,如果你的页面是一个繁忙的十字路口:
- 避免卡顿:把非关键任务(计算、日志、缓存维护)从关键渲染路径移开,减少长任务阻塞 UI
- 批量合并:将同一帧/同一 Tick 内的多次触发合并,减少重复工作(如多次 setState/渲染)
- 资源友好:利用浏览器空闲时间片执行"低优先级"工作,提升能效与设备续航
- 可控降级:在不支持空闲回调的环境里平滑退化,保证行为可预期
2. API 速览:requestIdleCallback
requestIdleCallback 能在浏览器空闲时调用回调函数,避免与关键渲染竞争帧预算。
2.1 签名
typescript
interface IdleDeadline {
timeRemaining(): number; // 估算本次空闲片段剩余的毫秒数
didTimeout: boolean; // 是否由于超时被强制执行
}
type IdleCallback = (deadline: IdleDeadline) => void;
requestIdleCallback(callback: IdleCallback, options?: { timeout?: number }): number;
cancelIdleCallback(handle: number): void;
2.2 关键点
deadline.timeRemaining():当前空闲片段还剩多少预算(大致)。执行重任务时应分片,剩余时间不足就留到下次空闲片段options.timeout:即使一直不空闲,到达超时时间也会强制执行,防止任务"饿死"- 兼容性:部分环境(或 Web Worker)不支持,需降级处理(微任务、RAF、setTimeout 等)
3. onTickEnd 与 onIdle 一览
先看整体对比,再深入拆解:
| 调度时机 | 使用微任务(Promise.then),在当前宏任务结束后立即执行 |
使用 requestIdleCallback,在浏览器空闲片段执行,支持 timeout |
|---|---|---|
| 适用场景 | "尽快但不阻塞当前执行栈" | "低优先级、可切片"的任务 |
| 粒度与开销 | 延迟极短,批量合并同一轮触发;适合轻任务与收尾逻辑 | 根据 deadline.timeRemaining() 分片执行;适合较重或可分割任务 |
3.1 典型搭配
throttleWithTickEnd(fn):同一 Tick 只执行一次,防抖合批(状态收敛、动画计数重置)throttleWith(onIdle, fn):空闲时批处理(日志、索引、缓存维护)fastRaf(fn):同一帧内批处理、与渲染同步
3.2 降级路径
当 requestIdleCallback 不可用时,onIdle 自动降级为 onTickEnd,保证一致的可用性与可预期性。
3.3 快速选择建议
- "尽快执行但别打断现在" →
onTickEnd - "有空再做且能切片" →
onIdle(带timeout)
4. 核心实现(带注释)
typescript
// 关键常量:空闲超时,避免持续繁忙时任务"饿死"
const IDLE_TIMEOUT = 500;
// 为了示例自包含:在项目中该类型已存在
type NoneToVoidFunction = () => void;
// 1) onTickEnd:使用微任务(Promise.then),在当前宏任务结束后统一执行
let onTickEndCallbacks: NoneToVoidFunction[] | undefined;
export function onTickEnd(callback: NoneToVoidFunction) {
if (!onTickEndCallbacks) {
// 第一次调用:初始化队列并在微任务中 flush
onTickEndCallbacks = [callback];
Promise.resolve().then(() => {
const currentCallbacks = onTickEndCallbacks!;
onTickEndCallbacks = undefined;
// 同一轮微任务内批量执行,避免多次触发产生多轮微任务
currentCallbacks.forEach((cb) => cb());
});
} else {
// 同一轮内追加,最终在一次微任务 flush 中一并执行
onTickEndCallbacks.push(callback);
}
}
// 2) onIdle:使用 requestIdleCallback 在空闲片段执行;不支持则降级到 onTickEnd
let onIdleCallbacks: NoneToVoidFunction[] | undefined;
export function onIdle(callback: NoneToVoidFunction) {
if (!self.requestIdleCallback) {
// 降级路径:仍保证"尽快但不阻塞当前调用栈"的体验
onTickEnd(callback);
return;
}
if (!onIdleCallbacks) {
// 第一次调用:初始化队列并在空闲片段中消费
onIdleCallbacks = [callback];
requestIdleCallback((deadline) => {
const currentCallbacks = onIdleCallbacks!;
onIdleCallbacks = undefined;
// 在一个空闲时间片中逐个执行,预算不足时提前中断
while (currentCallbacks.length) {
const cb = currentCallbacks.shift()!;
cb();
if (!deadline.timeRemaining()) break; // 时间片用尽:剩余回调留待下次空闲
}
if (currentCallbacks.length) {
// 仍有剩余回调:
if (onIdleCallbacks) {
// 若下一轮已排队,则前置到队列首部,保持公平性与有序性
onIdleCallbacks = currentCallbacks.concat(onIdleCallbacks);
} else {
// 否则逐个重新排入 onIdle,等待后续空闲时间片
currentCallbacks.forEach(onIdle);
}
}
}, { timeout: IDLE_TIMEOUT });
} else {
// 同一轮内继续积累,统一在下一个空闲片段消费
onIdleCallbacks.push(callback);
}
}
4.1 要点回顾
onTickEnd
- 微任务合批:本轮宏任务结束后、渲染前尽快执行
- 适合:轻量、需要快速响应但可延后到微任务的工作
onIdle
- 空闲调度 :在
deadline.timeRemaining()预算内分片执行,重任务不阻塞关键渲染 - 超时保护 :
timeout避免"饥饿" - 自动降级 :无
requestIdleCallback时退化为onTickEnd,保证行为可预期
5. 详细拆解
5.1 onTickEnd:将回调推入"微任务队列"
位置 :src/util/schedulers.ts:167
实现要点
- 使用
Promise.resolve().then(...)在"当前调用栈结束后"统一执行队列中的回调 - 在一次微任务 flush 中批量处理当前积累的回调,避免多次触发产生多轮微任务
代码摘录(简化)
typescript
let onTickEndCallbacks: NoneToVoidFunction[] | undefined;
export function onTickEnd(callback: NoneToVoidFunction) {
if (!onTickEndCallbacks) {
onTickEndCallbacks = [callback];
Promise.resolve().then(() => {
const currentCallbacks = onTickEndCallbacks!;
onTickEndCallbacks = undefined;
currentCallbacks.forEach((cb) => cb());
});
} else {
onTickEndCallbacks.push(callback);
}
}
适用场景
需要"本轮调用栈结束后立即(微任务)"执行、但又想批量合并的轻量任务(如状态变更的合并、收尾清理、动画计数重置等)。
5.2 onIdle:在浏览器空闲时执行,带超时与分片
位置 :src/util/schedulers.ts:186
实现要点
- 优先使用
requestIdleCallback,不支持则降级到onTickEnd - 设置
timeout = 500ms,避免持续繁忙时任务饥饿 - 使用
deadline.timeRemaining()在一个空闲片段内逐个消费队列;若预算不足,剩余回调留到下次空闲片段继续执行 - 若下一轮空闲已排队,则把剩余任务"前置"到下一轮队列首部,维持公平性与有序性
代码摘录(简化)
typescript
const IDLE_TIMEOUT = 500;
let onIdleCallbacks: NoneToVoidFunction[] | undefined;
export function onIdle(callback: NoneToVoidFunction) {
if (!self.requestIdleCallback) {
onTickEnd(callback);
return;
}
if (!onIdleCallbacks) {
onIdleCallbacks = [callback];
requestIdleCallback(
(deadline) => {
const currentCallbacks = onIdleCallbacks!;
onIdleCallbacks = undefined;
while (currentCallbacks.length) {
const cb = currentCallbacks.shift()!;
cb();
if (!deadline.timeRemaining()) break;
}
if (currentCallbacks.length) {
if (onIdleCallbacks) {
onIdleCallbacks = currentCallbacks.concat(onIdleCallbacks);
} else {
currentCallbacks.forEach(onIdle);
}
}
},
{ timeout: IDLE_TIMEOUT }
);
} else {
onIdleCallbacks.push(callback);
}
}
适用场景
日志、缓存、预取、索引重建、统计聚合等非关键路径工作;或"重动画完全结束后"的后处理(配合下节的 onFullyIdle)。
6. 实战应用场景
索引重建、缓存清理、统计聚合、埋点汇总、日志上传等,均可优先用 onIdle,并配合 timeout 防止长时间不执行。
7. 最佳实践建议
选择合适的调度器
- 需要"尽快但不抢占渲染":
onTickEnd - 需要"只在空闲时":
onIdle(带timeout)
大任务要"切片"
在 onIdle 回调中使用 deadline.timeRemaining() 控制单次工作量,剩余工作留待下次空闲。
幂等与可中断
onIdle 可能被切分多次执行;任务应可恢复/可重入,避免重复副作用。
保守处理异常
回调中捕获异常,避免打断后续批处理;必要时记录并延迟重试。
谨慎设置 timeout
过短会与关键渲染竞争,过长会导致延迟;结合业务 SLA 合理权衡。
8. 代码片段精选
onTickEnd(微任务批量执行)
位置 :src/util/schedulers.ts:167
typescript
export function onTickEnd(callback: NoneToVoidFunction) {
if (!onTickEndCallbacks) {
onTickEndCallbacks = [callback];
Promise.resolve().then(() => {
const currentCallbacks = onTickEndCallbacks!;
onTickEndCallbacks = undefined;
currentCallbacks.forEach((cb) => cb());
});
} else {
onTickEndCallbacks.push(callback);
}
}
onIdle(空闲回调 + 超时 + 分片)
位置 :src/util/schedulers.ts:186
typescript
export function onIdle(callback: NoneToVoidFunction) {
if (!self.requestIdleCallback) {
onTickEnd(callback);
return;
}
// ...见上文实现解析
}
批量刷新 API 更新
位置 :src/api/gramjs/updates/apiUpdateEmitter.ts:1
typescript
const flushUpdatesOnTickEnd = throttleWithTickEnd(flushUpdates);
flushUpdatesThrottled = throttle(
flushUpdatesOnTickEnd,
API_UPDATE_THROTTLE,
true
);
动画计数的 Tick 末尾重置
位置 :src/components/common/AnimatedCounter.tsx:1
typescript
const resetCounterOnTickEnd = throttleWithTickEnd(() => {
scheduledAnimationsCounter = 0;
});
完全空闲后的执行
位置 :src/lib/teact/heavyAnimation.ts:1
typescript
export function onFullyIdle(cb: NoneToVoidFunction) {
/* 见上文 */
}
9. 小结
onTickEnd适合"当前宏任务结束后立刻"批量执行的轻任务onIdle基于requestIdleCallback,在空闲时以"分片 + 超时兜底"的方式安全地处理低优先级任务- 配合
throttleWith、throttleWithTickEnd与fastRaf,可以在"及时响应"与"资源友好"之间取得良好平衡 - 在实际业务中,优先处理"对用户可感知"的工作,把计算与维护等非关键任务迁移到空闲时段,以获得更顺滑的 UI 与更可控的性能表现
通过合理的任务调度策略,我们就能让页面在处理复杂逻辑的同时,保持丝滑的用户体验!