深入理解JavaScript执行机制:从执行上下文到调用栈,八个代码示例彻底搞懂变量提升和作用域
你是否曾经被var的提升、let的暂时性死区、函数内部的变量覆盖等问题困扰?是否好奇 V8 引擎到底如何管理你的代码?本文将带你走进 JavaScript 的执行上下文、词法环境、调用栈等核心概念,并通过8 个完整的代码示例,逐步揭示 JS 代码从编译到执行的底层秘密。
核心概念速览
在分析代码之前,我们先快速梳理一下 JavaScript 的执行机制:
执行上下文(Execution Context):每当执行全局代码或调用一个函数时,V8 都会创建一个执行上下文对象。它包含三部分:
变量环境(Variable Environment):存储
var声明和function声明的绑定。词法环境(Lexical Environment):存储
let、const声明的绑定,并支持块级作用域。可执行代码:按顺序执行的语句。
编译阶段(Creation Phase):在执行代码之前,V8 会先“编译”当前执行上下文:
创建执行上下文对象。
扫描形参和变量声明(
var),在变量环境中添加键,初始值为undefined。统一形参与实参的值(仅在函数上下文中)。
扫描函数声明(
function),在变量环境中添加键,并将值指向函数体(会覆盖同名的变量声明)。
执行阶段(Execution Phase):从上到下逐行执行代码,修改变量的值、调用函数等。
调用栈(Call Stack):V8 用来管理函数调用关系的栈结构。每个函数被调用时,它的执行上下文被压入栈;函数执行完毕后,弹出并销毁。
暂时性死区(TDZ):从进入作用域到
let/const声明语句之前,这段区域内不能访问该变量,否则报错ReferenceError。
示例1:全局变量提升与函数提升
代码:example1.js
showName('极客时间'); console.log(myName); var myName = 'fl'; function showName(name) { // 注意:形参 name 已存在,下方 let 会导致重复声明错误 let name = '时间'; // SyntaxError: Identifier 'name' has already been declared console.log(name); var b = 1; console.log('函数showName执行', name); }输出与解析:
实际运行会抛出
SyntaxError: Identifier 'name' has already been declared,因为函数参数name已经绑定到当前函数作用域,而let name试图重复声明。
但是,我们先忽略这个错误,看看 V8 在编译阶段做了什么:
全局编译阶段:
变量环境:添加
myName: undefined,添加showName: function(...)(函数提升优先)。
函数
showName编译阶段(假设没有let name错误):变量环境:形参
name初始为undefined,然后被实参'极客时间'覆盖;接着var b被提升为b: undefined。函数体内没有其他函数声明。
这个例子告诉我们:函数声明整体提升,var变量提升但值为undefined,而let在词法环境中不允许在声明前访问,也不允许与形参或其它let重名。
为了让机制更清晰,下面提供一个修正版(展示正常的提升效果):
// 修正版示例 showName('极客时间'); console.log(myName); // undefined var myName = 'fl'; function showName(name) { console.log('函数showName执行', name); // 函数showName执行 极客时间 var b = 1; }输出:
函数showName执行 极客时间 undefined第1行调用
showName正常,因为函数提升。第2行打印
myName,此时变量提升但未赋值,输出undefined。第4行执行赋值
myName = 'fl',但代码中未再打印。
示例2:函数声明与变量声明的优先级
代码:example2.js
console.log(func); // [Function: func] function func() { console.log('func'); } var func = '123'; console.log(func); // '123'输出:
[Function: func] 123解析:
在全局编译阶段:
扫描函数声明
function func() {...},在变量环境中添加func: 函数体。扫描变量声明
var func,发现变量环境中已经存在func键,直接忽略(不会覆盖成undefined)。执行阶段:
第1行:打印
func,值为函数。第4行:赋值
func = '123',覆盖了原来的函数。第5行:打印
func,输出'123'。
结论:函数声明比var声明优先级更高,且var不会覆盖已存在的函数声明,但后续赋值语句可以改变其值。
示例3:函数形参、变量声明与函数声明同名
代码:example3.js
var a = 1; function fn(a) { console.log(a); // 输出什么? var a = 2; function a() {} var b = a; console.log(a); // 输出什么? } fn(3);输出:
[Function: a] 2详细解析(函数fn的编译 + 执行):
编译阶段(创建执行上下文):
找形参:
a→ 变量环境中添加a: undefined。统一形参与实参:实参
3→a = 3。找函数声明:
function a() {}→ 发现已有a,覆盖为函数体。找变量声明:
var a→ 发现a已存在,忽略;var b→ 添加b: undefined。
此时变量环境中的
a最终值为函数function a() {}。执行阶段:
console.log(a)→ 打印函数[Function: a]。var a = 2→ 将a赋值为2。function a() {}已经提升过,执行阶段不再处理。var b = a→ 此时a是2,所以b被赋值为2。console.log(a)→ 打印2。
结论:形参、函数声明同名时,函数声明会覆盖形参的值;后续赋值会改变该变量。
示例4:var的函数级作用域
代码:example4.js
function varTest() { var x = 1; if (true) { var x = 2; console.log(x); // 2 } console.log(x); // 2 } varTest();输出:
2 2解析:
var声明的变量没有块级作用域,只有函数作用域(或全局作用域)。在编译阶段,
var x被提升到函数顶部,初始为undefined。执行第2行:
x = 1。进入
if块,var x = 2并不会创建新变量,而是修改同一个函数作用域中的x。因此两次打印都是
2。
结论:var无视块级结构,容易造成意外的变量覆盖。
示例5:let的块级作用域与暂时性死区
代码:example5.js
function varTest() { var x = 1; if (true) { let x = 2; // 块级作用域内的新变量 let b = 3; console.log(x); // 2 } // console.log(b); // 报错:b is not defined console.log(x); // 1 } varTest();输出:
2 1解析:
外层
var x属于函数作用域。进入
if块时,词法环境会为块级作用域创建一个新的记录。let x = 2只在块内有效,不影响外层的x。块内的
b在外部无法访问。如果在
let x之前访问x(例如在块开头写console.log(x)),会报ReferenceError(暂时性死区)。
结论:let和const带来了真正的块级作用域,并且强制要求先声明后使用。
示例6:变量环境与词法环境的混用实战
代码:example6.js
function foo() { var a = 1; let b = 2; { // 块级作用域开始 let b = 3; var c = 4; let d = 5; console.log(a); // 1 console.log(b); // 3 } console.log(b); // 2 console.log(c); // 4 console.log(d); // ReferenceError: d is not defined } foo();输出:
1 3 2 4 ReferenceError: d is not defined解析(V8 内部视角):
函数
foo的变量环境包含:a(var)、c(var被提升,但c的赋值在块内,执行后才变为4)。词法环境外层包含
b = 2。当进入
{ ... }块时,V8 会创建一个新的词法环境子环境,其中包含b = 3、d = 5。块内的
console.log(a):在当前词法环境中找不到a,就向外层的变量环境查找,找到a = 1。块内的
console.log(b):当前词法环境中有b=3,直接输出。
离开块后,块级词法环境被销毁,外层
b恢复为2。c由于是var,它属于函数变量环境,不受块级影响,所以可以访问到4。d是let,仅在块内词法环境中存在,块外无法访问,报错。
结论:变量环境(var/function)和词法环境(let/const)协同工作,词法环境支持嵌套和作用域链。
示例7:let/const不可重复声明
代码:example7.js
let a = 1; let a = 2; // SyntaxError: Identifier 'a' has already been declared或者:
var a = 1; let a = 2; // SyntaxError: Identifier 'a' has already been declared输出:上述两种情况都会在编译阶段抛出SyntaxError,代码不会执行。
解析:
在词法环境中,V8 会检查当前作用域是否已经存在同名的
let/const或var/function标识符。如果发现重复,立即抛出语法错误,整个代码块都不会执行。
注意:
var和function可以重复声明(后者覆盖前者),但let/const绝对不允许。
这是 ES6 为 JS 修复的一个重要“bug”,让变量声明更加严谨。
示例8:调用栈与执行上下文的压栈/出栈
代码:example8.js
function first() { console.log('first start'); second(); console.log('first end'); } function second() { console.log('second start'); third(); console.log('second end'); } function third() { console.log('third start'); console.log('third end'); } first();输出:
first start second start third start third end second end first end调用栈变化过程:
执行全局代码,创建全局执行上下文(压栈)。
调用
first()→ 创建first执行上下文,压栈。在
first中调用second()→ 创建second执行上下文,压栈。在
second中调用third()→ 创建third执行上下文,压栈。third执行完毕,其上下文弹出栈,回到second。second执行完毕,弹出,回到first。first执行完毕,弹出,回到全局。全局代码执行完毕,最终清空栈。
每一个执行上下文在编译阶段都会重复“形参/变量提升、函数声明提升”等操作。调用栈保证了函数的嵌套调用能够正确返回,并维持作用域链。
总结
通过以上8 个示例,我们可以清晰地看到:
编译阶段决定了提升行为,执行阶段真正赋值。
变量环境(
var/function)与词法环境(let/const)分工明确,词法环境支持块级作用域和暂时性死区。调用栈是管理函数执行上下文的核心数据结构,每个函数调用都会创建新的上下文并压栈。
let和const修复了var的诸多缺陷,推荐在绝大多数场景下优先使用。
理解这些机制后,你将不再被变量提升、作用域嵌套等问题困扰,也能写出更可预测、更健壮的 JavaScript 代码。
如果你觉得本文对你有帮助,欢迎点赞、评论、转发!关注我,持续分享 JavaScript 底层原理与前端进阶知识。
