# 作用域

前面说, 变量是一个符号容器, 可以用来储存值, 并能在之后通过变量对这个值进行访问或修改. 那一个问题是, 变量储存在哪里, 程序又该如何使用它们呢?

简单说, 作用域是指变量的可访问性.

作用域通过实施一套规则, 来储存并维护声明的标识符( 变量 ), 以及与这些标识符相关的查询操作. 并确定当前执行的代码对这些标识符的访问权限.

又或者说, 作用域维护了标识符可访问范围,控制着变量和函数的可见性与生命周期

在 JavaScript 内部,作用域和对象类似,可见的标识符都是它的属性。但是作用域 “对象” 无法通过 JavaScript 代码访问,它存在于 JavaScript 引擎内部。

# 编译原理

尽管通常将 JavaScript 归类为 “动态” 或 “解释型” 语言,但事实上它是一门编译语言。

"编译型语言" 的首先将源代码编译生成机器语言,再由机器运行机器码, 编译型语言编写的应用在编译后能直接运行。"解释型语言" 也需要将源代码转换成机器可以理解机器语言,但是是在运行时转换的, 所以执行前需要解释器安装在运行环境中.

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

  1. 词法分析: 将由字符组成的字符序列分解成(对编程语言来说)有意义的代码块,这些代码块被称为『 词法单元 』.
  • 例如,var a = 2; 这段程序通常会被分解成为下面这些词法单元: var, a, =, 2, ;.
  1. 语法分析: 将由词法单元组成的 "词法单元流" 转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树, 被称为『 抽象语法树 』.
  • 例如 var a = 2; 的抽象语法树中可能会有一个叫作 VariableDeclaration 的顶级节点,接下来是一个叫作 Identifier (它的值是 a )的子节点,以及一个叫作 AssignmentExpression 的子节点。 AssignmentExpression 节点有一个叫作 NumericLiteral (它的值是 2 )的子节点。
  • 语法分析可视化网站
  1. 代码生成: 将 "抽象语法树" 转换为 "可执行代码" 的过程称被称为代码生成。也就是转换成可以让机器执行的机器语言.
  • 例如 var a = 2; 这段代码最后会让机器创建一个叫作 a 的变量(包括分配内存等),并将一个值为 2 的 Number 类型值储存在 a 中.

与传统的编译语言不同, JavaScript 的编译过程不是发生在构建之前的。对于 JavaScript 来说,大部分情况下编译发生在代码执行前几微秒的时间内. 简单说 JavaScript 在代码执行之前进行编译, 之后立即执行.

# 引擎, 编译器, 作用域

通过抽象 var a = 2; 这段代码是如何被执行的来理解引擎, 编译器和作用域的关系.

JavaScript 引擎负责程序的编译和执行.  当看到 var a = 2; 这段代码时, 它首先让编译器首先会将这段程序分解成 "词法单元",然后将词法单元解析成一个 "抽象语法树"。当编译器生成 "可执行代码" 的时候, 它会做如下处理:

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

# RHS & LHS

引擎在查找一个变量的时候, 会出现两种情况:

  • LHS 查询
  • RHS 查询

当变量出现在赋值操作的左侧的时候进行 LHS 查询, 出现在右侧时进行 RHS 查询。RHS 查询查找某个变量的值,而 LHS 查询则是试图找到变量的容器本身,从而可以对其赋值。

例如 console.log(a) 中对于 a 的引用就是 RHS 引用, 因为在这里需要取得储存在变量 a 中的值.

下面是一段代码的抽象执行情况:

Screen Shot 2019-02-24 at 1.24.29 AM

# 作用域链

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

# 相关异常

# ReferenceError

在变量还没有声明(在任何作用域中都无法找到该变量)的情况下, RHS 查询找不到目标变量. 引擎就会抛出 ReferenceError 异常。

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

# TypeError

当对变量的值进行不合理的操作,比如试图对一个非函数类型的值进行函数调用,或着引用 nullundefined 类型的值中的属性,那么引擎会抛出另外一种类型的异常,叫作 TypeError.

ReferenceError 同作用域判别失败相关, 而 TypeError 则代表作用域判别成功了,但是对结果的操作是非法或不合理的。

# 词法作用域

前面说 "作用域" 通过一套规则来管理引擎如何在当前作用域及其子作用域中查找和访问变量.

作用域共有两种主要的工作模型。词法作用域 和 动态作用于. 第一种是最为普遍的,同时被 JavaScript 所采用的 "词法作用域" ,在这里我们对这种作用域进行深入讨论。

前面说, 编译的第一个阶段叫 "词法分析", 其将源代码中的字符序列分解成词法单元.

简单地说,『 词法作用域 』 就是定义在词法分析阶段的作用域, 之后不会发生改变了. 标识符所处的词法作用域, 由其声明时所处的位置决定.

考虑下面代码:

// 全局作用域
function foo(a) {
    // foo 函数作用域
    var b = a * 2;
    
    function bar(c) {
        // bar 函数作用域
        console.log( a, b, c );
    }
    bar( b * 3 );
}
foo( 2 ); // 2, 4, 12

在这个例子中有三个逐级嵌套的作用域。

作用域的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置。引擎执行 console.log(..) 声明,并查找 abc 三个变量的引 用。它首先从最内部的作用域,也就是 bar(..) 函数的作用域气泡开始查找。引擎无法在这里找到 a ,因此会去上一级到所嵌套的 foo(..) 的作用域中继续查找。

作用域查找会在找到第一个匹配的标识符时停止.

# 欺骗词法 (eval, with)

如果词法作用域完全由写代码期间标识符所声明的位置来定义,怎样才能在运行时来 “修改” 词法作用域呢?

# eval

eval(..) 函数可以接受一个字符串为参数,并将其中的内容视为好像在书写时就存在于程序中这个位置的代码, 换句话说,程序运行之后动态生成的代码可以被当做一开始(词法期)就在那。

eval 中声明的标识符会对其所属的词法作用域进行修改, 而引擎并不在意.

function foo(str, a) {
    eval( str ); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo( "var b = 3;", 1 ); // 1, 3

上面代码中, foo(..) 内部创建了一个变量 b ,并遮蔽了外部(全局)作用域中的同名变量。

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

# with

with 通常被当作重复引用同一个对象中的多个属性的快捷方式

ar 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 被泄漏到全局作用域上了!

这个例子中创建了 o1o2 两个对象。其中一个具有a属性,另外一个没有。foo(..) 函数接受一个 obj 参数,该参数是一个对象引用,并对这个对象引用执行了 with(obj) {..}

with 块内部,我们写的代码看起来只是对变量 a 进行简单的词法引用,实际上就是一个 LHS 引用,并将 2 赋值给它。

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

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

也就是,当引擎在 o2 的作用域、foo(..) 的作用域和全局作用域中都没有找到标识符 a 时, 引擎自动创建了一个全局变量(非严格模式)并赋值为 2.

# 性能问题

eval(..)with 会在运行时修改或创建新的作用域,以此来欺骗其他在书写时定义的词法作用域。

但那又怎样呢?如果它们能实现更复杂的功能,并且代码更具有扩展性,难道不是非常好的功能吗?答案是否定的。eval(..)with 会让代码的执行变慢

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

但如果引擎在代码中发现了eval(..)with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给with 用来创建新词法作用域的对象的内容到底是什么。可能所有的优化可能都是无意义的,因此出现 eval(..)with 时最简单的做法就是完全不做任何优化。如果没有这些优化,代码会运行得更慢

另外一个不推荐使用 eval(..)with 的原因是会被严格模式所限制。

# 为什么要 "隐藏"?

# 最小暴露原则

有很多原因促成了这种基于作用域的隐藏方法。 它们大都是从 "最小特权原则" 中引申出来 的,也叫 "最小授权" 或 "最小暴露原则"。

这个原则是指在软件设计中,应该最小限度地暴露必要内容,而将其他内容都“隐藏”起来.

如果所有的变量和函数都在全局作用域中可以访问, 这可能会暴漏过多的变量或函数,而这些变量或函数本应该是私有的. 正确的代码应该是可以阻止对这些变量或函数进行访问的。

# 避免冲突

“隐藏”作用域中的变量和函数所带来的另一个好处,是可以避免同名标识符之间的冲突.

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

# 函数作用域

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

var a = 2; 

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

console.log( a ); // 2

上面这段代码中, 声明一个具名函数 foo() ,意味着 foo 这个名称本身“污染”了所在作用域(在这个例子中是全局作用域)。其次,必须显式地通过函数名调用这个函数才能运行其中的代码。

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

JavaScript 提供了能够同时解决这两个问题的方案:

var a = 2; 

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

console.log( a ); // 2

上面代码中, 函数会被当作函数表达式而不是一个标准的函数声明来处理。

WARNING

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

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

  • 函数声明, 变量名绑定在所在作用域
  • 函数表达式, 变量名绑定在其自身的函数中. 变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

# 匿名 & 具名

函数表达式可以是匿名的,而函数声明则不可以省略函数名.

匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但是 它也有几个缺点需要考虑:

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

给函数表达式指定一个函数名可以有效解决以上问题。始终给函数表达式命名是一个最佳实践:

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

# 立即执行函数表达式

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

这种模式很常见,术语叫做 "IIFE",立即执行函数表达式(Immediately Invoked Function Expression)

相较于传统的 IIFE 形式,很多人都更喜欢另一个改进的形式:(function(){ .. }()), 第二种形式中用来调用的 () 括号被移进了用来包装的 ( ) 括号中。

// 1
(function () {
    console.log(123);
})();

// 2
(function() {
    console.log(123);
}())

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

(function (global) {
    console.log(global);
})(window);

可以从外部作用域传递任何你需要的东西,并将变量命名为任何你觉得合适的名字。这对于改进代码风格是非常有帮助的。在代码风格上对全局对象的引用变得比引用一个没有 “全局” 字样的变量更加清晰。

# 块作用域

在 JavaScript 中 { } 并不会创建一个块作用域.

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

我们在 for 循环的头部直接定义了变量 i ,通常是因为只想在 for 循环内部的上下文中使 用 i ,而忽略了 i 会被绑定在外部作用域(函数或全局)中的事实。

可以通过下面几种方法实现块作用域:

# with

with 可以将对象处理成一个完全隔离的作用域.

with 从对象中创建出的作用域仅在 with 声明中而非外部作用域中有效。

# try/catch

JavaScript 的 ES3 规范中规定 try / catchcatch 分句会创建一个作用域,其中声明的变量仅在 catch 内部有效。

# let / const

变量

# 执行环境, 执行栈,变量对象, 活动对象

# 执行环境, 执行栈

"执行环境"(execution context) 也称作 "执行上下文" 是 JavaScript 的重要概念. 执行环境定义了变量或函数的访问权,决定了它们各自的行为。每个执行环境都有一个与之关联的 "变量对象"(variable object), 环境中定义的所有变量和函数都保存在这个对象中。虽然我们编写的代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

"全局执行环境" 是最外围的一个执行环境。根据 ECMAScript 实现所在的宿主环境不同,表示全局执行环境的对象也不一样。在浏览器中,全局执行环境被认为是 window 对象, 在 Node.js 中为 global 对象.

当某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁. 全局执行环境则直到应用程序退出, 例如关闭页面, 才会被销毁.

每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个 "环境栈" 中。当函数执行完毕, 函数的执行环境被从栈中弹出, 把控制权交给之前的执行环境.

当代码在一个环境中执行时,会创建 "变量对象" 的一个 "作用域链". 作用域链保证了对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,始终都是当前执行的代码所在环境的变量对象。作用域链中的下一个变量对象来自包含(外部)环境. 这样,一直延续到全局执行环境.

标识符解析是沿着作用域链一级一级地搜索标识符的过程。搜索过程始终从作用域链的前端开始,然后逐级地向后回溯,直至找到标识符为止.

# 变量对象, 活动对象

前面说创建执行上下文时与之关联的会有一个变量对象,该上下文中的所有变量和函数全都保存在这个对象中。进入到一个执行上下文时,此执行上下文中的变量和函数都可以被访问到,可以理解为被激活. 这个时候 "变量对象" 就被称作 "活动对象"

如果所在环境是函数,那么就会把这个函数的活动对象作为变量对象(在函数中,变量对象 == 活动对象) 它一开始只包含 arguments 对象。一般而言,函数执行过程,可以分成两步:

  1. 建立阶段
  • 创建变量, 参数, 函数, arguments 对象
  • 建立作用域链
  • 绑定 this
  1. 执行阶段 (以下无前后顺序)
  • 变量赋值
  • 函数引用
  • 执行代码

函数执行之前, 会先创建函数的执行环境. 然后进入执行环境, 创建变量对象.

然后扫描环境中的函数申明 (函数声明先于变量声明). 每扫描到一个函数什么就会在变量对象里面用函数名创建一个属性, 属性值为一个指针,指向该函数在内存中的地址. 如果函数名在变量对象中已经存在,对应的属性值会被新的引用覆盖.

然后扫描环境中的变量申明. 每扫描到一个变量就会用变量名作为属性名,其值初始化为 undefined. 如果该变量名已经存在,则直接跳过继续扫描 (不覆盖).

之后初始化这个执行环境的作用域链. 绑定 this 指向.

当函数被执行时, 再根据具体代码进行, 变量赋值, 函数引用, 执行代码.

上次更新: 7/4/2020, 4:14:54 AM