Nancy's Studio.

JS之闭包和原型那些事儿

Word count: 2,806 / Reading time: 11 min
2019/04/02 Share

Part1:作用域和闭包

作用域是什么?

我们需要一套设计良好的规则用于存储变量,并且之后可以方便地找到这些变量,这套规则被称为作用域。

JS的编译原理

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

  • 分词/词法分析

    在这个过程中由字符组成的字符串被分解成有意义的代码块,这些代码块即词法单元。比如var a = 2;会分解成var、a、=、2、;。

  • 解析/语法分析

    在这个过程中词法单元流(数组)转换成一个由元素逐级嵌套组成的代表程序语法结构的树,即“抽象语法树(AST)”。

  • 代码生成

    将抽象语法树转换成可执行代码。比如将var a = 2;的AST转化为一组机器指令,用来创建a变量(分配内存等),并将2存储在a中。

不过JS引擎相对这种传统的编译器要复杂得多,比如JS引擎在语法分析和代码生成时会对运行性能进行优化,包括对冗余元素的优化等。

对于JS来说,编译大多发生在代码执行前的几微秒甚至更短的时间内。

编译器遇到var a时会询问该作用域中是否已经有同名的变量,如果是编译器会忽略该声明,否则会声明一个新的变量a。接下来编译器会为引擎生成运行时所需的代码用于处理a=2这个赋值操作。引擎运行时会首先询问在当前作用域集合中是否存在一个叫作a的变量,如果存在引擎会把2赋给a,否则引擎继续查找该变量,若仍未找到则抛出异常。

JS引擎的LHS和RHS查询

LHS查询是查找变量的容器本身,从而对变量赋值;RHS查询即查找某个变量的值,或者说是取到它的源值。

“寻找赋值操作的目标”=>LHS查询;“获取变量的值”=>RHS查询

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

如果RHS查询在所有的嵌套作用域中都找不到所需变量引擎会抛出ReferenceError异常;而引擎在执行LHS查询时如果没有找到目标变量就会在全局作用域中创建一个具有该名称的变量,并将其返还给引擎(在非严格模式下),严格模式下也会报错。

词法作用域

词法作用域由变量和块作用域在代码中的位置决定。

有两种欺骗词法的机制:分别是eval()和with关键字的使用。欺骗词法作用域会导致性能下降,所以在代码中尽量不使用。

在执行eval(..)之后的代码时,引擎并不知道eval()内的代码是以动态的形式插入进来并对词法作用域进行修改的。

注意setTimeout(..)和setInterval(..)的第一个参数可以是字符串,字符串的内容会被解释成一段动态生成的函数代码。new Function(..)最后一个参数也可以接受代码字符串,也是类似的,不提倡使用。

with声明实际上是根据传递给它的对象凭空创建了一个全新的词法作用域,并且还会产生意想不到的影响,比如下面的例子。

1
2
3
4
5
6
7
8
9
function foo(obj){
with(obj){
a = 2;
}
}
var obj = { b:3 }
foo(obj); //注意obj中没有a变量
console.log(obj.a); //undefined
console.log(a); //2——a变量被泄露到全局作用域上了

函数作用域

把代码片段用函数声明进行封装实际上就是用作用域把这些代码隐藏了,将具体内容私有化,同时可以规避命名冲突。

1
2
function foo(){...}  //声明了一个具名函数,但foo这个函数名也会“污染”所在作用域
foo();

解决办法:

1
(function foo(){...})();  //立即执行函数表达式IIFE
1
2
3
4
var a = 2;
(function IIFE(obj){
console.log(obj.a);
})(window); //可以传入参数,函数也能传
1
2
3
4
5
6
7
8
9
10
//用匿名函数
setTimeout(function(){
alert('..');
},1000);
//匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难
//若没有函数名,当函数需要调用自身时只能通过过期的arguments.callee引用
//可以让匿名函数具名
setTimeout(function handler(){
alert('..');
},1000);

块级作用域

如for循环,with关键字,try/catch…还有各种{…}

let关键字可以将变量隐式地附加在一个已经存在的块级作用域上。比如let在for循环中会将变量i重新绑定到循环的每个迭代中。

1
2
3
4
5
6
7
8
9
10
11
for(let i=0;i<len;i++){
console.log(i);
}
//等同于
{
let j;
for(j=0;j<len;j++){
let i = j; //每个迭代重新绑定
console.log(i);
}
}

块级作用域有助于垃圾回收,为变量显式声明作用域并进行本地绑定十分有用。

1
2
3
4
5
6
7
8
9
function process(data){
//...
}
var bigData = {/*..*/};
process(bigData);
btn.addEventListener('click',function click(evt){
//click函数会形成一个覆盖整个作用域的闭包,可能导致bigData不被回收
console.log('clicked');
})

通过添加块级作用域解决

1
2
3
4
5
6
7
8
9
10
function process(data){
//...
}
{
let bigData = {/*..*/};
process(bigData);
}
btn.addEventListener('click',function click(evt){
console.log('clicked');
})

作用域提升

无论作用域中的声明出现在什么地方都会在代码本身被执行前首先进行处理,所有的声明(变量和函数)都会被“移动”到各自作用域的顶端。

函数声明和变量声明都会被提升,但是函数声明会优先被提升。

1
2
3
4
5
6
7
8
foo();  //1
var foo;
function foo(){ //函数声明会被优先提升
console.log(1);
}
foo = function(){
console.log(2);
}
1
2
3
4
5
6
7
8
//上面的代码实际是这样
function foo(){
console.log(1);
}
foo();
foo = function(){
console.log(2);
}
1
2
3
4
5
6
7
8
9
10
foo();  //3
function foo(){
console.log(1);
}
var foo = function(){
console.log(2);
}
function foo(){ //在后面出现的同名函数声明可以覆盖前面的
console.log(3);
}

作用域闭包

举个例子解释一下

1
2
3
4
5
6
7
8
9
function foo(){
var a = 2;
function bar(){
console.log(a);
}
return bar;
}
var baz = foo();
baz(); //2——这就是闭包的效果

bar()可以访问foo()的内部作用域,并将bar()作为返回值。然后将返回值赋给了baz,使得bar()函数在自身的词法作用域之外通过调用baz()被执行;foo()执行后其内部作用域不会被回收,因为bar()仍然在使用foo()的作用域。bar()拥有涵盖foo()内部作用域的闭包,使得该作用域能够一直存活,以供bar()在以后任何时间引用 ,bar()持有的对foo()内部作用域的引用就是闭包。

1
2
3
4
5
6
7
8
9
10
11
function foo(){
var a = 2;
function baz(){
console.log(a);
}
bar(baz);
}
function bar(fn){
fn(); //这里会调用传入的内部函数baz,闭包被使用
}
foo();

无论通过何种方式将内部函数传递到所在的词法作用域之外,都会使得它持有对原始作用域的引用,所以无论在何处执行这个函数都会使用闭包。

回调函数实际上也在使用闭包。

循环和闭包

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

上面这段代码我们预期会依次输出数字1~5,但实际上会已每秒一次的频率输出五次6。说明输出的是循环结束时的i值,这是因为延迟函数的回调会在循环结束时执行。我们试图假设每次迭代运行时都会给自己捕获一个i的副本,但它们还是被封闭在一个共享的全局作用域中,实际只有一个i。

我们需要在每次迭代中生成一个独有的闭包作用域。

1
2
3
4
5
6
7
8
9
//解决办法一
for(var i=1;i<=5;i++){
(function(){
var j = i;
setTimeout(function timer(){
console.log(j);
}, j*1000);
})(i);
}
1
2
3
4
5
6
7
//解决办法二
for(let i=1;i<=5;i++){ //每次迭代变量i都会被重新声明
//第一次迭代之后的每次迭代都会使用上一个迭代结束的值来初始化变量i
setTimeout(function timer(){
console.log(i);
},i*1000);
}

模块(重点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//这是一个模块
function module(){
var sth1 = 'value';
var sth2 = [1,2,3];
function doSomething(){
console.log(sth1);
}
function doAnother(){
console.log(sth2.join(','));
}
return { //返回值可以看作模块的公共API
doSomething: doSomething,
doAnother: doAnother
};
}
var foo = module();

模块模式需要具备两个必要条件:

  1. 必须有外部的封闭函数,该函数至少被调用一次(每次调用都会创建一个新的模块实例)。
  2. 封闭函数必须返回至少一个内部函数,这样内部函数才能在私有作用域中形成闭包,并可以访问或修改私有属性。

现代模块机制:

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
41
42
43
44
45
46
var MyModules = (function Manager(){
var modules = {}; //模块的内容
function define(name,deps,impl){
//name是我们定义的新模块的名字
//deps是新模块所依赖的模块名字的数组
//impl是个function
for(var i=0;i<deps.length;i++){
//将传入的deps数组的每个元素转变为模块的一个引用
deps[i] = modules[deps[i]];
}
//通过apply调用impl,将依赖模块的引用作为参数传入新模块的作用域中
modules[name] = impl.apply(impl,deps);
}
function get(name){
return modules[name];
}
return {
define: define,
get: get
};
})();

MyModules.define('bar',[],function(){
function hello(who){
return 'hello,' + who;
}
return {
hello: hello
};
});
MyModules.define('foo',['bar'],function(bar){
var val = 'happy';
function awesome(){
console.log(bar.hello(val).toUpperCase());
}
return {
awesome: awesome
};
});

var bar = MyModules.get('bar');
var foo = MyModules.get('foo');
console.log(
bar.hello('happy'); //hello,happy
);
foo.awesome(); //HELLO,HAPPY

插播apply()和call()的用法:链接

ES6模块API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//bar.js
function hello(who){
return 'hello,' + who;
}
export hello;

//foo.js
import hello from 'bar';
var val = 'happy';
function awesome(){
console.log(
hello(val).toUpperCase()
);
}
export awesome;

//baz.js
module foo from 'foo';
module bar from 'bar';
console.log(
bar.hello('happy'); //hello,happy
);
foo.awesome(); //HELLO,HAPPY

import可以将一个模块中的一个或多个API导入到当前作用域当中,并分别绑定到变量上;module会将整个模块的API导入并绑定到一个变量上。

模块文件中的内容会被当作包含在作用域闭包中一样来处理,就像函数闭包模块一样。

CATALOG
  1. 1. Part1:作用域和闭包
    1. 1.1. 作用域是什么?
    2. 1.2. JS的编译原理
    3. 1.3. JS引擎的LHS和RHS查询
    4. 1.4. 词法作用域
    5. 1.5. 函数作用域
    6. 1.6. 块级作用域
    7. 1.7. 作用域提升
    8. 1.8. 作用域闭包
    9. 1.9. 循环和闭包
    10. 1.10. 模块(重点)