# YDKJS-作用域
几乎所有编程语言最基本的功能之一,就是能够储存变量当中的值,并且能在之后对这个值进行访问或修改。
那么这些变量究竟储存在哪里呢?程序在需要时又如何找到它们呢?这些问题说明需要一套设计良好的规则来存储变量,并且之后可以方便地找到这些变量。这套规则被称为作用域。
但是,究竟在哪里而且怎样设置这些作用域的规则呢?
# 编译原理
JavaScript 通常被称为是一种具有函数优先的轻量级解释型语言,而事实上它是一种即时编译型的编程语言。与传统的编译语言不同,它不是提前编译的,编译结果也不能在分布式系统中进行移植。
在传统编译语言的流程中,程序中的一段源代码在执行之前会经历三个步骤(统称为编译):
- 分词/词法分析:将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。
- 解析/语法分析:将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树(抽象语法树, AST)。
- 代码生成: AST 转换为可执行代码的过程被称为代码生成。
对于 JavaScript 来说,大部分情况下编译发生在代码执行前的几微秒(甚至更短)的时间内。在我们所要讨论的作用域背后,JavaScript 引擎用尽了各种办法(比如 JIT,可以延迟编译甚至实施重编译)来保证性能最佳。
简而言之,任何 JavaScript 代码片段在执行前都要进行编译(通常就在执行前)。
# 理解作用域
作用域和上面所说的编译原理有什么关系呢?
作用域负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。
以语句 var a = 2;
为例,上编译器会进行如下处理:
- 遇到
var a
,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为a
。 - 接下来编译器会为引擎生成运行时所需的代码,这些代码被用来处理
a = 2
这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫作a
的变量。如果是,引擎就会使用这个变量;如果否,引擎会继续查找该变量。
查找的过程会由作用域进行协助,当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询。在这里,引擎会为变量 a
进行 LHS 查询。
如果引擎最终找到了 a
变量,就会将 2
赋值给它。否则引擎就会举手示意并抛出一个异常。
# 异常
当一个块或函数嵌套在另一个块或函数中时,就发生了作用域的嵌套。因此,在当前作用域中无法找到某个变量时,引擎就会在外层嵌套的作用域中继续查找,直到找到该变量,或抵达全局作用域。
如果 RHS 查询在所有嵌套的作用域中遍寻不到所需的变量,引擎就会抛出 ReferenceError 异常。
相较之下,当引擎执行 LHS 查询时,如果在顶层(全局作用域)中也无法找到目标变量,(非“严格模式”下)全局作用域中就会创建一个具有该名称的变量,并将其返还给引擎。
严格模式下 LHS 查询失败时,并不会创建并返回一个全局变量,同 RHS 查询失败时类似引擎会抛出 ReferenceError 异常。
# 词法作用域
作用域通常分为词法作用域和动态作用域(比如 Bash 脚本、Perl 中的一些模式等)。
词法作用域就是定义在词法阶段的作用域。换句话说,词法作用域是由你在写代码时将变量和块作用域写在哪里来决定的,因此当词法分析器处理代码时会保持作用域不变(大部分情况下是这样的)。
对此值得一提的是,无论函数在哪里被调用,也无论它如何被调用,它的词法作用域都只由函数被声明时所处的位置决定。
在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”(内部的标识符“遮蔽”了外部的标识符)。
# 欺骗词法
JavaScript 中的 eval(..)
函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码。
var b = 2
function foo(str, a) {
eval(str) // 欺骗
console.log(a, b)
}
foo('var b = 3;', 1) // 1, 3
如示例所示,eval(..)
调用的 "var b = 3;"
这段代码会被当作本来就在那里一样来处理。由于那段代码声明了一个新的变量 b
,根据“遮蔽效应”,它遮蔽了外部(全局)作用域中的同名变量。
技术上可以间接调用 eval(..)
来使其运行在全局作用域中,并对全局作用域进行修改。但无论何种情况,eval(..)
都可以在运行期修改书写期的词法作用域。
另外,在严格模式的程序中,eval(..)
在运行时有其自己的词法作用域,意味着其中的声明无法修改所在的作用域。
JavaScript 中还有其他一些功能效果和 eval(..)
很相似,比如 setTimeout(..)
和 setInterval(..)
的第一个参数可以是字符串,字符串的内容可以被解释为一段动态生成的函数代码。
Function 构造函数的行为也很类似,最后一个参数可以接受代码字符串,并将其转化为动态生成的函数(前面的参数是这个新生成的函数的形参)。但与 eval(..)
不同的是,Function 创建的函数只能在全局作用域中运行。
# with
JavaScript 中另一个难以掌握(并且现在也不推荐使用)的用来欺骗词法作用域的功能是 with
关键字,它通常被当作重复引用同一个对象中的多个属性的快捷方式,可以不需要重复引用对象本身。
var obj = {
a: 1,
b: 2,
c: 3,
}
obj.a = 2
obj.b = 3
obj.c = 4
// 使用 with 的快捷方式
with (obj) {
a = 3
b = 4
c = 5
}
可见 with
可以将一个没有或有多个属性的对象处理为一个完全隔离的词法作用域,因此这个对象的属性也会被处理为定义在这个作用域中的词法标识符。
需要注意的是 with
块可以将一个对象处理为词法作用域,但是这个块内部正常的 var
声明并不会被限制在这个块的作用域中,而是被添加到 with
所处的函数作用域中。
with ({}) {
a = 2 // 由于 a 属性并不存在提供的对象中,所以根据 LHS 查询会自动创建一个全局变量 a,其值为 2(非严格模式)
}
# 性能
欺骗词法作用域会导致性能下降。
JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
如果引擎在代码中发现了 eval(..)
或 with
,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(..)
会接收到什么代码,会产生什么样的影响。
最悲观的情况是如果出现了 eval(..)
或 with
,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。
# 隐藏内部实现
借助作用域的特性,我们可以做许多事情,比如根据最小特权原则,隐藏内部的具体实现:
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(..)
所控制。
# 命名冲突
“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突。
变量冲突的一个典型例子存在于全局作用域中。当程序中加载了多个第三方库时,如果它们没有妥善地将内部私有的函数或变量隐藏起来,就会很容易引发冲突。
许多库都会在全局作用域中声明一个名字足够独特的变量,通常是一个对象。这个对象被用作库的命名空间,所有需要暴露给外界的功能都会成为这个对象(命名空间)的属性,而不是将自己的标识符暴露在顶级的词法作用域中。
var namespace = {
bar: 'Hello world',
fn() {},
}
另外一种避免冲突的办法和现代的模块机制很接近,就是从众多模块管理器中挑选一个来使用。使用这些工具,任何库都无需将标识符加入到全局作用域中,而是通过依赖管理器的机制将库的标识符显式地导入到另外一个特定的作用域中。
# 函数作用域
我们确实可以使用函数将其中的变量和函数隐藏起来,但是这会导致一些额外的问题:
var a = 2
function foo() {
var a = 3
console.log(a) // 3
}
foo()
console.log(a) // 2
示例中必须声明一个具名函数 foo()
,意味着 foo
这个名称本身“污染”了所在作用域,其次,必须显式地通过函数名调用这个函数才能运行其中的代码。
// IIFE,用途包括:把它们当作函数调用并传递参数进去、解决 undefined 被修改、倒置代码的执行顺序
;(function foo() {
var a = 3
console.log(a) // 3
})()
新的示例中创建了一个函数表达式,而非函数声明。函数声明和函数表达式之间最重要的区别是它们的名称标识符将会绑定在何处,这里的 foo
被绑定在函数表达式自身的函数中而不是所在作用域。
换句话说,(function foo(){ .. })
作为函数表达式意味着 foo
只能在 ..
所代表的位置中被访问,外部作用域则不行。foo
变量名被隐藏在自身中意味着不会非必要地污染外部作用域。
# 块作用域
此前普遍认为 ES5 只有全局作用域和函数作用域,意味着每声明一个函数都会为其自身创建一个作用域,而其他结构都不会创建作用域(事实上这并不完全正确)。
非常少有人会注意到 JavaScript 的 ES3 规范中规定 try/catch
的 catch
分句会创建一个块作用域,其中声明的变量仅在 catch
内部有效。
try {
undefined() // 执行一个非法操作来强制制造一个异常
} catch (err) {
console.log(err) // 能够正常执行
}
console.log(err) // ReferenceError: err not found
直到 ES6,let
关键字可以将变量绑定到所在的任意作用域中。换句话说,let
为其声明的变量隐式地劫持了所在的块作用域。
let a = 1
{
let a = 2
}
console.log(a) // 1
# 声明提升
想一想,下面的代码会输出什么?会抛出 ReferenceError 异常吗?
console.log(a)
var a = 2
根据编译原理,编译器在处理这段代码的第一步中会先对变量进行声明,然后生成运行时的代码对变量进行赋值,也就是说这段代码真正的处理是:
var a
console.log(a)
a = 2
类似的,函数声明也会得到提升(函数表达式不会被提升):
foo()
function foo() {
console.log(a) // undefined
var a = 2
}
示例中,由于 foo
函数的声明(这个例子还包括实际函数的隐含值)被提升了,因此第一行中的调用可以正常执行。
需要注意的是:函数会首先被提升,然后才是变量,同名的函数声明会被覆盖,而变量则会被忽略。
# 闭包
当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
function foo() {
var a = 2
function bar() {
console.log(a) // 2
}
bar()
}
foo()
技术上讲,由于 bar()
访问了外部作用域中的变量,所以会产生一个涵盖 foo()
作用域的闭包,下面是一个更明显的例子:
function foo() {
var a = 2
function bar() {
console.log(a)
}
return bar
}
var baz = foo()
baz() // 2 —— 朋友,这就是闭包的效果
示例中,我们将 bar()
函数本身当作一个值类型进行传递,然后在它定义的词法作用域以外的地方执行,此时仍然能够访问 foo()
的内部作用域。
本质上无论何时何地,如果将(访问它们各自词法作用域的)函数当作第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。
在定时器、事件监听器、Ajax 请求、跨窗口通信、Web Workers 或者任何其他的异步(或者同步)任务中,只要使用了回调函数,实际上就是在使用闭包。
IIFE 是不是闭包?作者先是否定,然后又同意产生了闭包。当我在浏览器中的全局环境下运行时并没有显示有闭包,而放在函数中运行则会产生。
# 模块
从 namespace
到函数封装,模块的实现方式也在一步步进化,大多数模块依赖加载器/管理器本质上都是将这种模块定义封装进一个友好的 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,储存在一个根据名字来管理的模块列表中。