我们的程序之所以能够实现足够复杂的功能,很大程度是因为程序能够记住运行的状态。比如运行过程中变量的值,函数的声明等等。如果没有状态,程序的功能性将会受到很大的限制。程序查找状态的规则就叫做作用域。
速览编译原理
尽管我们将javascript归为动态语言或者解释性语言,但是javascript在运行前也需要编译。通常编译会发生在运行前极短的时间内。
在编译和运行过程中有以下几个重要的角色:
- 引擎:负责整个编译和运行的过程,如大名鼎鼎的
V8引擎 - 编译器:负责编译过程中一系列的累活,主要包括:代码词法分析及生成抽象语法树(AST);生成机器可识别指令代码
- 作用域:负责收集并维护所有声明的变量所组成的查询,并实施一套严格的规则确定当前执行的代码对这些变量的访问权限
以var a = 2为例。引擎在编译时和在运行时对这行代码的处理是完全不同的。
在编译时:编译器会询问作用域是否已经有一个同名的变量存在于当前作用域,如果是则忽略该声明,继续编译;否则要求作用域在当前作用域中声明一个新的变量a。
在运行时:引擎会首先询问作用域,在当前作用域是否存在a变量,如果存在就是用这个变量。否则继续往更上层作用域查找,如果直至全局作用域还未找到,在严格模式下会报错Reference Error;在非严格模式下会默认在全局作用域创建该变量。
作用域
在js中,作用域分为两大类:词法作用域和动态作用域;词法作用域是最普遍的模型。
动态作用域通常通过
eval或者with实现,通常不推荐使用,不做过多讲解。
词法作用域是由写代码时变量的位置在哪里决定的。包括全局作用域,函数作用域,块级作用域(ES6新增)。
function foo(a) {
var b = a + 2;
function bar(c) {
console.log(a, b, c);
}
bar(b + 3);
}
foo(1); // 1, 3, 6
- 全局作用域:只有一个标识符
foo foo所创建的函数作用域:有三个标识符a,bar,bbar所创建的函数作用域: 只有一个标识符c
作用域是严格包含的,全局作用域包含foo函数作用域,foo函数作用域包含bar函数作用域。一个作用域只存在一个父级作用域。
在上述代码中,执行到console.log时,会查找变量a,b,c。首先会从当前的最内部作用域(bar)开始查找,能够找到c,找不到a和b;继续往父级作用域(foo)查找,找到a和b。这种一层一层的查找路径就是作用域链。标识符解析就是按照作用域链查找的过程。
标识符的查找会在找到第一个标识符时停止。所以如果在多层嵌套的作用域中存在多个同名标识符。会使用内部的标识符。这种现象称为“遮蔽效应”。
function foo() {
var a = 10;
function bar() {
var a = 20;
console.log(a); // 20
}
bar();
}
foo();
块级作用域
for (var i = 0; i < 5; i++) {
console.log(i);
}
console.log(i); // 5
由于var声明的变量属于外部作用域(这里是全局作用域),所以在foo循环外部也能被访问。这其实是不合理的,我们并不希望i能在外部被访问。
通过es6中的let,const声明变量,可以将变量绑定在所在的任意作用域中({...}内部),即let所声明的变量劫持了所在的块级作用域。改写上面的例子:
for (let i = 0; i < 5; i++) {
console.log(i);
}
console.log(i); // ReferenceError: i is not defined
for循环头部中的let不仅将i绑定到循环的块中。也会将它重新绑定在循环的每一个迭代中,确保使用上一次循环迭代结束时的值重新进行赋值。相当于设置循环变量的部分是一个父作用域,循环内部是一个子作用域。
for (let i = 0; i < 5; i++) {
console.log(i);
}
它相当于:
{
let i;
for (i = 0; i < 5; i++) {
let j = i;
console.log(j);
}
}
const同样可以创建块级作用域,let通常用于声明变量,const用于声明常量。当我们说块级作用域时,一定是结合let或const来说的。
{
var a = 1; // 使用var声明,所以该{}不会生成一个新的块级作用域
{
let b = 2; // 使用let声明,所以该{}会生成一个新的块级作用域
}
}
需要注意,只有以上三种情况会生成作用域,单纯的对象语法不会生成作用域:
const a = {
name: "name",
say: function () {
// ......
},
};
上面的代码中只存在两个作用域,全局作用域和say函数作用域。a对象的{}不会生成一个新的作用域,它只是对象声明语法。
变量提升
我们通常会认为代码在执行时是由下往下逐行执行。这并不完全正确。
想一想下面两个console分别会输出什么呢?
console.log(a);
a = 2;
var a;
console.log(a);
第一个console会输出undefined,第二个会输出2。
这里需要回顾第一节的编译原理。在编译阶段会找到所有的声明,并用作用域将它们关联起来。所以所有声明都会在任何代码被执行前首先被处理。这就好像是把所有的声明提升到了当前作用域的最顶端。
只有声明本身会被提升,而赋值或其他运行逻辑会留在原地。
上面的代码相当于:
var a;
console.log(a);
a = 2;
console.log(a);
函数声明同样会被提升:
foo(); // 正确运行
function foo() {
console.log("this is foo");
}
变量提升有几个规则:
- 函数声明会首先被提升,然后才是变量声明
- 在进行函数提升时,如果已经存在相同的函数声明,则直接覆盖
- 在进行变量提升时,如果已经存在相同的声明(不管是函数声明还是变量声明),则忽略该声明
- 变量提升会发生在每一层作用域
foo(); // this is another foo
function foo() {
console.log("this is foo");
}
// 覆盖前一个函数声明
function foo() {
console.log("this is another foo");
}
var foo; // 已存在相同声明,该声明被忽略
需要注意,函数声明会被提升,但是函数表达式并不会被提升:
foo(); // TypeError: foo is not a function. foo此时是undefined
bar(); // ReferenceError: bar is not defined
// bar不会被提升
var foo = function bar() {
console.log("this is bar");
};
这个代码片段经过提升后,相当于:
var foo;
foo(); // TypeError: foo is not a function. foo此时是undefined
bar(); // ReferenceError: bar is not defined
foo = function () {
var bar= ...self... // 伪代码,表示bar指向该函数本身。所以bar实际上无法被外部作用域访问
// ...
}
foo();
然而,如果是使用let或者const声明的变量不会有变量提升。这就意味着在声明变量之前,无法使用该变量:
console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a;
a = 2;
闭包
闭包是js中一个十分重要的特性。甚至可以说如果没有闭包,就没有模块化。什么是闭包呢?当函数可以记住并访问所在的词法作用域是,就产生了闭包,即使函数是在当前词法作用域外执行。
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2
bar在它的词法作用域外执行。但是它还是能够按照原始的作用域链正确查找到a的值。这就是闭包。
通常,在执行完foo后,会将foo的整个作用域销毁。但闭包阻止了这件事情的发生,使得我们在foo执行完后,继续保持作用域,用于bar在之后对其进行引用。
无论使用何种方式对函数的值进行传递,当函数在它的词法作用域外被调用,都能观察到闭包。
var baz;
function foo() {
var a = 2;
function bar() {
console.log(a);
}
baz = bar;
}
foo();
baz(); // 2
举一个常见的模块化的例子:
// a.js
const name = "xxx";
export function getName() {
return name;
}
// b.js
import { getName } from "./a.js";
console.log(getName()); // xxxx
getName在它的词法作用域外被调用,但它能持有对原始定义作用域的引用,所以能够使用闭包获取正确的变量值。
实际上,闭包广泛存在js代码中:
function waitTime(msg, time) {
setTimeout(() => {
console.log(msg);
}, time);
}
waitTime("hello closure", 1000);
waitTime在1000ms后,它内部的作用域并没有消失,所以setTimeout中的匿名函数依旧有waitTime作用域的闭包,所以能够正确输出msg。
经典闭包问题
提到闭包,有一道面试题的出现频率十分的高,这段代码输出的是什么呢?
for (var i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 1000);
}
正常情况下,我们期望这段代码输出数字0~4,每隔一秒输出一个。现实情况是每隔一秒输出一个5。
这是因为var所声明的变量属于外部作用域,在这里就是全局作用域。所有setTimeout的回调函数共享这一个变量,当循环结束后,i已经变成5,这个运行时间远小于定时器的延时时间。所以当定时器的回调运行时,都输出了5。
所以解决方法是让每个循环块有自己的作用域,在自己的作用域中有单独的变量供定时器的回调函数调用。
解决方法一:使用IIFE为循环增加一个函数作用域。
for (var i = 0; i < 5; i++) {
(function (j) {
setTimeout(() => {
console.log(j);
}, j * 100);
})(i);
}
解决方法二:使用let为循环增加一个块级作用域。
for (let i = 0; i < 5; i++) {
setTimeout(() => {
console.log(i);
}, i * 100);
}
第二种方法几乎和原始代码一模一样,只是改变了变量的声明方式。