前言:为什么 JS 执行机制是大厂必考点?
在大厂前端面试中,JS 执行机制是「基础中的基础」—— 无论是字节、阿里还是腾讯,都会通过「代码输出题」「原理阐述题」考察你对单线程、事件循环、宏微任务的理解。比如:
- 「以下代码的输出顺序是什么?」(经典宏微任务题)
- 「JS 为什么是单线程?事件循环的工作原理是什么?」
- 「async/await 和 Promise 的执行顺序差异?」
理解 JS 执行机制,不仅能搞定面试,更能解决实际开发中的异步问题(比如回调地狱、接口请求顺序错乱)。本文将从「底层原理→核心概念→真题拆解」层层递进,帮你彻底掌握。
一、核心前提:JS 为什么是单线程?
- 单线程的定义
JS 设计之初就是「单线程」—— 同一时间只能执行一个任务,所有任务排队等待执行。
- 为什么不设计成多线程?
- 避免 DOM 操作冲突:如果多个线程同时操作 DOM,会导致页面渲染混乱(比如线程 A 要删除 DOM,线程 B 要修改 DOM)。
- 简化执行逻辑:单线程无需处理线程同步、锁机制等复杂问题,降低了语言复杂度。
- 单线程的问题与解决方案
单线程的痛点:如果有一个耗时任务(比如网络请求、定时器),会阻塞后续任务执行(页面卡死)。
解决方案:「异步编程」+「事件循环」—— 耗时任务放入「任务队列」,主线程先执行同步任务,待同步任务完成后,再从队列中取出异步任务执行。
二、执行上下文:JS 代码的「运行环境」
执行上下文是 JS 代码执行时的环境容器,包含「变量、函数、this 指向」等信息。每次执行代码,都会创建对应的执行上下文。
- 执行上下文的类型
| 类型 | 场景 | 示例 |
|---|---|---|
| 全局执行上下文 | 页面加载时,JS 引擎首先创建 | console.log(window) |
| 函数执行上下文 | 函数被调用时创建 | function fn() {} fn() |
| eval 执行上下文 | eval() 函数执行代码时创建(慎用) | eval('var a=1') |
- 执行上下文栈(调用栈)
JS 用「栈结构」管理执行上下文,遵循「先进后出」原则:
- 页面加载时,全局执行上下文入栈(栈底)。
- 函数调用时,函数执行上下文入栈。
- 函数执行完毕,其执行上下文出栈。
- 所有代码执行完毕,全局执行上下文出栈。
实例:执行上下文栈的变化
function a() {
b();
console.log('a 执行完毕');
}
function b() {
console.log('b 执行完毕');
}
a();
栈变化过程:
- 全局执行上下文入栈 → 栈:[全局]
- 调用 a() → a 执行上下文入栈 → 栈:[全局,a]
- a 中调用 b() → b 执行上下文入栈 → 栈:[全局,a, b]
- b 执行完毕 → b 出栈 → 栈:[全局,a]
- a 执行完毕 → a 出栈 → 栈:[全局]
-
所有代码执行完毕 → 全局出栈 → 栈空
-
执行上下文的创建过程
每个执行上下文创建时,会经历 2 个阶段:
- 初始化阶段:
- 确定 this 指向(全局上下文 this 是 window,函数上下文 this 取决于调用方式)。
- 创建「词法环境」(存储变量、函数声明)。
- 创建「变量环境」(存储 var 声明的变量,初始值为 undefined,存在变量提升)。
- 执行阶段:
- 赋值变量、执行函数调用、解析代码逻辑。
关键考点:变量提升 vs 函数提升
- 函数提升:函数声明会被提升到当前作用域顶部,可提前调用。
- 变量提升:var 声明的变量会被提升,初始值为 undefined;let/const 不会提升,存在「暂时性死区」。
// 函数提升:可提前调用
fn(); // 输出 "fn 执行"
function fn() {
console.log('fn 执行');
}
// 变量提升:var 初始值 undefined
console.log(a); // undefined
var a = 10;
// let/const 无提升:暂时性死区
console.log(b); // 报错:Cannot access 'b' before initialization
let b = 20;
三、作用域与作用域链:变量的「访问规则」
- 作用域的定义
作用域是变量、函数的可访问范围,决定了代码中变量的可见性。
- 作用域的类型
| 类型 | 定义 | 作用域链查找顺序 |
|---|---|---|
| 全局作用域 | 页面级作用域,所有代码可访问 | 自身 → 无 |
| 函数作用域 | 函数内部的作用域,仅函数内可访问 | 自身 → 外层函数 → 全局 |
| 块级作用域 | let/const 声明的变量所在的 {} 块(if/for/switch) | 自身 → 外层块 → 全局 |
- 作用域链
当访问一个变量时,JS 会从当前作用域开始查找,若找不到则向上一层作用域查找,直到全局作用域 —— 这个查找链条就是「作用域链」。
实例:作用域链查找
var globalVar = '全局变量';
function outer() {
var outerVar = '外层变量';
function inner() {
var innerVar = '内层变量';
console.log(innerVar); // 内层变量(当前作用域)
console.log(outerVar); // 外层变量(外层作用域)
console.log(globalVar); // 全局变量(全局作用域)
console.log(notExistVar); // 报错:not defined(全局也找不到)
}
inner();
}
outer();
- 闭包:作用域链的延伸(大厂高频考点)
闭包的定义
函数嵌套时,内层函数引用外层函数的变量 / 函数,且内层函数被外层函数外部访问 —— 此时内层函数就是闭包,会保留外层函数的作用域。
闭包的形成条件
- 函数嵌套(内层函数嵌套在外层函数中)。
- 内层函数引用外层函数的变量 / 函数。
- 内层函数被外层函数外部调用。
实例:闭包的应用与考点
function createCounter() {
let count = 0; // 外层函数变量,被内层函数引用
return function() {
count++; // 引用外层变量
return count;
};
}
const counter = createCounter(); // 内层函数被外部访问
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
闭包的考点
- 作用:保存变量状态(如计数器)、模块化(隐藏私有变量)。
- 问题:可能导致内存泄漏(闭包引用的外层变量不会被垃圾回收)。
- 解决方案:不再使用时,手动解除引用(counter = null)。
四、事件循环:异步编程的核心(大厂必问)
事件循环(Event Loop)是 JS 处理异步任务的核心机制,解决了单线程阻塞的问题。
- 核心概念:同步任务 vs 异步任务
- 同步任务:立即执行的任务,阻塞后续任务(如变量声明、函数调用、console.log)。
- 异步任务:不立即执行的任务,不会阻塞后续任务(如定时器、网络请求、DOM 事件、Promise)。
异步任务又分为「宏任务」和「微任务」,优先级不同:
| 类型 | 常见任务 |
|---|---|
| 宏任务 | setTimeout、setInterval、DOM事件、fetch、script(整体代码) |
| 微任务 | Promise.then/catch/finally、process.nextTick(Node 独有)、queueMicrotask |
-
事件循环的执行流程(浏览器环境)
-
执行全局同步任务(全局执行上下文入栈,执行完毕出栈)。
- 执行当前微任务队列中的所有微任务(按顺序执行,直到队列空)。
- 执行一次宏任务队列中的第一个宏任务(执行完毕后,出队)。
- 再次执行所有微任务(宏任务执行后,会清空微任务队列)。
- 重复步骤 3-4,形成循环。
流程图
同步任务 → 微任务队列(全部执行)→ 宏任务队列(执行第一个)→ 微任务队列(全部执行)→ ...
- 经典真题:宏微任务执行顺序
console.log('1'); // 同步任务
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
new Promise((resolve) => {
console.log('3'); // 同步任务(Promise 构造函数是同步的)
resolve();
}).then(() => {
console.log('4'); // 微任务
});
console.log('5'); // 同步任务
解题步骤
- 执行同步任务:console.log('1') → 输出 1;new Promise 构造函数同步执行 → 输出 3;console.log('5') → 输出 5。
- 同步任务执行完毕,执行微任务队列:Promise.then → 输出 4。
- 微任务队列空,执行宏任务队列第一个:setTimeout → 输出 2。
-
最终输出:1 → 3 → 5 → 4 → 2。
-
进阶考点:async/await 与事件循环
async/await 是 Promise 的语法糖,本质还是微任务。核心规则:
- async 函数执行时,遇到 await 会暂停,先执行 await 后面的表达式(同步)。
- await 后面的代码(即 await 所在行之后的代码)会被放入「微任务队列」,等待当前同步任务和已有微任务执行完毕后执行。
真题:async/await 执行顺序
async function async1() {
console.log('async1 start'); // 同步
await async2(); // 执行 async2(同步),暂停 async1,后续代码放入微任务
console.log('async1 end'); // 微任务
}
async function async2() {
console.log('async2'); // 同步
}
console.log('script start'); // 同步
setTimeout(() => {
console.log('setTimeout'); // 宏任务
}, 0);
async1();
new Promise((resolve) => {
console.log('promise1'); // 同步
resolve();
}).then(() => {
console.log('promise2'); // 微任务
});
console.log('script end'); // 同步
解题步骤
- 执行同步任务:
- console.log('script start') → 输出 script start。
- 调用 async1() → 输出 async1 start → 调用 async2() → 输出 async2 → await 暂停 async1,async1 end 放入微任务队列。
- new Promise 构造函数同步执行 → 输出 promise1 → then 放入微任务队列。
- console.log('script end') → 输出 script end。
- 同步任务执行完毕,执行微任务队列:
- 先执行 async1 end → 输出 async1 end。
- 再执行 promise2 → 输出 promise2。
- 微任务队列空,执行宏任务:setTimeout → 输出 setTimeout。
-
最终输出:script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout。
-
浏览器 vs Node.js 事件循环差异(大厂高频考点)
| 维度 | 浏览器环境 | Node.js 环境(11+ 版本) |
|---|---|---|
| 宏任务队列 | 1 个宏任务队列 | 6 个宏任务队列(按优先级执行,如 timers 队列优先级高于 poll 队列) |
| 微任务执行时机 | 每个宏任务执行后,清空微任务队列 | 每个宏任务执行后,清空微任务队列(与浏览器一致) |
| 微任务类型 | Promise.then、queueMicrotask | 新增 process.nextTick(优先级高于普通微任务) |
Node.js 特有考点:process.nextTick
console.log('1');
process.nextTick(() => {
console.log('2'); // 微任务(优先级最高)
});
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4'); // 普通微任务
});
console.log('5');
// 输出:1 → 3 → 5 → 2 → 4
五、大厂面试真题汇总(含解析)
真题 1:宏微任务混合执行
console.log('a');
setTimeout(() => {
console.log('b');
new Promise((resolve) => {
console.log('c');
resolve();
}).then(() => {
console.log('d');
});
}, 0);
new Promise((resolve) => {
console.log('e');
resolve();
}).then(() => {
console.log('f');
setTimeout(() => {
console.log('g');
}, 0);
});
console.log('h');
解析:
- 同步任务:a → e → h。
- 微任务:f → 微任务队列空。
- 宏任务 1(setTimeout):b → c → 微任务 d → 微任务队列空。
- 宏任务 2(setTimeout):g。
- 输出:a → e → h → f → b