JS 中的链
在 JavaScript 中有原型链和作用域链这两个概念,这只是自下而上的观看。如果自上而下的看得就会变成树,也就是用于解释 JavaScript 的抽象语法树(AST)。对于新手来说,两链一树实在过于抽象,以致于经常理解得模棱两可,但是如果能够透彻的理解的两链一树,那么其他概念就迎刃而解,例如继承,封装,闭包等。本文着重会讲述两链,两链都是单向链结构(如下图),只能向上追溯。
原型链
在说原型之前,我们先要了解一下类型,在《The ECMAScript Language Specification》的第八章(Types)定义了六种类型,分别是 undefined、null、boolean、string、number、object。一般会使用 typeof 进行类型检查,但是这里有些特殊的情况,我们来看看下面一段代码:
1
2
3
4
5
6
console.log(typeof null); // object
console.log(null instanceof Object); // false
function foo() {}
console.log(typeof foo); // function
console.log(foo instanceof Object); // true
从上面的这段代码会让我们很疑惑,实际上 null 是一个单独的类型,而 typeof null 的结果是一个 bug,这个 bug 官方也是给予了承认,但是由于历史原因,这个 bug 不会被修复。再来看 function,因为函数在进行构造函数检测的时候能够追溯到 Object 的,所以函数为 object 类型。至于为什么函数在进行类型检查的时候返回 function,可能是因为函数作为第一等公民的存在原因,不过这只是我的猜测。
再回到原型上来,原型只存在于对象之中,而函数具有原型,这也是函数为什么属于对象而不是单独类型的原因。对象会被存储在内存某个地方,而变量保存的是这个对象的引用,引用就像一根线,连接变量于内存中的实例,这个引用你可以理解为寻址。引用是一个很关键的点,不管是原型链还是作用域链都非常重要。再看看下面的代码:
1
2
3
4
5
6
var obj1 = {};
obj1.a = 1;
var obj2 = Object.create(obj1);
obj2.b = 2;
var obj3 = Object.create(obj2);
obj3.c = 3;
上面代码通过 Object.create 方法来创建对象从而实现原型继承,这里要注意一点,除了函数有 prototype 这个属性外,对象变量是没有这个属性,对象变量的有_proto_属性,这个是属于对象变量的原型属性。函数在被构造的时候会把 prototype 赋值给_proto_,但是这两个指向的是同一个对象。接下来用图片来看看 3 个对象的关系:
上图就是一个原型链,图中虚线圆圈表示对象的_proto_属性,一个对象的原型指向另外一个对象,如此往复,直到最终指向 Object.prototype 为止,而这里的指向是引用关系。我们在进行属性调用的时候,其实就是一个寻找属性的过程,从被调用的对象开始,通过原型链一个接一个的寻找,直到找到为止,这个过程是短路操作。如果我们需要调用 obj3.a,首先会在 obj3 上寻找 a,如果没有,就会通过原型去 obj2 上寻找 a,然后用相同的方式一直找,找到就中止,如果到 Object.prototype 也没有找到,那么就返回 undefined 给 a。这就是原型链属性查找。
说完原型链,再来说说原型链继承,继承概念来源于 OOP,通过子类继承父类来实现属性公用。由于 JavaScript 并没有类这个概念,即便在 ES6 给出了 class 这个语法,也依然只是语法糖,包括 new 操作也只是 JavaScript 对 OOP 的一种妥协。在 OOP 中也只有类具备继承,而对象是不行的。原型链继承是因为原型链属性查找的原因实现了类似于继承这样的一个表象,通过原型链属性查找来实现了属性的公用。在这里要理清下面两点:
- 属性并没有被继承下来,可以通过 Object.hasOwnProperty 来检查。
- 相同属性不是覆盖,而是查找短路。
由于原型链的指向是引用关系,因此直接修改原型的是非常危险的事情,就如下面这段代码:
1
2
3
4
5
6
console.log(obj3.a); // 1
console.log(obj3.b); // 2
obj2.a = 4;
obj2.b = 5;
console.log(obj3.a); // 4
console.log(obj3.b); // 5
可以发现原来的 obj3.a = 1 和 obj3.b = 2 全部被改掉了,这在实际工作中是非常危险的事情,由于原型被修改导致的问题很难被追查。所以 JavaScript 中的继承一般使用 Object.assign 和对象属性一一拷贝的方式来实现,这样可以隔离大部分引用,但是面对对象嵌套对象有时候就很无力,除非用递归来是实现层层拷贝,但是这样的消耗也是很恐怖的。
作用域链
理解完原型链后再理解作用域就会轻松很多,在这里把作用域限定函数作用域和全局作用域,可以把函数作用域比作对象,把父级函数作用域比作原型,把全局作用域比作 Object.prototype,这样是不是就清晰明了。
看图说话:
用代码来表示:
1
2
3
4
5
6
7
8
9
10
11
var globalScope = Object.prototype;
globalScope.a = 1;
var aooScope = Object.create(globalScope);
aooScope.b = 2;
var booScope = Object.create(aooScope);
booScope.c = 3;
var cooScope = Object.create(booScope);
cooScope.d = 4;
这样看是不是就很有意思多了,每个作用域相当于一个对象,里面的变量都是这个对象的属性,父级作用域相当于是子级作用域的原型,通过原型链属性查询,子级作用域可以访问父级作用域的属性,这个查找也基本短路属性,一级一级往上找,找到就停。还有一种说法是复制并写入,就是子级把父级的变量拷贝一份并合并给自己,在我看来,这明显不可取,如果修改父级变量,子级访问的肯定是修改的值。接下来了解下跟作用域息息相关的闭包,在 YDKJS 里面是这样定义闭包的:
Closure is when a function can remember and access its lexical scope even when it’s invoked outside its lexical scope.
这里就不去关注 remember 和 access,如果从作用域链的角度出发,这两点就是基本条件,关注点集中到 invoked outside,外部调用是啥意思呢,拿图中的代码来说就是在全局里面调用 coo 方法,这个怎么来调用就看下面的两段代码:
代码 1:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var a = 1;
var fn;
function aoo() {
var b = 2;
function boo() {
var c = 3;
function coo() {
var d = 4;
// todos
console.log(b);
}
fn = coo;
}
boo();
}
aoo();
fn(); // 2
代码 2:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a = 1;
function aoo() {
var b = 2;
function boo() {
var c = 3;
function coo() {
var d = 4;
// todos
console.log(b);
}
return coo;
}
return boo;
}
var fn = aoo()();
fn(); // 2
两段代码都是现实了外部调用,不管是代码 1 的直接赋值还是代码 2 的返回赋值,结果都是 fn 引用了 coo,用于引用是在外部调用,所以 coo 的作用域相对外部来说是封闭的,所以也就叫闭包。那么在回到刚才写的作用域链里面,fn 引用了 coo,coo 的作用域对象是 cooScope,这样就很清晰的看到 fn 在调用的时候是 coo 是如何访问父级作用的变量的。
结束语
我采用经济学里常用的方法,去掉大部分变量,通过对简单的变量分析来研究,因此这些解释有点粗糙,不过在我看来还是比较通俗易懂的。作用域还有很多内容,闭包也有其他的内容,但是这些都不是这里讨论。