你不知道的JavaScript:作用域和闭包

108 阅读13分钟

作用域

概念:是一套存储和查询变量的规则,用于确定在何处以及如何查找变量。

LHS和RHS(Right-hand side)

概念上理解为赋值操作的目标是谁(lhs)谁是赋值操作的源头(rhs)

异常的处理: 进行rhs查找时,如果失败则会抛出ReferenceError异常,而进行rhs时会分为两种情况,在非严格模式下找不到时会在全局作用域创建一个相应的变量,不会抛出错误;而严格模式下一样会抛出ReferenceError异常。

引用异常和类型错误区别:引用异常是LHS或者RHS查询失败时的异常,而类型异常(TypeError)则是对某个变量的值进行不合理操作时的报错:比如吧简单数据类型当成函数调用,或者从null中去获取某个属性等。

作用域嵌套

当前作用域没有找到相应变量时会一直向上级作用域查找,直到全局作用域为止

词法作用域

作用域共有两种最主要的模型:词法作用域以及动态作用域;其中词法作用域被大部分语言所采用(包括JavaScript),而动态作用域只有少数语言还在使用(Bash脚本以及per中的一些模式)

词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写 代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域 不变(大部分情况下是这样的)

全局变量会自动成为全局对象(比如浏览器中的 window 对象)的属性,因此

可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引

用来对其进行访问。 比如:window.a

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量

如果被遮蔽了,无论如何都无法被访问到

欺骗词法

如果词法作用域完全由写代码期间函数所声明的位置来定义,怎样才能在运行时来“修

改”(也可以说欺骗)词法作用域呢?

eval

默认情况下,如果 eval(..) 中所执行的代码包含有一个或多个声明(无论是变量还是函

数),就会对 eval(..) 所处的词法作用域进行修改。技术上,通过一些技巧可以间接调用 eval(..) 来使其运行在全局作用域中,并对全局作用域进行 修改。但无论何种情况,eval(..) 都可以在运行期修改书写期的词法作用域。

function foo(str, a) {
  eval( str ); // 欺骗!eval的执行改变了foo的词法作用域 声明了一个b变量
  console.log( a, b ); 
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

注意: 在严格模式的程序中,eval(..) 在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。

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

new Function(..) 函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转 化为动态生成的函数(前面的参数是这个新生成的函数的形参)。

with

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

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

var obj = { a: 1, b: 2, c: 3 };// 单调乏味的重复 "obj" 
obj.a = 2; obj.b = 3; obj.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 被泄漏到全局作用域上了!

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

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

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

不推荐使用这两种方式改变词法作用域: 会被严格模式所影响(限 制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。

函数作用域和块作用域

函数作用域

隐藏内部实现

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

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

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

规避冲突

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

全局命名空间

变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它 们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。

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

模块管理

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

显而易见,这些工具并没有能够违反词法作用域规则的“神奇”功能。它们只是利用作用 域的规则强制所有标识符都不能注入到共享作用域中,而是保持在私有、无冲突的作用域 中,这样可以有效规避掉所有的意外冲突。

立即执行函数表达式

虽然上述通过函数声明调用隐藏的技术可以解决一些问题,但是它并不理想,因为会导致一些额外的问题。首先, 必须声明一个具名函数,意味着这个函数名称本身“污染”了所在作用域。其次,必须显式地通过函数名调用这个函数才能运行其 中的代码。

如果函数不需要函数名(或者至少函数名可以不污染所在作用域),并且能够自动运行, 这将会更加理想。

幸好,JavaScript 提供了能够同时解决这两个问题的方案即立即执行函数表达式(function foo() {})()

块作用域

尽管函数作用域是最常见的作用域单元,当然也是现行大多数 JavaScript 中最普遍的设计 方法,但其他类型的作用域单元也是存在的,并且通过使用其他类型的作用域单元甚至可 以实现维护起来更加优秀、简洁的代码。

with

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

try/catch

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

let

let 关键字可以将变量绑定到所在的任意作用域中(通常是 { .. } 内部)。换句话说,let 为其声明的变量隐式地了所在的块作用域。

垃圾收集

另一个块作用域非常有用的原因和闭包及回收内存垃圾的回收机制相关。(后续详细讲解)

循环

一个 let 可以发挥优势的典型例子就是之前讨论的 for 循环。

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

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

const

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

提升

引擎会在执行 JavaScript 代码之前首先对其进行编译。编译阶段中的一部分工作就是找到所有的声明,并用合适的作用域将它们关联起来。因此,包括变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

函数优先

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

作用域闭包

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行(严格意义上的闭包)

下面我们来看一段代码,清晰地展示了闭包:

function foo() {
	var a = 2;
	function bar() { console.log( a ); }
	return bar;
}
var baz = foo();
baz(); // 2 —— 朋友,这就是闭包的效果。

在 foo() 执行后,其返回值(也就是内部的 bar() 函数)赋值给变量 baz 并调用 baz(),实 际上只是通过不同的标识符引用调用了内部的函数 bar()。

bar() 显然可以被正常执行。但是在这个例子中,它在自己定义的词法作用域以外的地方 执行。

在 foo() 执行后,通常会期待 foo() 的整个内部作用域都被销毁,因为我们知道引擎有垃圾回收器用来释放不再使用的内存空间。由于看上去 foo() 的内容不会再被使用,所以很自然地会考虑对其进行回收。

而闭包的“神奇”之处正是可以阻止这件事情的发生。事实上内部作用域依然存在,因此没有被回收。谁在使用这个内部作用域?原来是 bar() 本身在使用。 拜 bar() 所声明的位置所赐,它拥有涵盖 foo() 内部作用域的闭包,使得该作用域能够一 直存活,以供 bar() 在之后任何时间进行引用。 bar() 依然持有对该作用域的引用,而这个引用就叫作闭包。

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

模块

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

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

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

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

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

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

如果要更简单的描述,模块模式需要具备两个必要条件:

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

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