You don't know js(scopes & Closures)

作用域和闭包

最近读了 You don’t know js 的Scope & Closures章节,记录一下学到的。

编译简介

先简单介绍一下一般语言的编译过程:

  1. 标记解释/词法分析

    把一段字符串的程序解析成几个有意义的部分。比如var a = 2;在这一步中会被解释成var, a, =, 2;。这两个词的具体意思是什么呢?或者说区别是什么,举个例子,这里决定a是单独的一个标记,还是作为另外一个标记中的一部分比如说var a这是个单独的标记。词法分析就是在做这件事情。

  2. 解析

    把上一步做的一系列的标记解析成一个抽象语法树(简称AST)。比如说var a = 2,可以被解析为:

    screenshot

  3. 生成代码

    这一步是把 AST 变成可执行代码 。这里先不做详细介绍了,只知道var a = 2最后的结果是会创建一个变量a(申请一个内存空间等等的操作),然后把2这个值存进变量a

但是 JS 不同于传统的预编译语言如(C, Java 等),它是一门边运行边编译的语言。比如说这里的var a = 2;是在它被即可执行前才编译的,而不是在整个程序开始之前。这里是先编译分析var a的变量声明,再执行a = 2

作用域( scope )介绍:

scope 是指一系列规则来决定一个变量在哪里怎么被找到。这个查找过程涉及这两种 LHS ( setter 查询,left-hand-side)、RHS ( getter 查询, right-hand-side)。

先解释下 LHS 和 RHS,对于var a = 2

  1. 首先做 RHS 查询(RHS),var a,查询a的变量,一个 getter 操作。
  2. 然后a = 2,对变量 a 做一个赋值,属于一个 setter 操作。

LHS 和 RHS 的差别在哪里?

比如这段代码:

1
2
3
4
5
6
function foo(a) {
console.log( a + b );
b = a;
}
foo(2);

执行到console.log( a + b )的时候,compiler对这里做两个RHS查询,一个a和一个ba在这里被赋值为 2 了,而b没有在当前作用域也就是function foo{}里被声明。这时候,JS 会抛出一个b的ReferenceError错误。

改一下代码(如果在浏览器环境做测试,请刷新下当前 console,下面例子请都这样操作:

1
2
3
4
5
function foo(a) {
b = a;
}
foo(2);

执行到b = a;的时候,compiler 对这里的 b 做 LHS 操作,同样b也没有在foo中声明,可是没有报错。继续在 console 里输出 b,会得到 2 的结果。

如果不在 JS 的严格模式下,compiler 会自动对 LHS 查询的变量做创建工作。

在改一下代码:

1
2
3
4
5
6
7
8
function foo(a) {
console.log( a + b );
b = a;
}
var b = 3;
foo(2);

输出结果是5;
可以看到,LHS 和 RHS,是会 scope 向上查询的,直到找到了var b的声明。

继续改:- -!

1
2
3
4
5
6
7
function foo(a) {
console.log( a + b );
b = a;
}
foo(2);
var b = 3;

输出结果是 NaN!

引出下一个话题Hoisting(变量提升)。

回到之前聊的 JS 编译过程,这段程序也是先编译var b, 再执行b = 3;整个程序编译后是这样的:

1
2
3
4
5
6
7
8
9
function foo(a) {
console.log( a + b ); // b 是undefined
b = a;
}
var b;
foo(2);
b = 3;

看上面的程序,一个是声明方法,另一个是声明变量。既然编译器不是程序的书写顺序来编译,那么如果我同时有一个变量名和方法名声明都叫 foo,这时候哪个优先级更高呢?

1
2
3
4
5
6
7
8
9
foo(); // 1
var foo = function() {
console.log( 2 );
};
function foo() {
console.log( 1 );
}

可以看到变量 foo 是先声明的,可是方法 foo 是被优先编译的。编译后的代码如下:

1
2
3
4
5
6
7
8
9
10
11
function foo() {
console.log(1);
}
var foo;
foo = function() {
console.log(2);
};
foo();

顺便提一句,JS里的方法声明和方法变量声明是不一样的

方法变量声明:

1
2
3
4
5
foo(); // not ReferenceError, but TypeError!
var foo = function bar() {
// ...
};

这里编译为:

1
2
3
4
5
6
7
var foo;
foo(); //不确定foo的类型是不是方法
foo = function bar() {
// ...
};

方法声明:

1
2
3
4
5
foo(); // 正常运行
function foo() {
// ...
};

看下一段程序:

1
2
3
4
5
6
7
8
9
foo(); // "b"
var a = true;
if (a) {
function foo() { console.log( "a" ); }
}
else {
function foo() { console.log( "b" ); }
}

输出结果是 b! 这里涉及到两个问题:

  1. JS 的块作用域({...})并没有什么卵用。还是会用 else 里面的声明。
  2. 方法声明编译的时候会被覆盖。

继续聊下一个话题—块作用域。

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

如果 JS 有像 C 语言一样的块级作用域,那么这里的 i 应该会抛出ReferenceError的错误。可是这里输出了 10,也就是说 JS 的块级作用域是无效的。

那么 JS 是怎么来实现块级作用域呢?答案是 function。比如:

1
2
3
4
5
6
7
8
9
10
11
12
function foo(a) {
var b = 2;
// some code
function bar() {
// ...
}
// more code
var c = 3;
}

这里的变量b, cfunction bar都是对外隐藏的,只在foo function 的作用域里有效。

既然 function 可以来隔离作用域,那么我们可以轻松实现作用域隔离。

1
2
3
4
5
6
7
8
9
10
11
var a = 2;
function foo() { // <-- insert this
var a = 3;
console.log( a ); // 3
} // <-- and this
foo(); // <-- and this
console.log( a ); // 2

但是这里又有foo这个函数名把全局的命名空间给污染了。JS 通常的解决方式是:

1
2
3
4
5
6
7
8
9
10
var a = 2;
(function foo(){ // <-- insert this
var a = 3;
console.log( a ); // 3
})(); // <-- and this
console.log( a ); // 2

这样既做到了作用域里面的变量没有污染全局,foo方法也没有污染全局。这种方式叫做立即执行函数表达式(IIFE–Invoking Function Expressions Immediately)

ES6 的 let 与 const

let 与 const 提供了 JS 的块级作用域。比如回到之前的:

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

这里的let i就只在 for 循环里块级作用域中起效。

闭包

闭包的定义:

Closure is when a function is able to remember and access its lexical scope even when that function is executing outside its lexical scope.

简而言之,是指一个方法在它词法作用域外被调用的时候,还能引用到它词法作用域上下文的变量的方法。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
function foo() {
var a = 2;
function bar() {
console.log(a);
}
return bar;
}
var baz = foo();
baz(); // 2 --baz() (也就是bar)在外部调用,还可以捕获到它上下文的变量`var a`

这里的 baz 就是闭包,它在外部被调用的时候还能”认识”变量var a,而变量a是在foo函数里声明的,并且只在foo函数里有效。

看一个经典的理解闭包的案例:

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

这里的输出结果是,每一秒打印一个 6,一共打印了 5 次。

如果是一个刚接触 JS 的人,可能会认为这里应该是1,2,3,4,5。每隔一秒打印一个。

首先来看,为什么是输出 6。setTimeout函数是在 for 循环结束后执行的(即使setTimeout(.., 0)),for 循环结束的时候i的值是 6。所以打印了 5 次 6.

回想之前说到的块级作用域,这里的var i不仅仅在 for 循环块级作用域中有作用,在全局作用域中也是有作用的,如果想要输出 1,2,3,4,5的话,那就让循环里的每个 i 都只在一个单独的作用域里。之前也说到,只有 function 可以有独立的作用域。

所以这里尝试改成这样:

1
2
3
4
5
6
7
for (var i= 1; i <= 5; i++) {
(function() {
setTimeout(function timer() {
console.log(i);
}, i * 1000 );
})();
}

很遗憾,结果还是一样的,打印了 5 个 6。为什么呢?虽然这里用 IIFE 创建分割了循环的作用域,可是它什么都没有做,因为这是作用域啥都没干,i还是外部作用域变量的那个 i,i 的值没有在这个作用域中被捕获,还是循环后的6。

所以这里尝试捕获一下 i 的在 IIFE 的作用域中:

1
2
3
4
5
6
7
8
for (var i = 1; i <= 5; i++) {
(function(){
var j = i;
setTimeout(function timer() {
console.log(j);
}, j * 1000 );
})();
}

成功了!1,2,3,4,5 每隔一秒被打印了。

更优雅些:

1
2
3
4
5
6
7
for (var i = 1; i <= 5; i++) {
(function(j){
setTimeout(function timer() {
console.log(j);
}, j * 1000 );
})(i);
}

但是说到底,我们的需求很简单,只是想要 for 循环的块级作用域起效果就行了,用闭包来实现作用域显得有些啰嗦。

所以回想一下之前说到的ES6的let

1
2
3
4
5
6
for (var i = 1; i <= 5; i++) {
let j = i; // yay, block-scope for closure!
setTimeout(function timer() {
console.log(j);
}, j * 1000);
}

这里每次,用 j 来创建一个有效的块级作用域就可以了。

当然 ES6 对 for 循环里的 let 也做了特殊的行为操作—每次循环的时候都会自动创建一个新的 let i,所以可以像下面这么优雅的写了。

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

模块化开发

如果不是很了解前端模块化开发的价值,可以参考一下玉伯的这篇文章—前端模块化开发的价值

Revealing Module

下面这段是典型的一个 Revealing 模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function CoolModule() {
var something = "cool";
var another = [1, 2, 3];
function doSomething() {
console.log(something);
}
function doAnother() {
console.log(another.join(" ! "));
}
return {
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

上面这段模块化的代码做到了什么:

  1. 如果外部不调用CoolModule()方法的话,方法体里面的代码是不会被编译执行的(前面说到过 JS 是边编译边执行的)
  2. CoolModule返回了一个新对象,只有新对象里的属性(闭包方法)能访问CoolModule这个作用域里的变量(有点像私有属性了)

Modern Modules

现在已经有很多库在这方面都做的不错了,这里介绍个最简单的概念实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var MyModules = (function Manager() {
var modules = {};
function define(name, deps, impl) {
for (var i = 0; i < deps.length; i++) {
deps[i] = modules[deps[i]];
}
modules[name] = impl.apply(impl, deps);
}
function get(name) {
return modules[name];
}
return {
define,
get
};
})();

这段代码同时包含了模块的导出,和模块的引入功能,看下如何使用它:

1
2
3
4
5
6
7
8
9
MyModules.define("bar", [], function () {
function hello(who) {
return "Let me introduce: " + who;
}
return {
hello: hello
};
});

定义bar模块,并且在这个模块里不引入其他模块。

1
2
3
4
5
6
7
8
9
MyModules.define("foo", ["bar"], function (bar) {
function awesome(which) {
console.log(bar.hello(which).toUpperCase());
}
return {
awesome
};
});

定义另外 foo 模块,并且在 foo 模块中引入 bar 模块。

1
2
3
4
5
6
7
var foo = MyModules.get("foo");
console.log(
bar.hello("hippo")
); // Let me introduce: hippo
foo.awesome("David"); // LET ME INTRODUCE: DAVID

JS 里的模块化,有两点必须要满足:

  1. 通过一个外部的方法来创建作用域。
  2. 外部方法返回的对象至少要有一个闭包来捕获方法作用域里的其他内部对象。

一道微博流行的面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function Foo() {
getName = function () { alert (1); };
return this;
}
Foo.getName = function () { alert (2);};
Foo.prototype.getName = function () { alert (3);};
var getName = function () { alert (4);};
function getName() { alert (5);}
//请写出以下输出结果:
1. Foo.getName();
2. getName();
3. Foo().getName();
4. getName();
4. new Foo.getName();
5. new Foo().getName();
6. new new Foo().getName();

.

.

.

答案是:

  1. 2 — Foo对象的属性方法
  2. 4 — 编译后的代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function Foo() {
    getName = function () { alert(1); };
    return this;
    }
    function getName() { alert(5);} // 函数声明高于变量声明
    var getName;
    getName = function () { alert(4);};
    Foo.getName = function () { alert(2);};
    Foo.prototype.getName = function () { alert(3);};
    getName(); // 4
  3. 1 — Foo()调用过程中 1. 更改了 getName 变量的实现 2. Foo() 返回的对象是全局对象 this。所以 getName() 还是全局对象的调用只不过实现改了。

4、5、6 首先考验的是对 JS 运算优先级的认识,不熟悉可以先参考MDN的资料再考虑答案是什么。

IV. 2 — 点操作优先级高于new操作符,所以这里new (Foo.getName)(),也就是 2

V. 3 — 根据操作符优先级,首先翻译为(new Foo()).getName()new Foo()返回一个与 Foo 原型链链接的新对象而不是Foo()返回的全局对象。这里这个新对象就去找Foo.prototype上的getName属性。

VI. 3 — 和上一个一样,只不过是又多了个 new 操作。

动态作用域

之前介绍都是词法作用域,解释了JS在编译期间是如何查找变量的规则。现在看看 JS 运行时候的作用域是怎么定义的,JS 里的 this 就是动态作用域来决定的(之前一篇介绍this的)。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
function foo() {
console.log(a);
}
function bar() {
var a = 3;
foo();
}
var a = 2;
bar();

这里会输出 2 还是 3 呢?

从词法作用域角度来看,编译的时候,是先去找foo里的 a 变量,找不到,然后去找全局的 a,全局的是2。所以foo()稳稳的输出 2。

可是,在bar运行的时候,foo同样也会去找 a 的值,但是它首先会在bar的作用域里查找,所以bar稳稳的输出 3。

所以除了考虑词法作用域(编译时查找),动态作用域也是要考虑的(运行时查找)。