前言:
你是否遇到过这种灵异事件:明明代码是从上往下写的,变量却在声明前就能用?明明写在 if 里的变量,在 else 外面还能打印出来?
别慌,这不是鬼打墙,这是 JavaScript 的“历史包袱”。今天我们就扒开 V8 引擎的底裤(划掉,底层),聊聊 JS 作用域的前世今生,看看 ES6 是如何在一片狼藉中重建秩序的
一、ES5 时代:一场持续十年的大型社死现场
1. 全局变量与局部变量:你以为你在家,其实你在大街上
var globaVar = '我是全局变量'
function myFunctin () {
var localVar = '我是局部变量'
console.log(globaVar) // 我是全局变量
console.log(localVar) // 我是局部变量
}
myFunctin()
console.log(localVar) // Uncaught ReferenceError: localVar is not defined
这部分还算正常,对吧?
但真正的噩梦才刚刚开始……
2. 变量提升:JS 最臭名昭著的“特色”
showName()
console.log(myname)
var myname = '路明非'
function showName () {
console.log('函数showName执行了')
}
// 输出:
// 函数showName执行了
// undefined
你以为 myname 还没出生?不!var myname 已经被提前“登记户口”了,只是还没“上户口本”(赋值)。
真实发生了什么(编译阶段):
var myname; // 先提升,值是 undefined
function showName() { ... } // 函数声明整体提升
// 执行阶段
showName() // 能正常调用
console.log(myname) // undefined
myname = '路明非' // 终于赋值
这就导致了经典的“变量在不被察觉的情况下被覆盖”:
var name = '小明'
function showName () {
console.log(name) // 你以为是小明?天真!
if (true) {
var name = '大厂的苗子'
}
console.log(name) // 大厂的苗子
}
showName()
// 输出:undefined + 大厂的苗子
var name 被提升到函数最顶部,悄无声息地把外面的小明给“鲨”了。这就是传说中的隐形变量污染。
3. 没有块级作用域:循环里的 i 永远不会死
function foo () {
for (var i = 0; i < 7; i++) {}
console.log(i) // 7!!!不是报错,是7!
}
foo()
在 Java、C++ 里,{} 里的变量出块就死。
在 ES5 的 JavaScript 里,{} 只是个摆设,var 永远提升到最近的函数作用域。
这也是为什么当年大家疯狂用 IIFE 制造“伪块级作用域”:
(function() {
var tmp = '我只活在这一亩三分地';
})()
console.log(tmp) // ReferenceError
二、ES6:人类终于赢了,变量开始讲道理
1. let/const 登场:我们支持块级作用域了!
let name = '小明'
function showName () {
console.log(name) // 报错!Cannot access 'name' before initialization
if (true) {
let name = '大厂的苗子'
}
console.log(name) // 小明
}
showName()
这次小明活下来了!因为 let 创建的 name 被关进了 if 这个块级作用域的小黑屋,出不来!
再看看循环:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0)
}
// 正确输出 0 1 2
换成 var 就是三个 3,换成 let 就正常了。这简直是前端面试界的救世主。
2. 暂时性死区(TDZ):再也不许提前用!
{
console.log(name) // ReferenceError
let name = '小红'
}
你甚至不能在 let 声明之前“闻一闻”这个变量的名字。这就是传说中的暂时性死区,比防疫隔离还严格。
三、执行上下文视角:ES6 是怎么把 var 和 let 和平共处的?
这是最硬核的部分,建议泡杯咖啡慢慢看。
JavaScript 执行上下文长这样(简化版):
关键来了:块级作用域是通过词法环境的栈结构实现的!
四、硬核解密 —— V8 是如何通过“一国两制”实现兼容的
ES6 既要支持新的块级作用域,又要向下兼容那“破旧”的 var 变量提升。V8 引擎是怎么做到的?
答案是:执行上下文中的“一国两制” 。
让我们看一个复杂的混合代码:
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()
当 foo() 执行时,V8 会创建执行上下文,这个上下文包含了两部分:
- 变量环境 (Variable Environment) :专门收容 var 声明的变量(旧时代移民)。
- 词法环境 (Lexical Environment) :专门管理 let 和 const(新时代公民)。
图解执行过程
第一步:编译阶段(只声明,不赋值)
- 变量环境:发现 var a 和 var c。注意!虽然 c 在块级作用域里,但因为 var 无视块级作用域,它被提升到了顶层变量环境,初始化为 undefined。
- 词法环境:发现顶层的 let b。初始化,但处于未赋值状态。
第二步:执行阶段(入栈与出栈)
当代码执行到 {} 块级作用域内部时:
-
变量赋值:a 变成了 1,b (外层) 变成了 2,c 变成了 4。
-
词法环境的栈结构:
- V8 在词法环境中维护了一个小型栈结构。
- 当进入块级作用域,内部的 let b = 3 和 let d = 5 会被压入这个栈的栈顶。
- 外部的 let b = 2 依然存在,但在栈底。
第三步:变量查找(LHS/RHS)
当执行 console.log(a) 时,引擎的查找路线是:
- 词法环境栈顶:如果你在块级作用域里,先找栈顶。没找到!
- 如果栈顶没有,就往下找。
- 如果词法环境都没找到,就去变量环境里找。
- 还找不到?去外层函数的执行上下文找(作用域链)。
第四步:出块级作用域
当 { ... } 执行完毕,词法环境的栈顶元素(b=3, d=5)弹出销毁。
此时再执行 console.log(b),找到的就是栈底的 b=2。
而 console.log(d) 会报错,因为 d 已经随着栈顶弹出了。
但 console.log(c) 依然能打印出 4,因为它是 var 声明的,安安稳稳地躺在变量环境里,不受块级作用域销毁的影响。
四、总结:从 ES5 到 ES6,我们到底进步了多少?
| 特性 | ES5 (var) | ES6 (let/const) |
|---|---|---|
| 变量提升 | 有,值是 undefined | 没有(有暂时性死区) |
| 块级作用域 | 不支持 | 支持 |
| 重复声明 | 允许(覆盖) | 不允许(SyntaxError) |
| 全局对象污染 | var 会挂到 window | let/const 不会 |
| 循环中的闭包问题 | 经典坑(全都是最后的值) | 完美解决 |
| 可读性 | 灾难级 | 正常人类水平 |
写在最后
JavaScript 的作用域故事,就是一个“从精神病到正常人”的励志故事。
- 1995年,布兰登·艾克10天搞定JS,本来只想加点动画,谁知道火了。
- 为了最快实现“变量声明后就能随便用”,直接搞了个变量提升。
- 结果十几年后,全世界前端都在为这个“快捷键”还债。
- 直到2015年 ES6 横空出世,终于把这个历史遗留问题优雅地解决了,还完美兼容老代码。
所以下次有人说“JavaScript 设计得很烂”的时候,你可以理直气壮地回:
“曾经是挺烂的,但人家知错能改啊!现在已经是个讲道理的好语言了!”
PS:
本文提到的“执行上下文”,其实就是JS的执行机制,若有不甚了解的朋友,可以参考一下作者以前的文章