🚀 从"变量提升"到"调用栈爆炸":V8 引擎是如何偷偷执行你的 JavaScript 的?
你有没有写过这样的代码,然后一脸懵逼地看着控制台输出 undefined?
ini
console.log(myName); // undefined?
var myName = 'chen';
或者更离谱的:
scss
showName(); // 居然能执行?!
function showName() {
console.log('函数showName被执行');
}
别慌!这不是魔法,也不是浏览器在跟你开玩笑。这背后,是 V8 引擎 在编译阶段悄悄为你安排的一切。
今天我们就来揭开 JavaScript 执行机制的神秘面纱------让你不仅知道"发生了什么",还知道"为什么发生",顺便还能在面试时装个大杯 ☕️!
🧠 一、JS 不是"边解释边执行"的吗?怎么还有"编译"?
很多人以为 JavaScript 是"纯解释型语言",一行一行地执行,错了就报错。但实际上,现代 JS 引擎(比如 Chrome 的 V8)早就不是这样了!
V8 会把你的代码 先快速编译(JIT 编译) ,再执行。整个过程分为两个阶段:
- 编译阶段(Compilation Phase)
- 执行阶段(Execution Phase)
而且这两个阶段几乎是"无缝衔接"的------你感觉不到编译的存在,但它确实在执行前的一刹那完成了所有准备工作。
💡 小知识:JIT = Just-In-Time(即时编译),意思是"用的时候才编译",但其实它比传统解释器快得多!
🎭 二、变量提升?函数优先?真相在这里!
来看一段经典代码:
ini
showName();
console.log(myName);
console.log(hero);
var myName = 'chen';
let hero = '123';
function showName() {
console.log('函数showName被执行');
}
输出结果是:
javascript
函数showName被执行
undefined
ReferenceError: Cannot access 'hero' before initialization
Why?
✅ 编译阶段 V8 干了这些事:
-
创建全局执行上下文(Global Execution Context)
-
扫描所有
var声明和函数声明:var myName→ 提升为myName = undefinedfunction showName()→ 整个函数体被提升(函数声明优先级最高!)
-
let/const声明:- 被放入 词法环境(Lexical Environment)
- 但处于 暂时性死区(Temporal Dead Zone, TDZ) ,访问就报错!
V8 在执行某段代码前,会先进行快速解析,并为即将执行的代码创建一个执行上下文,其中包含变量环境(存放
var和函数声明)和词法环境(存放let/const,并管理 TDZ)。
所以:
showName()能执行 → 函数被完整提升 ✅console.log(myName)→undefined(var提升但未赋值)✅console.log(hero)→ 报错 ❌(let在 TDZ 中)
🤯 记住口诀:函数 > var > let/const
函数声明最牛,
var次之但会"占座",let/const最守规矩但脾气大!
函数声明这么厉害,有没有一种方法能够限制他呢? 有的兄弟,有的!
函数表达式(无论是用 var、let 还是 const)都不会被提升 !只有函数声明才会被完整提升
就像这样!
go
func(); // TypeError: func is not a function
let func = () => {
console.log('函数表达式不会提升');
}
报错不是因为用了 let,而是因为你根本没写"函数声明"。
🧱 三、执行上下文 & 调用栈:JS 的"舞台后台"
想象 JS 执行就像一场话剧:
- 调用栈(Call Stack) = 舞台
- 执行上下文(Execution Context) = 演员的剧本 + 道具箱
每次函数调用,V8 就:
- 创建一个新的执行上下文(包含变量环境、词法环境、this 等)
- 把它压入调用栈顶部
- 执行完后,弹出栈,内存回收(垃圾回收器微笑点头)
举个栗子 🌰:
ini
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
var b = a;
console.log(a);
}
fn(3);
输出:
3
2
编译阶段(fn 内部):
- 参数
a被初始化为3 var a发现重复声明 → 忽略(var允许重复)- 所以第一个
console.log(a)是3
执行阶段:
a = 2赋值 → 第二个输出2
⚠️ 注意:在非严格模式下,函数声明可能会覆盖同名参数(这是 JS 的历史包袱),但这种写法极易引发混乱,强烈建议避免在函数内用函数声明覆盖参数名!
如果我加一个东西呢
ini
var a = 1;
function fn(a) {
console.log(a);
var a = 2;
function a() {}; //加一个这个结果会是什么呢
var b = a;
console.log(a);
}
fn(3);
结果就变成了
csharp
[Function: a]
2
让我们来理解一下
第一步:理解函数内部的"作用域初始化"
在进入函数 fn 执行上下文时,JavaScript 引擎会做以下事情(按顺序):
- 创建形参(parameters)
- 函数声明提升(function declarations are hoisted and initialized)
- 变量声明提升(variable declarations are hoisted but not initialized)
但要注意:函数声明的提升优先级高于变量声明和形参!
第二步:详细展开 fn 内部的提升过程
函数定义:
css
function fn(a) {
console.log(a);
var a = 2;
function a() {};
var b = a;
console.log(a);
}
提升后,等价于:
css
function fn(a) {
// 步骤1:形参 a 被初始化为传入的值 3
// 步骤2:函数声明 function a() {} 被提升,并且 **覆盖** 形参 a
// 步骤3:var a; 也被提升,但因为函数声明已经存在,var 的提升不会覆盖函数
function a() {} // 函数声明被提升并初始化
var a; // 变量声明被提升,但无效果(因为 a 已经是函数)
var b;
// 现在开始执行代码:
console.log(a); // 此时 a 是函数 function a() {}
a = 2; // 这里把 a 重新赋值为数字 2
b = a; // b = 2
console.log(a); // 输出 2
}
⚠️ 关键点:函数声明不仅被提升,而且在作用域初始化阶段就被赋值(即函数体),而
var声明只是提升变量名,不赋值。
第三步:为什么形参 a 被覆盖?
当你写 function fn(a),相当于在函数顶部有一个 var a = 3(传入值)。
但是随后遇到 function a() {},这是一个函数声明,它会:
- 在作用域创建阶段就绑定名称
a到这个函数; - 覆盖掉形参的初始值。
所以,在执行第一行 console.log(a) 之前,a 已经是函数了!
对比:如果没有 function a() {}
如果去掉函数声明:
javascript
function fn(a) {
console.log(a); // 3
var a = 2;
console.log(a); // 2
}
这时 var a 的提升不会改变 a 的值(因为 var a 和形参 a 是同一个绑定),所以第一次输出是 3。
总结
- 函数声明会被完全提升(包括函数体),并且优先级高于形参和
var声明。 - 因此,在
fn开始执行时,a被初始化为function a() {},而不是传入的3。 - 后续
var a = 2是对a的重新赋值,所以第二次console.log(a)输出2。
🔒 四、var vs let:不只是"能不能重复声明"
| 特性 | var |
let / const |
|---|---|---|
| 提升 | 提升到顶部,值为 undefined |
存在 TDZ,不能提前访问 |
| 作用域 | 函数作用域 | 块级作用域 {} |
| 重复声明 | 允许(静默忽略) | 报错! |
| 全局对象绑定 | window.myVar |
不绑定 |
ini
var a = 1;
var a = 2; // 没事,JS:我习惯了
let b = 3;
let b = 4; // SyntaxError! 别想糊弄我!
🙅♂️
let的态度: "要么一次到位,要么别碰我!"
🧬 五、值类型 vs 引用类型:复印机 vs 地址条
让我们来看个例子
ini
let str = 'hello';
let str2 = str;
str2 = '你好';
console.log(str,str2);
console.log(str.length);
let obj = {
name: '张三',
age: 18
}
let obj2 = obj;
obj2.age++;
console.log(obj2,obj);
结果是
css
hello 你好
5
{ name: '张三', age: 19 } { name: '张三', age: 19 }
诶?奇了怪了,为什么都是更改个值, str 没改 obj 却改了呢?
原因就是str 是简单数据类型,obj 是复杂数据类型
- 简单数据类型(string/number/boolean) :存在 栈内存,直接复制值
- 复杂数据类型(object/array/function) :存在 堆内存 ,变量存的是 地址指针
💡 想真正"复制对象"?用
structuredClone()、...展开符,或者JSON.parse(JSON.stringify())(有坑!慎用)
注:实际内存分配比"栈/堆"模型复杂得多(涉及逃逸分析等),但这个模型对理解赋值行为非常有帮助。
🧩 六、严格模式:让 JS 变得"讲规矩"
javascript
'use strict';
var a = 1;
var a = 2; // 在非严格模式下没事,但在严格模式?照样没事!
等等?不是说严格模式更严格吗?
没错,但 var 重复声明在 任何模式下都不报错 (这是历史包袱😅)。
真正会被严格模式拦住的是:
- 给未声明变量赋值
this指向undefined(而非window)- 删除不可删除的属性等
⚠️ 注意:在非严格模式下,函数声明可能会覆盖同名参数(这是 JS 的历史包袱),但这种写法极易引发混乱,强烈建议避免在函数内用函数声明覆盖参数名!
🎯 总结:V8 的执行流程图
csharp
你的代码
↓
V8 接管 → 编译阶段(一瞬间!)
├─ 创建执行上下文(全局 or 函数)
├─ 提升:函数 > var > let/const(TDZ警告!)
├─ 初始化参数、变量环境、词法环境
↓
执行阶段
├─ 从上到下执行可执行代码
├─ 遇到函数 → 新建上下文 → 压入调用栈
└─ 函数结束 → 弹出栈 → 内存回收
💬 最后说两句
JavaScript 的执行机制看似玄学,其实逻辑非常清晰。理解"编译先行"和"执行上下文" ,你就超越了 80% 的前端新手。
下次再看到 undefined 或 TDZ 报错,别慌------那是 V8 在温柔地提醒你:"兄弟,顺序乱了,重排一下!"