JavaScript 中 script 整体代码属于宏任务怎么理解呢?
Category(分类): JavaScript Status: 已整理(2026)
原文是一个关于“script 整体代码是不是宏任务”的社区问答。本文保留问题、历史术语和原始示例,同时把“宏任务/微任务”的社区说法改成浏览器规范中的 task、microtask 和脚本处理模型。
一、先给结论
console.log() 这样的同步语句本身不是一个宏任务。它只是当前脚本执行步骤中的一条同步语句。
在浏览器中,宿主环境会通过 HTML Standard 定义的脚本处理算法启动一段经典脚本、模块脚本、事件监听器回调或计时器回调。社区常把“初始 script 整体代码”作为一个 task 来帮助理解,但这不表示:
- 每条同步语句都是一个 task;
- 所有脚本都进入同一个叫“宏任务队列”的队列;
- 脚本一定被规范包装成一个隐藏事件回调;
- Promise 对象本身是一个微任务;
- 某个 task 只能执行由它自己创建的 microtask。
更准确的浏览器执行轮廓是:
宿主选择并运行一个 task
↓
执行该 task 中的同步 JavaScript,调用栈清空
↓
执行 microtask checkpoint,直到微任务队列为空
↓
浏览器可能更新渲染
↓
宿主选择下一个可运行的 task
HTML Standard 还定义了多个 task source。用户代理可以在不同来源之间选择下一个任务,因此不能把浏览器理解成只有一个严格先进先出的“宏任务总队列”。
二、历史社区模型中的 macro-task 和 micro-task
原文引用的说法是:
任务队列分为 macro-task(宏任务)与 micro-task(微任务),在最新标准中,它们被分别称为 task 与 jobs。
这段话有历史背景,但不能直接当作现行规范定义:
- HTML Standard 主要讨论 task、task source、task queue 和 microtask queue;
- ECMAScript 使用 Job 抽象描述 Promise reaction 等语言层工作,但浏览器如何调度脚本、计时器和渲染还需要 HTML Standard;
setTimeout的回调属于计时器相关 task;Promise.prototype.then()注册的 reaction 会在 microtask queue 中运行;queueMicrotask()直接安排一个微任务;process.nextTick()是 Node.js API,不是浏览器标准的微任务 API;setImmediate()主要是 Node.js 等运行时的 API,不应列为浏览器通用宏任务;Object.observe已被废弃并移除,不应作为现代示例;UI rendering不是可以在所有浏览器中直接调用或排队的一个统一“宏任务”接口。
所以“宏任务/微任务”可以作为入门简称,但写严谨文章时应注明运行时,并优先使用 task/microtask 的规范术语。
三、script 整体代码是怎样开始执行的
页面加载时,浏览器解析 HTML,并依据脚本元素的类型和属性执行脚本。脚本并不是先被转换成“指令区”,再由 JavaScript 自己把它排入一个宏任务队列;解析器、脚本元素算法、模块加载器和事件循环共同决定执行时机。
3.1 常见脚本类型
| 脚本形式 | 典型调度特点 |
|---|---|
经典内联 <script> | 解析器遇到它时通常会暂停解析并执行;执行期间的同步语句属于当前脚本执行步骤 |
经典外部 <script src> | 默认会阻塞解析,下载并执行后继续解析 |
<script defer src> | 下载可与解析并行,按文档顺序在解析完成后执行;通常在 DOMContentLoaded 之前完成 |
<script async src> | 下载可与解析并行,下载完成后尽快执行;多个 async 脚本不保证文档顺序 |
<script type="module"> | 模块脚本默认具有类似 defer 的延后执行特征,并按模块依赖图加载 |
<script type="module" async> | 模块图准备好后尽快执行,不再保持普通 defer 的顺序语义 |
| 动态插入的脚本 | 具体时机由动态脚本、async 设置和加载结果决定,不应与解析器插入脚本混为一谈 |
“script 整体代码是宏任务”是对常见页面启动阶段的一种简化描述。更精确的说法是:脚本执行由宿主的 HTML 脚本处理算法启动,通常在一个任务或脚本处理步骤中运行;脚本里面的同步代码不会各自拆成一个个 task。
3.2 调用栈与 task 不是一回事
可以把几个层次区分开:
- task:宿主调度的一次工作入口,例如计时器回调或事件回调;
- 调用栈:该 task 或脚本执行同步代码时,函数调用逐层进入和退出的运行时结构;
- microtask:在当前 task 的同步执行完成后,于 microtask checkpoint 中运行的工作,例如 Promise reaction。
调用一个普通函数通常只会增加调用栈,不会自动产生新的 task:
function greet() {
console.log('函数调用仍在当前同步执行中')
}
greet()
四、原始示例为什么输出 1、2、3、5、4
原文示例:
(function test() {
setTimeout(function () {
console.log(4)
}, 0)
new Promise(function executor(resolve) {
console.log(1)
for (var i = 0; i < 10000; i++) {
if (i === 9999) resolve()
}
console.log(2)
}).then(function () {
console.log(5)
})
console.log(3)
})()
在常见浏览器环境中,输出为:
1
2
3
5
4
执行过程可以这样描述:
- 当前脚本执行同步代码;
setTimeout注册一个未来的计时器 task,但不会立即执行回调; new Promise的 executor 是同步调用的,所以先输出1;- 循环结束时调用
resolve(),但.then()回调不会同步执行,只会安排一个 Promise reaction microtask; - executor 继续同步输出
2,随后脚本继续输出3; - 当前脚本 task 的同步部分完成,microtask checkpoint 执行
.then()回调,输出5; - 之后浏览器才会选择计时器 task,输出
4。
原文把这个过程比作“宏任务是主人,微任务是奴隶”。这个比喻容易记忆,但不准确:微任务不是只属于创建它的 task,也不能插入当前同步代码中间。microtask checkpoint 会持续清空微任务队列;一个微任务还可以继续安排新的微任务。
更小的示例:
console.log('script start')
setTimeout(() => console.log('timer task'), 0)
queueMicrotask(() => console.log('queueMicrotask'))
Promise.resolve().then(() => console.log('promise reaction'))
console.log('script end')
常见输出顺序是:
script start
script end
queueMicrotask
promise reaction
timer task
setTimeout(..., 0) 表示“计时器达到最早可调度时间后排队”,不表示零毫秒立即执行。实际延迟还会受到当前任务、嵌套计时器规则、后台页面降频和浏览器调度影响。
五、浏览器事件循环的几个重要边界
5.1 微任务会一直清空
在一次 microtask checkpoint 中,队列不一定只执行当时已有的任务。微任务可以继续安排微任务,浏览器会继续处理,直到队列为空;因此无限产生微任务可能阻塞渲染和后续 task:
let count = 0
function scheduleAgain() {
count += 1
if (count < 3) queueMicrotask(scheduleAgain)
}
queueMicrotask(scheduleAgain)
不要用递归微任务替代需要让出主线程的长计算;需要分片时可以考虑 setTimeout、MessageChannel、scheduler.yield()(按目标浏览器验证)或 Worker。
5.2 渲染不是一个普通的“宏任务”
浏览器会在适当时机进行样式计算、布局、绘制和合成。requestAnimationFrame 回调通常用于下一次绘制前的视觉更新,但不能简单列入一个跨运行时都相同的宏任务清单。后台页面、设备刷新率和浏览器策略都可能改变具体时机。
5.3 Promise executor 与 reaction 不同
下面两部分的时机不同:
const promise = new Promise(resolve => {
console.log('executor:同步')
resolve()
})
promise.then(() => {
console.log('reaction:微任务')
})
console.log('当前脚本继续同步执行')
executor 在创建 Promise 时同步运行;then 注册的 reaction 在当前同步代码完成后的 microtask checkpoint 中运行。
六、浏览器与 Node.js 不要混用一张表
浏览器和 Node.js 都有事件循环,但宿主实现不同:
- 浏览器的 task、microtask、渲染和 HTML 脚本调度由 Web 平台规范共同定义;
- Node.js 使用 libuv 阶段处理计时器、I/O、check 等工作,并提供
process.nextTick(); - Node.js 还会处理 Promise 微任务,但
process.nextTick()的调度优先级和浏览器没有直接对应物; setImmediate()在 Node.js 中有明确语义,在浏览器中不能作为通用 API;- 在 Node.js 中比较
setTimeout(0)与setImmediate()的输出顺序,还取决于代码处于主模块还是 I/O 回调等上下文。
因此,面试题若没有说明运行环境,不能直接把浏览器和 Node.js 的输出规则混在一起。process.nextTick、setImmediate 和浏览器渲染应单独标注运行时。
七、在控制台输入 console.log() 是宏任务吗
不能简单回答“是”。DevTools 控制台的求值入口是浏览器开发者工具实现的宿主行为,不应直接等同于页面 <script> 标签的初始脚本 task。console.log() 调用本身仍是当前求值中的同步操作:
console.log('这是一条同步调用')
如果控制台代码中安排了异步工作,才分别观察对应机制:
setTimeout(() => console.log('计时器回调'), 0)
Promise.resolve().then(() => console.log('Promise reaction'))
控制台的具体封装、是否复用同一个执行上下文和顶层 await 行为可能随浏览器 DevTools 改变,所以应把控制台当作宿主特定环境,而不是用它定义规范模型。
八、原文中的说法如何保留和修正
原文说“保存起来待执行的回调才能算任务”,可以作为入门直觉,但需要补充:
- 任务入口不只来自回调函数,也可能由解析器和宿主算法启动;
- 不是所有函数调用都是任务;
- Promise reaction 是微任务,不是普通计时器 task;
- 一个 task 完成后会进行微任务检查点,但微任务不是“主人 task 的附属物”;
- 事件循环不是简单的“从宏任务队列取一个,再取它创建的微任务”,而是由宿主根据 task source、微任务检查点和渲染机会共同调度。
九、参考资料与原文
- WHATWG:Event loops
- WHATWG:The script element
- WHATWG:Timers
- MDN:JavaScript execution model
- MDN:Using microtasks with
queueMicrotask() - MDN:Promise.prototype.then()
- MDN:Window.setTimeout()
- Node.js:Understanding setImmediate()
- Node.js:process.nextTick()
- 原文引用:JS 中,script(整体代码)是怎么入栈的?(SegmentFault 可能需要浏览器访问验证)
- 原文引用:前端基础进阶(十二):深入核心,详解事件循环机制