前段时间,我学习过程中,即便学了一段时间前端的知识,碰到变量提升、暂时性死区和作用域结合的代码,还是很容易出错。网上多数教程经常看完感觉自己弄懂了,然而上手写代码就状况百出。
我在学习弄懂之后,用八段实测代码,顺着V8引擎编译、执行、调用栈的完整流程来拆解JS底层的执行逻辑。
一、先搞懂核心骨架:执行上下文与调用栈
JS并非逐行直接运行代码,整体流程固定为:读取源码 → 预编译(创建执行上下文)→ 逐行执行代码,而所有执行上下文都会交给调用栈统一管理。
- 执行上下文的两大分区
每一段可执行代码(全局、函数体)都会生成独立执行上下文,内部拆分两块存储空间:
1. 变量环境:存放 var 声明变量、函数声明、函数形参;编译阶段统一赋值 undefined ,存在变量提升特性;函数声明会直接完整赋值函数体,优先级高于变量。
2. 词法环境:存放 let 、 const 声明变量,遵循块级作用域规则,不存在变量提升,声明前访问会触发暂时性死区,禁止重复声明同名标识符。
- 调用栈运行规则
调用栈是V8引擎管理代码执行顺序的栈结构,遵循后进先出:
1. 程序启动时,全局执行上下文率先入栈,常驻栈底;
2. 调用函数时,生成函数执行上下文压入栈顶,代表当前正在运行的代码;
3. 函数执行完毕后,对应上下文直接出栈销毁,释放内存;
4. 栈空则程序结束。
预编译会在每段代码执行前瞬间完成,这也是变量提升现象的根源。预编译固定三步走:
1. 扫描所有变量、函数声明,挂载到对应环境;
2. 函数上下文同步匹配形参与实参;
3. 函数声明覆盖同名变量,直接赋值完整函数逻辑。
二、实测分段拆解:8段代码逐个验证底层规则
下面结合运行结果印证上面的底层规则
案例1:函数声明提升优先级高于var变量
showName('羊羊')
console.log(myname)
var myname = '两只羊'
function showName(name) {
console.log('函数showName执行');
var b = 1;
}
运行结果:控制台先打印 函数showName执行 ,随后输出 undefined 。 解析:预编译阶段, showName 作为函数声明直接存入变量环境,调用不受代码顺序限制; myname 通过var提升,初始值为 undefined ,赋值语句还未执行,打印不会报错但取不到字符串。
案例2:var仅提升声明,赋值语句顺序不变
var myname
function showName() {
console.log('函数showName执行');
}
showName();
console.log(myname);
myname = "两只羊"
运行结果:先打印函数执行日志,再输出 undefined 。 解析:变量提升只会拉起声明,赋值操作严格保留原代码顺序,在赋值语句前访问变量只能拿到初始值undefined。
案例3:同名标识符,函数声明覆盖var变量
console.log(func);
function func() {}
var func = '123'
运行结果:控制台打印完整func函数体,而非undefined。 解析:预编译流程中,函数声明处理顺序晚于var变量,会直接覆盖同名变量的undefined值,代码执行到赋值语句后,func才会被覆盖为字符串 123 。
案例4:函数内部预编译,形参优先提升
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,赋值实参3;随后扫描到函数声明 function a(){} ,直接覆盖形参的值;执行到 var a=2 赋值语句,变量a被修改为数字2。
案例5:var无块级作用域,仅划分函数作用域
function varTest() {
var x = 1;
if(true) {
var x = 2;
console.log(x);
}
console.log(x);
}
varTest();
运行结果:连续打印两行2。 解析:var只存在函数级作用域,if块不会生成独立空间,两个x指向变量环境里同一个变量,内层赋值直接修改外层的值。
案例6:let存在块级作用域,触发暂时性死区
function varTest() {
var x = 1;
if(true) {
let x = 2;
let b = 3;
console.log(x);
}
console.log(x);
}
console.log(b);
console.log(x);
varTest();
运行结果:if内打印2,函数内打印1,外部访问b、x直接抛出 is not defined 。 解析:let绑定块级作用域,if大括号生成独立词法环境,内部x和外层var x互不干扰;块内变量无法被外部访问,超出作用域直接销毁。
案例7:词法环境栈隔离不同层级块作用域
function foo() {
var a = 1
let b = 2
{
let b = 3;
var c = 4;
let d = 5;
console.log(a)
console.log(b)
}
console.log(b);
console.log(c)
console.log(d);
}
foo()
运行结果:依次打印1、3、2、4,最后打印d时报错。 解析:外层a属于变量环境,全局函数内均可访问;块内b属于独立词法环境,仅在大括号内生效;var c不受块限制,整个函数内通用;d仅存在块级词法环境,外部访问失效。
案例8:let禁止同作用域重复声明标识符
let a = 1;
function a() {}
运行结果:直接抛出语法错误 Identifier 'a' has already been declared 。 解析:词法环境规则严格,只要当前作用域内存在let/const声明的标识符,同层级不能再次用var、function、let重复定义,暂时性死区机制规避了变量提升带来的污染问题。
三、三类声明关键字横向对比,彻底理清使用边界
用执行上下文的两套存储环境,把 var 、 function 、 let/const 的差异整理清晰:
1. var 存入变量环境,存在变量提升,初始值undefined;仅函数作用域有效,块级作用域不隔离;允许重复声明,极易污染全局变量,现代开发不推荐使用。
2. function函数声明 存入变量环境,提升优先级最高,预编译直接赋值完整函数;同名场景下覆盖var变量;全局、函数作用域均可定义,块级作用域内存在兼容性差异。
3. let / const 存入词法环境,无变量提升,声明前访问进入暂时性死区;严格绑定块级作用域,内外变量互不干扰;禁止同作用域重复声明,const还限制变量重赋值,是项目开发首选。
四、最后:理解执行机制对实际开发的价值
很多人在初学时觉得:执行上下文、调用栈是纸上谈兵,日常开发用不到,事实恰恰相反
1. 排查线上诡异bug:变量打印undefined、标识符未定义、函数调用报错,根源基本都是作用域和变量提升;
2. 规避全局污染:let/const块级作用域可以隔离临时变量,防止同名变量相互覆盖;
3. 看懂栈报错信息:调用栈报错能精准定位递归死循环、多层函数调用溢出问题;
4. 读懂闭包底层逻辑:闭包本质就是函数执行上下文销毁后,词法环境、变量环境被保留,衍生出变量常驻内存的特性。
JS是一门解释型语言,但V8引擎引入预编译环节后,执行逻辑变得严谨且复杂。死记结论只能应付简单面试,结合案例顺着编译、执行的流程推导,才能灵活应对所有边界场景。