w3ctech

【 js 基础 】作用域和闭包

一、编译过程

常见编译性语言,在程序代码执行之前会经历三个步骤,称为编译。

步骤一:分词或者词法分析

将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单元。

例子:

var a = 2;

这一句通常被分解成为下面这些词法单元:var 、a 、 = 、2、; 。

步骤二:解析或者语法分析

将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree, AST)

例子:

var 、a 、 = 、2、; 会生成类似与下面的语法树: alt text

步骤三:代码生成

将 抽象语法树 (AST)转换为可执行代码。

然而对于解释型语言(例如JavaScript)来说,通过词法分析和语法分析得到语法树,没有生成可执行文件的这一过程,就可以开始解释执行了。

对于 var a = 2; 进行处理的时候,会有 引擎、编译器、还有作用域的参与。
引擎:从头到尾负责整个 Javascript 程序的编译及执行过程。
编译器:负责语法分析及代码生成等。
作用域:负责收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符(变量)的访问权限。

他们是这样合作的:
首先编译器会进行如下处理:
1、var a,编译器会从作用域中寻找是否已经有一个该名称的变量存在于同一个作用域的集合中。如果是,编译器会自动忽略该声明,继续进行编译;否则它会要求作用域在当前作用域的集合中声明一个新的变量,并命名为 a 。
2、接下来编译器会为引擎生成运行时所需的代码,这些代码用来处理 a = 2 这个赋值操作。引擎运行时会首先从作用域中查找 当前作用域集合中是否存在 变量 a。如果有,引擎就会使用这个变量。如果没有,引擎就会继续向上一级作用域集合中查找改变量。

然后 如果引擎最终找到了 变量 a,就赋值 2 给它。如果没有找到,就会抛出一个异常。

总结:
变量的赋值操作分两步完成:第一步 由编译器在作用域中声明一个变量(前提是之前没有声明过),第二步 是在运行时引擎会在作用域中查找该变量,如果可以找到,就对其赋值。

二、作用域
1、RL 查询
在上一部分我们说到了,引擎会对变量 a 进行查找。而查找分为两种,一是 LHS(Left-Hand-Side) 查询,二是 RHS(Right-Hand-Side) 查询。
LHS 查询:试图找到变量的容器本身,从而可以对其赋值。也就是查找 变量 a 。
RHS 查询:查找某个变量的值。查找变量 a 的值,即 2。
例子:

console.log(a); // 这里对 a 是一个 RHS 查询,找到 a 的值,并 console.log 出来。
a = 2; // 这里对 a 是一个 LHS 查询,找到 变量 a,并对其赋值为 2 。
function foo(a){
    console.log(a); // 2
}

foo(2); // 这里首先对 foo() 函数调用,执行 RHS 查询,即找到 foo 函数,然后 执行了  a = 2 的传参赋值,这里首先执行 LHS 查询 找到 a 并赋值为 2,然后 console.log(a) 执行了 RHS 查询。

这里还要说一下 作用域的嵌套:当一个块或函数嵌套在另一个块或函数中时,就发生了所用的嵌套。遍历查找嵌套作用域,是首先从当前作用域中查找变量,如果找不到,就像上一级继续查找,当抵达全局作用域时,无论找到还是没有找到,查找都将结束。

如果 RHS 查找在所有嵌套作用域中都没有找到所需变量,引擎就会抛出 ReferenceError。如果找到了所需变量,但你想要进行不合理的操作,比如对非函数类型的值进行调用等,引擎就会抛出 TypeError 。

如果 LHS 查找在顶层全局作用域中都没有找到所需变量,如果是在非严格模式下,全局作用域会创建一个具有该名称的变量,并将其返回给引擎,如果是在严格模式下,引擎就会抛出 ReferenceError。

ReferenceError 和 TypeError 是比较常见的异常,你需要知道它们的不同,对你排除程序问题有很大帮助。

2、词法作用域

由你在写代码时将变量和块作用域写在哪里来决定的。

例子:

function foo(a){
    var b = a*2;
    function bar(c){
        console.log(a,b,c);
    }

    bar(b*3);
}
foo(2); // 2,4,12

在这段代码中有三层作用域,如图所示嵌套:

作用域1:包含着全局作用域,其中有标识符:foo.

作用域2:包含着 foo 所创建的作用域,其中有三个标识符:a、 b、 bar。

作用域3:包含着 bar 所创建的作用域,其中有标识符:c。

在查找变量时,作用域查找会在找到第一个匹配的标识符时停止。而且它只查找一级标识符,比如a 、b、c,而对于 foo.bar.baz ,词法作用域只会查找 foo 标识符,找到这个变量之后,对象属性访问规则会分别接管对 bar 和 baz 属性的访问。

这里还要说一点,全局变量会自动成为全局对象的属性,所以可以间接的通过全局对象属性的引用来对其进行访问。

window.a

3、提升

变量和函数在内的所有声明都会在任何代码被执行前首先被处理。

举个例子:

当你看到 var a = 2; 时,可能会认为这是一个声明,但实际上 Javascript 会将其看成两个声明:var a ; 和 a = 2;并且在不同阶段执行。var a 是在编译阶段进行的,而 a = 2 会被留在原地等待执行阶段。

这个过程就好像变量和函数声明从它们在代码中出现的位置被“移动”到了最上面,这个过程就叫做变量提升。

例子:

foo();

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

学了上面的知识,你应该可以猜到 foo() 可以正常执行,而 console.log(a) 会打出 undefined; 原因是当把提升应用到上面代码,代码就相当于 下面的形式:

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

foo();

对于变量提升要注意另外两个知识点:

1、函数声明会被提升,而函数表达式却不会被提升。

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

例子:

函数声明:

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

函数表达式:

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

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

那么对于提升,来看个例子:

foo(); // 报TypeError错误

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

这段代码相当于

var foo;
foo();  // 此时 foo 为 undefined,而我们尝试对它进行函数式调用,属于不合理操作,报 TypeError 错误。
foo = function() {
    var a;
    console.log(a);
    a = 2; 
}

2、函数会被优先提升,然后是变量。

例子:

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

会输出 1 为不是 2,这段代码提升之后相当于:

function foo(){
    console.log(1);
}
foo(); 
foo = function(){
    console.log(2);
}

注意,var foo 尽管出现在 function foo() 之前,但它是重复的声明,因为函数声明会被提升到普通变量之前。重复的 var 声明会被忽略,但出现在后面的函数声明却会覆盖前面的。

例子:

foo(); // 3

function foo(){
    console.log(1);
}
var foo = function(){
    console.log(2)
}

function foo(){
    console.log(3)
}

三、闭包

所谓 闭包:

当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。

例子:

function foo(){
    var a = 2;
    function bar(){
        console.log(a);
    }
    return bar;
}

var baz = foo();
baz(); //2

例子中,通过调用 baz 来调用 foo 内部的 bar , bar 在自己定义的词法作用域以外的地方执行,在 foo 执行之后,通常会期待 foo 的整个内部作用域被销毁,因为引擎的垃圾回收器会释放不再使用的内存空间。看上去 foo 不再被使用,所以很自然的考虑到对其进行回收,然而闭包就是阻止这样的事情发生,事实上内部作用域依然存在,没有被回收,因为 bar 依然在使用该作用域。

bar 拥有 涵盖 foo 内部作用域的闭包,使得该作用域能够一直存活,以供 bar 在之后任何时间进行引用。

bar 依然持有对该作用域的引用,而这个引用就叫作 闭包。

bar 在定义时的词法作用域以外的地方被调用,闭包使得函数可以继续访问定义时的作用域。

再举个例子:

function wait(message){
    setTimeout(function timer(){
        console.log(message)
    },1000)
}

wait("hi");

timer 具有 涵盖 wait 作用域的闭包,保有对 message 的引用。

wait 执行 1s 后,它的内部作用域不会消失,timer 依然保有 wait 作用域的闭包,所以 可以获得 message 并 console.log,这就是闭包。

也就是说,如果将函数作为第一级的值类型并到处传递,你就会看到闭包在这些函数中的应用。像定时器、事件监听器、Ajax 请求、跨窗口通信或者任何异步任务中,只要使用了回调函数,实际上就是在使用闭包。

例子:

function foo(){
    var a = 2;
    function baz(){
        console.log(a); // 2
    }
    bar(baz);
}

function bar(fn){
    fn(); //闭包
}

内部函数 baz 传递给 bar,当在 bar 中调用 baz时,就可以访问到他定义时所在的作用域中的变量,console.log 出 a。

例子:

for (var i=0;i<=5;i++){
    setTimeout(function timer(){
        console.log(i)
    },i*1000)
}

我们预期输出数次1-5,每秒一次,每次一个。

然而 真正的 输出结果却是 五个 6。

原因是 timer 在 循环结束之后即 i 等于 6 时, 才执行,就算你将 setTimeout 的时间 设为 0 ,回调函数也会在循环结束之后才执行。

那么我们应该怎么解决呢?

我们希望每次循环时,timer 都会给自己捕获一个 i 的副本,然而根据作用域的原理,实际情况却是 尽管 循环的 5 个函数是在各个迭代中被分别定义的,但是它们都封闭在一个共享的全局作用域中,因此实际上只有一个 i,所有函数共享一个 i 的引用。如果将延迟函数的回调重复定义5次,不使用循环,那它同这段代码完全等价的。

所以解决办法就是 循环的过程中每个迭代都需要一个闭包作用域。而 立即执行函数 正好可以做到这一点。

for (var i=0;i<=5;i++){
    (function(i){
        setTimeout(function timer(){
            console.log(i)
        },i*1000)
    })(i)
}

在循环中使用 立即执行函数会为每个迭代生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代的内部,每个迭代都会含有一个正确的 i 等待 console。

问题解决。

现在 你应该真正的明白 作用域和闭包了,找点题做吧,加深一下印象,不然你还会回来的。


感谢并学习自:

《你不知道的JavaScript》上卷 (炒鸡推荐大家看)

w3ctech微信

扫码关注w3ctech微信公众号

共收到0条回复