JavaScript Scope

作用域是一套用于确定在何处以及如何查找变量的规则. 赋值操作会导致LHS查询, 获取变量值的操作会导致RHS查询. 引擎在处理代码时会优化处理变量声明和函数声明, 这种机制叫做提升.

JavaScript编译原理

在传统的编译语言流程中, 程序的执行会经过三个步骤, 我们称这个过程为编译

  • 分词: 词法分析(将字符串分解成有意义的代码块)
  • 解析: 语法分析(将词法单元转换成一个由元素逐级嵌套代表的程序语法结构的树)
  • 代码生成(将上面生成的树转换为可执行的代码)

JavaScript是属于解释性语言, 但是代码的编译过程与上面类似, 现在浏览器执行JavaScript代码非常快速, 一般编译过程发生在代码运行前的一瞬间.

scope(作用域)

作用域负责收集和维护由所有变量组成的查询, 并且确定一套规则来判断当前代码对这些变量的访问权限.

对于var foo = 0;来说, 编译器首先在当前作用域声明一个变量foo, 之后在运行时JS引擎会从当前作用域中查找变量foo, 再然后将数字0赋值给变量foo.

scope chain(作用域链)

每一个函数都有自己的执行环境, 当函数嵌套在另一个函数或块中, 就会形成作用域链. 作用域链的前端就是当前执行环境, 全局执行环境的变量即作用域链的顶端. 在当前作用域中找不到变量时, JS引擎会沿着作用域链一级一级向上查询, 直到找到为止.

1
2
3
4
5
var a = 4;
var foo = function (b) {
return a + b;
}
foo(5); // 9

上面的例子中, foo的作用域中没有变量a, 需要使用变量a需要向上一级查找.

LHS and RHS

JS引擎进行变量查询的方式有两种, 分别是LHS和RHS. LHS表示查询赋值操作的目标, RHS表示查询赋值操作的源头. 简单的说, LHS查询是寻找这个变量的容器本身, 从而进行赋值操作, RHS查询是查找某个变量的值.

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

对上面的例子进行分析:

  • 要执行函数foo, 需要知道其赋值操作的源头, 从而进行RHS引用
  • 接下来把数字1赋值给函数foo的形参a, 需要进行一次LHS引用, 这是一个隐式变量分配
  • 对console进行RHS引用, console是一个内置对象, 有log()方法
  • 确认形参a的值, 进行一次RHS查询, 将形参a的值传递给console对象的log()方法

异常

如果RHS查询在所有的作用域链都没有找到所需的变量时, JS引擎就会抛出ReferenceError异常. 如果RHS查询到目标变量, 但尝试进行非法操作时, JS引擎会抛出TypeError异常.

如果LHS查询在所有的作用域链中都找不到所需的变量时, 就会在全局作用域中创建该变量并返还给JS引擎.

注意: 严格模式中禁止自动或隐式创建变量,LHS查询失败时同样会返回ReferenceError异常.

词法作用域

作用域一般有两种工作模式, 一种是为大多数语言使用的词法作用域, 另一种是动态作用域.

词法作用域就是定义在词法阶段的作用域. 词法作用域是由书写代码是函数声明位置决定的. 作用域一旦确定基本上会保持不变. 当然也有欺骗词法的方法, 但不推荐使用, 欺骗作用域会导致性能下降.

1
2
3
4
5
6
7
8
9
// 全局作用域, 只有foo标识符
function foo(m) { // foo的作用域, m, n和bar三个标识符
var n = m - 1;
function bar(x) { // bar的作用域, 只有x标识符
console.log(m, n, x);
}
bar(n + 3);
}
foo(5);

欺骗词法-eval

eval()方法会接收一个字符串作为参数, 并将字符串视作代码片段进行执行.

1
2
3
4
5
var bar = function (str,a) {
eval(str);
console.log(a, b);
}
bar('var b = 4', 2); // 2 4

严格模式下, eval()方法创建的变量不能被调用.

1
2
3
4
5
6
var bar = function (str, a) {
"use strict";
eval(str);
console.log(a, b);
}
bar('var b = 4', 2); // ReferenceError: b is not defined

使用eval()方法生成代码将造成性能上的损失, 不推荐使用.

欺骗词法-with

with()方法会将一个对象的引用当作作用域来处理, 将对象的属性当作作用域的标识符来处理, 从而创建一个新的作用域. 这种方法也会使JS引擎在编译时无法对作用域进行优化, 从而造成性能上的损失.

1
2
3
4
5
6
7
8
9
10
11
var obj = {
a: 'one',
b: 'two',
c: 'three'
}

with(obj) {
a = 'four',
b = 'five',
c = 'seven'
}

严格模式中with()方法被完全禁止.

函数作用域

属于这个函数的全部变量可以在整个函数内部或其嵌套的作用域内部使用, 这即是函数的作用域.

1.隐藏内部实现

函数作用域可以将声明在该函数内部的变量或函数隐藏起来, 从而实现良好的设计模式. 在模块和对象的API设计中, 这种方法很常见也很实用.

下面是利用对象全局命名空间来规避冲突的方法:

1
2
3
4
5
6
7
8
9
var nameSpace = {
doSomeThing: function() {
...
},
doAnotherThing: function() {
...
},
...
};

2.立即执行函数表达式(IIFE)

我们希望函数不需要函数名或者函数名不会污染所在的作用域, 并且能够自动执行. 这是我们可以使用IIFE来实现.

1
2
3
4
5
6
7
8
9
10
11
var bar = {
a: 'zero',
b: 'one',
c: 'two'
};

(function IIFE(global) { // 将bar对象的引用传递进去, 参数命名为global
var a = 'three';
console.log(a); // three
console.log(global.a); // zero
})(bar);

块作用域(try..catch)

ES3中规定try..catch会创建一个块级作用域, 其中声明的变量仅在catch内部有效.

1
2
3
4
5
6
try {
console.log(e);
} catch(err) {
console.log(err); // 正常执行
}
console.log(err); // ReferenceError

块作用域-let

ES6中引用了let关键字, 可以将变量绑定到任意的作用域中. let将其声明的变量隐式的放到了它所在的块作用域中.

1
2
3
4
5
6
7
8
9
10
11
for (let i = 0; i < 10; i++) {
console.log(i); // 正常执行
}
console.log(i); // ReferenceError

//使用{..}显式声明变量
{
let a = 0;
console.log(a); // 0
}
console.log(a); // ReferenceError

块作用域-const

ES6中引入了const关键字, 用来声明常量. 同样可以用来创建块作用域.

1
2
3
4
5
{
const b = 9;
console.log(b); // 9
}
console.log(b); // ReferenceError

声明在这个作用域中的变量都将作用在这个作用域中.

函数和变量提升

作用域中的声明将在代码本身执行之前首先处理, 将这个过程想象成所用声明(变量和函数)都会被移动到各自作用域的前端, 这个过程称为提升.

先有声明后有赋值.只有函数和变量会进行提升

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
a = 0;
var a;
console.log(a); // 0

// 上面代码, 编译器会把声明var a; 提升到全局顶部首先进行处理, 即按照如下顺序处理

var a;
a = 0;
console.log(a); // 0

// 只有声明本身会被提升, 赋值和其它逻辑语句会停留在原地

console.log(a); // undefined
var a = 0;

// 编译器把var a = 0; 视为两部分, 即var a;和a = 0;
// 将var a;提升到全局顶部, 首先进行处理, 赋值操作a = 0; 留在原地.
// 编译器会按照如下形式处理这段代码:

var a;
console.log(a); // undefined
a = 0;

// 每个作用域只会在自己的作用域范围内进行提升

bar();
function bar() {
console.log(a); // undefined
var a = 0;
}

// 上面代码块中, 全局作用域中的函数声明会被提升, bar函数作用域中的var a;也会被提升
// 即按照下面的顺序进行处理

function bar() {
var a;
console.log(a); // undefined
a = 0;
}
bar();

函数首先会被提升, 然后才是变量. 要避免重复声明, 当变量声明和函数声明混合在一起容易造成混乱.