前言:面向程序设计有四大特性——抽象、封装、多态、继承,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 只是一个用于标注构造函数引用的一个共享属性。那么原型对象、构造函数、实例的关系如下图所示:

javascript中的原型对象 javascript原型和原型链特点_搜索

原型链是什么

我们上面有说到过,原型对象也是一个对象,既然是对象,那么它就可以通过 new Object() 的方式创建,所以它也是 Object 构造函数的一个实例,那么它同样也有自己的原型对象,结构如图:

javascript中的原型对象 javascript原型和原型链特点_搜索_02

当然,按理说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()
  • 构造函数和原型混合创建(日常写原生用的基本上就是这种了)