- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
本篇文章围绕 Modern JavaScript Tutorial(en.javascript.info)中《Scheduling: setTimeout and setInterval》章节的一道高难度思考题展开,完整剖析setTimeout回调的真实执行时机:为什么即使设置了 100ms 延迟,回调也总是要等当前脚本全部执行完毕之后才会触发。读完本文,你将掌握定时器与事件循环(Event Loop)的协作机制、setTimeout/setInterval的完整 API 用法、嵌套定时器的间隔精度差异,以及浏览器 4ms 最小延迟限制等实战要点。
题目回顾:这个 setTimeout 究竟会显示什么?
原题出自 4-settimeout-result/task.md,题目代码如下:
let i = 0; setTimeout(() => alert(i), 100); // ? // assume that the time to execute this function is >100ms for(let j = 0; j < 100000000; j++) { i++; }题目给出三个候选答案:
- 在循环之后执行;
- 在循环之前执行;
- 在循环开始阶段执行。
并追问:alert最终会显示什么?
正确答案:循环之后执行,alert 显示 100000000
根据 官方解答:
任何一个
setTimeout都只会等当前代码全部执行完之后才运行。此时的i是最后一次递增后的值:100000000。
let i = 0; setTimeout(() => alert(i), 100); // 100000000 // assume that the time to execute this function is >100ms for(let j = 0; j < 100000000; j++) { i++; }两个关键结论:
- 回调在循环之后运行——即使循环耗时远超 100ms,回调也不会"插队";
alert显示的是循环结束后的最终值100000000——回调读取i的时刻发生在循环完成之后,因此它看到的不是中途某个中间值。
答案背后的核心原理:定时器回调不会抢占当前脚本
要理解上面的结论,需要回到 JavaScript 的执行模型。浏览器(以及 Node.js)中的 JS 执行流程基于事件循环(Event Loop),参见 2-ui/99-ui-misc/03-event-loop/article.md。
单线程与任务队列
JavaScript 引擎是单线程的,同一时刻只能执行一段代码。事件循环的通用算法极其简单:
- 只要队列中有任务,就从最老的任务开始依次执行;
- 队列为空时睡眠等待,直到新任务出现,再回到第 1 步。
以下都属于"任务"(macrotask):
- 外部脚本
<script src="...">加载完成后,执行它是任务; - 用户移动鼠标,派发
mousemove事件并执行处理器是任务; setTimeout计时到点时,运行其回调也是任务。
当引擎正忙于执行一段同步脚本时,期间到期的事件、定时器并不会立即执行,而是被放入"宏任务队列"(macrotask queue,V8 术语),按先来先服务(FIFO)的顺序排队。当前脚本执行完毕,引擎才会从队列头部取出下一个任务。
同步代码永远先行
回到题目:setTimeout(() => alert(i), 100)只是把回调注册进调度器并设定 100ms 的计时,并没有真正执行它。紧接着的for循环是同步代码,一旦开始执行,引擎便全力运行它,期间不会让出执行权。
即便循环耗时超过 100ms,定时器早已"到期",但它的回调只能作为新任务被丢进队列末端等待;只有当庞大的for循环(约 1 亿次递增)跑完、当前脚本结束,引擎才会取出队列中的定时器回调执行。因此:
- 回调必然在循环之后运行;
- 它读取
i时,循环已经完成,i已经是100000000。
这正是"零延迟"小节所强调的语义:定时器回调只会在当前脚本彻底结束后才被调度执行。即使写成setTimeout(func)(delay 为 0),回调也是"当前脚本之后尽快执行",而不是"立刻执行"。
setTimeout 基础回顾:完整 API 用法与参数
本节的完整讲解见 08-settimeout-setinterval/article.md。setTimeout允许我们"安排"函数在指定时间之后运行一次,其完整语法为:
let timerId = setTimeout(func|code, [delay], [arg1], [arg2], ...)各参数含义:
| 参数 | 说明 |
|---|---|
func|code | 要执行的函数,或一段代码字符串。通常传函数;字符串形式仅因历史原因保留,不推荐使用 |
delay | 运行前的延迟,单位毫秒(1000ms = 1 秒),默认 0 |
arg1, arg2, ... | 传给函数的参数 |
带参数的基本用法
function sayHi(phrase, who) { alert( phrase + ', ' + who ); } setTimeout(sayHi, 1000, "Hello", "John"); // Hello, John如果第一个参数是字符串,JS 会自动为其创建函数,例如setTimeout("alert('Hello')", 1000)也能工作,但官方明确建议改用箭头函数:setTimeout(() => alert('Hello'), 1000)。
经典陷阱:传函数引用,而不是调用它
新手常犯的错误是在函数后面加括号:
// 错误写法! setTimeout(sayHi(), 1000);setTimeout期待的是一个函数引用,而sayHi()会立即执行该函数,并把执行结果(本例中为undefined)传给setTimeout——结果什么都不会被调度。正确写法是不加括号:setTimeout(sayHi, 1000)。
用 clearTimeout 取消执行
setTimeout返回一个"定时器标识符"timerId,可用它取消执行:
let timerId = setTimeout(...); clearTimeout(timerId);let timerId = setTimeout(() => alert("never happens"), 1000); alert(timerId); // 定时器标识符 clearTimeout(timerId); alert(timerId); // 仍是同一个标识符(取消后不会变成 null)浏览器中timerId是一个数字;在其他环境(如 Node.js)它可能是带有额外方法的对象。由于这些方法不属于 JavaScript 规范,各环境实现略有差异是正常的。
"延迟 100ms"不等于"100ms 后一定执行"
题目中的delay = 100只表示最早可执行时间,而非保证的执行时刻。定时器的实际触发受事件循环状态制约:当前同步任务越长,回调被推迟得越久。
事件循环章节的 使用场景一:拆分 CPU 密集型任务 正好演示了这一点——一个从 1 数到 10 亿的同步循环会让浏览器"卡死":
let i = 0; let start = Date.now(); function count() { // 做一个繁重的任务 for (let j = 0; j < 1e9; j++) { i++; } alert("Done in " + (Date.now() - start) + 'ms'); } count();该章节给出的解法是用零延迟的嵌套setTimeout把大任务拆成小块,让引擎在每块之间"喘口气",得以响应点击事件、渲染界面。这也印证了同一个事实:定时器回调永远无法打断正在执行的同步代码。
深入实践:setInterval 与嵌套 setTimeout 的对比
setInterval:按固定间隔重复执行
setInterval语法与setTimeout相同,但会按给定间隔定期重复运行函数,直到调用clearInterval(timerId)停止:
let timerId = setInterval(func|code, [delay], [arg1], [arg2], ...)下面的例子每 2 秒弹出一条消息,5 秒后停止:
// 每 2 秒重复 let timerId = setInterval(() => alert('tick'), 2000); // 5 秒后停止 setTimeout(() => { clearInterval(timerId); alert('stop'); }, 5000);需要注意:在大多数浏览器(包括 Chrome 和 Firefox)中,弹窗alert/confirm/prompt显示期间,内部计时器仍在"滴答"走动。如果你迟迟不关掉弹窗,下一个alert会在关闭的瞬间立即弹出,实际间隔会小于 2 秒。
嵌套 setTimeout:更灵活、间隔更精确
定时重复的另一种方式是嵌套setTimeout——在当前回调的末尾再调度下一次:
let timerId = setTimeout(function tick() { alert('tick'); timerId = setTimeout(tick, 2000); // (*) 在本次回调末尾安排下一次 }, 2000);嵌套setTimeout比setInterval更灵活:下一次调用可以根据当前这次的结果动态调整。例如一个每 5 秒向服务器请求数据的服务,当服务器过载时可以把间隔翻倍为 10、20、40 秒……:
let delay = 5000; let timerId = setTimeout(function request() { ...send request... if (request failed due to server overload) { // 增大下一次运行的间隔 delay *= 2; } timerId = setTimeout(request, delay); }, delay);更重要的是,嵌套setTimeout能更精确地控制"两次执行之间的间隔"。对比下面两段代码:
// setInterval 版本 let i = 1; setInterval(function() { func(i++); }, 100);// 嵌套 setTimeout 版本 let i = 1; setTimeout(function run() { func(i++); setTimeout(run, 100); }, 100);对setInterval来说,内部调度器每 100ms 运行一次func(i++),但func自身的执行时间会"吃掉"一部分间隔——真实的间隔其实小于 100ms。极端情况下,如果func每次执行都超过delay,调用之间将完全没有停顿:
而嵌套setTimeout由于新调用总是在上一次结束之后才安排,可以保证固定的间隔(此处为 100ms):
两张时序图取自同一章节目录 08-settimeout-setinterval,直观展示了两种调度方式下"调用时刻"的差异。
零延迟 setTimeout 与浏览器 4ms 限制
零延迟:当前脚本结束后尽快执行
setTimeout(func, 0)(或直接setTimeout(func))是一个特殊用法:把回调安排得"尽可能早",但调度器只会在当前脚本执行完毕后才调用它。典型示例:
setTimeout(() => alert("World")); alert("Hello");输出顺序是"Hello"然后"World"——第一行把调用"放进 0ms 后的日程表",但调度器要等当前脚本结束后才会"查看日程表",所以同步的"Hello"先出现。
浏览器对嵌套定时器的 4ms 下限
浏览器中存在历史遗留限制:HTML Living Standard 规定,嵌套定时器超过 5 层之后,间隔被强制至少为 4 毫秒。下面这段自调度代码记录了每次调用的真实延迟:
let start = Date.now(); let times = []; setTimeout(function run() { times.push(Date.now() - start); // 记录距离上次调用的真实延迟 if (start + 100 < Date.now()) alert(times); // 100ms 后展示这些延迟 else setTimeout(run); // 否则重新调度 }); // 输出示例: // 1,1,1,1,9,15,20,24,30,35,40,45,50,55,59,64,70,75,80,85,90,95,100前几次定时器立即执行(符合规范),之后出现9, 15, 20, 24...——4ms+ 的强制间隔开始生效。setInterval(f)也有类似行为:前几次以零延迟运行,之后按 4ms+ 间隔运行。此限制仅存在于浏览器端;服务端 JS(如 Node.js)没有该限制,并提供了setImmediate等替代调度方式。
内存与垃圾回收注意事项
把函数传给setInterval/setTimeout时,调度器会创建对它的内部引用,即使没有其他引用,该函数也不会被垃圾回收,直到它被调度执行(对setTimeout)或调用clearInterval(对setInterval)。
// 该函数在调度器调用它之前一直驻留内存 setTimeout(function() {...}, 100);副作用在于:函数引用其外层词法环境,因此只要函数活着,外层变量也会活着,可能占据比函数本身大得多的内存。当不再需要某个定时器时,即使它很小,也最好显式取消它。
巩固练习:每秒输出一个数字(两种实现)
同章节还有一道配套练习 1-output-numbers-100ms/task.md:编写函数printNumbers(from, to),从from开始每秒输出一个数字,直到to结束,要求分别用setInterval和嵌套setTimeout实现。
官方解答见 1-output-numbers-100ms/solution.md:
// 方案一:setInterval function printNumbers(from, to) { let current = from; let timerId = setInterval(function() { alert(current); if (current == to) { clearInterval(timerId); } current++; }, 1000); } // 用法: printNumbers(5, 10);// 方案二:嵌套 setTimeout function printNumbers(from, to) { let current = from; setTimeout(function go() { alert(current); if (current < to) { setTimeout(go, 1000); } current++; }, 1000); } // 用法: printNumbers(5, 10);注意两种方案的共同点:第一次输出前都有 1000ms 的初始延迟。如果希望第一次立即执行,可以先把go单独调用一次,再启动定时器:
function printNumbers(from, to) { let current = from; function go() { alert(current); if (current == to) { clearInterval(timerId); } current++; } go(); // 立即输出第一个数字 let timerId = setInterval(go, 1000); } printNumbers(5, 10);小结
setTimeout(func, delay, ...args)与setInterval(func, delay, ...args)允许在delay毫秒后运行一次/定期运行func;- 取消执行需要调用
clearTimeout/clearInterval,并传入setTimeout/setInterval返回的标识符; - 所有定时器回调都只会在当前同步代码执行完毕之后运行——这是"
setTimeout显示 100000000"这道题的核心结论; - 嵌套
setTimeout是比setInterval更灵活的替代方案,能更精确地控制两次执行之间的间隔; - 零延迟
setTimeout(func, 0)用于"尽快但在当前脚本之后"执行,常用于拆分 CPU 密集型任务、展示进度或延迟到事件完全处理后执行; - 浏览器对 5 层以上的嵌套定时器及
setInterval存在最小 4ms 的历史延迟限制; - 所有调度方法都不保证精确延迟:CPU 过载、标签页后台化、笔记本省电模式都可能把最小定时器分辨率提升到 300ms 甚至 1000ms。
若要深入定时器与事件循环的更多实战场景(拆分重型计算、进度条、宏任务与微任务的区别),可继续阅读 事件循环章节。
- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
相关推荐
Sunshine 串流主机:把书房的游戏 PC 变成客厅游戏舱,10 分钟出画面
Sunshine 串流主机:把书房的游戏 PC 变成客厅游戏舱,10 分钟出画面 周五晚上,你想窝在沙发打电脑里的 3A 大作,可游戏跑在书房的机器上,人却走不
文档/教程前端Metro运行时系统:如何在设备上执行打包后的代码
Metro运行时系统:如何在设备上执行打包后的代码 🚇 Metro是React Native的JavaScript打包器,负责将开发者的源代码转换为可在移动设
构建工具移动开发CLIivy中的混合框架调试:同时在多个后端运行代码进行对比
ivy中的混合框架调试:同时在多个后端运行代码进行对比 你是否还在为深度学习模型在不同框架间的行为差异而头疼?训练时PyTorch结果完美,部署到TensorF
人工智能机器学习开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考