作用域的概念

作用域是一个抽象概念,它描述了一组 binding (绑定) 的生效范围。

我们的程序中的某个部分,某个 name 绑定着某个 entity,而同样的 name,在我们程序的另一个部分,可能绑定了另一个 entity,或者根本不存在这个 name

这段话用了几个抽象概念描述作用域的表现形式。即,作用域就是一组 name 跟 entity 的绑定,这种绑定是有起作用的范围的。

解释一下这里用到的几个概念:

  • name: 名称,可理解为允许我们引用变量、常量、函数、对象等等等的标识符。
  • entity: 实体,可理解为内存中实际的数据、代码等
  • binding: 绑定,即 name 跟 entity 之间的关联关系。一个 name 绑定一个 entity,称为引用该对象。

在 JavaScript 中,两个不同的函数内部的同名变量,就完全是不同的东西,有各自的作用范围。 而作用域,正是这种限定变量在程序不同部分的可访问性的概念。

看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var x = '🍐'
function a() {
  var x = '🍎'
  console.log(x)
}
function b() {
  var x = '🍌'
  console.log(x)
}

console.log(x) // 🍐
a() // 🍎
b() // 🍌

我们在全局作用域定义了一个变量 x ,在函数 ab 里面也各自使用了 x 变量, 然而,这三个 x 各自拥有自己的起作用的范围。

词法作用域(lexical scope)

JavaScript 主要使用的是词法作用域(静态作用域)。 词法作用域是在编译过程词法分析阶段就已经能确定的作用域,确定之后不会再改变。

这里用了“主要”修饰,是因为 JavaScript 还有少量情况(with,eval)具备动态作用域的行为。

对应词法作用域的是动态作用域,即运行时才能确定的作用域。

词法作用域的特点是,标识符的解析过程,从代码定义处(最近)的环境中查找,而不是调用时的环境。

看代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var a = 'a~'
function sayA() {
  console.log(a)
}

sayA() // 打印 a~

function say() {
  var a = 'b~'
  console.log(a) // 打印 b~
  sayA() // 打印 a~
}

say()

代码验证了这个结论,sayA 函数不论是在全局作用域调用,还是在函数作用域里面调用,对内部的 a 变量的解析,都不会改变。 这个变量始终代表全局作用域中的 a ,因为 sayA 定义处(最近)的环境就是全局环境。

如果是使用的是动态作用域的语言,那么在调用 say() 的时候,内部的 sayA() 应该打印出 b~ 这个字符串值,因为对于标识符a的解析,是动态地从执行环境中查找的。

作用域链

JavaScript 中,某个标识符,在解析其值的时候,是沿着一级级的环境(后面介绍)查找的,看代码

1
2
3
4
5
6
7
8
9
10
var x = 'x'

function outer() {
  function inner() {
    console.log(x)
  }
  inner()
} 
outer() // 打印 x

inner 函数在调用时,使用了一个变量 x,并且正确打印出来了其值,然而 inner 内部并无该名字的本地变量。 这说明了,JavaScript 使用了某种机制,可以解析到外层作用域。

实际上过程是这样的:

  1. inner 内部查找 x 是否有绑定,结果是没有
  2. inner 代码定义处的环境,即 outer 里查找 x 是否有绑定,结果仍然是没有
  3. outer 代码定义处的环境,即 全局环境 里查找 x 是否有绑定,结果是有,嗯,x 的解析这样就圆满解决了。

解析标识符就是这样一个从小圈子往外层圈子查询的过程。查询的每一级环境就像链条中的一环,所以,这个查询的作用域链条,就叫做作用域链。 其中,最外层的一环是全局环境,如果在全局环境都查找不到标识符的绑定,那么,就会抛出一个引用错误( ReferenceError )。

词法环境、环境记录

为了更深入理解这些概念,下面再从 JavaScript 语言规范层面来分析。

ES5 之后的语言规格文件中,使用了词法环境、环境记录这些规范类型(*)来描述这些问题。

其中词法环境是个链表的结构,用于实现作用域链的层级结构,而每个词法环境中,都持有一个环境记录,用于存储标识符和值的绑定。

  1. 规范类型是指在 JavaScript 语言规范里面使用的类型,用于描述实现的原理,引擎可以自由实现。
  2. ES3 中,有个相关的概念是活动对象,ES5 之后不再使用,而使用词法环境、环境记录这些新的概念。
  3. ES5 之后引入这些新概念,更多的是性能上的考量,不再使用活动对象这种低效率的方式实现,引擎可以选择先进、高性能的实现机制。

词法环境 (Lexical Environments)

词法环境用于定义标识符在代码的词法嵌套结构上的关联关系。 诸如 函数声明, {}块结构(ES6之后), try 的 catch 分句 这样的代码每次执行都会创建一个新的词法环境。

一个词法环境,由两部分组成:

Environment Record outer

其中 Environment Record 是一个环境记录,用于存储绑定;outer 是个指针,指向外部词法环境,用于实现层级结构。

很显然,这是一个典型的链表结构。

标识符查找过程,就是在环境记录里面查找绑定,查找不到,就通过 outer 指针,在外部词法环境中查找。 直至找到,或历尽链条的末端为止。

链条的末环是全局环境,其outer为null。

环境记录 (Environment Records)

环境记录用于存储绑定,这些绑定均创建于其关联的词法环境范围内。

环境记录有以下子类

  • 声明环境记录子类 (declarative Environment Record)
  • 对象环境记录子类 (object Environment Record)
  • 全局环境记录子类 (global Environment Record)

其中声明环境记录中,又派生出了以下子类

  • 函数环境记录子类 (function Environment Records)
  • 模块环境记录子类 (module Environment Records),(ES6 之后)

下面简单介绍下这些环境记录。

声明环境记录

声明环境,记录了在其作用域内通过声明定义的一系列标识符的绑定。

每个声明环境记录关联着一个 ECMAScript 程序作用域,这个作用域包含着变量声明(var, let)、常量声明(const)、类声明(class)、模块声明(export, import)、函数声明(function)等等声明语句。

声明环境记录直接持有、维护着这些声明语句所创建的绑定。实现词法作用域的就是声明环境记录。

用对象语法表示

1
2
3
4
5
6
environment = {
  environmentRecord: {
    // 这里存储绑定
  },
  outer: ...
}

如下代码

1
2
3
4
function a() {
  var x = 'x'
  console.log(x)
}

声明函数 a 会创建一个词法环境,该词法环境包含一个声明环境记录用于关联 a 函数的作用域,该环境记录里面持有变量 x 及其值 'x' 的绑定。代码表示如下

1
2
3
4
5
6
environment = {
  environmentRecord: {
    x: 'x'
  },
  outer: ...
}

这样在函数里面使用变量 x 的时候,就能在环境记录中查找到这个绑定从而成功解析 x

对象环境记录

对象环境记录,记录了其作用域内除了通过声明之外方式定义的绑定(witheval)。 对象环境记录本身不直接保存绑定关系,而是使用了一个关联的对象来保存这些绑定关系,这个对象叫做绑定对象(binding object)

用对象语法表示

1
2
3
4
5
6
7
8
environment = {
  environmentRecord: {
    bindingObject: {
      // 这里存放绑定
    }
  },
  outer: ...
}

我们在使用 with 的时候,使用的正是对象环境记录,with的对象就是绑定对象。 在with代码块里使用的环境记录就是一个对象环境记录。

这种环境记录让 JavaScript 拥有 “动态作用域” 特征,无法在词法分析阶段确定标识符的绑定,实现上也难以优化,是一种低效率的机制,也容易出现难以理解的程序执行表现。

因此,在 ES5 的严格模式中,彻底移除了with的支持,而且eval也不再在调用的上下文中创建变量,从而提供了一个存粹的词法作用域。这样无论在理解上,还是在程序的性能上都更加高效。

全局环境记录

全局环境需要独立成一个类型,是因为,我们在全局作用域中,除了可以使用声明来创建标识符绑定,也可以直接在 window 对象上定义属性。 即全局环境记录实际上拥有声明环境记录、对象环境记录的双重性质。

所以在实现上,一个全局环境记录其实就是一个封装了声明环境记录和对象环境记录的复合型环境记录。 声明环境记录、对象环境记录作为两个独立的组件存在于一个全局环境记录中。

在浏览器中,全局环境的对象环境记录组件的绑定对象是 window 对象。 所以,我们可以直接使用 window.propertyName 的方式在全局环境中创建绑定。

不过因为对象环境记录是一种低效的实现,未来的ES版本中,似乎将会移除掉这种环境记录,以提供一个高效、统一的实现。

结尾

结尾放一个大量 JavaScript 新手都会踩的坑

1
2
3
4
5
6
7
8
9
10
var funcs = []
for (var i = 0; i < 3; i += 1) {
  funcs.push(function(){
    console.log(i)
  })
}
funcs.forEach(function(func){
  func()
})
// 打印出3个 `3`

这段代码将打印出 3 个 3。 原因也很简单,在 ES6 之前,JavaScript 块级不会创建自身的作用域。 因此,for 循环里面定义的3个匿名函数,解析i变量的时候,会随着作用域链解析外层作用域(即for语句所处的作用域)中的 i,而末尾调用这三个匿名函数的时候,i 的值已经被循环的自增操作改变为3,三个匿名函数中的 i 都解析出 3这个结果也就显而易见了。

理解了这点之后,解决这个问题也无比简单,就是在匿名函数的本地作用域里面各自维护i这个变量就行了:

1
2
3
4
5
6
7
8
9
10
11
12
var funcs = []
for (var i = 0; i < 3; i += 1) {
  funcs.push(function(i){
    return function() {
      console.log(i)
    }
  }(i))
}
funcs.forEach(function(func){
  func()
})
// 如期望地打印出 `0`, `1`, `2`

ES6 中更简单,使用 let来声明 i 以绑定作用域在代码块里:

1
2
3
4
5
6
const funcs = []
for (let i = 0; i < 3; i += 1) {
  funcs.push(()=>{ console.log(i) })
}
funcs.forEach(func => func())
// 如期望地打印出 `0`, `1`, `2`