前言:为什么你需要彻底理解 JS 的执行机制?
你是否曾遇到过以下"诡异"现象?
- 函数在声明前就能调用?
console.log(x)输出undefined而不是报错?- 循环中的
setTimeout全部输出同一个值? - 在
if块中用var声明变量,却影响了整个函数?
这些看似"反直觉"的行为,其实都源于 JavaScript 引擎底层的 执行机制。而要真正掌握这门语言,不能只停留在"会用 API",必须深入理解:
- JS 是如何运行的?
- 什么是执行上下文(Execution Context)?
- 变量提升(Hoisting)是怎么回事?
- 作用域(Scope)到底是什么?
- 为什么
let/const比var更安全? - V8 引擎内部是如何处理这些概念的?
本文将结合基础代码,逐行注解、层层递进,带你从零构建对 JavaScript 执行机制的完整认知体系。全文基于现代 JS(ES6+)标准,并兼容历史背景,力求既讲清原理,又解决实际问题。
第一章:JavaScript 的运行模型------编译 + 执行
很多人误以为 JavaScript 是"解释型语言",一行一行直接执行。但事实是:现代 JavaScript 引擎(如 V8)采用"先编译,再执行"的两阶段模型。
1.1 两阶段执行流程
当一段 JS 代码被加载时,V8 引擎会经历两个关键阶段:
阶段一:编译阶段(Compilation / Parsing)
- 扫描整段代码
- 识别所有 变量声明 (
var,let,const)和 函数声明 - 构建 执行上下文(Execution Context)
- 对
var和function进行 变量提升(Hoisting)
注意:只有声明被提升,赋值不会!
阶段二:执行阶段(Execution)
- 按代码顺序执行赋值、函数调用、表达式求值等操作
- 动态创建新的执行上下文(如调用函数时)
- 管理 调用栈(Call Stack)
这种设计使得 JS 能在运行前"预知"有哪些变量和函数可用,但也带来了"变量提升"这一争议特性。
第二章:执行上下文(Execution Context)------JS 运行的"容器"
每当 JS 执行一段可执行代码(全局代码或函数),引擎会为其创建一个 执行上下文。它是 JS 代码运行的"沙盒环境"。
2.1 执行上下文的组成
每个执行上下文包含三个核心部分:
| 组成 | 说明 |
|---|---|
| 变量环境(Variable Environment) | 存放 var 声明的变量(函数级作用域) |
| 词法环境(Lexical Environment) | 存放 let/const 声明的变量(支持块级作用域) |
| this 绑定 | 当前上下文中的 this 指向 |
关键区分:
var→ 变量环境let/const→ 词法环境
这就是为什么 var 和 let 行为不同------它们被存放在执行上下文的不同"房间"里!
2.2 执行上下文的生命周期
-
创建阶段(编译阶段):
- 确定作用域
- 提升变量和函数
- 初始化变量环境和词法环境
-
执行阶段:
- 逐行执行代码
- 赋值、调用、修改变量
-
销毁阶段:
- 函数执行完毕后,上下文出栈
- 变量被垃圾回收(除非被闭包引用)
第三章:变量提升(Hoisting)------JS 的"历史包袱"
3.1 什么是变量提升?
变量提升(Hoisting) 是指:在编译阶段,JS 引擎将 var 变量声明和 function 声明"移动"到当前作用域顶部的行为。
注意:只有声明被提升,赋值留在原地!
3.2 为什么会有变量提升?
因为JavaScript 当时是一个 KPI 项目,没想到会火起来,设计周期短......为了简单设计,没有块级作用域,再把变量统一提升到函数顶部,是最快最简单的设计。
早期 JS 为了快速实现浏览器脚本功能,牺牲了严谨性。变量提升虽简化了实现,却带来了大量陷阱。
3.3 实战分析:文件 1.js
javascript
showName(); // (1)
console.log(myname); // (2)
var myname = '张三'; // (3)
function showName() {
console.log(myname); // (4)
}
编译阶段(提升后等效代码):
javascript
// 函数声明完整提升(包括函数体)
function showName() {
console.log(myname);
}
// var 声明提升,但赋值不提升
var myname; // → undefined
// 执行阶段代码
showName(); // (1) 调用函数
console.log(myname); // (2) 输出 undefined
myname = '张三'; // (3) 赋值
执行结果:
- (1)
showName()→ 打印undefined(因为此时myname已声明但未赋值) - (2)
console.log(myname)→undefined - (4) 函数内
console.log(myname)→ 同样undefined
💡 结论 :函数可以提前调用,但变量值在赋值前是
undefined。
第四章:作用域(Scope)------变量的"可见范围"
作用域决定了变量在哪些地方可以被访问,以及何时被销毁。
4.1 三种作用域类型
| 类型 | 描述 | 生命周期 |
|---|---|---|
| 全局作用域 | 在任何地方都能访问 | 页面存活期间 |
| 函数作用域 | 仅在函数内部可见(ES5 主要作用域) | 函数调用期间 |
| 块级作用域 | 仅在 {} 块内可见(ES6 新增) |
块执行期间 |
ES5 不支持块级作用域 ,这是
var问题的根源!
4.2 文件 2.js:全局 vs 局部变量
javascript
var globalVar = "全局变量"; // (1) 全局作用域
function myFunction() {
var localVar = "局部变量"; // (2) 函数作用域
console.log(globalVar); // (3) 访问全局变量
console.log(localVar); // (4) 访问局部变量
}
myFunction(); // (5) 调用函数
console.log(globalVar); // (6) 全局可访问
console.log(localVar); // (7) 报错!
分析:
- (1)
globalVar在全局作用域,任何地方可访问。 - (2)
localVar在myFunction内部,属于函数作用域。 - (7) 尝试在函数外访问
localVar→ReferenceError: localVar is not defined
作用域隔离:函数内部变量对外部不可见,这是封装的基础。
第五章:var 的致命缺陷------无视块级作用域
5.1 问题根源
在 ES5 中,var 只认函数边界,不认 {} 块 。即使你在 if、for、while 中用 var,变量仍属于整个函数!
5.2 文件 3.js:var 在 if 块中的灾难
javascript
var name = '张三'; // (1) 全局变量
function showName() {
console.log(name); // (2) 输出什么?
if (true) {
var name = '李四'; // (3) var 声明!
}
console.log(name); // (4) 输出什么?
}
showName();
编译阶段(提升后):
javascript
var name = '张三';
function showName() {
var name; // 提升!遮蔽全局 name
console.log(name); // → undefined
if (true) {
name = '李四'; // 赋值
}
console.log(name); // → '李四'
}
执行结果:
- (2)
undefined(不是'张三'!) - (4)
'李四'
为什么不是
'张三'?因为函数内部的
var name遮蔽(shadowing) 了全局变量,并且提升后初始值为undefined。
这就是 var 的典型陷阱:你以为在操作全局变量,其实创建了一个同名的局部变量!
第六章:ES6 救星------let/const 与块级作用域
为了解决 var 的问题,ES6 引入了:
let:块级作用域变量const:块级作用域常量- 暂时性死区(Temporal Dead Zone, TDZ)
6.1 暂时性死区(TDZ)
在块内,let/const 声明的变量在声明前处于 TDZ ,访问会抛出 ReferenceError。
ini
console.log(x); // ReferenceError
let x = 1;
这比
var的undefined更安全------早报错,早修复。
6.2 文件 4.js:let 的块级作用域
javascript
let name = '张三'; // (1) 全局 let 变量
function showName() {
console.log(name); // (2) → '张三'
if (false) {
let name = '李四'; // (3) 块级变量(但 if 为 false,不执行)
}
console.log(name); // (4) → '张三'
}
showName();
分析:
- (1) 全局
name是let,块级作用域。 - (3)
if (false)块未执行,let name = '李四'从未创建。 - (2)(4) 都访问全局
name→'张三'
无污染、无遮蔽 :
let完美隔离作用域。
6.3 对比 var vs let:循环中的经典问题
文件 6.js(var 版):
css
function foo(){
for (var i = 0; i < 100; i++) { }
console.log(i); // 输出 100!因为 var i 被提升到函数顶部
}
foo();
如果换成 let:
ini
for (let i = 0; i < 100; i++) { }
console.log(i); // ReferenceError: i is not defined
let i仅存在于for循环的块级作用域中,循环结束后自动销毁。
闭包陷阱示例:
javascript
// var 版本(错误)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出 3, 3, 3
}
// let 版本(正确)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出 0, 1, 2
}
原因 :let 在每次循环迭代中创建一个新的绑定(binding),而 var 只有一个共享变量。
第七章:执行上下文视角------var 与 let 如何共存?
现代 JS 必须同时支持 var(向下兼容)和 let/const(新标准)。V8 引擎如何做到?
答案: "一国两制" ------ 在同一个执行上下文中,使用两个独立的存储空间。
7.1 文件 7.js:融合示例
csharp
// 执行上下文的角度,var/let 融合
function foo(){
var a = 1; // → 存入"变量环境"
let b = 2; // → 存入"词法环境"
{
let b = 3; // → 新建块级词法环境,b=3 遮蔽外层 b=2
}
}
执行过程:
- 进入
foo,创建执行上下文 - 扫描
var a→ 放入 变量环境 (初始undefined) - 扫描
let b→ 放入 词法环境(处于 TDZ) - 执行
var a = 1→ 变量环境中的a赋值为1 - 执行
let b = 2→ 词法环境中的b赋值为2 - 进入
{}块 → 创建 新的词法环境 - 在新环境中
let b = 3→ 不影响外层b
关键设计:
- 变量查找时,先查词法环境,再查变量环境
let/const优先级更高,避免与var冲突
第八章:其他控制结构的作用域行为
8.1 文件 5.js:空块与循环
scss
if (1){} // 空 if 块(合法,但无意义)
while (1){} // 无限循环(语法合法,但会卡死)
function foo(){} // 函数声明(会被提升)
for (let i = 0; i < 100; i++) {
console.log(i); // let i 仅在此 for 块中有效
}
重点:
if、while、for的{}都是 块级作用域- 但只有使用
let/const才能体现其作用 function foo(){}是函数声明,会被提升
即使块为空,JS 也为其创建作用域(虽然没变量,但结构存在)。
第九章:作用域链(Scope Chain)------变量查找的完整路径
9.1 什么是作用域链?
想象你在一个多层办公楼里找一个人:
- 你先在自己办公室找(当前作用域)
- 找不到,就去你部门的公共区域找(父级作用域)
- 还找不到,就去整栋楼的大厅找(全局作用域)
- 如果大厅也没有 → "查无此人!"
作用域链(Scope Chain)就是 JavaScript 引擎查找变量时,沿着"嵌套作用域"一层层向上搜索的路径。
关键规则:
- 查找顺序:从内向外(当前 → 父级 → ... → 全局)
- 路径在函数声明时就固定了 (不是调用时!)→ 这叫 词法作用域(Lexical Scope)
9.2 作用域链是如何构建的?
每当一个函数被声明(不是调用!),JS 引擎就会记录:
"这个函数是在哪个作用域里写的?它的'爸爸'是谁?"
这个"家族关系链"就是作用域链。
举个生活化例子:
javascript
// 全局作用域(大楼大厅)
var myName = '李四';
function foo() {
// 函数作用域(foo 办公室)
var myName = '张三';
function bar() {
// 函数作用域(bar 小隔间)
console.log(myName);
}
bar(); // 调用 bar
}
foo();
问题:
console.log(myName)输出什么?
很多人以为:bar 是在 foo 里面调用的,所以应该输出 '张三' ------ 这是对的,但原因更重要!
真正原因:
-
bar函数声明的位置 在foo内部 -
所以
bar的作用域链是:bar 自身 → foo → 全局 -
查找
myName时:bar自己没有myName- 去
foo里找 → 找到var myName = '张三' - 停止查找!返回
'张三'
注意:即使
bar被拿到全局调用,结果也一样!因为作用域链在声明时就锁定了。
9.3 文件 1.js 再分析:为什么输出 李四?
1.js 是一个经典反直觉案例:
ini
function bar(){
console.log(myName);
}
function foo(){
var myName = '张三';
bar(); //运行时查找,先在当前作用域查找,没有找到,就去父作用域查找
}
var myName = '李四';
foo(); // 李四
为什么不是 '张三'?
关键点 :bar 函数是在全局作用域中声明的!
所以 bar 的作用域链是:bar → 全局,根本不包含 foo!
执行过程:
-
foo()被调用 -
foo内部创建局部变量myName = '张三' -
调用
bar() -
bar开始查找myName:- 自己没有
- 看"爸爸"是谁 → 全局作用域
- 在全局找到
myName = '李四'
-
输出
'李四'
💡 记住 :作用域链看函数在哪写的,不是在哪调用的!
"作用域链查找的规则 一定要知道作用域链 变量的查找路径按函数申明的时候,即在编译阶段就确定了,不会改变词法作用域"
9.4 作用域链查找优先级:词法环境 vs 变量环境
当查找一个变量时,JS 引擎会按以下顺序:
- 当前词法环境 (
let/const) - 当前变量环境 (
var) - 外层函数的词法/变量环境
- 全局环境
- 找不到 →
ReferenceError
✅ 因此,
let name会遮蔽var name,因为词法环境优先。
第十章:最佳实践与总结
10.1 为什么变量提升是"设计缺陷"?
- 导致代码行为与直觉不符
- 容易造成变量覆盖
- 本应销毁的变量未被销毁(内存泄漏风险)
- 无法实现真正的块级逻辑隔离
但早期 JS 为了快速实现,选择了最简单的方案。
10.2 ES6 如何解决?
| 问题 | ES5 (var) |
ES6 (let/const) |
|---|---|---|
| 作用域 | 函数级 | 块级 ✅ |
| 提升行为 | 声明提升,值为 undefined |
暂时性死区(TDZ)✅ |
| 重复声明 | 允许(静默覆盖) | 报错 ✅ |
| 循环变量 | 共享同一变量 | 每次迭代新绑定 ✅ |
10.3 开发建议
- 永远不要使用
var(除非维护老项目) - 优先使用
const,只有需要重赋值时才用let - 理解 TDZ :不要在声明前访问
let/const变量 - 利用块级作用域 :用
{}显式隔离逻辑 - 避免全局变量污染:使用模块化(ESM)或 IIFE
第十一章:附录------所有文件逐行注解汇总
📄 1.js
javascript
showName(); // 函数声明被提升,可提前调用
console.log(myname); // var 提升 → undefined
var myname = '张三'; // 声明提升,赋值在执行阶段
function showName() {
console.log(myname); // 函数内访问提升后的 myname → undefined
}
📄 2.js
javascript
var globalVar = "全局变量"; // 全局作用域
function myFunction() {
var localVar = "局部变量"; // 函数作用域
console.log(globalVar); // 访问全局
console.log(localVar); // 访问局部
}
myFunction(); // 调用
console.log(globalVar); // 全局可访问
console.log(localVar); // 局部变量不可见
📄 3.js
javascript
var name = '张三';
function showName() {
console.log(name); // undefined(函数内 var name 提升遮蔽全局)
if (true) {
var name = '李四'; // var 无视块级,提升到函数顶部
}
console.log(name); // '李四'
}
📄 4.js
javascript
let name = '张三';
function showName() {
console.log(name); // '张三'(无遮蔽)
if (false) {
let name = '李四'; // 块级作用域,但未执行
}
console.log(name); // '张三'
}
📄 5.js
scss
if (1){} // 空块
while (1){} // 无限循环(慎用)
function foo(){} // 函数声明(提升)
for (let i = 0; i < 100; i++) {
console.log(i); // let i 块级作用域
}
📄 6.js
css
function foo(){
for (var i = 0; i < 100; i++) { }
console.log(i); // 100(var 提升到函数顶部)
}
📄 7.js
ini
function foo(){
var a = 1; // → 变量环境
let b = 2; // → 词法环境
{
let b = 3; // → 新词法环境,遮蔽外层 b
}
}
第十二章:闭包(Closure)------让函数"记住"它出生的地方
12.1 什么是闭包?
闭包(Closure) 是指:
一个函数能够访问并操作其词法作用域外部的变量 ,即使这个函数在外部被调用,这些变量依然存在。
简单说:函数带着它的"出生证明"(作用域)一起旅行。
12.2 闭包形成的两个必要条件
闭包的形成条件
- 函数嵌套
- 内部函数引用了外部函数的变量
二者缺一不可!
12.3闭包实战
以下是闭包的经典范例:
ini
// 特殊的地方
function foo() {
var myName = '张三';
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function() {
console.log(test1);
return myName;
},
setName: function(newName) {
myName = newName;
}
}
// return 可以被外部访问
return innerBar; // 闭包形成的条件------函数嵌套函数
}
var bar = foo(); // 出栈
bar.setName('李四'); // setName 执行上下文创建
bar.getName();
console.log(bar.getName()); // 李四
逐行拆解:
-
foo()被调用:- 创建执行上下文
- 初始化
myName = '张三',test1 = 1,test2 = 2 - 定义对象
innerBar,其方法getName和setName引用了myName和test1
-
return innerBar:foo执行完毕,执行上下文本应销毁- 但
innerBar的两个方法仍然引用着myName和test1 - JS 引擎发现:"这些变量还有人用!" → 不回收!
-
var bar = foo():bar拿到了innerBar对象- 此时
foo已出栈,但myName和test1仍在内存中
-
bar.setName('李四'):- 调用
setName,它修改了foo内部的myName myName从'张三'变成'李四'
- 调用
-
bar.getName():- 访问
test1(输出1) - 返回
myName(现在是'李四')
- 访问
💡 这就是闭包!
即使
foo已经执行完、出栈,它的局部变量myName和test1依然活着,因为被返回的函数"背"着它们。
12.4 闭包的内存模型:专属"背包"比喻
readme.md 有一个绝妙比喻:
"有点像给 getName,setName 方法背的一个专属背包。这个闭包里面的变量叫自由变量"
- 每个闭包函数都有一个隐藏的背包([[Scope]] 内部属性)
- 背包里装着它声明时所在作用域的所有自由变量(被引用但不在自己作用域内的变量)
- 无论函数在哪里被调用,打开背包就能拿到这些变量
12.5 闭包的作用与风险
作用:
- 封装私有变量 (如
myName外部无法直接访问,只能通过getName/setName) - 延长变量生命周期
- 实现模块模式、工厂函数等高级模式
风险:
- 内存泄漏:如果闭包长期持有大对象,且未释放,会导致内存占用过高
- 循环引用:闭包引用 DOM,DOM 又引用闭包 → 垃圾回收失败
最佳实践:
- 不再需要时,将引用设为
null(bar = null) - 避免不必要的闭包
12.6 闭包 + 作用域链 = JS 的灵魂
闭包之所以能工作,完全依赖于作用域链的静态性:
- 函数声明时,作用域链就固定了
- 即使函数被传递到任何地方,它的作用域链不变
- 因此总能找到"老家"的变量
🌟 一句话总结闭包 :
"函数 + 它声明时的作用域 = 闭包"
结语:掌握机制,方能驾驭语言
JavaScript 的执行机制看似复杂,但一旦理解 执行上下文、作用域链、变量提升、块级作用域、闭包 这五大支柱,你就能:
- 写出更安全、可预测的代码
- 轻松调试"诡异"问题
- 深入理解
this、异步、模块等高级概念 - 在面试中展现扎实功底
记住:JS 不是魔法,只是你还没看清它的执行上下文。