你不知道的JavaScript(上)

1,037 阅读31分钟

前言

“你不知道的JavaScript”系列丛书要做的事是:学习并且深入理解整个JavaScript,尤其是那些“难的部分”。在本书中,我们要直面当前JavaScript开发者不求甚解的大趋势,他们往往不会深入理解语言内部的机制,遇到困难就会退缩。我们要做的恰好相反,不是退缩,而是继续前进。你们应当像我一样,不满足于只是让代码正常工作,而是想要弄清楚“为什么”。我希望你能勇于挑战这条崎岖颠簸的“少有人走的路”,拥抱整个JavaScript。掌握了这些知识之后,无论什么技术、框架和流行词语你都能轻松理解。

目录

第1章作用域是什么

第2章词法作用域

第3章函数作用域和块作用域

第4章提升

第5章作用域闭包

第1章 作用域是什么

1.1编译原理

尽管通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。但与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。尽管如此,JavaScript引擎进行编译的步骤和传统的编译语言非常相似,在某些环节可能比预想的要复杂。

在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤,统称为“编译”。

  • 分词/词法分析(Tokenizing/Lexing)

这个过程会将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。例如,考虑程序var a=2;。这段程序通常会被分解成为下面这些词法单元:var、a、=、2、;。空格是否会被当作词法单元,取决于空格在这门语言中是否具有意义。

分词(tokenizing)和词法分析(Lexing)之间的区别是非常微妙、晦涩的。主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的。简单来说,如果词法单元生成器在判断a是一个独立的词法单元还是其他词法单元的一部分时,调用的是有状态的解析规则,那么这个过程就被称为词法分析。

  • 解析/语法分析(Parsing) 这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST)。var a=2;的抽象语法树中可能会有一个叫作VariableDeclaration的顶级节点,接下来是一个叫作Identifier(它的值是a)的子节点,以及一个叫作AssignnentExpression的子节点。AssignmentExpression节点有一个叫 Numericliteral(它的值是2)的子节点。

  • 代码生成

将AST转换为可执行代码的过程称被称为代码生成。这个过程与语言、目标平台等息息相关。抛开具体细节,简单来说就是有某种方法可以将var a=2;的AST转化为一组机器指令,用来创建一个叫作a的变量(包括分配内存等),并将一个值储存在a中。

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

对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内。简单地说,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。

1.2理解作用域

理解作用域要从了解以下几方面开始

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

当你看见var a=2;这段程序时,很可能认为这是一句声明。事实上,引擎认为这里有两个完全不同的声明,一个由编译器在编译时处理,另一个则由引擎在运行时处理。

下面我们将var a=2;分解,看看引擎和它的朋友们是如何协同工作的。

编译器首先会将这段程序分解成词法单元,然后将词法单元解析成一个树结构。但是当编译器开始进行代码生成时,它对这段程序的处理方式会和预期的有所不同。可以合理地假设编译器所产生的代码能够用下面的伪代码进行概括:“为一个变量分配内存,将其命名为a,然后将值2保存进这个变量。”然而,这并不完全正确。

事实上编译器会进行如下处理。

  1. 遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a。

  2. 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理a=2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量(查看1.3节 作用域链)。如果引擎最终找到了a变量,就会将2赋值给它。否则引擎就会抛出一个异常!

总结:变量的赋值操作会执行两个动作,首先编译器会在当前作用域中声明一个变量(如果之前没有声明过),然后在运行时引擎会在作用域中查找该变量,如果能够找到就会对它赋值。否则抛出异常。

编译器在编译过程的第二步中生成了代码,引擎执行它时,会通过查找变量a来判断它是否已声明过。查找的过程由作用域进行协助,但是引擎执行怎样的查找,会影响最终的查找结果。

在我们的例子中,引擎会为变量a进行LHS查询。另外一个查找的类型叫作RHS。

LHS查询和RHS查询分别是一个赋值操作的左侧和右侧。换句话说,当变量出现在赋值操作的左侧时进行LHS查询,出现在右侧时进行RHS查询。讲得更准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。从这个角度说,RHS 并不是真正意义上的“赋 值操作的右侧”,更准确地说是“非左侧”。[可以简单的理解为LHS为赋值操作,比如:a=2,RHS为查询操作比如:console.log(a)]赋值操作还有其他几种形式,因此在概念上最 好将其理解为“赋值操作的目标是谁(LHS)”以及“谁是赋值操作的源头(RHS)”。

1.2.1演员表
1.2.2对话
1.2.3编译器有话说
1.2.4引擎和作用域的对话

1.3作用域嵌套\

我们说过,作用域是根据名称查找变量的一套规则。实际情况中,通常需要同时顾及几个作用域。 当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达最外层的作用域(也就是全局作用域)为止。

遍历嵌套作用域链的规则很简单:引擎从当前的执行作用域开始查找变量,如果找不到, 就向上一级继续查找。当抵达最外层的全局作用域时,无论找到还是没找到,查找过程都 会停止。

function foo(a){
    console.log( a+b);
}
var b=2;
foo(2);//4

对b进行的RHS引用无法在函数foo内部完成,但可以在上一级作用域(在这个例子中就是全局作用域)中完成。

整个查找过程对话如下:

引擎:foo的作用域兄弟,你见过b吗?我需要对它进行RHS引用。
作用域:听都没听过,走开。
引擎:foo的上级作用域兄弟,咦?有眼不识泰山,原来你是全局作用域大哥,太好了。你,见过b吗?我需要对它进行RHS引用。
作用域:当然了,给你吧

1.4异常

为什么区分LHS和RHS是一件重要的事情?因为在变量还没有声明(在任何作用域中都无法找到该变量)的情况下,这两种查询的行为是不一样的。

如果RHS查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会拋出ReferenceError异常。值得注意的是,ReferenceError是非常重要的异常类型。

相较之下,当引擎执行LHS查询时,如果在顶层(全局作用域)中也无法找到目标变量,全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎,前提是程序运行在非“严格模式”下。

ES5中引入了“严格模式”。同正常模式,或者说宽松/懒惰模式相比,严格模式在行为上有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在严格模式中LHS查询失败时,并不会创建并返回一个全局变量,引擎会抛出同RHS查询失败时类似的ReferenceError异常。

接下来,如果RHS查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用null或undefined类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作TypeError。ReferenceError同作用域判别失败相关,而TypeError则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

第2章词法作用域

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域,我们会对这种作用域进行深入讨论。另外一种叫作动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。

2.1词法阶段

词法化的过程会对源代码中的字符进行检查,如果是有状态的解析过程,还会赋 予单词语义。简单地说,词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)。

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的。注意,这里所说的气泡是严格包含的。我们并不是在讨论文氏图'这种可以跨越边界的气泡。换句话说,没有任何函数的气泡可以(部分地)同时出现在两个外部作用域的气泡中,就如同没有任何函数可以部分地同时出现在两个父级函数中一样。

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。抛开遮蔽效应,作用域查找始终从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止。

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此 可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引 用来对其进行访问。window.a通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量 如果被遮蔽了,无论如何都无法被访问到。

无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处 的位置决定。

2.2欺骗词法

JavaScript 中有两种机制来实现这个目的。社区普遍认为在代码中使用这两种机制并不是 什么好主意。但是关于它们的争论通常会忽略掉最重要的点:欺骗词法作用域会导致性能下降。

eval

JavaScript中的eval(..)函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。换句话说,可以在你写的代码中用程序生成代码并运行,就好像代码是写在那个位置的一样。[可以将eval执行看做在当前位置新插入一块代码,跟原有代码融为一体]无论何种情况eval(..)都可以在运行期修改书写期的词法作用域。

JavaScript中 还 有 其 他 一 些 功 能 效 果 和eval(..)很 相 似。setTimeout(..)和 setInterval(..) 的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的 函数代码。这些功能已经过时且并不被提倡。不要使用它们!

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

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

with

JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是 with 关键字。可以有很多方法来解释 with,在这里我选择从:它如何同被它所影响的词法作用域进行交互。这个角度来解释它。

with 通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身eval(..) 函数如果接受了含有一个或多个声明的代码,就会修改其所处的词法作用域,而 with 声明实际上是根据你传递给它的对象凭空创建了一个全新的词法作用域。

function foo(obj){
    with (obj){
        a = 2;
    }
}

var o1 = {
    a:3
}
var o2 = {
    b:3
}

foo(o1);
console.log(o1.a);//2
foo(o2);
console.log( o2.a);//undefined
console.log(a);//2不好,a被泄漏到全局作用域上了!当在o2作用域中查不到a时非严格模式下,a会被创建成一个全局变量

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

性能

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

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

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

如果代码中大量使用 eval(..) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。

对于eval(..) 和 with,这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

第3章函数作用域和块作用域

3.1函数中的作用域

函数作用域的含义是指,属于这个函数的全部变量都可以在整个函数的范围内使用及复 用(事实上在嵌套的作用域中也可以使用)。这种设计方案是非常有用的,能充分利用 JavaScript 变量可以根据需要改变值类型的“动态”特性。但与此同时,如果不细心处理那些可以在整个作用域范围内被访问的变量,可能会带来意 想不到的问题。

3.2隐藏内部实现

有很多原因促成了这种基于作用域的隐藏方法。它们大都是从最小特权原则中引申出来 的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度地暴露必 要内容,而将其他内容都“隐藏”起来,比如某个模块或对象的 API 设计。

这个原则可以延伸到如何选择作用域来包含变量和函数。如果所有变量和函数都在全局作 用域中,当然可以在所有的内部嵌套作用域中访问到它们。但这样会破坏前面提到的最小 特权原则,因为可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的,正确 的代码应该是可以阻止对这些变量或函数进行访问的。

function doSomething(a) {
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}

function doSomethingElse(a) { 
    return a - 1;
}
var b;
doSomething( 2 ); // 15

//将上面的代码改成如下设计,将方法私有化

function doSomething(a) {
    function doSomethingElse(a) {
        return a - 1; 
    }
    var b;
    b = a + doSomethingElse( a * 2 );
    console.log( b * 3 );
}
doSomething( 2 ); // 15

设计上将具体内容私有化了,设计良好的软件都会 依此进行实现。

规避冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突, 两个标识符可能具有相同的名字但用途却不一样,无意间可能造成命名冲突。冲突会导致 变量的值被意外覆盖。

// 例如
function foo() { 
    function bar(a) {
        i = 3; // 修改for循环所属作用域中的i
        console.log( a + i );
    }
    for (var i=0; i<10; i++) {
        bar( i * 2 ); // 糟糕,无限循环了!
    } 
}
foo();
  1. 全局命名空间 变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它 们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。

    这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。

  2. 模块管理 另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。

显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域中,这样可以有效规避掉所有的意外冲突。因此,只要你愿意,即使不使用任何依赖管理工具也可以实现相同的功效。第 5 章会介绍 模块模式的详细内容。

3.3函数作用域

首先,包装函数的声明以 (function... 而不仅是以 function... 开始。尽管看上去这并不 是一个很显眼的细节,但实际上却是非常重要的区别。函数会被当作函数表达式而不是一个标准的函数声明来处理。

// 对以下代码进行包装
var a = 2;
function foo() { // <-- 添加这一行
    var a = 3; console.log( a ); // 3
} // <-- 以及这一行 foo(); // <-- 以及这一行
console.log( a ); // 2

// 包装后效果如下
var a = 2;
(function foo(){ // <-- 添加这一行 var a = 3;
    console.log( a ); // 3 
})(); // <-- 以及这一行 
console.log( a ); // 2

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

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

匿名和具名

对于函数表达式你最熟悉的场景可能就是回调参数了,比如:

setTimeout( function() {
    console.log("I waited 1 second!");
}, 1000 );

这叫作匿名函数表达式,因为 function().. 没有名称标识符。函数表达式可以是匿名的,

而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。 匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是它也有几个缺点需要考虑。

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的arguments.callee引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。
  3. 匿名函数省略了对于代码可读性/可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。

行内函数表达式非常强大且有用——匿名和具名之间的区别并不会对这点有任何影响。给函 数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:

setTimeout( function timeoutHandler() { // <-- 快看,我有名字了! 
    console.log( "I waited 1 second!" );
}, 1000 );

立即执行函数表达式

由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,比如 (function foo(){ .. })()。第一个 ( ) 将函数变成表 达式,第二个 ( ) 执行了这个函数。这种模式称之为:IIFE,代表立即执行函数表达式 (Immediately Invoked Function Expression);

函数名对 IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使 用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值 得推广的实践。

相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }())。仔 细观察其中的区别。第一种形式中函数表达式被包含在 ( ) 中,然后在后面用另一个 () 括 号来调用。第二种形式中用来调用的 () 括号被移进了用来包装的 ( ) 括号中。

这两种形式在功能上是一致的。选择哪个全凭个人喜好。

IIFE 的另一个非常普遍的进阶用法是把它们当作函数调用并传递参数进去。

var a = 2;
(function IIFE( global ) {
    var a = 3;
    console.log( a ); // 3 console.log( global.a ); // 2
})( window );
console.log( a ); // 2

我们将 window 对象的引用传递进去,但将参数命名为 global,因此在代码风格上对全局 对象的引用变得比引用一个没有“全局”字样的变量更加清晰。当然可以从外部作用域传 递任何你需要的东西,并将变量命名为任何你觉得合适的名字。

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

undefined = true; // 给其他代码挖了一个大坑!绝对不要这样做! 
(function IIFE( undefined ) {
    var a;
    if (a === undefined) {
        console.log( "Undefined is safe here!" );
    }
})();

IIFE 还有一种变化的用途是倒置代码的运行顺序,将需要运行的函数放在第二位,在 IIFE 执行之后当作参数传递进去。这种模式在 UMD(Universal Module Definition)项目中被广泛使用。尽管这种模式略显冗长,但有些人认为它更易理解。

var a = 2;
(function IIFE( def ) { 
    def( window );
})(function def( global ) {
    var a = 3;
    console.log( a ); // 3 
    console.log( global.a ); // 2
});

函数表达式 def 定义在片段的第二部分,然后当作参数(这个参数也叫作 def)被传递进 IIFE 函数定义的第一部分中。最后,参数 def(也就是传递进去的函数)被调用,并将 window 传入当作 global 参数的值。

3.4块作用域

除 JavaScript 外的很多编程语言都支持块作用域。就目前ES6之后块级作用域由于 let 和 const 的提出而支持了。尽量少用var声明,尽可能的减少对全局作用域的污染。

我们直接定义的变量,通常是因为只想在 函数内部的上下文中使用 i,而忽略了使用var声明会被绑定在外部作用域(函数或全局)中的事实。 这就是块作用域的用处。变量的声明应该距离使用的地方越近越好,并最大限度地本地化。

块作用域是一个用来对之前的最小授权原则进行扩展的工具,将代码从在函数中隐藏信息 扩展为在块中隐藏信息。

with

我们在第 2 章讨论过 with 关键字。它不仅是一个难于理解的结构,同时也是块作用域的一 个例子(块作用域的一种形式),用 with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

try/catch

非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch 的 catch 分句会创建一个块作用域,其中声明的变量仅在 catch 内部有效。从 ES3 开始,try/catch 结构在 catch 分句中具有块作用域。

let

用 let 将变量附加在一个已经存在的块作用域上的行为是隐式的。 使用 let 进行的声明不会在块作用域中进行提升。声明的代码被运行之前,声明并不“存在”。

{
    console.log( bar ); // ReferenceError: bar is not defined
    let bar = 2;
}
  1. 垃圾收集
// 示例
function process(data) {
    // 在这里做点有趣的事情
}
var someReallyBigData = { .. };
process( someReallyBigData );
var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt) {
    console.log("button clicked");
}, /*capturingPhase=*/false );

click 函数的点击回调并不需要 someReallyBigData 变量。理论上这意味着当 process(..) 执行后,在内存中占用大量空间的数据结构就可以被垃圾回收了。但是,由于 click 函数形成 了一个覆盖整个作用域的闭包,JavaScript 引擎极有可能依然保存着这个结构(取决于具体 实现)。 块作用域可以打消这种顾虑,可以让引擎清楚地知道没有必要继续保存 someReallyBigData 了:

function process(data) {
    // 在这里做点有趣的事情
}

// 在这个块中定义的内容可以销毁了! 
{
    let someReallyBigData = { .. }; 
    process( someReallyBigData );
}

var btn = document.getElementById( "my_button" );
btn.addEventListener( "click", function click(evt){
    console.log("button clicked");
}, /*capturingPhase=*/false );
  1. let循环
for (let i=0; i<10; i++) { 
  console.log( i );//0 1 2 3 4 5 6 7 8 9
}
console.log( i ); // ReferenceError

for 循环头部的 let 不仅将 i 绑定到了 for 循环的块中,事实上它将其重新绑定到了循环 的每一个迭代中,确保使用上一个循环迭代结束时的值重新进行赋值。

const

除了 let 以外,ES6 还引入了 const,同样可以用来创建块作用域变量,但其值是固定的 (常量)。之后任何试图修改值的操作都会引起错误。

var foo = true;
if (foo) {
    var a = 2;
    const b = 3; // 包含在 if 中的块作用域常量
    a = 3; // 正常!
    b = 4; // 错误!
}

console.log( a ); // 3
console.log( b ); // ReferenceError!

第4章提升

console.log( a ); // underfind
var a = 2;

在这段代码中,结果会输出 underfind 而不是错误,其原因为何呢

引擎会在解释 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。第 2 章中展示了这个机制,也正是词法作用域的核心内容。

因此,正确的思考思路是,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

JavaScript 实际上会将以上代码看成两个 声明:var a;和a = 2;。第一个定义声明是在编译阶段进行的。第二个赋值声明会被留在 原地等待执行阶段。

// 实际执行过程
var a;// 先声明变量a,a=underfind;// 再进行默认值赋值
console.log( a ); // underfind
a = 2;

var声明会被提升,let、const则不会被提升;函数声明会被提升,但是函数表达式却不会被提升。

函数声明和变量声明都会被提升。但是一个值得注意的细节(这个细节可以出现在有多个“重复”声明的代码中)是函数会首先被提升,然后才是变量。

foo(); // 1
var foo;
function foo() { 
  console.log( 1 );
}
foo = function() { 
  console.log( 2 );
};
foo();//2

尽管重复的 var 声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面的。

foo(); // 3 重复声明会被覆盖掉哦
var foo;
function foo() { 
  console.log( 1 );
}
foo = function() { 
  console.log( 2 );
};
function foo() { 
  console.log( 3 );
}
foo();

此前版本,这个过程不会像下面的代码暗示的那样可以被条件判断所控制,但是最新版本的话,则可以控制,在if判断外层调用foo则会报ReferenceError错误

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

最新版本测试结果(无论是放在顶部还是底部执行都会报ReferenceError):

foo(); // ReferenceError: foo is not defined
var a = true; 
if (a) {
  function foo() { 
    console.log("a"); 
  }
} else {
  function foo() { 
    console.log("b"); 
  }
}
//foo(); // ReferenceError: foo is not defined

无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。 可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的 最顶端,这个过程被称为提升。

声明本身会被提升,而包括函数表达式的赋值在内的赋值操作并不会提升。 要注意避免重复声明,特别是当普通的 var 声明和函数声明混合在一起的时候,否则会引起很多危险的问题!

第5章作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用 域之外执行。基于词法作用域的查找规则,函数可以访问外部作用域中的变量。

最常见的实现模块模式的方法通常被称为模块暴露,

模块模式需要具备两个必要条件。
  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。

  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。

一个具有函数属性的对象本身并不是真正的模块。从方便观察的角度看,一个从函数调用 所返回的,只有数据属性而没有闭包函数的对象并不是真正的模块。

模块也是普通的函数,因此可以接受参数。模块模式另一个简单但强大的变化用法是,命名将要作为公共 API 返回的对象。

通过在模块实例的内部保留对公共 API 对象的内部引用,可以从内部对模块实例进行修 改,包括添加或删除方法和属性,以及修改它们的值。

模块模式的两个 特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。

换句话说,模块就是模块,即使在它们外层加上一个友好的包装工具也不会发生任何变化。

基于函数的模块并不是一个能被稳定识别的模式(编译器无法识别),它们 的 API 语义只有在运行时才会被考虑进来。因此可以在运行时修改一个模块 的 API(参考前面关于公共 API 的讨论)。

相比之下,ES6 模块 API 更加稳定(API 不会在运行时改变)。由于编辑器知 道这一点,因此可以在(的确也这样做了)编译期检查对导入模块的 API 成 员的引用是否真实存在。如果 API 引用并不存在,编译器会在运行时抛出一 个或多个“早期”错误,而不会像往常一样在运行期采用动态的解决方案。

ES6 的模块没有“行内”格式,必须被定义在独立的文件中(一个文件一个模块)。浏览 器或引擎有一个默认的“模块加载器”(可以被重载,但这远超出了我们的讨论范围)可 以在导入模块时异步地加载模块文件。