拒绝 JS “玄学”:从变量提升到块级作用域的底层救赎

58 阅读6分钟

前言:

你是否遇到过这种灵异事件:明明代码是从上往下写的,变量却在声明前就能用?明明写在 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 执行上下文长这样(简化版):

image.png

关键来了:块级作用域是通过词法环境的栈结构实现的!

四、硬核解密 —— 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 会创建执行上下文,这个上下文包含了两部分:

  1. 变量环境 (Variable Environment) :专门收容 var 声明的变量(旧时代移民)。
  2. 词法环境 (Lexical Environment) :专门管理 let 和 const(新时代公民)。

图解执行过程

第一步:编译阶段(只声明,不赋值)
  • 变量环境:发现 var a 和 var c。注意!虽然 c 在块级作用域里,但因为 var 无视块级作用域,它被提升到了顶层变量环境,初始化为 undefined。
  • 词法环境:发现顶层的 let b。初始化,但处于未赋值状态。

11645361-1FAD-4E1C-B03A-8557F2DB6C0C.png

第二步:执行阶段(入栈与出栈)

当代码执行到 {} 块级作用域内部时:

  1. 变量赋值:a 变成了 1,b (外层) 变成了 2,c 变成了 4。

  2. 词法环境的栈结构

    • V8 在词法环境中维护了一个小型栈结构
    • 当进入块级作用域,内部的 let b = 3 和 let d = 5 会被压入这个栈的栈顶
    • 外部的 let b = 2 依然存在,但在栈底。

67F3FB8C-13CF-4F1A-8934-AC15CCA9358C.png

第三步:变量查找(LHS/RHS)

当执行 console.log(a) 时,引擎的查找路线是:

  1. 词法环境栈顶:如果你在块级作用域里,先找栈顶。没找到!
  2. 如果栈顶没有,就往下找。
  3. 如果词法环境都没找到,就去变量环境里找。
  4. 还找不到?去外层函数的执行上下文找(作用域链)。

9625B41F-066C-4BD2-AF8B-44B93C395CF9.png

第四步:出块级作用域

当 { ... } 执行完毕,词法环境的栈顶元素(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 会挂到 windowlet/const 不会
循环中的闭包问题经典坑(全都是最后的值)完美解决
可读性灾难级正常人类水平

写在最后

JavaScript 的作用域故事,就是一个“从精神病到正常人”的励志故事。

  • 1995年,布兰登·艾克10天搞定JS,本来只想加点动画,谁知道火了。
  • 为了最快实现“变量声明后就能随便用”,直接搞了个变量提升。
  • 结果十几年后,全世界前端都在为这个“快捷键”还债。
  • 直到2015年 ES6 横空出世,终于把这个历史遗留问题优雅地解决了,还完美兼容老代码。

所以下次有人说“JavaScript 设计得很烂”的时候,你可以理直气壮地回:

“曾经是挺烂的,但人家知错能改啊!现在已经是个讲道理的好语言了!”

PS:

本文提到的“执行上下文”,其实就是JS的执行机制,若有不甚了解的朋友,可以参考一下作者以前的文章