从prototype的设计初衷剖析JS原型和原型链

1,686 阅读14分钟

prototype

官方定义:

在JavaScript中,prototype对象是实现面向对象的一个重要机制。每个函数就是一个对象(Function),函数对象都有一个子对象 prototype对象,类是以函数的形式来定义的prototype表示该函数的原型,也表示一个类的成员的集合。

也就是说,每个函数都有一个prototype属性,这个属性是指向一个对象的引用,这个对象称为原型对象(显式原型),原型对象包含函数实例共享的方法和属性,将函数用作构造函数调用(使用new操作符调用)的时候,新创建的对象会从原型对象上继承属性和方法,这就是prototype的设计意图,帮助JavaScript实现面向对象中的继承,即实现实例之间方法和属性的共享,解决在传统代码中,我们每创建一个对象实例,每个实例都会有重复的方法属性,在创建数量较多的对象实例时,代码冗余,占用内存多的问题

js中用函数来定义类,函数可作为构造函数使用,用prototype属性来实现继承,prototype可以理解为一个共享内存空间。

我们来写一个函数试下:

不用prototype创建类实例

function Person(age) {
  this.age = age;
  this.eat = ()=>{console.log('吃')}
}

const tom = new Person(20);
const sam = new Person(15)
console.log(tom) // Person {age: 20, eat: ƒ}
console.log(sam) // Person {age: 15, eat: ƒ}

上例Person即为构造函数,每次通过new 关键字创建一个实例对象时,会return一个全新的相互独立对象,其构造函数上定义属性和方法,在每个实例对象深拷贝一份,放在不同内存空间,这会占用比较多的内存,所以大部分情况下我们将属性私有,方法共享

使用prototype实现方法共享

function Person(age) {
  this.age = age;
}

Person.prototype.eat = ()=>{console.log('吃')};

const tom = new Person(20);
const sam = new Person(15);
console.log(tom) // Person {age: 20}
console.log(sam) // Person {age: 15}
tom.eat() // 吃

很明显,两个实例之间共享了eat方法,因为我们并没有在类中定义也可以成功调用,这是怎么做到的呢,tom和sam实例是如何获取到Person类的prototype的?就是通过 __proto__这个属性。

__proto__

__proto__(隐式原型) 是 [[Prototype]] 属性的 getter/setter,[[Prototype]]是js的一个隐藏属性,该属性指向其构造函数的原型对象(prototype),使用__proto__可以读取和设置这个隐藏属性。

例如我们输出tom的实例,就可以看到这个隐藏属性:

验证一下上面的定义:

function Person(age) {
  this.age = age;
}

Person.prototype.eat = ()=>{console.log('吃')};

const tom = new Person(20);
const sam = new Person(15);
console.log(sam.__proto__ === Person.prototype); // true

sam.__proto__指向了Person.prototype

我们可以看一下它们俩的输出,是一致的。

prototypeFunction独有的属性,而__proto__是每个对象都具有的属性,js中“万物皆对象”,prototype以及函数都是对象,所以它们也有__proto__属性。

实际上,__proto__调用的是Object.prototype.__proto__,即每个对象都继承了Object类的__proto__属性,所以它是每个对象都有的,该属性指向其构造函数的原型对象(prototype)。

对于函数本身来说,他的构造函数是js内置的Function,所以它的__proto__属性则指向Function.prototype

function F(){}
F.__proto__ === Function.prototype // true

Function类也是函数,Object类也是函数,是否也符合上面的规则呢?答案是肯定的。

Function.__proto__ === Function.prototype // true
Object.__proto__ === Function.prototype // true

原型链

当你访问一个对象不存在的属性时,例如上例的eat方法,对象会去它的__proto__属性上查找,即父类的prototype对象上,sam.__proto__指向了Person.prototype,如果你访问的属性在Person.prototype也不存在,那又会继续往Person.prototype.__proto__上找,这时就会找到JS顶层对象的原型Object.prototype了,Object.prototype再往上找就没有了,也就是null,这其实就是原型链,原型链的终点是Object.prototype

Function.prototype.__proto__ === Object.prototype;
Object.prototype.__proto__ === null; 

Object.prototype.__proto__ === null是唯一终点,避免了原型链无限循环引用。

__proto__本质上一个内部属性,标准明确规定,只有浏览器必须部署这个属性,其他运行环境不一定需要部署,而且新的代码最好认为这个属性是不存在的。因此,无论从语义的角度,还是从兼容性的角度,都不要使用这个属性,而是使用Object.setPrototypeOf()(写操作)、Object.getPrototypeOf()(读操作)、Object.create()(生成操作)代替。

constructor

constructor 属性是专门为 function 而设计的,它存在于每一个function 的prototype 属性中。这个constructor 保存了指向 function 的一个引用

例如,在定义一个函数时:

function F() {
    // ...
}

JavaScript 内部会执行如下几个动作:

  1. 为该函数添加一个原型(即 prototype)属性 
  2. 为 prototype 对象额外添加一个 constructor 属性,并且该属性保存指向函数F 的一个引用

F.prototype.constructor === F; // true

我们可以利用这个特性来完成下面的事情:

// 对象类型判断,如

const f = new F(a, b);

f.constructor === F; // true

// 其实 constructor 的出现原本就是用来进行对象类型判断的,但是 constructor 属性易变,不可信赖。我们有一种更加安全可靠的判定方法:instanceof 操作符。下面代码仍然返回 trueinstanceof F; // true

// 获取构造函数有几个参数
f.constructor.length // 2

// 在不能直接访问类的情况下想给类增加新方法,如类在闭包中
let a,b;
(function(){
  function A (arg1, arg2) {
    this.a = 1;
    this.b=2; 
  }
 
  A.prototype.log = function () {
    console.log(this.a);
  }
  a = new A();
  b = new A();
})()

a.log(); // 1

A.prototype.log2 = function(){ console.log(this.b)} // Uncaught ReferenceError: A is not defined

b.constructor.prototype.log2 = function(){ console.log(this.b)} // 使用constructor获取到类本身,再在原型属性上添加方法
b.log2(); // 2

注意上面我们用了 f.constructor,这并不是实例的自有属性,还记得原型链么,这是它从父类的原型对象里找到的。即f.constructor === F.prototype.constructor === F

new 操作符

在讲重头戏继承前,我们先了解一下new 操作符创建一个实例的过程:

  1. 开辟新的内存空间,创建一个新的空对象
  2. 这个对象的隐式原型__proto__指向构造函数的原型对象prototype
  3. 把构造函数中的 this 指向新创建的空对象并且执行构造函数返回执行结果
  4. 判断返回的执行结果是否是引用类型,如果是引用类型则返回执行结果,如果不是引用类型或null则返回创建的新对象
function myNew(Fn, ...args) {
  const obj = Object.create(Fn.prototype); // 创建一个空对象,把对象的原型指向构造函数的原型对象
  const result = Fn.apply(obj, args); // 把构造函数的this指向新对象执行并返回结果
  return result instanceof Object ? result : obj; // 如果构造函数有显式返回值且是引用类型,包括Functoin, Array, Date, RegExg, Error,则返回该值,否则返回新创建对象
}

试一下:

function F(value) {
  this.value = value;
  // return this; // 可以显式返回this,如果不显式返回的话,会返回创建的新对象
}

const f = myNew(F, 'value');
console.log(f); // new 返回值为对象 { value: "value", [[Prototype]]: Object }
console.log(f.value); // 'value'

继承

上面讲了,实例是如何通过__proto__属性去继承父类的prototype原型对象,乃至父类的父类的原型对象以实现共享原型链上的所有属性的,那类与类之间又是怎么实现继承的呢,有很多种方法,我们挨个来说。

一、原型链继承

核心:父类实例作为子类的原型

function Parent() {
  this.parentAge = 50;
  this.nums = [1, 3, 5];
  this.obj = {a: 3};
}
Parent.prototype.run = () => { console.log('跑') };
function Child() {
  this.childAge = 20;
}

Child.prototype = new Parent(); // 父类实例作为子类的原型
// 上面说了new 返回值为对象,即Child.prototype === { parentAge: 50, nums: [1, 3, 5], obj: {a: 3}, [[Prototype]]: Object },constructor被搞没了,所以要加回来
Child.prototype.constructor = Child;

const child1 = new Child();
child1.parentAge = 22; // 仅仅是给child1添加自己的属性,并没有访问原型上的数据
// child1.obj = {}; // child2.obj还是会输出{a: 3},不会发生改变
child1.obj.a = 6; // child2.obj会输出{a: 6}
child1.nums.push(6);
const child2 = new Child();

console.log(child2.run()) // 跑
console.log(child2.parentAge);    // 50,parentAge并没有改变
console.log(child2.childAge);    // 20
console.log(child2.obj);    // {a: 6}
console.log(child2.nums);    // [1, 3, 5, 6],child1里面添加了6,child2也被改变了

优点:父类方法和属性可以复用。

缺点:

  • 子类构建实例时不能向父类传递参数,因为new子类时父类实例早已被创建好。

  • 父类的引用属性会被所有子类实例共享,如代码中的numsobj属性当你修改它们时,实际上是修改的父类原型上的属性,所以所有实例都会受影响,无法实现多继承。

    原因很简单,实例读取属性时,自身没有的就去找原型链,如child1.obj.a就是在原型链上找到的,因为Child.prototype引用了父类的实例对象,所有new的实例的原型都指向同一个对象,所以其中一个实例修改了原型上的属性,那其它实例再读取时就会读到修改后的。

    但是有一种情况需要注意,当你直接给属性赋值的时候,如child1.obj = {}child1.parentAge = 22,是不会修改父类原型上的属性的,而是给当前实例添加自己的属性,像这样:

    function Child() {}
    
    Child.prototype = {parentAge: 50, nums: [1,3,5], obj: {a: 3}};
    Child.prototype.constructor = Child;
    
    const child1 = new Child();
    child1.parentAge = 70;
    child1.obj = {};
    
    console.log(child1)
    console.log(child1.parentAge)
    
    const child2 = new Child();
    
    console.log(child2.parentAge);
    console.log(child2.obj);
    

    结果:child1有了自己的属性parentAge值为70,且child2并没有受影响,因为原型上的parentAge值还是50,obj对象同理。

    image.png

    那为什么会这样呢,原因是:

    我们知道访问一个对象自身没有的属性时会在它的原型链上找,引擎在读取属性赋值操作时也会沿着原型链查找,当在原型对象上找到该属性时,会先查看其属性描述符[[writable]](默认为true)。
    若[[writable]]为true,则不会对原型对象上的属性进行操作,而是在该实例上创建一个新的同名属性。若[[writable]]为false,则不会修改原型也不会创建属性,赋值操作静默失败.
    还是放张图理解下:

    image.png

    这样设计的原因是:

    1. 原型链的作用之一是为对象提供默认值,即当对象自身不存在某属性的时候,这个属性应该表现出的默认值。为这个属性赋值的时候,不应该通过“改变默认值”(修改原型链上的属性)来做到,而应该通过创建一个新的值来覆盖默认值(默认值仍然存在,但它的优先级低于对象自有属性)。
    2. 多个对象可能共享同一个原型对象,如果对其中一个对象的属性赋值就可以改变原型对象的属性,那么 "="操作符会变得非常危险,你可能一不小心就把已经设计好的通用类给改了,从而影响到共享这个类原型的所有实例,出现很多无法预期的错误

    综上,要注意直接给属性赋值和修改属性内部值的区别。直接给属性赋值会给实例添加新属性,修改属性内部值会修改实例的原型链上的属性。

二、构造函数继承

核心: 子类调用父类的构造函数,这是所有继承中唯一一个不涉及到prototype的继承

function Parent(age) {
  this.age = age;
  this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
    Parent.call(this, age)
}

const child1 = new Child();
child1.nums.push(6);
const child2 = new Child();

console.log(child2.run()) // child2.run is not a function
console.log(child2.age);    // 20
console.log(child2.nums);    // [1, 3, 5]

优点:

  • 父类的引用属性不会被共享
  • 子类构建实例时可以向父类传递参数

缺点:

  • 不能访问原型属性和方法,方法不能复用

三、组合继承

核心: 原型链继承和构造函数继承的组合,兼具了二者的优点。是较常用的一种继承方法

function Parent(age) {
  this.age = age;
  this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
    Parent.call(this, age); //  第二次调用父类构造函数
}
Child.prototype = new Parent(); // 第一次调用父类构造函数

const child1 = new Child();
child1.nums.push(6);
const child2 = new Child();

console.log(child2.run()) // 跑
console.log(child2.age);    // 20
console.log(child2.nums);    // [1, 3, 5]

优点:

  • 父类的方法可以被复用
  • 父类的引用属性不会被共享
  • 子类构建实例时可以向父类传递参数

缺点:调用了两次父类的构造函数,第一次给子类的原型添加了父类属性,第二次给子类的构造函数添加了父类的属性,构造函数中如果有的话原型中的就用不到了,这种被覆盖的情况造成了性能上的浪费。

new 操作符就类似组合继承

四、原型式继承

核心: 基于已有对象为原型创建新对象,可用于普通对象之间的继承。

function create (o) { 
    function F() {};
    F.prototype = o; // 注意这里,同原型链继承一样,也是prototype属性浅拷贝了一个对象。
    return new F();
}

var parent = {
	age: 40,
	nums: [1, 2],
        run: ()=>{console.log('跑')},
}
var child1 = create(parent)
var child2 = create(parent)

child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]

优点:父类方法属性可以复用。

缺点:

  • 父类的引用属性会被所有子类实例共享
  • 子类构建实例时不能向父类传递参数

ECMAScript 5 通过新增 Object.create()方法规范化了原型式继承。这个方法接收两个参数:一 个用作新对象原型的对象和(可选的)一个为新对象定义额外属性的对象。

即上面代码可以改为

var parent = {
	age: 40,
	nums: [1, 2],
        run: ()=>{console.log('跑')},
}
var child1 = Object.create(parent); // 也是浅拷贝
var child2 = Object.create(parent);

child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]

五、寄生式继承

核心:使用原型式继承可以获得一份目标对象的浅拷贝,在这个浅拷贝对象上进行增强,添加一些方法属性。(或者你理解为增强式继承)

var parent = {
	age: 40,
	nums: [1, 2],
        run: () => {console.log('跑')},
}

function clone(original) {
    let clone = Object.create(original);
    clone.getFriends = function() { // 添加其他方法寄生在对象上
      return this.friends
    };
    return clone;
}

var child1 = clone(parent);
var child2 = clone(parent);

child1.nums.push(3)
console.log(child2.nums) // [1, 2, 3]

优缺点和原型式继承一样,只是寄生式继承添加了更多的方法。

六、 寄生组合式继承

核心:在前面几种继承方式的优缺点基础上进行改造,得出了寄生组合式的继承方式,这也是所有继承方式里面相对最优的继承方式。

function clone (parent, child) {
    // 这里改用 Object.create,因为是对父类原型的复制,所以可以减少组合继承中多进行一次构造的过程,也不会出现子类的原型和构造函数上有重复的属性
    child.prototype = Object.create(parent.prototype); // 原型链继承 + 原型式继承
    child.prototype.constructor = child;
}
  
function Parent(age) {
  this.age = age;
  this.nums = [1, 3, 5];
}
Parent.prototype.run = () => { console.log('跑') };
function Child(age) {
    Parent.call(this, age); // 构造函数继承
}
clone(Parent, Child); // 组合式继承
Child.prototype.say = () => { console.log('说') }; // 寄生式继承:即在Object.create()生成的新对象上增强方法。

const child1 = new Child(20);
child1.nums.push(6)
const child2 = new Child(20);

console.log(child1.age) // 20
console.log(child2.nums) //  [1, 3, 5]
child2.say() // 说
child2.run() // 跑

把上面五种继承方式整合起来,就是寄生组合式继承了。

寄生组合式继承是相对完美的一种继承方式,ES6的Class extends关键字内部实现方式就是寄生组合式继承。

总结:

以上,本文对prototype,proto,原型链、以及继承的六种方法进行了解析。

通俗点说,JS就是对象的大杂烩,一个应用会产生成千上万个对象,每个对象之间都有类似和通用的地方,如果每创建一个对象就拥有自己独立的一套方法和属性,那内存岂不是要炸了,所以在生产对象(创建构造函数)时,给它赋予一个额外的共享空间即prototype,别的对象可以引用它的共享空间,它的共享空间也可以引用别的对象的共享空间,这些共享空间之间的相互引用就是通过__proto__来达成,整个引用形成的链条就是原型链,原型链最底层的对象可以访问的整个链条的所有属性和方法。

constructor属性用于获取类本身,F.prototype.constructor === F,可用于判断类型、获取参数个数、添加原型方法等。

new一个对象的过程是,创建一个对象把它的原型指向构造函数的原型,然后将对象作为构造函数的this执行并执行结果。原理类似组合式继承。

在ES6之前实现继承的方式有,原型链继承、构造函数继承、组合式继承、原型式继承、寄生式继承、寄生组合式继承,寄生组合式继承整合了以上几种继承方式,是相对完美的一种继承方法。

ES6的Class extends关键字内部实现就是寄生组合式继承。

如果怕自己记得不扎实,可以收藏起来多捋几遍,最好自己手动敲一敲,加深印象。

感谢你花费宝贵的时间阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与发家分享更多干货