ES6作用域革命告别变量提升,代码更清爽!

119 阅读10分钟

深入理解JavaScript ES6作用域:从变量提升到块级作用域的革命

引言:一个让初学者困惑的JavaScript特性

在JavaScript中,有一项特性常常让初学者感到困惑,甚至导致难以调试的bug。让我们先来看一段代码:

showName();
console.log(myname);
var myname = "行行";
function showName() {
    console.log('函数showname执行了');
}

这段代码的输出结果是:

函数showname执行了
行行

为什么showName()可以在定义之前调用?为什么console.log(myname)输出的是"行行"而不是undefined?这背后隐藏着JavaScript的一个重要特性:变量提升(Hoisting)。

变量提升是JavaScript引擎在编译阶段将var声明的变量和函数提升到作用域顶部的行为。这种设计导致了很多与直觉不符的代码行为,是JavaScript的一个设计缺陷。

JavaScript作用域的历史背景:为什么会有这个"缺陷"?

要理解这个问题,我们需要回到JavaScript的历史。

JavaScript最初是由Brendan Eich在1995年设计的,仅用了10天时间。当时的设计目标是给网页添加简单的动态效果,而不是作为一个完整的编程语言。JavaScript的设计者选择不支持块级作用域,主要是为了简化语言设计,让JavaScript更容易实现和学习。

在1995年,其他编程语言如C、Java、C++等都支持块级作用域,但JavaScript没有。JavaScript的设计者选择不支持块级作用域,主要是为了:

  1. 简化设计:避免引入复杂的块级作用域机制
  2. 加快实现:让浏览器厂商能快速实现JavaScript引擎
  3. 保持简单:让初学者更容易上手

正如JavaScript的创造者Brendan Eich后来所说:"JavaScript当时是一个KPI项目,没想到会火起来,设计周期很短。"

这种设计在当时是合理的,但随着JavaScript的发展,它暴露出了严重的缺陷:变量提升导致代码行为与直觉不符,增加了学习难度和维护成本。

作用域的基本概念

在开始讨论ES6的块级作用域之前,我们需要先理解作用域的基本概念。

1. 作用域的定义

作用域(Scope)是指变量和函数的可访问范围。在JavaScript中,作用域决定了变量的可见性和生命周期。

2. 作用域的类型

JavaScript有三种作用域:

  • 全局作用域:在任何地方都能访问,生命周期是页面周期
  • 函数局部作用域:只能在函数内部访问,生命周期是函数执行周期
  • 块级作用域:ES6新增,可以在{}块内定义作用域

从var到let/const:ES6如何解决作用域问题

ES6(ECMAScript 2015)引入了letconst,解决了ES5中变量提升和块级作用域的问题。让我们通过代码来理解。

1. var的变量提升(ES5的缺陷)

先看一段使用var的代码:

var name = '刘锦苗';
function showName() {
    console.log(name);
    if (true) {
        var name = '大厂的苗子';
    }
    console.log(name);
}
showName();

这段代码的输出是:

undefined
大厂的苗子

为什么? 因为var name = '大厂的苗子';被提升到了函数作用域的顶部,所以函数内部的name被提升为undefined。即使if(true)条件为真,var name声明仍然会被提升。

执行过程

  1. 编译阶段:var name;被提升到函数作用域顶部

  2. 运行阶段:

    • console.log(name)nameundefined
    • if(true):条件为真,name = '大厂的苗子'执行
    • console.log(name)name大厂的苗子

关键点

  • var声明的变量会被提升到函数作用域顶部
  • 函数内部的var name会遮蔽全局的name,因为函数作用域优先于全局作用域
  • 由于var没有块级作用域,即使在if块中声明,也会在函数作用域内生效

2. let的块级作用域(ES6的解决方案)

现在看使用let的代码:

let name = '刘锦苗'
function showName() {
    console.log(name)
    if(false){
        let name = '大厂的苗子';
    }
    console.log(name)
}
showName()

这段代码的输出是:

刘锦苗
刘锦苗

为什么? 因为let name = '大厂的苗子';if(false)块中,但由于let支持块级作用域,这个声明不会被提升,且if(false)条件为假,所以不会执行,name仍然使用全局的值。

3. 暂时性死区(Temporal Dead Zone)

ES6中,letconst声明的变量有一个"暂时性死区"(TDZ)特性。这意味着在变量声明之前访问该变量会抛出ReferenceError

let name = '刘锦苗';
{
    console.log(name); // ReferenceError: name is not defined
    let name = '大厂的苗子';
}
console.log(name);
                ^
ReferenceError: Cannot access 'name' before initialization

在这个例子中,console.log(name)会抛出ReferenceError,因为name在声明之前被访问了。这是TDZ的体现。

执行上下文:理解变量提升和作用域的关键

为了深入理解JavaScript的作用域机制,我们需要了解执行上下文的概念。

1. 执行上下文

执行上下文(Execution Context)是JavaScript引擎执行代码时的环境。它包含变量环境(Variable Environment)和词法环境(Lexical Environment)。

  • 变量环境:存储var声明的变量,以及函数声明。
  • 词法环境:存储letconst声明的变量,以及块级作用域。

2. 编译阶段和运行阶段

JavaScript引擎在执行代码时,分为两个阶段:

  1. 编译阶段:解析代码,创建执行上下文,变量提升。
  2. 运行阶段:执行代码,赋值变量。

3. 代码执行过程:以foo函数为例

我们来看一下用户提供的示例图中foo函数的执行上下文:

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
}
foo();

执行过程

  1. 编译阶段

    • 创建执行上下文
    • 变量环境:a = undefinedc = undefined
    • 词法环境:b = undefined(但处于TDZ,不能访问)
  2. 运行阶段

    • a = 1(赋值到变量环境)

    • b = 2(赋值到词法环境)

    • 进入块级作用域:

      • 创建新的词法环境栈,b = 3(覆盖了外层的b
      • c = 4(赋值到变量环境,因为var声明会被提升到函数作用域顶部)
      • d = 5(赋值到新的词法环境)
    • 打印a:1(变量环境)

    • 打印b:3(词法环境栈顶)

    • 退出块级作用域:词法环境栈弹出,b恢复为2

    • 打印b:2(词法环境)

    • 打印c:4(变量环境)

    • 打印d:ReferenceError(词法环境已弹出)

通过下面这张图让你更直观感受foo函数的执行上下文:

lQLPKHfb1wmakBvNAo7NBHaw2WBp0Xeew0kJAa7ewdsuAA_1142_654.png

关键点

  • var声明的变量在变量环境中,会被提升到函数作用域顶部
  • letconst声明的变量在词法环境中,不会被提升,存在TDZ
  • 词法环境使用栈结构,块级作用域执行时,会查找栈顶的变量

为什么ES6要引入块级作用域?

ES6引入块级作用域有以下几个原因:

  1. 解决变量提升的问题letconst不会被提升,避免了变量提升导致的bug。
  2. 更精确的作用域控制:允许在需要的地方定义变量,而不是在整个函数作用域内。
  3. 符合其他语言的设计:C、Java、C#等语言都支持块级作用域,JavaScript也应该支持。
  4. 增强代码可读性:开发者能更清晰地理解变量的作用域。

作用域机制的对比总结

特性varletconst
作用域函数作用域块级作用域块级作用域
变量提升
暂时性死区
重复声明允许不允许不允许
作用域提升提升到函数作用域顶部不提升不提升

实际开发中的最佳实践

基于以上分析,以下是ES6中作用域的最佳实践:

  1. 优先使用let和const:避免使用var,除非你明确需要变量提升。
  2. 理解暂时性死区:在声明之前不要访问变量。
  3. 使用块级作用域:在需要的地方定义变量,而不是在整个函数作用域内。
  4. 避免重复声明letconst不允许在同一个作用域中重复声明变量。
  5. 理解执行上下文:在编写复杂代码时,理解变量环境和词法环境的区别。

为什么ES5不支持块级作用域?

ES5(ECMAScript 5)不支持块级作用域,主要有以下原因:

  1. 历史原因:JavaScript设计时,块级作用域不是重点考虑的因素。
  2. 简化设计:为了快速实现,JavaScript选择了函数作用域,避免了块级作用域的复杂性。
  3. 浏览器竞争:当时浏览器厂商之间的竞争很激烈,需要快速实现JavaScript引擎。

正如README.md中所述:"ES5为了简单设计,不用支持。没有了块级作用域,再把作用域内部的变量统一提升到作用域的顶部(执行上下文中的作用域),是最快最简单的设计。"

ES6如何让变量提升和块级作用域统一和谐

ES6通过以下方式解决了这个问题:

  1. 分离变量环境和词法环境

    • var声明的变量放在变量环境中
    • letconst声明的变量放在词法环境中
  2. 词法环境的栈结构

    • 块级作用域执行时,会创建一个新的词法环境栈
    • 变量在栈顶查找,执行完后出栈,确保外界不可访问
  3. 暂时性死区

    • 在变量声明之前访问变量会抛出ReferenceError
    • 确保变量在声明前不会被意外使用

结论:JavaScript作用域的演进

JavaScript从ES5到ES6的作用域机制演进,反映了语言设计的成熟过程:

  1. ES5的变量提升:简单但有缺陷的设计,导致代码行为与直觉不符。
  2. ES6的块级作用域:通过letconst引入,解决了变量提升的问题。
  3. 执行上下文的优化:通过分离变量环境和词法环境,使作用域机制更加清晰。

正如JavaScript的创造者Brendan Eich所说:"JavaScript当初设计的时候就是为了给页面加动态效果。" 从这个简单的目标出发,JavaScript已经发展成为一门功能强大的语言,而作用域机制的演进正是这一过程的缩影。

最后思考

JavaScript的作用域机制从ES5到ES6的演进,告诉我们一个重要的道理:设计决策往往受历史背景影响,但随着语言的发展,我们可以不断优化和改进

作为开发者,我们需要理解这些历史背景,才能更好地使用JavaScript。同时,我们也应该意识到,即使是最简单的语言设计,也可能因为历史原因而留下"缺陷",而这些"缺陷"往往需要通过后续版本来修复。

在实际开发中,我们应该:

  • 优先使用ES6+的特性(letconst、块级作用域等)
  • 理解执行上下文和作用域机制
  • 避免使用var,除非有特殊需求

通过这些实践,我们可以写出更加清晰、可维护的JavaScript代码。


参考资料

  1. MDN Web Docs: JavaScript Scope
  2. ECMAScript 6: Block-Level Declarations
  3. JavaScript: The Good Parts by Douglas Crockford
  4. Brendan Eich on JavaScript's Design

通过这篇文章,希望你能够深入理解JavaScript的作用域机制,特别是从ES5到ES6的演进过程。理解这些概念,将帮助你写出更加优雅、高效的JavaScript代码。