需求背景
实习的时候,产品经理提出需求:在我们的 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(分片调度)或 requestIdleCallback | 用 setTimeout 分批处理,代码在下一轮事件循环执行;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));
});
}
结合上面的串行轮询,就能防止接口长时间无响应。