别跟渲染抢车道:参考telegram-tt 用 requestIdleCallback 让页面丝滑提速

别跟渲染抢车道:参考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,在空闲时以"分片 + 超时兜底"的方式安全地处理低优先级任务
  • 配合 throttleWiththrottleWithTickEndfastRaf,可以在"及时响应"与"资源友好"之间取得良好平衡
  • 在实际业务中,优先处理"对用户可感知"的工作,把计算与维护等非关键任务迁移到空闲时段,以获得更顺滑的 UI 与更可控的性能表现

通过合理的任务调度策略,我们就能让页面在处理复杂逻辑的同时,保持丝滑的用户体验!

相关推荐
全马必破三6 小时前
React的设计理念与核心特性
前端·react.js·前端框架
Qinana6 小时前
🌐 从 HTML/CSS/JS 到页面:浏览器渲染全流程详解
前端·程序员·前端框架
前端架构师-老李18 小时前
React 中 useCallback 的基本使用和原理解析
前端·react.js·前端框架
梦6501 天前
什么是react?
前端·react.js·前端框架
IT古董1 天前
【前端】从零开始搭建现代前端框架:React 19、Vite、Tailwind CSS、ShadCN UI 完整实战教程-第1章:项目概述与技术栈介绍
前端·react.js·前端框架
前端小咸鱼一条1 天前
18. React的受控和非受控组件
前端·react.js·前端框架
@大迁世界1 天前
第06章:Dynamic Components(动态组件)
前端·javascript·vue.js·前端框架·ecmascript
UIUV1 天前
微信小程序开发学习笔记:从架构到实战
前端·javascript·前端框架