前言:面向程序设计有四大特性——抽象、封装、多态、继承,JS 的继承与其他语言不同,有自己独有的一套基于原型的继承,它是通过原型和原型链组织起来的一种独特的特性。所以也应该抽空好好总结一下原型、原型链、继承。
原型是什么
原型是指原型对象,它的本质是一个对象。由于语言设计之初,Brendan Eich 并不打算引入类的概念,对象都是通过 new 命令调用构造函数来创建的,这就导致了没有办法共享属性和方法(属性只能挂载在 this,也就是它的实例上,没有一个共有的东西来共享属性),为了实现这一个共有的属性,所以 Brendan Eich 决定为构造函数设置一个 prototype 属性(这里应该算是用到了函数也是对象的特性,为函数添加了一个 prototype 属性)。现在我们就拥有了一个这样的结构:
class Function {
public static prototype = {} //这个对象就是原型
}
但是 prototype
指向的原型对象,只是一个用来存放共享属性和方法的一个空间,那么实例化后的对象怎么获取他们呢?这里就设计了 __proto__
私有属性来指向原型对象,用于构建实例与原型对象的关系,使实例能够查找到这一片空间。现在我们可以看到这个结构慢慢变成了这样:
class Function {
public static prototype = {}
private __proto__:Record<string, any="">
constructor() {
this.__proto__ = Function.prototype //__proto__ 属性指向原型对象
}
}
因为一个构造函数可以生成多个实例,官方定义了一个属性 constructor
描述是 返回创建该对象的函数的引用,也就是构造函数的引用,因为这个 constructor 是一个 共享属性
,所以将其放到了原型对象上,用于所有实例共享,我们可以看到结构慢慢地变成了这样:
class Function {
public static prototype = {
constructor: Function //返回构造函数的引用
}
private __proto__:Record<string, any="">
constructor() {
this.__proto__ = Function.prototype
}
}
总结
原型就是原型对象,构造函数的 prototype
、构造函数实例化对象的 __proto__
都指向原型对象,它的设计初衷是为了能够共享方法和共享属性。__proto__
的设计是起了一个指向原型对象的指针的作用,能够让所有实例都访问到原型对象;constructor
只是一个用于标注构造函数引用的一个共享属性。那么原型对象、构造函数、实例的关系如下图所示:
原型链是什么
我们上面有说到过,原型对象也是一个对象,既然是对象,那么它就可以通过 new Object()
的方式创建,所以它也是 Object 构造函数的一个实例,那么它同样也有自己的原型对象,结构如图:
当然,按理说Object.prototype
仍然是一个对象,它的 __proto__
仍然会指向指向 Object.prototype
也就是它本身,构成一个自环,为了避免这种无意义事情的发生,所以将 Object.prototype.__proto__
置为了 null
,含义为空节点(这里有一个特性,就是 __proto__
的指向是能够变更的)。
现在我们来看从 test → Test.prototype → Object.prototype → Null 这个结构,是不是就是一个链表结构 —— test 实例作为头节点, __proto__
作为 next 指针,Object.prototype 作为尾节点指向空。所以原型链就是通过原型对象构成的一条链表结构
补充1 —— 对象属性的搜索机制
因为有原型的存在,一个对象的属性既可以存放于构造函数里,也能存放在原型对象上,用于所有实例共享,所以查找对象的属性也需要先查找构造函数里是否存在,接着通过 __proto__
查找原型对象是否存在。但原型对象同样也是一个对象,它如果搜索不到自己的属性也同样会通过 __proto__
查找该属性是否再它的原型对象上存在,依次层层向上搜索,直到当原型对象为 null,其实也就是一个链表查找。
用代码语言描述就是下面这个样子:
const a = new Proxy(new Object(), {
get: (target, key) => {
let cur = target
// 如果 cur 为 null 就停止,说明没找到
while(cur !== null) {
if(cur[key]) {
return cur[key]
} else if(typeof cur[key] === 'undefined') {
cur = cur.__proto__ //不断层层搜索
}
}
return undefined
}
})
补充2——基于原型的继承
由于上面说的对象属性搜索机制存在,所以对象可以不需要显式地调用 __proto__
去寻找属性,而原型对象中的属性是所有实例共享的,只要修改了prototype对象,就会同时影响实例对象,在不清楚具体实现的眼中看来,原型对象就好像是实例对象的原型,而实例对象则好像"继承"了原型对象一样。
举个例子:
function Duck(){}
Duck.prototype.quack = () => console.log('嘎嘎')
const duck1 = new Duck()
const duck2 = new Duck()
duck1.quack(); // 嘎嘎
duck2.quack(); // 嘎嘎·
//修改叫声
Duck.prototype.quack = () => console.log('扑哧')
duck1.quack(); // 扑哧
duck2.quack(); // 扑哧
// 由于修改原型对象上的 quack 方法会导致所有实例对象的方法发生改变,看上去就好像实例对象继承了原型对象,原型对象作为父类修改自己的方法影响了子类。(严格地来说不能说是实例对象上的方法,方法还是存放在原型对象上,只是一种搜索机制而已)
当然,这种继承只是看起来,引用《你不知道的JavaScript》中的话,就是:
继承意味着复制操作,然而 JavaScript 默认并不会复制对象的属性,相反,JavaScript 只是在两个对象之间创建一个关联,这样,一个对象就可以通过委托访问另一个对象的属性和函数,所以与其叫继承,委托的说法反而更准确些。
附录
关于设计模式里的原型模式和 JS 原型的思考
看到有很多文章和原型模式扯上关系,但在我反复看过很多本设计模式的书之后,我发现这两者好像不太一样,一个侧重于复制原型生成对象,一个是共享原型生成对象。
- 原型模式只是我们日常所说的深拷贝和浅拷贝,是使用对象复制生成对象的方式创建对象,节约用 new 重新运行构造函数花费的时间。
- JavaScript 中的原型对象——是所有实例对象共享的同一个原型对象,目的是为了开辟一个实例对象共享的变量空间。
JavaScript 创建对象的几种方式
- Object 构造函数创建:
new Object()
- Object 静态方法创建:
Object.create(null)
- 使用字面量创建:
var a = {}
- 使用工厂模式创建
function ObjectFactory(name) {
const a = {}
a.name = name
return a
}
var obj = new ObjectFactory('test')
- 使用构造函数创建
function ObjectFactory(name) {
this.name = name
}
var obj = new ObjectFactory('test')
- 使用原型创建对象
function ObjectFactory() {}
ObjectFactory.prototype.name = '123'
var obj = new ObjectFactory()
- 构造函数和原型混合创建(日常写原生用的基本上就是这种了)