0%

js-作用域

几乎所有的编程语言最基本的功能之一,就是能够储存变量中的值,并且能在之后对这个值进行访问和修改。事实上,正是这种储存和访问变量的值的能力将状态带给了程序。那么,这些变量是储存在哪里?使用的时候程序如何找到它们?这些问题说明需要一套设计良好的规则来存储变量,并且可以以后方便找到这些变量,这套规则就称之为作用域。

虽然通常将JavaScript归类为“动态”或“解释执行”语言,但事实上它是一门编译语言。但于传统的编译语言不同的是,它不是提前编译的。

传统编译语言的编译过程

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

  • 分词/词法分析
    这个过程会将由字符组成的字符串源代码分解成有意义的代码块,这些代码块被称为词法作单元(token)。例如:源代码var a = 2;,会被分解为vara=2;。空格在这里是辅助显示的,作用是让代码更可读,这种情况下对语言是没啥实际意义的,所以不会被当做词法单元。
  • 解析/语法分析
    这个过程是将词法单元流转化为一个由元素逐级嵌套所组成的代表了程序语法结构的树,这个树被称为“抽象语法树(Abstract Syntax Tree)”
  • 代码生成
    这个就是将AST转化为可执行代码的过程。简单的来讲,就是将var a = 2;的AST转化为一组机器指令,用来创建一个叫做a的变量,并将一个值2存储在a中。

相比来讲,JavaScript引擎要复杂的多。

js的编译执行过程

首先,JavaScript引擎不会有大量的时间用来进行优化,因为JavaScript的编译过程不是发生在构建之前。大多数情况下,编译发生在代码执行前的几微秒的时间内。在我们所要讨论的作用域背后,JavaScript引擎用各种办法(比如JIT,可以延迟编译甚至实施重编译)来保证性能最佳。简单来说任何JavaScript代码片段在执行前都要进行编译(通常就在执行前)。

在JavaScript源代码执行过程中,有三个角色拥有不同的能力:

  • 引擎 从头到尾负责整个JavaScript程序的编译以及执行过程。
  • 编译器 负责语法分析以及代码生成等脏活累活。
  • 作用域 负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限。

那源代码var a = 2;来讲,引擎认为这里有两个声明,一个有编译器在编译时处理,一个由引擎在运行时处理。

遇到var a,编译器会询问作用域是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器就会忽略该声明继续进行编译;否则它会要求作用域在当前作用域集合中重新声明一个新变量,并命名为a。

接下来编译器会为引擎生成处理时所需的代码,这些代码被用来处理a=2这个赋值操作。引擎运行时会首先询问作用域,在当前的作用域集合中是否存在一个叫a的变量,如果是,引擎就会使用这个变量;如果不是,引擎会继续查找该变量。

如果引擎最终找到了a变量,就会将2赋值给它。否则引擎就会抛出异常。

变量的赋值操作会执行两个动作:

  1. 首先编译器会在当前作用域中声明一个变量(如果之前没声明过)。
  2. 然后运行时引擎会在作用域中查找该变量,如果能找到就会对它赋值。

编译器在编译过程的第二步生成了可执行代码,引擎在执行它时,会通过查找变量来判断它是否声明过。查找的过程由作用域协助。但是引擎执行怎么的查找,会影响最终的查找结果。

查找有两个类型:

  • LHS 给变量赋值时查找,比如var a = 2;
  • RHS 获取变量的值的查找,比如console.log(a);

作用域嵌套

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

为什么要区分LHS和RHS?因为在变量还没声明的情况下,这两种查询的行为是不一样的。

对于一个未声明的变量:

  • 进行RHS查询时是无法找到该变量的,引擎会抛出ReferenceError异常。
  • 进行LRS查询时,如果在顶层(全局作用域)中也无法找到该变量,就会在全局作用域中创建一个具有该名称的变量,并将其返回给引擎,前提是在非“严格模式”。

ES5中引入了“严格模式”,同正常的模式不同,其中的一个行为就是严格模式禁止🚫自动或隐式地创建全局变量,引擎会抛出RHS查询失败类似的异常ReferenceError异常。

作用域查找会在找到第一个匹配的标识符时停止。在多层的嵌套作用域中可以定义同名的标识符,这叫做“遮蔽效应”,内部的标识符遮蔽了外部的标识符。

全局变量会自动成为全局对象的属性。因此可以间接的通过全局对象

作用域包含了一系列的容器,容器里面包含了标识符(变量、函数)的定义,这些容器相互嵌套形成蜂窝型嵌套作用域。

那么究竟是什么形成了作用域的容器?只有函数可以形成吗?

函数作用域

最常见的作用域是基于函数的作用域,意味着每个函数都会为自身创建作用域。无论标识符出现在作用域的何处,这个标识符所代表的变量或函数都附属于所处的作用域容器中。

函数的作用域是指,属于这个函数的全部变量都可以在整个函数范围内使用及复用。这样可以达到隐藏内部实现和避免标识符冲突的作用。

隐藏内部实现

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

实际上就是使用函数将这些代码包含在一个作用域容器之中,也就是这段代码中的所有声明的标识符都将绑定在这个新创建的函数作用域之中,然后用这个作用域来隐藏它们。

为什么“隐藏”变量和函数是一个有用的技术?

这种基于作用域隐藏的方法,大都是从最小特权原则中引申出来的,也叫最小授权或最小暴露原则。这个原则是指在软件设计中,应该最小限度的暴露必要的内容,而将其它内容都“隐藏”起来。比如某个模块或对象的API设计。这个规则可以延伸到如何选择作用域来包含变量和函数。当然将所有的变量和函数放在全局作用域中是可以在所有的内部嵌套作用域中访问到它们。但这样会破会最小特权原则。因为可能会暴露过多的变量和函数。而正确的代码应该是可以阻止这些变量或函数进行访问的。

规避冲突

隐藏作用域中的变量或函数带来的另外一个好处就是可以避免同名标识符之间的冲突。两个标识符可能具有相同的名字但用途不一样,无意间可能造成命名冲突,会导致变量的值会被意外覆盖。因此,“隐藏”内部声明时唯一的最佳选择。

全局命名空间

当程序中加载了多个第三方库时,如果它们没有妥善的将内部私有的函数或变量隐藏起来,就会很容易引发冲突。

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

例如:

1
2
3
4
5
6
var MyReallyCoolLibrary = {
awesome: 'stuff',
doSomething: function() {
...
}
};

模块化管理

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

函数声明和函数表达式

当然我们知道,如何声明一个函数:

1
2
3
function foo() {
...
}

这样,我们调用这个函数的时候需要这样子:

1
foo();

有些函数我们只是想让它自动运行,在声明后再主动调用一次显得很多余,那可不可以直接在声明的时候就运行这个函数呢?

当然可以,js提供了这种方式,我们只需要这样:

1
2
3
(function foo() {
...
})();

我们来分析下,函数声明的时候是以括号(function开始的,而不仅仅是function开始。这样,函数会被当做函数表达式而不是一个标准的函数声明来处理。

函数表达式和函数声明之间最重要的区别是它们的名称标识符将会绑定在何处。拿上面的这个函数表达式来讲,函数的标识符foo被绑定在函数表达式自身的函数中,而不是所在作用域中。换句话说,函数表达式foo只能在...所代表的位置中杯访问,外部作用域则不行。这样,foo变量名被隐藏在自身中意味着不会非必要地污染外部作用域。

上面这种形式的函数表达式(function(){...})()也叫做立即执行函数表达式(IIFE,Immediately Invoked Function Expression)。分解来讲,由于函数的声明包含在一个()内,所以函数称为了一个表达式,通过在末尾加上另一个(),可以立即执行这个函数表达式。

块作用域

在JavaScript中,带有块作用域风格的代码可能会很陌生。比如:

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

我们需要个循环,所以在for语句里面定义了变量i。但是这个i声明后会绑定在外部作用域(函数或全局中)。变量的声明应该距离使用的地方越近越好,并最大限度的本地化。这样,开发者需要检查自己的代码,以避免在作用范围外意外的使用某些变量。

let声明

幸好,在ES6中引入了新的关键字let,提供了除了var以外的另一种变量声明方式。

let关键字可以将变量绑定到所在的任意作用域中(通常是{...}内部)。例如:

1
2
3
4
5
6
var foo = true;
if (foo) {
let bar = foo * 2;
console.log(bar); // 2
}
console.log(bar) // error

只要声明是有效的,在声明中的任意位置都可以使用{...}来为let创建一个用于绑定的块。一般用var声明的变量会被“提升”,“提升”是指声明会被视为存在于其所出现的作用域的整个范围之内。但是使用let声明的变量不会在块作用域中提升。声明的代码被运行之前,并不“存在”。

1
2
3
4
5
if (foo) {
console.log(bar); // error
let bar = 2;
console.log(bar); // 2
}

let循环:

1
2
3
for (let i = 0; i < 10; i ++) {
console.log(i);
}

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

const声明

除了let外,ES6还引入了const关键字,同样可以创建块作用域变量,但其值是固定的,即常量,无法修改,尝试修改会引发错误。

1
2
3
4
5
{
const a = 1;
a = 2; // error
}
console.log(a); // error

本质上,声明在一个函数内部的变量或函数会在所处的作用域中“隐藏”起来,这是有意为之的良好软件的设计原则。但和函数作用域单元相对应,可以创建属于某个代码块(通常是{...}内部)的块级作用域。开发者可以根据需要选择使用何种作用域,创造可读、可维护的优良代码。

参考自:《你不知道的JavaScript(上卷)》

码字辛苦,打赏个咖啡☕️可好?💘