第一部分: 作用域与闭包

你不知道的JS

1. 作用域

1.1 编译原理

Javascript是动态或解释执行语言,但事实上是一门编译语言,但是与传统的编译语言不同,他不是提前编译,编译结果也不能在分布式系统中进行移植,尽管JavaScript引擎进行编译的步骤和传统的编译语言非常相似,在某些环节是非常复杂的

编译型语言: 编译型语言是相对于解释语言存在的,编译型语言首先将源代码编译成机器语言,再由机器运行机器码(二进制); 它在执行之前需要一个专门的编译过程,把程序编译成为机器语言文件,运行时不需要重新编译,直接使用编译的结果就行,执行效率依赖编译器

解释型语言: 解释型语言是相对于编译型语言存在的,源代码不是直接翻译成机器语言,而是先翻译成中间代码,再由解释器对中间代码进行解释运行,程序不需要编译,程序在运行时踩翻译成机器语言,每执行一次都要翻译一次

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

1. 分词/词法分析

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

2. 解析/语法分析

这个过程是将词法单元流(数组)转成一个由元素逐级嵌套组成的代表了程序语法结构的树.这个树被称为抽象树(AST) var a = 2;的抽象语法树中可能会有一个叫作VariableDeclaration的顶级节点,接下来是一个叫Identifier(它的值是a)的子节点,以及一个叫做AssignmentExpression的子节点.AssignmentExpression节点有一个叫做NumericLiteral(它的值是2)的子节点

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1S2TKExT-1582532883299)(C:\Users\a4244\Desktop\img\23.png)]

3. 代码生成

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

Javascript的编译过程不是发生在构建之前,对于JavaScript来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内

引擎: 从头到尾负责整个JavaScript程序的编译及执行过程

编译器: 引擎的好朋友之一,负责语法分析及代码生成等脏活累活

作用域: 引擎的另一位好朋友,负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限

1. 2. 运行var a = 2;

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

  2. 接下来编译器会为引擎生成运行时所需要的代码,这些代码被用来处理a=2这个赋值操作.引擎运行时会首先询问作用域,在当前的作用域中是否存在一个叫做a变量.如果是,引擎会使用这个变量,否则引擎会继续查找改变量

  3. 如果引擎找到最终的a,就会赋值2给它,否则引擎就会举手示意并抛出异常

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

1.3 编译器

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

在上述的例子中,引擎会为变量a进行LHS查询.另外一个查找的类型叫做RHS

  • RHS查询: 就是查找某个变量
    • foo(…),查询foo函数
    • 函数内Console对象的查询
    • 对函数参数进行RHS查询
  • LHS查询: 试图找到变量的容器本身,从而可以对其赋值,把2赋值给参数a时,进行LHS查询
// 对a的引用是RHS引用,因为这里并没有赋予任何值,相应地,需要查找并取得a的值,这样才能找到将值传递给console.log(...)
console.log(a);
// a的引用则是LHS引用,因为我们关心的并不是当前的值,只是想要=2这个赋值操作找到一个目标
a = 2;

LHS: 赋值操作的目标是谁

RHS: 谁是赋值操作的源头

function foo(a) {
    console.log(a); // 2
}
foo(2);

最后一行foo(…)函数的调用需要对foo进行RHS引用,一位着"去找到foo"的值,并把它给我并且意味着foo的值需要被执行,因此它最好真的是一个函数类型的值

代码中隐式的a=2操作可能很容易被忽略掉,这个操作发生在2被当做参数传递给foo(…)函数时,2会被分配给参数a,伪类该参数a(隐式的分配值),需要进行一次LHS查询

这里还有对a进行RHS引用,并且将得到值传递给console.log(…) console.log(…)本身也需要一个引用才能执行因此console对象进行RHS查询,并且检查得到的值中是否有一个叫做log的方法

最后在概念上可以理解为LHS和RHS之间通过对值2进行交互来将其传递进log(…)(通过变量a的RHS查询). 假设在log(…)函数的原生实现中他可以接收参数,在将2赋值给其中第一个(也许叫做args1)参数之前,这个参数需要进行LHS引用查询

1.4 对话

  • 引擎: 我说作用域,我需要为foo进行RHS引用.你见过他吗

  • 作用域: 别说,我见过他,编译器那小子刚刚声明过他,它是一个函数,给你

  • 引擎: 根们你太够意思了,好吧我来执行下foo

  • 引擎: 作用域,我还有个事,我需要为a进行LHS引用,你见过吗

  • 作用域: 这个我见过,编译器最近把她声明为foo的一个形式参数了,你拿去吧

  • 引擎: 好的,现在我就把2赋值给a , 还有console进行RHS引用, 你见过他吗?

  • 作用域: 这个巧了,我就是干这个的,这个我也有,console是一个内置对象,给你,

  • 引擎: 等下,我看看这里面有没有log(…) , 我看见了,是一个函数

  • 引擎: 能帮我找一个对a的RHS引用吗,虽然我记得它但是我想确认下

  • 作用域: 放心吧,这个变量我没有动过,拿走

  • 引擎: 真棒,我来把a的值,也就是2, 传递进log(…)

1.5 测验

function foo(a) { 
    var b = a;
    return a + b; 
} 
var c = foo( 2 );

引擎:作用域老哥,问你个事。

作用域:引擎老弟,啥事,尽管说。

引擎:我需要对c进行LHS引用,你见过它吗?

作用域:哦,这事啊,见过,刚刚编译器老弟刚声明了它,他是一个变量,拿去吧。

引擎:谢谢老哥,那我给他赋值,还有老哥,我要对foo进行RHS引用。

作用域:这个也有的,我找找,喏,在这,是一个函数,给你。

引擎:好嘞!太感谢了老哥,那我来执行下foo。

作用域:那都不是事!看看还有吗?我在帮你看看!

引擎:扎心了老铁!还有还有,我要对a进行LHS引用,帮我找找有它吗?

作用域:稍等啊,有有,刚刚编译器把他声明为foo的形式参数了,给你。

引擎:好的,老哥我要以身相许!还有我要对b进行LHS引用,你见过吗?

作用域:、、、只要别以身相许都好说,b在、、、哦,在这,它是foo函数里的,一个变量,给你 给你!

引擎:么么哒老哥!那这个a呢,我要对它进行RHS引用,虽然有点印象,但是还是确认下。

作用域:是的没错,还是那个a没有动过,放心拿走用吧!

引擎:好的好的!老哥,最后一个问题,帮帮老弟,帮我我就是你的,我要分别对a b 进行RHS引 用,在帮老弟看看确认下,拜托啦,萌萌哒!

作用域:(꒪Д꒪)ノ,有有有,都没变过,你放心用吧!

引擎:都不知道怎么谢老哥了,今晚有空不,今晚、、、诶,别走啊老哥,明晚也行、、、

1.6 作用域嵌套

作用域是根据名称查找变量的一套规则

function foo(a){
    console.log(a + b);
}
var b = 2;
foo(2);
/*
	对话:
		引擎: foo的作用域兄弟,你见过b吗? 我需要对它进行RHS引用
		foo函数作用域: 听都没有听过,我帮你找我大哥全局作用域过来
		引擎: 好的,
		全局作用域: 我这里有,给你吧
*/

对b进行RHS引用无法在函数foo内部完成,但可以在上一级作用域中完成

1.7 异常

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

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

第一次对 b 进行 RHS 查询时是无法找到该变量的。也就是说,这是一个“未声明”的变 量,因为在任何相关的作用域中都无法找到它。

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

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

ES5 中引入了“严格模式”。同正常模式,或者说宽松 / 懒惰模式相比,严格模式在行为上有很多不同。其中一个不同的行为是严格模式禁止自动或隐式地创建全局变量。因此,在 严格模式中 LHS 查询失败时,并不会创建并返回一个全局变量,引擎会抛出同 RHS 查询 失败时类似的 ReferenceError 异常。 接下来,如果 RHS 查询找到了一个变量,但是你尝试对这个变量的值进行不合理的操作, 比如试图对一个非函数类型的值进行函数调用,或着引用 null 或 undefined 类型的值中的 属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError。 ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对 结果的操作是非法或不合理的。

2. 作用域

2.1 作用域

作用域共有两种主要的工作模型: 第一种是被大多数编程语言所采用的词法作用域.另一种是动态作用域

2.2 词法阶段

词法作用域就是定义在词法阶段的作用域,换句话,词法作用域是由你在写代码时将变量和块作用域写在哪决定的,因此词法分析器处理代码时会保持作用域不变

function foo(a) {
    var b = a *2;
    function bar(c){
        console.log(a,b,c);
    }
    bar(b*3);
}
foo(2); // 2, 4, 12

在这里插入图片描述
①全局全局作用域,其中只有一个标识符: foo

②foo所创建的作用域,其中有三个标识符: a, bar,b

③bar所创建的作用域,其中只有一个标识符: c

在上一个代码片段中,引擎执行 console.log(…) 声明,并查找 a、b 和 c 三个变量的引 用。它首先从最内部的作用域,也就是 bar(…) 函数的作用域气泡开始查找。引擎无法在 这里找到 a,因此会去上一级到所嵌套的 foo(…) 的作用域中继续查找。在这里找到了 a, 因此引擎使用了这个引用。对 b 来讲也是一样的。而对 c 来说,引擎在 bar(…) 中就找到 了它。

2.3 欺骗词法

这种方法会导致性能下降

2.3.1 eval

Javascript中的eval()函数可以接受一个字符串为参数

使用eval的方法是通过代码欺骗和假装成书写时(也就是词法期)代码就在那,来实现修改词法作用域环境

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

function foo(str, a) {
    eval(str); // 这里欺骗
    console.log(a,b);
}
var b = 2;
foo("var b = 3;" , 1)//  有点像php中的function foo("var b = 3", 1); 默认值

eval(…) 调用中的 “var b = 3;” 这段代码会被当作本来就在那里一样来处理。由于那段代 码声明了一个新的变量 b,因此它对已经存在的 foo(…) 的词法作用域进行了修改。事实 上,和前面提到的原理一样,这段代码实际上在 foo(…) 内部创建了一个变量 b,并遮蔽 了外部(全局)作用域中的同名变量。

当 console.log(…) 被执行时,会在 foo(…) 的内部同时找到 a 和 b,但是永远也无法找到 外部的 b。因此会输出“1, 3”而不是正常情况下会输出的“1, 2”。

默认情况下,如果 eval(…) 中所执行的代码包含有一个或多个声明(无论是变量还是函 数),就会对 eval(…) 所处的词法作用域进行修改

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

2.3.2 with

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

var obj = {
    a: 1,
    b: 2,
    c: 3
}
// 单词乏味重复
obj.a = 2;
obj.b = 3;
obk.c = 4;
// 简单的快捷方式
with(obj) {
    a= 3;
    b =4;
    c= 5;
}
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被泄漏到全局作用域中

这个例子中创建了 o1 和 o2 两个对象。其中一个具有 a 属性,另外一个没有。foo(…) 函 数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {…}。 在 with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个 LHS 引用,并将 2 赋值给它。

当我们将 o1 传递进去,a=2 赋值操作找到了 o1.a 并将 2 赋值给它,这在后面的 console. log(o1.a) 中可以体现。而当 o2 传递进去,o2 并没有 a 属性,因此不会创建这个属性,o2.a 保持 undefined。但是可以注意到一个奇怪的副作用,实际上 a = 2 赋值操作创建了一个全局的变量 a。这 是怎么回事?

with 可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对 象的属性也会被处理为定义在这个作用域中的词法标识符。但是这个块内部正常的 var 声明并不会被限制在这个块的作用域中,而是被添加到 with 所处的函数作 用域中。

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

当我们传递 o1 给 with 时,with 所声明的作用域是 o1,而这个作用域中含 有一个同 o1.a 属性相符的标识符。但当我们将 o2 作为作用域时,其中并没有 a 标识符, 因此进行了正常的 LHS 标识符查找

o2 的作用域、foo(…) 的作用域和全局作用域中都没有找到标识符 a,因此当 a=2 执行 时,自动创建了一个全局变量(因为是非严格模式)。

2.4 动态作用域

动态作用域是 JavaScript 另一个重要机制 this 的表亲

词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规 则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设你没有使用 eval() 或 with)。

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

词法作用域让 foo() 中的 a 通过 RHS 引用到了全局作用域中的 a,因此会输出 2。

而动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调 用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。 因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出 3。

function foo(){
    console.log(a); // 3,而不是2
    // 实际foo外层是全局作用域,所以foo作用域链向外找就是2
}
function bar() {
    var a = 3;
    foo();
}
var a = 2;
bar();

因为当 foo() 无法找到 a 的变量引用时,会顺着调用栈在调用 foo() 的地 方查找 a,而不是在嵌套的词法作用域链中向上查找。由于 foo() 是在 bar() 中调用的, 引擎会检查 bar() 的作用域,并在其中找到值为 3 的变量 a。

但这其实是因为你可能只写过基于词法作用域的代码(或者至少以词法作用域为基础进行 了深入的思考),因此对动态作用域感到陌生。如果你只用基于动态作用域的语言写过代 码,就会觉得这是很自然的,而词法作用域看上去才怪怪的。 需要明确的是,事实上 JavaScript 并不具有动态作用域。它只有词法作用域,简单明了。 但是 this 机制某种程度上很像动态作用域。 主要区别:词法作用域是在写代码或者说定义时确定的,而动态作用域是在运行时确定 的。(this 也是!)词法作用域关注函数在何处声明,而动态作用域关注函数从何处调用。 最后,this 关注函数如何调用,这就表明了 this 机制和动态作用域之间的关系多么紧密。 如果想了解更多关于 this 的详细内容,需要理解 this 和对象原型。

3. 函数作用域与块作用域

3.1 隐藏内部实现

对函数的传统认知就是先声明一个函数,然后再向里面添加代码。但反过来想也可以带来 一些启示:从所写的代码中挑选出一个任意的片段,然后用函数声明对它进行包装,实际 上就是把这些代码“隐藏”起来了。

实际的结果就是在这个代码片段的周围创建了一个作用域气泡,也就是说这段代码中的任何声明(变量或函数)都将绑定在这个新创建的包装函数的作用域中,而不是先前所在的 作用域中。换句话说,可以把变量和函数包裹在一个函数的作用域中,然后用这个作用域 来“隐藏”它们。

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

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

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

在这个代码片段中,变量 b 和函数 doSomethingElse(…) 应该是 doSomething(…) 内部具体 实现的“私有”内容。给予外部作用域对 b 和 doSomethingElse(…) 的“访问权限”不仅 没有必要,而且可能是“危险”的,因为它们可能被有意或无意地以非预期的方式使用, 从而导致超出了 doSomething(…) 的适用条件。更“合理”的设计会将这些私有的具体内 容隐藏在 doSomething(…) 内部

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

现在,b 和 doSomethingElse(…) 都无法从外部被访问,而只能被 doSomething(…) 所控制。 功能性和最终效果都没有受影响,但是设计上将具体内容私有化了,设计良好的软件都会 依此进行实现。

3.2 规避冲突

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

function foo() {
    function bar(a) {
        i = 3; // 修改for循环所属作用域中的i
        console.log(a + i);// 3 5 
    }
    for(var i = 0; i < 10; i++) {
        bar(i * 2); // 无限循环: 0 2
    }
}
foo();

bar(…) 内部的赋值表达式 i = 3 意外地覆盖了声明在 foo(…) 内部 for 循环中的 i。在这 个例子中将会导致无限循环,因为 i 被固定设置为 3,永远满足小于 10 这个条件。这也说明函数表达式的参数是进入函数再计算的,如果把bar函数里面的i声明下var,这样指向的内存就不一样,就不会影响for循环中的i

3.3 全局命名空间

变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它 们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。 这些库通常会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象 被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属 性,而不是将自己的标识符暴漏在顶级的词法作用域中。

var MyReallyCoolLobrary = {
    awseome: "stuff",
    doSomething: function() {
        //...
    }
    doAnotherThing: function() {
        // ...
    }
}

3.4 模块管理

另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来 使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器 的机制将库的标识符显式地导入到另外一个特定的作用域中。 显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用 域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域 中,这样可以有效规避掉所有的意外冲突。

3.5 函数作用域

我们已经知道,在任意代码片段外部添加包装函数,可以将内部的变量和函数定义“隐 藏”起来,外部作用域无法访问包装函数内部的任何内容。

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

虽然这种技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先, 必须声明一个具名函数 foo(),意味着 foo 这个名称本身“污染”了所在作用域(在这个 例子中是全局作用域)。其次,必须显式地通过函数名(foo())调用这个函数才能运行其 中的代码。如果函数不需要函数名,并且能够自动运行,这将会更加理想

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

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

比较一下前面两个代码片段。第一个片段中 foo 被绑定在所在作用域中,可以直接通过 foo() 来调用它。第二个片段中 foo 被绑定在函数表达式自身的函数中而不是所在作用域中。 换句话说,(function foo(){ … }) 作为函数表达式意味着 foo 只能在 … 所代表的位置中 被访问,外部作用域则不行。foo 变量名被隐藏在自身中意味着不会非必要地污染外部作 用域。

3.6 匿名和具名

对于函数表达式最熟悉的就是回调参数

setTimeout( function() {
    console.log("I want to");
},1000)

这叫作匿名函数表达式,因为 function()… 没有名称标识符。函数表达式可以是匿名的, 而函数声明则不可以省略函数名——在 JavaScript 的语法中这是非法的。

匿名函数缺点

  1. 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。

  2. 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用, 比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑 自身。

  3. 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让 代码不言自明。

给函数命名可以解决上面的缺点

setTimeout( function timeoutHandler() {
    console.log("I want to");
},1000)

3.7 立即执行函数

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

由于函数被包含在一对 ( ) 括号内部,因此成为了一个表达式,通过在末尾加上另外一个 ( ) 可以立即执行这个函数,比如 (function foo(){ … })()。第一个 ( ) 将函数变成表 达式,第二个 ( ) 执行了这个函数。 这种模式很常见,几年前社区给它规定了一个术语:IIFE,代表立即执行函数表达式 (Immediately Invoked Function Expression); 函数名对 IIFE 当然不是必须的,IIFE 最常见的用法是使用一个匿名函数表达式。虽然使 用具名函数的 IIFE 并不常见,但它具有上述匿名函数表达式的所有优势,因此也是一个值 得推广的实践。

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:

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

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

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

3.8 块级作用域

  • with(es3)
  • try/catch(es3)
  • let
  • const
3.8.1 es6: let

let进行的声明不会再块级作用域中进行替身.声明的代码被运行之前,声明并不存在

var foo = true;
if(foo) {
    let bar = foo * 2;
    bar = something(bar);
    console.log(bar);
}
console.log(bar); // 报错ReferenceError
3.8.2 垃圾回收

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。这里简要说明一 下,而内部的实现原理,也就是闭包的机制

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 );

为变量显式声明块作用域,并对变量进行本地绑定是非常有用的工具,可以把它添加到你 的代码工具箱中了。

3.8.3 es6: const

const: 值是固定不变的,引用类型的地址不变,里面的东西可以变,比如数组里面添加元素;如果强行修改地址会报错

3.9 块级作用域的替代方案

想要在es6之前使用块级作用域怎么办

{
    let a = 2;
    console.log(a);
}
console.log(a); // 报错: ReferenceError

es6之前使用块级作用域

try{
	throw 2; // 抛出的错误是一个值: 2
}catch(a) {
    console.log(a); // 2
}
console.log(a); // ReferenceError

4. 提升

JS并不是在执行时由上到下一行行执行的,但是实际上并不是完全正确,有一个特殊情况会导致错误

a = 2;
var a;
console.log(a); // 2
console.log(b); // undefined
var b = 2;

编译阶段中的异步分工作就是找到所有的声明,并适合的作用域将他们关联起来,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理,我们看见上述的var a = 2;这个声明时,JS实际上会将其看成两个声明:var a;a = 2;.第一个定义声明实在编译阶段进行的,第二个赋值声明会被留在原地等待执行阶段

上面代码会以这种形式处理

var a;
var b;
a = 2;
console.log(a); // 2
console.log(b); // undefined
b = 2;

这个过程就好像变量和函数声明从它们在代码中出现的位置被"移动"到最前面,这个过程叫做变量的提升,只有声明本身会被提升,而肤质或其他运行逻辑会留在原地

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

foo函数的声明(这个例子还包含实际函数的隐含值)被提升,因此第一行中的调用可以正常执行,另外注意每个作用域都会进行提升操作,因此上面代码会被理解为下面形式

function foo() { // 函数声明被提升
    var a; //变量声明被提升
    console.log(a); // undefined
    a = 2;
}
foo();

注意函数声明会被提升,但是函数表达式不会被提升

foo();
var foo = function bar() {
    // ....
}
// 上面代码实际是这样运行的

var foo; // 变量提升
foo(); // 不是ReferenceError,而是TypeError
// 因为 foo对undefined进行函数调用从而导致非法操作
foo = function bar() {
    // ....
}

即使是具有函数表达式,名称标识符在赋值之前也无法在所在作用域中使用

foo(); // TypeError
bar(); // ReferenceError
var foo = function bar() {
    // ...
}
// 上述代码经过提升,可以理解为
var foo();
foo(); // TypeError
bar(); // ReferenceError
foo = function bar() {
    // ...
}

4.1 函数优先

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

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

上面代码片段会被引擎理解为

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

注意, var foo尽管出现在function foo()… 的声明之前,但它是重复的声明(因此被忽略),因为函数声明会被提升到普通变量之前; 但是尽管重复的var声明会被忽略掉,但出现在后面的函数声明还是可以覆盖前面

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

上面重复定义不推荐,是非常槽糕的,二姐经常会导致各种问题

5. 作用域闭包

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

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

基于词法作用域的查找规则,函数bar()可以访问外部作用域中的变量啊(RHS引用查询), bar()对a的引用的方法是词法作用域的查询规则,而这些规则只是闭包的一部分

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

函数bar()的词法作用域能够访问foo()内部作用域,然后我们将bar()函数本身当做一个类型进行传值,我们将bar所引用的函数对象本身当做返回值

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

在foo()执行之后,其返回值(也就是内部的bar()函数)赋值给变量baz并调用baz(),实际上只是通过不同的表示符引用调用了内部的函数bar();bar()显然可以被正常执行.但是在这个例子中,它在自己定义的词法作用域意外的地方执行在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃 圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很 自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此 没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。 拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一 直存活,以供 bar() 在之后任何时间进行引用。 bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。 因此,在几微秒之后变量 baz 被实际调用(调用内部函数 bar),不出意料它可以访问定义时的词法作用域,因此它也可以如预期般访问变量

这个函数在定义时的词法作用域以外的地方被调用。闭包使得函数可以继续访问定义时的 词法作用域。 当然,无论使用何种方式对函数类型的值进行传递,当函数在别处被调用时都可以观察到 闭包。

function foo() {
    var a = 2;
    function baz() {
        console.log(a); // 2
    }
    bar(baz)
}
function bar(fn) {
    fn(); // 这里就是闭包
}

把内部函数baz传递给bar,当调用这个内部函数时(fn),它涵盖的foo()内部作用域的闭包就可以观察到了,因为它能够访问a, 传递函数当然也可以是间接的

var fn;
function foo() {
    var a = 2;
    function baz() {
        console.log(a);
    }
    fn = baz; // 将baz分配给全局变量
}
function bar() {
    fn(); // 这里就是闭包
} 
foo();
bar(); // 2

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

5.1 理解闭包

function wait(message) {
    setTimeout(function timer() {
        console.log(message);
    },1000)
}
wait("Hello, closure");

将内部函数(timer)传递给setTimeout(…).timer具有涵盖wait(…)作用域的闭包,因此还保有对变量message的引用,wait(…)执行1000毫秒后,他的内部作用域并不会消失,timer函数依然保有wait(…)作用域的闭包

深入到引擎的内部原理中,内置的工具函数 setTimeout(…) 持有对一个参数的引用,这个 参数也许叫作 fn 或者 func,或者其他类似的名字。引擎会调用这个函数,在例子中就是 内部的 timer 函数,而词法作用域在这个过程中保持完整。 这就是闭包。

思考如下代码

function setupBot(name, selector) {
    $(selector).click(function activator() {
        console.log('Activating: ' + name);
    })
}
setupBot( "Closure Bot 1", "#bot_1" ); 
setupBot( "Closure Bot 2", "#bot_2" );

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

5.2 循环与闭包

for循环是最常见的例子

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

解析每秒输出6,一共五次,延迟函数的回调会在循环结束时才执行。事实上, 当定时器运行时即使每个迭代中执行的是 setTimeout(…, 0),所有的回调函数依然是在循 环结束后才会被执行,因此会每次输出一个 6 出来。

我们试图假设循环中的每个迭代在运行时都会给自己“捕获”一个 i 的副本。但是 根据作用域的工作原理,实际情况是尽管循环中的五个函数是在各个迭代中分别定义的, 但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个 i。

当然所有函数共享一个 i 的引用。循环结构让我们误以为背后还有更复杂的 机制在起作用,但实际上没有。如果将延迟函数的回调重复定义五次,完全不使用循环, 那它同这段代码是完全等价的。

for(var i =1; i < 5; i++) {
    (function(){
        var j = i
        setTimeout(function timer() {
            console.log(j);
        },i*1000)
    })();
}
// 代码改进,如下
for(var i =1; i < 5; i++) {
    (function(j){
        setTimeout(function timer() {
            console.log(j);
        },j*1000)
    })(i);
}

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的 作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问

用let解决

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

5.3 模块

function foo() {
    var something = "cool";
    var another = [1,2,3];
    function doSomething() {
        console.log(something);
    }
    function doAnother() {
        console.log(another.join("!"));
    }
}

这上面不是闭包,看下面

function CoolModule() {
    var something = "cool";
    var another = [1,2,3];
    function doSomething() {
        console.log(something);
    }
    function doAnother() {
        console.log(another.join("!"));
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

这个模式在JS中被称为模块.最常见的实现模块模式的方法通常被称为模块暴露,这里展示的是其变体

首先,CoolModule() 只是一个函数,必须要通过调用它来创建一个模块实例。如果不执行 外部函数,内部作用域和闭包都无法被创建。

其次,CoolModule() 返回一个用对象字面量语法 { key: value, … } 来表示的对象。这 个返回的对象中含有对内部函数而不是内部数据变量的引用。我们保持内部数据变量是隐 藏且私有的状态。可以将这个对象类型的返回值看作本质上是模块的公共 API。

这个对象类型的返回值最终被赋值给外部的变量 foo,然后就可以通过它来访问 API 中的 属性方法,比如 foo.doSomething()。

doSomething() 和 doAnother() 函数具有涵盖模块实例内部作用域的闭包(通过调用 CoolModule() 实现)。当通过返回一个含有属性引用的对象的方式来将函数传递到词法作 用域外部时,我们已经创造了可以观察和实践闭包的条件。

  1. 必须有外部的封闭函数,该函数必须至少被调用一次(每次调用都会创建一个新的模块 实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并 且可以访问或者修改私有的状态。

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

上面的CoolModule()的独立的模块创建器,可以被调用多次,每次调用都会创造一个新的模块实例.当然只需要一个实例时,可以对这个模块进行简单的改进来实现单例模式

var foo = (function CoolModule() {
    var something = "cool";
    var another = [1,2,3];
    function doSomething() {
        console.log(something);
    }
    function doAnother() {
        console.log(another.join("!"));
    }
    return {
        doSomething: doSomething,
        doAnother: doAnother
    }
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

模块也是普通函数,因此可以接受

function CollModule(id) {
    function identify() {
        console.log(id);
    }
    return{
        identify: identify
    }
}
var foo1 = CoolModule("foo 1");
var foo2 = CoolModule("foo 2");
foo1.identify(); // "foo 1"
foo2.identify(); // "foo 2"

模块模式另一个简单但强大的变化用法是,命名将要作为公共API返回对象

var foo = (function CoolModule(id) { 
    function change() { 
        // 修改公共 API 
        publicAPI.identify = identify2; 
    }
    function identify1() { 
        console.log( id ); 
    } 
    function identify2() { 
        console.log( id.toUpperCase() ); 
    } 
    var publicAPI = { 
        change: change, 
        identify: identify1 
    }; 
    return publicAPI; 
})( "foo module" ); 
foo.identify(); // foo module 
foo.change(); 
foo.identify(); // FOO MODULE

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

5.4 现代的模块机制

大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。这里 并不会研究某个具体的库,为了宏观了解我会简单地介绍一些核心概念

var MyModules = (function Manager() { 
    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 
    }; 
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装 函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管 理的模块列表中

定义模块

MyModules.define( "bar", [], function() { 
    function hello(who) { 
        return "Let me introduce: " + who; 
    } 
    return { hello: hello }; 
} ); 
MyModules.define( "foo", ["bar"], function(bar) { 
    var hungry = "hippo"; 
    function awesome() { 
        console.log( bar.hello( hungry ).toUpperCase() );
    } 
    return { awesome: awesome }; 
} ); 
var bar = MyModules.get( "bar" ); 
var foo = MyModules.get( "foo" ); 
console.log( bar.hello( "hippo" ) ); // Let me introduce: hippo 
foo.awesome(); // LET ME INTRODUCE: HIPPO

“foo” 和 “bar” 模块都是通过一个返回公共 API 的函数来定义的。“foo” 甚至接受 “bar” 的 示例作为依赖参数,并能相应地使用它。

为我们自己着想,应该多花一点时间来研究这些示例代码并完全理解闭包的作用吧。最重 要的是要理解模块管理器没有任何特殊的“魔力”。它们符合前面列出的模块模式的两个 特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。

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

5.5 未来的模块机制

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

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

// bar.js
function hello(who) {
    return "Let me introduce: " + who;
}

// foo.js
// 仅从 "bar" 模块导入 hello()
import hello from "bar"; 
var hungry = "hippo"; 
function awesome() { 
    console.log( hello( hungry ).toUpperCase() ); 
} 
export awesome;

// baz.js
// 导入完整的 "foo" 和 "bar" 模块
module foo from "foo"; 
module bar from "bar"; 
console.log( bar.hello( "rhino" ) ); 
// Let me introduce: rhino 
foo.awesome(); // LET ME INTRODUCE: HIPPO

import 可以将一个模块中的一个或多个 API 导入到当前作用域中,并分别绑定在一个变量 上(在我们的例子里是 hello)。module 会将整个模块的 API 导入并绑定到一个变量上(在 我们的例子里是 foo 和 bar)。export 会将当前模块的一个标识符(变量、函数)导出为公 共 API。这些操作可以在模块定义中根据需要使用任意多次。

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值