Better

Ethan的博客,欢迎访问交流

你不知道的 JS 上卷读书笔记1/2

你不知道的 JS 上卷对 JS 中几个特别基础且重要的点进行了讲解,这是一篇读书笔记。

前言

虽然 JS 可能是最容易上手的语言之一,但是由于其本身的特殊性,相比其他语言,能真正掌握 JS 的人比较少。

JS 非常特殊,只学一部分的话,非常简单,但是想要完整的学习会很难。当开发者感到迷惑时,他们通常会责怪语言本身,而不是怪自己对语言缺乏了解。这个系列就是为了解决这个问题,让你打心眼儿里心欣赏这门语言。

作用域

编译原理,程序中一段源代码在执行之前会经历三个步骤,统称为编译

  1. 分词/词法分析:将字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元
  2. 解析/语法分析:将词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树称为抽象语法数(AST)
  3. 将AST转换为可执行代码的过程被称为代码生成。这个过程与语言、目标平台等息息相关

比起那些编译构成只有三个步骤的语言的编译器,JS 引擎要复杂的多,例如在语言分析和代码生成阶段有特定的步骤来对性能进行优化,包括对冗余元素进行优化等。

  • 首先,JS引擎不会有大量的时间来进行优化,因为和其他语言不同,JS 的编译过程不是发生在构建之前
  • 大部分情况下编译发生在代码执行前的几微妙的时间内。在作用域背后,JS 引擎用尽了各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳

理解作用域我们需要了解三个角色

  • 引擎:负责整个 JS 程序的编译及执行过程
  • 编译器:负责语法分析及代码生成
  • 作用域:负责收集并维护由所有声明的标志符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标志符的访问权限

关于 var a = 2,引擎会认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理

  1. 遇到 var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域中,如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域中声明一个新的变量,命名为 a
  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理赋值操作。引擎首先询问作用域,当前作用域中是否存在一个叫做 a 的变量,如果是,引擎使用这个变量,如果否,引擎会继续查找
  3. 如果找到了 a 变量,就会将 2 赋值给它,否则引擎会举手示意并抛出一个异常

变量的赋值会进行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值

总结:引擎首先会在代码执行前对其进行编译,在这个过程中,像 var a = 2 这样的声明会被分解成两个独立的步骤

  1. 首先,var a 在其作用域中声明新变量,这会在最开始的阶段,也就是代码执行前进行
  2. 接下来,a = 2 会查询变量 a 并对其进行赋值

引擎判断变量 a 是否声明过,查找过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查询结果。这里需要理解两个术语 LHS 查询和 RHS 查询

当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。简单来说 RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而对其赋值。

赋值操作并不意味着就是等号赋值操作符,赋值操作还有其他几种形式,因此概念上最好理解为赋值操作的目标是谁(LHS)以及谁是赋值操作的源头(RHS)

作用域嵌套:当一个块或函数嵌套在另一个块或函数时,就发生了作用域嵌套,因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止

为什么区分 LHS 和 RHS 呢?因为在变量还没有声明的情况下,这两种查询的行为是不一样的

  • RHS 查询所有嵌套的作用域中遍寻不到所需变量,引擎就会抛出 ReferenceError 异常
  • LHS 查询如果无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返回给引擎,前提是程序运行在非严格模式下

ES5 引入了严格模式,严格模式在行为上有很多不同,其中一个不同的行为是严格模式下禁止自动或隐式的创建全局变量,因此严格模式下的 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询失败时类似的 ReferenceError 异常

如果 RHS 查询到一个变量,但是你尝试对这个变量的值进行不合理的操作,比如对非函数类型进行函数调用,或者引用 null 或 undefined 类型的值中的属性,那么引擎就会抛出另一种类型的异常,就做 TypeError。

词法作用域

作用域共有两种主要的工作模型

  • 词法作用域:最为普遍的,被大多数编程语言所采用
  • 动态作用域:小众,但仍有一些编程语言在使用(bash脚本、Perl)

词法阶段:大部分标准语言编译器的第一个工作阶段叫做词法化(也叫单词化),词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋予单词语义。这是理解词法作用域及其名词来历的基础。

词法作用域就是定义在词法阶段的作用域,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况是这样的,因为有一些欺骗词法作用域的方法,这些方法在词法分析器处理过后依旧可以修改作用域,但这种机制可能有点难以理解,事实上,让词法作用域根据词法关系保持书写时的自然关系不变,是一个非常好的最佳实践)

遮蔽效应:作用域查找会在找到第一个匹配的标志符时停止,在多层嵌套的作用域中可以定义同名的标志符,内部的标志符遮蔽了外部的标志符

备注:全局变量会自动称为全局对象(比如浏览器中的window对象)的属性,因此可以不直接通过全局对象的词法名称,而是简洁的通过对全局对象属性的引用对其访问,通过这种方式可以访问哪些被同名变量所遮蔽的全局变量,但非全局的变量,如果被遮蔽颗,如论如何都无法被访问到。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定,编译的词法分析阶段基本能够知道全部标识符在哪里以及是如何声明的,从而能够预测在执行过程中如何对它们进行查找。

如果词法作用域完全由写代码期间函数所声明的位置来定义,怎么才能在运行时来修改(欺骗)词法作用域呢。在 JS 中有两种机制实现这个目的,但需要注意的是:欺骗词法作用域会导致性能下降。

eval

eval 可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码,换句话说:可以在你写的代码中用程序生成代码并运行。

在执行 eval 之后的代码时,引擎并不知道或在意前面的代码是以动态形式插入进来的,并对词法作用域的环境进行修改的,引擎只会如往常的进行词法作用域查找。

在实际情况中,可以非常容易的根据程序逻辑动态的将字符串拼接在一起之后再传递进入,eval 通常被用来执行动态创建的代码。

在严格模式下,eval 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域

备注:JS 中还有一些功能效果和 eval 类似,setTimeout 和 setInterval 的第一个参数可以是字符串,字符串的内容可以被解释为动态生成函数代码,这些功能已经过时且不被提倡,不要使用他们!

new Function 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转换为动态生成的函数(前面的函数是这个新生成函数的形参)。这种构建函数的语言比 eval 略微安全一些,但也要尽量避免使用。

在程序中动态生成代码的使用场景非常罕见,因为它所带来的好处无法抵消性能上的损失。

with 关键字

with 通常被当做重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。

需要知道的是,在 with 块内部,实际上就是一个 LHS 引用,我们需要知道这个的目的就是,如果对象不存在该属性,则赋值操作会泄露到全局作用域。

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。

尽管 with 块可以将一个对象处理为词法作用域,但是这个块内部正常的 var 声明并不会被限制在这个块作用域中,而是被添加到 with 所处的函数作用域中。

eval 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

另一个不推荐使用 eval 和 with 的原因是会被严格模式所影响(限制),with 被完全禁止,而在保留核心功能的前提下, 间接或非安全的使用 eval 也被禁止了。

性能

JS 引擎会在编译阶段进行数项的性能优化,其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的位置,才能在执行过程中快速找到标识符。

如果引擎在代码中发现了 eval 和 with,它只能简单的假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval 会接受到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象内容到底是什么。

最悲观的情况是,如果出现了 eval 和 with,所有的优化可能都是无意义的,因此最简单的做法就是不做任何优化。

函数作用域和块作用域

什么产生了新的作用域呢?只有函数能产生吗?JS 中的其他结构能生成新的作用域吗?

最常见的问题是 JS 是基于函数的作用域,意味着每声明一个函数都会为其自身创建一个作用域,而其他结构都不会创建作用域。事实上,这并不完全正确

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复用(嵌套的作用域也可以使用),这种设计是非常有用的,能充分利用 JS 变量可以根据需要改变值类型的动态特性。

隐藏内部实现:利用作用域的特点,可以把变量和函数包裹在一个函数作用域中,然后用这个作用域来隐藏它们。

为什么隐藏变量和函数是一个有用的技术?其实有很多原因促成了这种基于作用域的隐藏方法。大都是从最小特权原则中引申出来的,也叫做最小授权或最小暴露原则。过多的暴露不仅没有必要,而且可能是危险的,因为它们可能被有意或无意的以非预期的方式使用,从而导致超出了使用条件。

规避冲突:隐藏作用域的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突,无意间造成的命名冲突,会导致变量的值被意外覆盖。

  1. 全局命名空间:当引用多个第三方库时,如果没有妥善的将内部私有的函数或变量隐藏去来,就会很容易引发冲突。因此这些库通常会在全局作用域中声明一个足够特别的名字,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
  2. 模块管理:另一种规避冲突的方式,就是选用模块管理器。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显示的导入到另一个特定的作用域中。

函数作用域

我们已知将任意代码片段外部添加包装函数,可以将内部的变量和函数定义隐藏起来,外部作用域无法访问包装函数内部的任何内容,虽然这可以解决一些问题,但并不理想,因为会导致一些额外问题,首先必须声明一个具名函数,这个函数本身污染了所在的作用域,此外必须显示的通过函数名调用这个函数才能运行其中代码。

如果函数不需要函数名(或至少函数名可以不污染所在作用域),并且能够自动运行,这将会更加理想。立即执行函数(IIFE)可以帮助到我们。

立即执行函数以(function...而不仅是以function...开始,就是这个小细节,函数会被当做函数表达式而不是一个标准的函数声明来处理

区分函数声明和表达式最简单的方法是看function关键字出现在声明中的位置(不仅是一行代码,而是整个声明中的位置),如果function是声明中的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

函数声明和函数表达式之间最重要的区别就是他们的名称标志符将会绑定在何处。

函数声明会被绑定在所在的作用域中,可以直接通过函数名调用它。函数表达式被绑定在函数表达式自身的函数中而不是在所在作用域中。函数表达式的变量名被隐藏在自身中意味着不会非必要的污染外部作用域。

对于函数表达式你最熟悉的场景可能就是回调函数了。函数表达式可以是匿名的,而函数声明则不可以忽略函数名。匿名函数表达式书写起来简单,但有几个缺点需要考虑

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试变困难
  2. 如果没有函数名,需要引用自身,只能使用已经过期的arguments.callee引用。
  3. 匿名函数省略了对于代码可读性很重要的函数名

行内函数表达式非常强大且有用,给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践。

接下来进一步说说 IIFE,首先有两种用法

  • (function() {})()
  • (function() {}())

IIFE的另一个进阶用法是把他们当做函数调用并传递参数进去,比如传递全局对象,用来改善代码可读性。你可以传递任何你需要的东西,并命名为任何你觉得合适的名字。

另一个应用场景是解决 undefined 标识符默认值被错误覆盖导致的异常。将一个参数命名为 undefined,但是在对应的位置不传入任何值,这样就可以保证在代码块中的 undefined 标识符真的是 undefined。

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当做参数传递进去,这种模式在 UMD 项目中广泛使用。

块作用域

尽管函数作用域是最常见的作用域单元,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可以实现维护起来更加优秀、简洁的代码。

块作用域用处:变量声明应该距离使用的地方越近越好,并最大限度的本地化。块作用域是对最小授权原则进行扩展的工具,将代码从函数中隐藏信息扩展为块中隐藏信息。

块作用域,如果存在的话,对保证变量不会被混乱的复用及提升代码的可维护性都有很大帮助。但很可惜,JS 表面上看并没有块作用域相关的功能。

深入研究后,加上 ES6 的到来,以下这些都是块作用域

  • with:从对象中创建出来的作用域仅在 with 生命中而非外部作用域中有效
  • try/catch:catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效
  • let:将变量绑定到所在的任意作用域中,通常是{}内部,let声明的变量隐式的劫持了所在的块作用域
  • const

在这里重点说说 let,const 很多地方表现和 let 类似

用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。在开发和修改代码的过程中,如果没有密切关注哪些块作用域中有绑定的变量,并且习惯性的移动这些块或者将其包含在其他块中,就会导致代码变得混乱。

因此为块作用域显示的创建块可以部分解决这个问题,使变量的附属关系变得更加清晰。显示的块作用域风格非常容易书写,并且和其他语言中块作用域的工作原理一致。只要声明是有效的,在声明中的任意位置都可以使用{}括号来为let创建一个用于绑定的块。

使用 let 进行的声明不会在块作用域中进行提升。声明的代码在被运行之前,声明并不存在。

块作用域另一个非常有用的原因和闭包及回收内存垃圾的回收机制有关。思考如下代码片段

function process(data) {

}

var someBigData = {}

process(someBigData)

btn.addEventListener('click', function click(evt) {
    console.log('button click')
})

以上代码中,click 的点击回调并不需要 someBigData 变量。理论上意味着当 process 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了,但是由于 click 函数生成一个覆盖整个作用域的闭包,JS 引擎极有可能依然保存着这个结构(取决于具体怎么实现)

此时块作用域可以打消这种顾虑,让引擎清晰的知道没有必要继续保存 someBigData 了。

function process(data) {

}

{
    let someBigData = {}

    process(someBigData)
}

btn.addEventListener('click', function click(evt) {
    console.log('button click')
})

为变量显示声明块作用域,并对变量进行本地绑定是非常有用的工具。

let 循环的特别之处,for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上他将其重新绑定到了 for 循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值,代码解释如下

{
    let j;
    for(j = 0; j < 10; j++) {
        let i = j
        console.log(i)
    }
}

你可能会觉得有点懵逼,但是这其实很有趣,在闭包中我们就可以感受到

提升

作用域同其中的变量声明出现的位置有某种微妙的联系,

提升:指声明会被视为存在于其所出现的作用域的整个范围内,这个过程好像变量和函数声明从它们在代码中出现的位置被移动到了最上面,因此就叫做提升。

直觉上会认为 JS 代码在执行时是有上到下一行一行执行的。但实际上这并不完全正确,有一个特殊情况会导致这是错误的。

为了解释这些特殊问题,就要拿出编译器的内容了。引擎会在解释 JS 代码之前首先对其进行编译。编译阶段的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。

正确的思考是:包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。对于 var a = 2;JS 会看成两个声明,var a 和 a = 2,第一个定义声明在编译阶段进行,第二个赋值声明会被留在原地等待执行阶段。

需要注意的点

  • 每个作用域都会进行提升操作(所有的声明都移动到各自作用域的最顶端)
  • 函数声明会提升,但是函数表达式不会提升
  • 即使是具名函数表达式,名称标识符在赋值之前也无法在所在作用域中使用
  • 函数声明和变量声明都会提升,有个细节就是:函数首先会被提升,然后才是变量。也就是函数优先原则(同名变量声明无法覆盖函数)
  • 尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的

一个普通块内部的函数声明通常会被提升到所在作用域的顶部,不会像下面代码暗示的那样可以被条件控制

foo();
var a = true; 
if(a) { 
    function foo() {console.log('a')}
} else {
    function foo() {console.log('b')}
}

以上行为并不可靠,在 JS 未来版本中有可能发生改变,因此应该尽可能避免在块内部声明函数。

作用域闭包

对于那些有一点 JS 使用经验但从未真正理解闭包概念的人来说,理解闭包可以看作是某种意义上的重生!手动滑稽。

JS 中闭包无处不在,你只需要能够识别并拥抱它

闭包是基于词法作用域书写代码时所产生的自然结果,你甚至不需要为了利用他们而有意识的创建闭包。闭包的创建和使用在你的代码中随处可见。你缺少的是根据你自己的意愿来识别、拥抱和影响闭包的思维环境

闭包的定义:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

看个最简单的代码

function foo() {
    var a = 2
    function bar() {
        console.log(a) // 2
    }
}

这是闭包吗?从技术上讲,也许是,但确切的说并不是,最准确的用来解释 bar 对 a 的引用的方法是词法作用域的查找规则,而这些规则只是闭包的一部分,但是是非常重要的一部分。从纯学术的角度而言,上述代码中,函数 bar 具有一个涵盖 foo 作用域的闭包,但是通过这种方式定义的闭包并不能直接进行观察。在看一段清晰的代码

function foo() {
    var a = 2
    function bar() {
        console.log(a)
    }
    return bar
}
var baz = foo()
baz() // 2

在 foo 执行后,通常会期待 foo 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收期用来释放不在使用的内存空间,由于看上去 foo 的内容不在被使用,所以很自然会考虑对其回收。而闭包的神奇之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。那么谁在使用这个内部作用域?bar 本身在使用。

因为 bar 声明的位置,它拥有涵盖 foo 内部作用域的闭包,使得该作用域能够一直存活,以供 bar 在之后的任何时间进行引用。bar 依然持有对该作用域的引用,这个引用就叫做闭包。

这个函数在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的词法作用域。

无论通过何种手段将内部函数传递到所在的词法作用域以外,它都会持有对原始定义作用域的引用,无论在何处执行这个函数都会使用闭包。

本质上无论何时何地,如果将(访问各自词法作用域的)函数当做第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers或任何其他的异步任务中,只要使用了回调函数,实际上就是使用闭包。

IIFE:立即执行函数准确来说也并不是闭包,也是通过普通的词法作用域查找。尽管IIFE不是观察闭包的恰当例子,但它的确创建了闭包,并且也是最常用来创建可以被封闭起来得闭包的工具

循环和闭包

初学者学习闭包时,经常会看到和循环结合起来的例子,主要是因为在循环中稍不注意,会将我们的错误放大。

根据作用域的工作原理,实际情况尽管是循环中的多个函数是在各个迭代中分别定义的,但是她们都被封闭在一个共享的全局作用域中。这就导致一个缺陷,我们需要更多的闭包作用域,特别是在循环的过程中每个迭代都需要一个闭包作用域。此时我们就可以通过IIFE来创建作用域。这里还有个需要注意的点就是,如果我们直接创建多个空的作用域是不行的,这样最终还会找到共享的作用域去。因此它需要有自己的变量。一般可以通过参数传入。

之前讲到块作用域时,let 循环中有个有趣的特性,那就是 let 不仅将 i 绑定到了 for 循环的块中,事实上他将其重新绑定到了 for 循环的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。因此下面的代码可以正常工作

for(let i = 1; i <= 5; i++) {
    setTimeout(function timer() {
        console.log(i)
    }, i * 1000)
}

// 这个也可以工作
for(var i = 1; i <= 5; i++) {
    let j = i
    setTimeout(function timer() {
        console.log(j)
    }, j * 1000)
}

模块

直接看代码,这个模式在 JS 中称为模块。

function foo() {
    var test1 = 20;
    var test2 = 30;
    function func1() {}
    function func2() {}
    return {
        func1: func1,
        func2: func2
    }
}

模块模式另一个简单且强大的用法是命名将要作为公共 API 返回的对象,通过在模块实例内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修改,包括添加或删除方法和属性,以及修改他们的值。

模块模式的两个特点

  1. 为创建内部作用域而调用了一个包装函数
  2. 包装函数的返回值必须至少包括一个对内部函数的引用,这样就会创建涵盖整个包装函数内部作用域的闭包

现代化模块机制

var MyMudles = (function() {
    var modules = []
    function define(name, deps, impl) {
        for(var i = 0; i < deps.length; i++) {
            deps[i] = modules[deps[i]]
        }
        modules[name] = impl.apply(impl, deps)
    }
    function get(name) {
        return modules[name]
    }
    return {
        define: define,
        get: get
    }
})()

未来的模块机制:ES6 为模块增加了一级语法支持,在通过模块系统进行加载时,ES6 会将文件当做独立的模块来处理。每个模块都可以导入其他模块或者特定的 API 成员,同样也可以导出自己的 API 成员。

基于函数的模块并不是一个能被静态识别的模式(编译器无法识别),他们的 API 语义只有在运行时才会被考虑进来,因此可以在运行时修改一个模块的 API。

相比之下,ES6 模块 API 是静态的(API 不会在运行时改变)。由于编译器知道这一点,因此可以在编译器检查对导入模块的 API 成员的引用是否真实存在。如果 API 不存在,编译器会在编译时就抛出早期错误,而不会等到运行期再动态解析。

ES6 的模块没有行内格式,必须被定义在独立的文件中(一个文件一个模块)。浏览器或引擎有一个默认的模块加载器可以在导入模块时同步的加载模块文件。

  • import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量上
  • mudule 会将整个模块的 API 导入并绑定到一个变量上
  • export 会将当前模块的一个标识符导出为一个公共 API

模块文件中的内容会被当做好像包含在作用域闭包中一样来处理,就和前面介绍的函数闭包模块一样

补充

这部分属于对知识的扩充

动态作用域

JS 是词法作用域,为啥我们还要提到这个呢?实际上动态作用域是 JS 另一个重要机制 this 的表亲。

动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用,换句话说,作用域是基于调用栈的,而不是代码中的作用域嵌套。

需要明确的事实就是:JS 并不具有动态作用域,它只有词法作用域,简单明了,但是 this 机制某种程度上很像动态作用域。

主要区别

  • 词法作用域实在写代码和定义时确定的,而动态作用域是在运行时确定的(this 也是)
  • 词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用

块作用域替代方案

块作用域在功能上和代码风格上都拥有很多激动人心的新特性,那么在 ES6 到来之前,如何使用块作用域呢?答案就是 catch 分句。

但是代码写起来实在是太丑陋了,但这不是重点,重点是工具可以将 ES6 的代码转换成能在 ES6 之前环境中运行的形式,你可以使用块作用域来写代码,并享受它带来的好处,然后在构建时通过工具来对代码进行预处理,使之可以在部署时正常工作。

比如 Google 维护着的一个名为 Traceur 的项目,就是做这件事情。这个工具会将我们的代码片段转换成什么样子

{
    try {
        throw undefined
    } catch(a) {
        a = 2
        console.log(a)
    }
}

为什么不直接使用 IIFE 来创建作用域

  • try/catch的性能的确很糟糕,但技术层面上没有合理的理由来说明try/catch必须这么慢,或者会一直慢下去
  • IIFE 和 try/catch 并不是完全等价的,因为如果将一段代码中的任意一部分拿出来用函数进行包裹,会改变这段代码的含义,其中的 this、return、break、continue 都会发生变化,因此 IIFE 并不是一个 普适方案,它只适合在某些情况进行手动操作

this词法

ES6 中有一个主题用非常重要的方式将 this 同词法作用域联系起来了,叫做箭头函数。

箭头函数在涉及 this 绑定时的行为和普通函数的行为完全不一致。它放弃了所有普通 this 绑定的规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。



留言