js执行
我们可以把代码从“字符串”到“运行”的过程分为三个大的阶段:
阶段一:词法分析 & 语法分析(分词/解析)
这是引擎的 Parser(解析器) 在干活:
- 分词(Tokenizing/Lexing):把
var a = 1;拆成var,a,=,1。 - 生成 AST(抽象语法树):把这些词按照语法规则排成一棵树。
- 重点:这一步主要检查语法错误(比如少了个括号),它还没开始分配变量内存
词法分析生成 AST 时作用域已经定下了雏形,引擎就已经通过代码的缩进和位置确定了:
- 函数 A 包着 函数 B。
- 函数 B 包着 变量 C。
阶段二:预编译(创建执行上下文)
一. 执行上下文
当 JS 引擎执行代码前,它会扫描所有的代码(并不是执行代码),统称为“编译”
(1):环境记录
第一次扫描:创建快照(预解析)
- 建立环境记录(类似分类):
- 扫描所有
function关键字,把整个函数体存进去(函数提升)。 - 扫描所有
var声明,给它们分配内存并初始化为undefined(变量提升)。 - 识别
let和const,虽然也记录了它们,但标记为“不可访问”(暂时性死区)。
面试总结:所谓的暂时性死区 (TDZ) 和变量提升,其实在js引擎第一次扫描代码的时候就已经决定了。
| 变量类型 | 分类时的动作 (Creation Phase) | 内存状态 | 访问结果 |
var | 创建并初始化 | 已经分配了内存,并填入了 undefined 作为默认值。 | 可以访问(返回 undefined)。 |
let / const | 仅创建(注册) | 内存地址已被预留,但引擎严禁对其进行任何形式的访问(甚至不给 undefined)。 | 报错(ReferenceError)。 |
(2):词法分析
拍下照片时,引擎会记下这个函数是在哪里定义的,确定它的“父级”是谁。这个过程最重要的就是理解什么是域(词法作用域,块作用域,函数作用域)
词法作用域:简单理解是单词化,对所有的字符进行检查。就是你所写的代码,写在哪,定在哪,这个单词(变量/函数)到底写在谁的地盘里?

块级作用域 (Block Scope) —— ES6 核心
- 地盘范围:由一对大括号
{ ... }包裹的区域(如if,for,while或纯{})。- 快照特征:只对
let和const生效。- 引擎动作:第一次扫描时,如果在大括号内看到
let/const,引擎会为这个{}专门创建一个临时的环境记录。
var a = 1;
function outer() {
var b = 2;
if (true) {
var c = 3;
let d = 4;
}
console.log(c); // 输出什么?
console.log(d); // 输出什么?
}
outer();
你的“高级”分析步骤:
一:全局第一次扫描:创建全局环境记录,记录 a = undefined,记录 outer 函数体。
二:执行 outer(),触发局部第一次扫描:
引擎操作:
- 建立
outer的环境记录。 - 扫描
var b:存入outer记录,设为undefined。 - 扫描
var c:虽然在if里,但var穿透块级,存入outer记录,设为undefined。 - 扫描
let d:发现它在{}块里,引擎为这个if块单独创建了一个块级环境记录,记录d为“不可访问”。
函数作用域 (Function Scope)
地盘范围:由
function关键字包裹的代码块。快照特征:
只要看到
function,引擎就会在“快照阶段”为其预留一个独立的“环境记录”。函数内部定义的变量,外部绝对无法访问。
三:开始执行 outer 内部代码:
- 执行到
if块内,给c赋值为 3,给块级记录里的d赋值为 4。 - 出了
if块:块级环境记录被销毁,d彻底消失。 - 结果:
console.log(c):在outer记录里找到了c,打印console.log(d):在outer记录里找不到,向上级(全局)也找不到,报错 ReferenceError。
(3):变量提升
当同名的
var和function同时存在时,函数声明的优先级更高
-
引擎动作:如果环境记录中已存在同名变量,函数声明会直接覆盖该变量的记录。
-
代码表现:
console.log(a); // 输出 [Function: a] var a = 10; function a() {}
(4):作用域链 (Scope Chain)
1. 静态属性:[[Environment]]
作用域链的本质不是在执行时动态生成的,而是在函数创建时就“刻在基因里”的。
-
当引擎创建一个函数对象时,会给它一个内部属性
[[Environment]]。 -
这个属性指向该函数定义时所在的执行上下文的环境记录。
-
这就是为什么 JS 是词法作用域(静态作用域)的原因:无论函数在哪里被调用,它能访问哪些变量在它写下的那一刻就定死了。
2. 链式查找过程
当你在函数内访问一个变量时,引擎的动作如下:
-
在当前环境记录中查找。
-
若找不到,通过
[[Environment]]引用进入父级环境记录。 -
重复此过程,直到找到变量或到达 全局环境记录(Global Environment)。
-
若全局也找不到,非严格模式下创建全局变量或报错。
深度扩展:this 指向
很多开发者容易把 this 和作用域混为一谈,但它们是两个完全不同的机制:
-
作用域:是静态的,基于代码位置。
-
this:是动态的,基于调用方式。
(5):调用栈 / 执行栈 (Call Stack)
-
数据结构:栈(Stack)—— 先进后出 (LIFO, Last In First Out)。
-
规则:
-
代码执行最开始,先要把全局执行上下文 (Global Execution Context) 压入栈底。
-
每当遇到一个函数调用
func(),引擎就会为该函数创建一个新的函数执行上下文,并将其压入 (Push) 栈顶。 -
当函数执行完毕(遇到
return或代码结束),该函数的上下文就会被弹出 (Pop) 销毁,控制权交还给下一层的上下文。
-
console.log('1. Global start');
function first() {
console.log('2. Inside first');
second(); // 调用 second
console.log('4. Back to first');
}
function second() {
console.log('3. Inside second');
}
first(); // 调用 first
console.log('5. Global end');
步骤 1:启动程序
| |
| [ Global Context ]| <-- 栈底常驻
+-------------------+
步骤 2:执行 first() 引擎创建 first 的上下文并压入栈。
| |
| [ first Context ]| <-- 当前正在执行这里
| [ Global Context ]|
+-------------------+
步骤 3:在 first 中调用 second() 引擎暂停 first,创建 second 的上下文并压入栈顶
| [ second Context ]| <-- 控制权在这里
| [ first Context ]| <-- 暂停等待
| [ Global Context ]|
+-------------------+
步骤 4:second() 执行完毕 second 出栈(内存销毁),控制权回到 first。
| | <-- second 被销毁
| [ first Context ]| <-- 恢复执行
| [ Global Context ]|
+-------------------+
步骤 5:first() 执行完毕 first 出栈。
| |
| |
| [ Global Context ]| <-- 回到全局,直到程序关闭
+-------------------+
执行栈总结:
- 单线程:JS 只有一个主线程,同一时间栈顶只有一个上下文在运行。
- 爆栈 (Stack Overflow):如果递归调用没有终止条件(
function a() { a(); }),栈会不断增加直到超出浏览器限制,抛出错误。 - 同步机制:必须等栈顶的函数执行完,下面的代码才能继续。
(6) 闭包 (Closure) —— 栈与堆的“博弈”
引出问题: 根据上面的栈机制,函数执行完,上下文弹出,内存被销毁(垃圾回收 GC)。 那么问题来了:如果不希望变量被销毁怎么办?
代码是如何在“栈”和“堆”之间配合的
var a = 1; // 基础类型
var b = { name: "Hi" }; // 引用类型(对象)
var c = b; // 赋值
内存图示 :
栈内存 (Stack) 堆内存 (Heap)
(有序,存放基础值和地址) (无序,存放大数据)
+--------------------------+ +---------------------+
| 变量名 | 值 (Value) | | |
+--------------------------+ | |
| a | 1 | | 地址: 0x001 |
|--------|------------------| | { |
| b | 地址(0x001) ------指向-----> name: "Hi" |
|--------|------------------| | } |
| c | 地址(0x001) ------指向-----> |
+--------------------------+ +---------------------+
- 执行顺序:代码依然在栈里一行行执行。
- 变量
a:直接在栈里存了1。 - 变量
b:
- JS 引擎在堆里开辟一块空间,存入
{ name: "Hi" },生成地址0x001。 - 然后把
0x001这个地址写在栈里b的旁边。
4.变量 c:
c = b并不是把大对象复制了一份。- 而是把
b手里的地址0x001抄了一份给c。 - 结果:
b和c指向堆里的同一个东西。
回到刚才的“闭包”问题
你刚才困惑的是:为什么栈弹出了,堆里的东西还在?
function outer() {
var name = "Stack Overflow";
function inner() {
// inner 引用了 outer 的变量 name
console.log(name);
}
return inner; // 把 inner 函数本身返出去了!
}
var myFunc = outer(); // outer 执行完毕,理应出栈销毁
myFunc(); // 这里在全局调用 inner,竟然还能打印出 "Stack Overflow"?
深度解析: 按照“栈”的逻辑,outer 执行完,name 变量应该随着 outer 上下文的销毁而消失。但是 myFunc() 依然访问到了它。 这就是闭包。
闭包的本质:
- 词法作用域的规则:内部函数总是可以访问外部函数的变量。
- 生命周期的延长:当内部函数被返回并在外部被持有时,即便外部函数执行完毕(从栈中弹出),JS 引擎发现内部函数引用了外部的变量,它就不会回收这部分内存。
图例解释 (闭包的内存模型):当 outer 出栈后,内存里发生了什么?
正常情况 (无闭包):
- Stack:
outer弹出。 - Heap (堆内存):
outer的环境记录(Environment Record)被 GC 回收。
闭包情况:
Stack (执行栈) Heap (堆内存)
+----------------+ +---------------------------+
| | | Closure (outer) Scope |
| [ Global ctx ] | ------------>| { name: "Stack Overflow" }| <---+
| (myFunc 正在运行)| +---------------------------+ |
+----------------+ |
|
+---------------------------+ |
| Function Object: inner | |
| [[Environment]] pointer --|-----+
+---------------------------+
outer虽然从栈中弹出了。- 但是
inner函数对象(现在赋给了myFunc)依然存在。 inner的身上有一个秘密属性[[Environment]],它像一根脐带,紧紧抓住了outer创建时的作用域对象(Closure Scope)。- 因为这根“脐带”的存在,垃圾回收器(GC)不敢回收
outer的变量对象。
闭包总结:
闭包 = 函数 + 该函数对周围状态(词法环境)的引用。 简单说:闭包就是函数带着它出生时的环境(背包)一起流浪。
闭包的优缺点:
- 优点:
- 数据私有化:外部无法直接修改
name,只能通过inner修改(模块化模式的基础)。 - 状态保持:柯里化(Currying)、防抖节流等高阶函数的基础。
-
缺点:
- 内存泄漏风险:因为变量一直不被回收,滥用闭包会导致内存占用过高。
// ==========================================
// Part : 闭包 (Closure) 原理演示
// ==========================================
console.log('--- Part 2: Closure Demo ---');
function createCounter() {
// 这个 count 变量就是“被捕获”的变量,存在于堆内存的 Closure Scope 中
let count = 0;
return {
increment: function() {
count++;
console.log(`Counter value: ${count}`);
},
decrement: function() {
count--;
console.log(`Counter value: ${count}`);
},
// 这一步证明了数据的私有性
checkScope: function() {
console.log('Can I access count directly from outside? No.');
}
};
}
const counterA = createCounter(); // 创建第一个闭包实例
const counterB = createCounter(); // 创建第二个闭包实例(互不干扰)
console.log('Operating Counter A:');
counterA.increment(); // 1
counterA.increment(); // 2
console.log('Operating Counter B:');
counterB.increment(); // 1 (证明闭包之间环境是独立的)
console.log('Operating Counter A again:');
counterA.decrement(); // 1 (Counter A 依然记得它之前的状态)
// 尝试销毁闭包释放内存
// counterA = null; // 切断引用,GC 此时才会回收对应的 Scope
阶段三:执行阶段 (Execution Phase)
这是 JS 引擎真正开始“干活”的阶段。在这个阶段,引擎会从上到下逐行读取代码,进行赋值和逻辑运算。
1. 核心任务:从 undefined 到 “真值”
还记得阶段二吗?
-
var a = 10;在阶段二只是var a(内存里是undefined)。 -
到了阶段三,引擎读到这一行,才会执行
a = 10的赋值操作。
对比图表:
| 代码行 | 阶段二:创建阶段 (Creation Phase) | 阶段三:执行阶段 (Execution Phase) |
var name = "a"; | 内存中注册 name,值为 undefined | 将 "a" 赋值给 name |
function add(a, b){...} | 整个函数体被存入内存 | 跳过(除非被调用) |
console.log(name); | 忽略 | 执行打印操作,去内存找 name 的值 |
2. 引擎的内心戏:LHS 与 RHS 查询
这是高级工程师必须掌握的细节。在执行阶段,当引擎看到一个变量时,它的查找方式分为两种(这决定了报错类型):
LHS (Left-Hand Side) —— “容器在哪里?”
- 场景:变量出现在赋值符号的左边。
- 目的:试图找到变量的容器,以便把值存进去。
- 例子:
a = 2;(引擎问:去哪找a这个位置给我存2?) -
失败后果:
- 非严格模式:如果在全局也找不到,引擎会在全局帮你自动创建一个(这就导致了隐式全局变量泄露)。
- 严格模式 (
'use strict'): 报错ReferenceError。
RHS (Right-Hand Side) —— “值是什么?”
- 场景:变量出现在赋值符号的右边(或者作为参数、或者单独出现)。
- 目的:试图获取变量的值。
- 例子:
console.log(a);(引擎问:a的值是什么?拿出来给我。) - 失败后果:如果作用域链找遍了都找不到,直接报错
ReferenceError。
3. 执行上下文出栈
阶段三的最后一步: 当一个函数内部的所有代码执行完毕(或者遇到 return):
-
该函数的执行上下文从执行栈 (Call Stack) 中弹出。
-
如果不涉及闭包,该上下文对应的环境记录(内存) 会被垃圾回收机制(GC)标记并回收
异步机制与事件循环 (Event Loop)
之前我们聊的“执行栈”是同步的,也就是“一条道走到黑”。但如果 JS 只有这一条道,当你请求一个接口需要 5 秒钟时,整个网页就会卡死 5 秒,用户什么都点不了。这显然是不可接受的。
为了解决这个问题,JS 引入了异步 (Asynchronous) 机制。
一、 宏观架构:浏览器的“多线程”辅助
首先要纠正一个误区:JS 引擎(如 V8)是单线程的,但是浏览器(宿主环境)是多线程的。你可以把 JS 引擎想象成一个光杆司令(主线程),但他背后有一个强大的秘书团(Web APIs)。
主线程 (JS Engine/Call Stack):
- 也就是我们之前说的“执行栈”。
- 特点:只有一个人,一次只能做一件事。所有同步代码必须在这里执行。
Web APIs (秘书团):
- 浏览器提供的能力,比如
setTimeout、DOM Events、AJAX/Fetch。 - 特点:它们在后台运行,不占用主线程。比如你定了个闹钟,是浏览器在帮你倒计时,不是 JS 引擎自己在数数。
任务队列 (Task Queue):
- 当秘书团把事情做完了(比如倒计时结束了、数据请求回来了),它们不能直接把回调函数塞回主线程(会打断正在执行的代码)。
- 它们只能把回调函数扔进一个候车室(队列),排队等待被叫号。
Event Loop (交通指挥官):
- 它的工作非常单一且枯燥:不断地检查。
- 它盯着两边:执行栈空了吗?队列里有人排队吗?
- 规则:只有当执行栈彻底空了,它才会从队列里拉一个人出来,推入执行栈去执行。
二、 宏任务与微任务:VIP 通道
“候车室”其实不只一个。为了区分任务的轻重缓急,JS 设计了两条队列:
宏任务队列 (Macro-task Queue) 和 微任务队列 (Micro-task Queue)。
1. 宏任务 (Macro-task) —— 普通乘客
宏任务是由宿主环境(浏览器/Node)发起的任务。
常见成员:
script(整体代码 script 标签)setTimeout/setIntervalsetImmediate(Node.js)I/O(文件读写)UI Rendering(页面渲染)
2. 微任务 (Micro-task) —— VIP 乘客
微任务是由 JS 引擎自身发起的任务,通常涉及状态改变,需要尽快处理。
常见成员:
Promise.then/.catch/.finally(注意:new Promise是同步的)process.nextTick(Node.js,优先级极高)MutationObserver(监听 DOM 变化)
三、 Event Loop 的完整循环流程
这是最关键的逻辑,请务必记在脑子里:
循环步骤:
1.执行栈 (Stack):同步代码执行,直到栈清空。
2.微任务检查 (Check Micro):
- 执行栈空了,Event Loop 立刻去看微任务队列。
- 重点:它会把微任务队列里的所有任务一口气全执行完!如果你在微任务里又产生了新的微任务,也会在这一轮里插队执行完。
- 直到微任务队列清空为止。
3.渲染 UI (Render):
- (浏览器通过这里决定是否需要更新页面显示)。
4.宏任务检查 (Check Macro):
- 去宏任务队列里取出一个(只取一个!)任务。
- 推入执行栈执行。
5.回到步骤 1。
口诀:
同步执行完,全清微任务;渲染更界面,再拿宏任务
四、 代码实战分析 (手把手推演)
console.log('1. Script start'); // 同步
setTimeout(function() {
console.log('2. setTimeout'); // 宏任务
}, 0);
new Promise(function(resolve) {
console.log('3. Promise constructor'); // 同步!Promise 构造函数是立即执行的
resolve();
}).then(function() {
console.log('4. Promise then'); // 微任务
});
console.log('5. Script end'); // 同步
详细推演过程:
第一轮:同步代码 (Script 宏任务)
- 遇到
console.log('1...')-> 输出 1。 - 遇到
setTimeout-> 这是一个宏任务。Web API 把它拿走,0ms 后将回调放入宏任务队列。 - 遇到
new Promise-> 构造函数内的代码是同步的!-> 输出 3。 - 遇到
resolve()-> 触发.then。这是一个微任务。放入微任务队列。 - 遇到
console.log('5...')-> 输出 5。 - 当前状态:
-
栈:空。
-
微队列:
[then回调]。 -
宏队列:
[setTimeout回调]。
第二轮:清空微任务
- Event Loop 发现栈空了,优先看微队列。
- 执行
then回调 -> 输出 4。 - 微队列清空。
第三轮:执行宏任务
- Event Loop 去宏队列拿一个任务。
- 执行
setTimeout回调 -> 输出 2。
最终结果:1 -> 3 -> 5 -> 4 -> 2
闭包js栈事件循环&spm=1001.2101.3001.5002&articleId=157512549&d=1&t=3&u=eae20334b83c4be3a32550a950ffaf13)
1599

被折叠的 条评论
为什么被折叠?



