定时调度的三种实现方式

54 阅读4分钟

需求背景

实习的时候,产品经理提出需求:在我们的 AI 项目中,实现“打字机效果”
具体表现:每隔 xx 毫秒输出 n 个字符,直到输出完成。

实际上,这属于定时调度问题在固定时间间隔内执行特定任务


定时调度常见场景

  • 打字机效果:逐字输出文本
  • 轮询接口:定期请求服务端数据
  • 心跳检测:保持长连接活跃
  • 动画逐帧渲染:控制渲染帧速率
  • 数据批量处理:分片执行任务防止阻塞

三种实现方式

1. setInterval

const timer = setInterval(() => {
  outputNextChars();
  if (done) clearInterval(timer);
}, interval);

优点:

  • 写法简单
  • 天然固定周期

缺点:

  • 如果任务耗时 > interval,可能任务堆积,导致延迟累积
  • 定时精度受浏览器限制,长时间可能漂移

2. 递归 setTimeout

let done=true
function step() {
  outputNextChars();
  if (!done) {
    setTimeout(step, interval);
  }
}
setTimeout(step, interval);

优点:

  • 不会堆积任务,执行完后再调度下一次
  • 时间间隔更准确,误差可控

缺点:

  • 代码略复杂
  • 需要手动处理停止条件

3. requestAnimationFrame 更准确

渲染进程所有运行在主线程上的任务都需要先添加到消息队列中,然后事件循环系统顺序执行消息队列。 ​ eg: 解析 DOM; 改变 web 大小, 重新布局; js 垃圾回收; 异步执行 js 代码​

但是定时器的任务不能直接放置在消息队列中,他需要按照时间间隔来执行。因此,chrome 除了消息队列外,新增了个延时队列,在每次执行完任务后,执行延迟队列中的任务,计算出到期任务,依次执行。最小执行时间4ms。

let last = performance.now();
function frame(now) {
  if (now - last >= interval) {
    outputNextChars();
    last = now;
  }
  if (!done) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

优点:

  • 与浏览器渲染同步,适合动画、平滑效果
  • 在空闲标签页会自动暂停 → 节能

缺点:

  • 不支持精确固定毫秒,依赖刷新率
  • 后台标签页会暂停 → 打字中断
  • 需要额外代码做时间差计算

✅ 我选择的方案

对于打字机效果,我最终选择 递归 setTimeout

  • 原因:

    • 打字机是简单的固定间隔输出,不涉及高帧率动画
    • setTimeout 递归精度更高,不会像 setInterval 那样堆积任务
    • requestAnimationFrame 在后台会暂停,不适合文本连续输出

总结

  • 定时调度有多种方式,需根据场景选择:

    • 高精度、后台可运行 → 递归 setTimeout
    • 动画、UI 渲染 → requestAnimationFrame
    • 简单轮询 → setInterval

对于本项目的打字机效果:
我使用递归 setTimeout 实现,兼顾精度与控制性。


不同定时调度场景适合的技术手段不同:

场景更适合的实现方式理由
轮询接口:定期请求服务端数据递归 setTimeout接口耗时不可控,递归 setTimeout 更安全,可避免请求堆积。
心跳检测:保持长连接活跃递归 setTimeout心跳要持续且可调整,并且不能因任务延迟而重叠;递归方式能在上一次发送完成后再调度下一次。
动画逐帧渲染:控制渲染帧速率requestAnimationFrame与浏览器渲染同步,避免卡顿和掉帧,视觉体验好。
大数据批量处理:分片执行任务防止阻塞setTimeout(分片调度)或 requestIdleCallbacksetTimeout 分批处理,代码在下一轮事件循环执行;requestIdleCallback 更适合在浏览器空闲时处理非关键任务。

大数据批量处理:分片执行任务防止阻塞也是react fiber架构的底层原理,不过fiber使用的是自己实现的 Scheduler,优先级调度+时间切片,在执行一段工作后检查当前帧是否还剩时间deadline.timeRemaining()),如果超时就中断任务,让浏览器先去渲染,再回到任务队列继续未完成的部分。 React Fiber Scheduler更精细,而settimeout最少延迟4ms

附上串行轮询hooks

function startPolling(interval = 5000) {
  let stopped = false;

  async function poll() {
    if (stopped) return;

    try {
      const res = await fetch('/api/data');// 或 ws.send('ping')就是心跳机制
      const data = await res.json();
      console.log('轮询数据:', data);
    } catch (e) {
      console.error('请求失败:', e);
    }

    // 等请求结束再等待 interval
    setTimeout(poll, interval);
  }

  poll();

  return () => { stopped = true; };
}

const stop = startPolling(3000);
// stop() 停止轮询

给每次请求加一个超时时间,防止接口卡死导致轮询卡住。

js
复制编辑
function fetchWithTimeout(url, timeout = 3000) {
  return new Promise((resolve, reject) => {
    const controller = new AbortController();
    const timer = setTimeout(() => {
      controller.abort();
      reject(new Error('请求超时'));
    }, timeout);

    fetch(url, { signal: controller.signal })
      .then(res => {
        clearTimeout(timer);
        resolve(res);
      })
      .catch(err => reject(err));
  });
}

结合上面的串行轮询,就能防止接口长时间无响应。