JavaScript 学习笔记——作用域

作者: 零度冰华 | 来源:发表于2018-03-16 15:51 被阅读8次

    本人水平有限,如有叙述不当或者错误之处,望各位指出,十分感谢!

    作用域

    我们要知道,在 JavaScript 中,作用域分为三种类型:

    • 全局作用域
    • 函数作用域
    • 块级作用域

    全局作用域

    全局作用域,简而言之就是定义在所有函数外部,可以在程序任意地方访问到其中(全局)量的 规则(如浏览器窗体中可以随处访问 window;Web Service 或 Web Workers 中可以随处访问 self;Node.js 中可以随处访问 global)。在 JS 引擎一进入一段有效的 JS 代码,就会初始化一个全局对象,并使用 window/self/global 指向这个全局对象,这样 全局作用域 便是最早出现的。

    有趣的是 window = null 是无效操作,也就是说 window 是只读的,有点像 ES5 的 InfinityNaN 是只读的,不可修改(ES3 中可以修改),但是记这篇笔记时 self = nullglobal = null 是有效的,危险!

    函数作用域

    函数作用域,这又是什么呢?这里我们先引入新概念:

    • 词法作用域(lexical scope / static scope)
    • 动态作用域(dynamic scope)

    我们知道,浏览器有一个 JS 引擎线程,负责 编译运行 JS 代码。因为 JS 文件在浏览器中下载后就需要生效,这种情景需求使得浏览器中的 JS 是即时编译且即时执行的。其中编译阶段会有一个 词法分析 的过程,它读到函数声明时便会为此函数生成一个 符号表(Symbol Table,一种键值对形式的哈希表数据结构,保存了函数的作用域信息,包括标识符、内存地址、数据类型、内部变量等),如果函数内还嵌套了子函数,引擎便会为其生成一个子表,以此类推,形成一个树的结构,而一个符号表即为其节点。

    这个过程是在编译时完成的,我们可以看到这里已经生成了一个树,而到执行阶段时,遇到 取值操作 (RHS)时便会在其相应的符号表中进行查询操作,如果查找到了变量,就会将其值返回作为取值操作的结果;如果找不到,就顺着树的结构往上级节点(即,上级嵌套作用域)查询,直到找到这个变量对应的标识符,取得其值;否则,若到了根节点(全局作用域)都无法找到其值,就返回 ReferenceError 引用错误。

    这时,我们发现自己似乎形成作用域的印象了。是的,函数作用域 就是上面所描述这样的 词法作用域,它在编译阶段就已经绑定了,不过在编译阶段作用域已经不存在了,取而代之的是一个可以查询的符号表。但是我们在理解代码时,仍然可以将其抽象成作用域来解释代码运行过程。

    动态作用域 与之相对应,它不是在编译阶段绑定的,而是根据其 调用 时所在作用域来绑定当前作用域,与声明函数的位置无关。在 JS 中,只有 词法作用域,没有 动态作用域,但是 this 可以实现类似 动态作用域 的效果。这个详见有关 this 的笔记。

    块级作用域

    块级作用域 在 JS 中比较特殊。因为在这里 ifforwhile 等这些我们以为会生成自己作用域的代码块,全都没有按我们所想的那样运作,而是和上下文共享同一个作用域,所以很多时候我们不得不用匿名函数去手动生成一个函数作用域来达到我们想要的效果。我相信大家曾经都写过这样的代码:

    var btn = document.getElementsByTagName('button');
    for (var i = 0; i < 10; i++) {
      btn[i].onclick = function () {
        console.log(i);
      }
    }
    
    console.log(i); // 10
    

    for 代码块外部访问到了内部声明的变量,而且我们点击页面上这个十个按钮,控制台打印出来的全是 10。即并没有生成所谓的 块级作用域i 在这十个点击事件的公共作用域内。为了实现我们想用的效果,我们只能使用 函数作用域 来模拟:

    var btn = document.getElementsByTagName('button');
    for (var i = 0; i < 10; i++) {
      (function (i) {
        btn[i].onclick = function () {
          console.log(i);
        }
      })(i)
    }
    
    console.log(i); // 10
    

    这里我们可以发现,使用了立即执行的匿名函数,我们在每次循环时手动生成了一个 函数作用域,并将 i 的当前值绑定在相应作用域内,从而实现了点击第 N 个 button 打印 N 的功能。

    try {
      throw 1;
    } catch(a) {
    
      var b = 2;
      console.log(a); // 1
    
    }
    
    console.log(b); // 2
    console.log(a); // ReferenceError
    

    从这个例子中我们可以看到,在 try/catch 代码块中的变量 a 外部访问不到,而 b 却能访问到,也是就是说,这个在 ES3 标准中就有的 块级作用域 是相当畸形的……还不如我们使用立刻执行的匿名函数来模拟 块级作用域 的效果。

    不过好在,ES6 标准的到来,引入了 letconst 两种新的声明方式,以及显式代码块结构 { ... },使得 块级作用域 的标准完善不少。

    let btn = document.getElementsByTagName('button');
    for (let i = 0; i < 10; i++) {
      btn[i].onclick = function () {
        console.log(i);
      }
    }
    console.log(i); // ReferenceError
    

    现在我们想要的效果终于来了,使用 let 声明索引 i 后,我们发现每次循环都在 for 代码块内生成了新的作用域,这就是 块级作用域 了,在外部对 i 的取值操作已经变成了 ReferenceError 引用错误,也就是说外部作用域没有 i 的声明信息。

    这样,在除了在函数内部,我们现在又可以在 { ... } 这种结构(包括 forwhileiftry/catch、甚至直接使用 { ... } 等代码块结构语法)的内部使用 块级作用域 的规则了,这样有助于我们轻松地编写出更安全的高质量代码。

    {
      let b = 1;
      var a = 2;
    }
    console.log(a); // 2
    console.log(b); // ReferenceError
    

    我相信你已经明白了上面这个例子输出这样结果的原因。

    变量提升

    只要是一个作用域,就会有 变量提升 这个规则。我们先来看一个例子:

    a(); // 3
    
    // 函数声明
    function a() {
        console.log(1);
    }
    
    // 函数表达式
    var a = function () {
        console.log(2);
    }
    a(); // 2
    
    function a() {
        console.log(3);
    }
    a(); // 2
    
    // 具名函数表达式
    var a = function a() {
        console.log(4);
    }
    a(); // 4
    

    在这个例子中,我们可以看到输出的数字顺序好像杂乱无章,但其实理解 变量提升 这个概念后你就明白了。

    我们知道 JS 在下载后分两个阶段——编译和执行阶段。在编译过程中,作用域会先记录下声明的标识符。比如 var a = 1; 这句话,在编译时相应作用域会记录下声明 var a, 不管这句话写在同一级作用域的哪里,都会在编译阶段就记录在了这个作用域中,这就相当于把声明提到了此作用域的最上面,形成了 变量提升。而后,进入一行一行执行阶段时,读到的是 a = 1 这样的 赋值操作(LHS),引擎便查询作用域中变量 a 的信息,将值写入其内存地址。我们可以看到,存在编译和执行这样的先后顺序,在同一作用域的前提下,不管变量声明写在哪里,都相当于写在了这个作用域的开头,这就是一种 变量提升

    这里的例子也是这样,只不过不是单纯的变量声明提升,而是针对函数。提到函数,就不得不说说定义函数的两种形式:

    • 函数声明
    • 函数表达式

    函数声明 主要就是我们常用的定义函数方式,如例子中的注释所示。这样声明的函数在编译时作用域会记录下函数的标识符,同时将其标记为函数类型,并将函数内容等信息全部记录在这个标识符下。这样之后调用此函数便可以做到直接执行函数内容。但是我们知道 JS 没有显式支持 重载 这个机制,如果编译到后面,JS 引擎发现我们又声明了一次同样函数名的函数,便会直接覆盖之前的函数内容。这里,最后一个这样 函数声明 形式定义的同名函数输出 3,在编译阶段覆盖了之前声明的函数内容,那么第一行调用 a() 便会直接在作用域中查询到这个函数打印 3

    函数表达式 编译时待遇和上面 var a = 1; 差不多,编译时作用域只会记录下 变量声明 var a,值为 undefined(不过在 JS 中,函数声明的 优先级 高于变量声明,所以这个 undefined 并没有覆盖之前的函数声明内容,否则的话第一行调用函数会报 TypeError 这个错误),等到执行赋值操作时,才会把后面的函数赋给这个变量,所以这个 函数表达式 之后执行的函数调用全为此 函数表达式 的内容——打印 2

    那么最后一个具名函数表达式是怎么回事呢?原来编译时因为作用域只会先记录下最后一个函数的 var a,而并不能记录下等号后具名函数的标识符 a,相当于这个具名函数的标识符是完全被忽略的,从下面这个例子我们就能看出这一点:

    a(); // TypeError
    b(); // ReferenceError
    
    var a = function b() {
        console.log(4);
    }
    a(); // 4
    b(); // ReferenceError
    

    变量提升 的规则概括成一句话就是:“只有声明本身会被提升,而赋值或其它运行逻辑会留在原地。”

    let 和 const 的变量提升

    当然,上面所说的变量声明使用的是关键词 var,那么 ES6 中的 letconst 是怎么处理变量提升的呢?

    为了解释 letconst 变量提升,我们还要再引入概念,即声明过程是存在一个 生命周期

    • 创建(Declaration phase):将变量注册到作用域中。
    • 初始化(Initialization phase):为作用域中查询到的相应变量分配内存,并将地址的引用绑定到这个变量上来。在这一步,变量会默认地被初始化为 undefined
    • 赋值(Assignment phase):将值赋给已经初始化的变量。

    JS 编译阶段遇到 var 声明时会创建这个变量并初始化一个值 undefined,然后在执行阶段再将后面的赋值操作进行下去。所以会出现下面的情景:

    console.log(a); // undefined
    var a = 1;
    console.log(a); // 1
    

    但是编译阶段遇到 let 声明时,JS 引擎只会创建这个变量,但并不会初始化它,而是在之后的执行阶段再初始化、赋值。所以会出现这样的情景:

    console.log(a); // ReferenceError: can't access lexical declaration `a' before initialization
    let a; // 这里初始化成 `undefined`,但是没有赋值操作
    console.log(a); // undefined
    
    console.log(b); // ReferenceError: b is not defined
    
    let c = 1; // 这里不仅有初始化,还有赋值
    console.log(c); // 1
    

    对比变量 ab 的例子(火狐浏览器控制台中报错更详细,可以看到区别,但是 Chrome 不会区别报错),我们可以看到 let 在编译阶段创建的变量是有提升的,但是没有初始化,执行时取值操作发现这个变量没有值可以引用,便会报错……而 const 的变量提升也是同理了……

    至此,我们可以看到,letvar 不仅有作用域上的区别,还有声明生命周期上的区别。

    参考

    - 《你不知道的 JavaScript (上卷)》

    - 深入浅出ES6(十四):let 和 const

    - 认识 V8 引擎

    - 我用了两个月的时间才理解 let

    - JavaScript variables lifecycle: why let is not hoisted

    相关文章

      网友评论

        本文标题:JavaScript 学习笔记——作用域

        本文链接:https://www.haomeiwen.com/subject/cetzfftx.html