技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

关于 setTimeout 你所不知道的地方:详解 setTimeout

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/82-关于setTimeout你所不知道的地方,详解setTimeout
本文目录8 个章节
  1. 基本用法
  2. 延迟不是精确时间
  3. 延迟参数如何处理
  4. setTimeout 与微任务的顺序
  5. 回调中的 this
  6. 计时器的取消与资源管理
  7. 浏览器与 Node.js 的差异
  8. 常见误区总结

关于 setTimeout 你所不知道的地方:详解 setTimeout

Category(分类): JavaScript Status: 已整理

setTimeout 很容易被理解成“等待指定时间后立刻执行”。更准确的说法是:它向宿主注册一个计时器;当延迟达到后,回调有资格作为一个后续 task 执行,但还要等待当前执行栈、已经排队的任务、微任务检查点以及宿主的调度策略。

基本用法

const timerId = setTimeout(() => {
  console.log('尽量不早于约 500ms 执行')
}, 500)

clearTimeout(timerId)

返回值是一个计时器标识,可以传给 clearTimeout。在浏览器中通常是数字,在 Node.js 中通常是 Timeout 对象;不要依赖它的具体类型,只要把它原样传回清理函数即可。

延迟不是精确时间

const start = performance.now()

setTimeout(() => {
  console.log(`实际延迟:${performance.now() - start}ms`)
}, 0)

// 这段同步工作会阻塞当前 JavaScript 执行线程
for (let i = 0; i < 1e8; i++) {}

即使延迟是 0,回调也不会在当前同步代码中间执行。它至少要等当前 task 结束;如果主线程繁忙、页面被隐藏、系统节能或浏览器进行了后台节流,实际时间可能明显更长。

HTML Standard 规定了计时器嵌套级别等规则:当嵌套级别超过规定阈值且请求延迟小于 4ms 时,浏览器会将延迟限制到至少约 4ms。这里的“4ms”仍然只是调度下限,不是保证回调在 4ms 时运行。隐藏页面还有浏览器实现的更强节流策略。

let count = 0
const start = performance.now()

function tick() {
  count++
  if (count <= 8) {
    console.log(count, performance.now() - start)
    setTimeout(tick, 0)
  }
}

setTimeout(tick, 0)

不要用这种循环实现高精度动画或高精度计时。动画应优先考虑 requestAnimationFrame,需要测量耗时时使用 performance.now(),服务端任务则应使用适合 Node.js 的调度和监控方案。

延迟参数如何处理

浏览器会把延迟参数转换为数字;负数、NaN 等情况不会让回调在当前调用栈同步执行,通常会按 0 处理后再受计时器规则约束。非常大的延迟也不能当作无限精确的时间值。

Node.js 文档对 setTimeout 有额外说明:延迟会被转换为整数;小于 1、NaN 或大于 2147483647 的值通常按约 1ms 处理,超过有符号 32 位整数范围的延迟不会按字面意义等待那么久。浏览器和 Node.js 的细节不同,跨环境代码不要依赖某个具体的最小值。

setTimeout(() => console.log('delay 会被转换'), '10')
setTimeout(() => console.log('负数也会进入后续调度'), -1)

setTimeout 与微任务的顺序

console.log('script start')

setTimeout(() => {
  console.log('timer')
}, 0)

Promise.resolve().then(() => {
  console.log('promise reaction')
})

queueMicrotask(() => {
  console.log('queueMicrotask')
})

console.log('script end')

在浏览器中,常见输出为:

script start
script end
promise reaction
queueMicrotask
timer

Promise reaction 和 queueMicrotask 会在当前 task 结束后的 microtask checkpoint 中执行;计时器回调属于后续 task。不同任务源的跨源选择和渲染机会不是一个简单的“所有宏任务全局 FIFO”队列。

如果微任务不断添加新的微任务,就可能长时间阻塞计时器和页面渲染:

function keepBusy() {
  queueMicrotask(keepBusy)
}

// 不要在生产页面执行:它会让后续 task 很难获得执行机会
// keepBusy()

回调中的 this

把对象方法直接传给 setTimeout 时,调用形式不是 object.method(),因此不能指望它自动携带对象作为 this

const counter = {
  value: 1,
  show() {
    console.log(this.value)
  },
}

setTimeout(counter.show, 0) // 丢失原来的调用对象
setTimeout(() => counter.show(), 0) // 保留调用形式
setTimeout(counter.show.bind(counter), 0) // 显式绑定

在严格模式、ES module 或 class 方法中,独立调用的 this 通常是 undefined;非严格脚本中的独立普通函数调用才可能把 this 转换为全局对象。不要用“定时器里的 this 永远是 window”作为通用结论。

计时器的取消与资源管理

const id = setTimeout(() => {
  console.log('不会输出')
}, 1000)

clearTimeout(id)

clearTimeoutclearInterval 在浏览器中使用同一套计时器标识,但代码应使用与创建 API 对应的清理函数,以便表达意图。组件卸载、页面离开或请求被取消时,应清理不再需要的计时器。

对于可取消的异步流程,可以把计时器与 AbortSignal 结合:

function delay(ms, { signal } = {}) {
  return new Promise((resolve, reject) => {
    if (signal?.aborted) {
      reject(signal.reason)
      return
    }

    const id = setTimeout(resolve, ms)
    signal?.addEventListener('abort', () => {
      clearTimeout(id)
      reject(signal.reason)
    }, { once: true })
  })
}

更完整的生产实现还应在 resolve/reject 后移除监听器,避免长生命周期 signal 上积累监听器。

浏览器与 Node.js 的差异

  • 浏览器的计时器回调由 HTML 事件循环作为 task 调度,还会受到页面可见性、刷新率和后台节流影响。
  • Node.js 的计时器由 libuv/Node 事件循环协调,Node 文档明确指出回调执行时间和顺序不应被当作精确保证。
  • setImmediate() 是 Node.js API;浏览器没有对应的标准 Window API。Node 中 setImmediatesetTimeout(..., 0) 的相对顺序还取决于代码所在的阶段和 I/O 上下文。
  • Node 的 process.nextTick() 不等同于计时器,也不应直接套用浏览器的渲染模型。

常见误区总结

  1. setTimeout(fn, 500) 表示“500ms 后才有资格调度”,不是“500ms 时精准执行”。
  2. setTimeout(fn, 0) 仍然是异步 task,不会插入当前同步代码。
  3. 计时器不能替代动画帧、精准定时器或后台任务调度器。
  4. 高频递归计时器应考虑漂移、节流、取消和页面生命周期。
  5. 不要把浏览器、Node.js、不同版本和不同可见性状态下的时序写成一个绝对规则。

原文参考:关于setTimeout你所不知道的地方

参考资料:

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS