是时候该更新 “原型“ 相关的旧知识库了

137 阅读10分钟

在 JavaScript 中可以通过原型实现继承,JavaScript 只有一种结构:对象,因此 JavaScript 的继承和其他面向对象的类继承不同,它是对象的继承。

对象原型和对象继承

在 JavaScript 对象中有个特殊的内部属性 [[Prototype]],该属性指向的对象叫'原型',该属性值只能是 null 或者对象。 当读取对象一个属性时,会先在该对象上找是否有这个属性,没有的话会从原型对象上找是否有这个属性。通过这种方式实现对象继承。

code1.png

原型链

原型链就该对象的原型对象内部也有 [[Prototype]] 属性,他指向另一个带有 [[Prototype]] 属性的对象,而形成的一个链条。例如数组,数组的原型对象中 [[Prototype]] 属性时指向 Object.prototype

模拟类继承

JavaScript 也提供了一种类似类继承的方式,也就是构造函数的 prototype 属性;还有 ES6 增加了 Class extends 类继承,但其实只是一个语法糖,实际也是用构造函数的 prototype 属性来实现的。

在老的 JavaScript 中,构造函数的 prototype 是唯一可靠的设置原型的方法。

let animal = {
  eats: true
};

function Rabbit(name) {
  this.name = name;
}

Rabbit.prototype = animal;

let rabbit = new Rabbit("White Rabbit");

alert( rabbit.eats ); // true

当函数执行 new 操作时会使创建的 this 对象的 [[Prototype]] 属性指向函数的 prototype 对象。

没有设置函数的 prototype 属性时,也会给其默认生成一个只有 constructor 属性的对象。

constructor 属性指向自身函数,因此我们创建对象也可以使用 constructor 来创建对象,但这种方式要注意一点是constructor 属性时可以被修改的。

function Rabbit(name) {
  this.name = name;
  alert(name);
}

let rabbit = new Rabbit("White Rabbit");

let rabbit2 = new rabbit.constructor("Black Rabbit");

数组的 mapfilter 等返回的新数组也是通过 new 调用 this 原型对象的 constructor 来创建的对象。

早期基于构造函数的 prototype 属性有6种继承模式:

注:以下模式名称中提到的原型都是指构造函数的 prototype 属性,因为在早期,构造函数的 prototype 是唯一可靠的设置原型的方法。

  1. 原型式继承:将父对象赋值给构造函数的 prototype 属性上然后 new 执行构造函数生成继承对象,Object.create 就是这个原理。
function object(person) {
 function F() {}
 F.prototype = person
 return new F()
}

let person = {
 name:'小明',
 colors:['red','blue']
}

let person1 = object(person)
person1.colors.push('green')
let person2 = object(person)
person1.colors.push('yellow')
console.log(person) //['red','blue','green','yellow']

这种方式能通过 instanceOfisPrototypeOf 的检测,而缺点是 JavaScript 中对象是引用的,也就是所有子对象的原型 [[Prototype]] 都指向同一个父对象,而往往我们开发的时候对象的属性值会经常改变,如果父对象属性值改变将会导致其所有的子对象的该属性值也跟着改变,有时这是一个潜藏比较深的隐患。

  1. 原型链继承:模拟类继承的方式。
function SuperType() {
 this.property = true;
}
SuperType.prototype.getSuperValue = function() {
 return this.property;
};
function SubType() {
 this.subproperty = false;
}
// 继承SuperType
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function () { //注意 不能通过对象字面量的方式添加新方法,否则上一行无效
 return this.subproperty; 
};
let instance = new SubType();
console.log(instance.getSuperValue()); // true

这种方式的优缺点和原型式继承一样。

  1. 借用构造函数继承:利用 call 或者 apply 在子构造函数中指向父构造函数传子构造函数的 this 进去。
function SuperType(name) {
 this.colors = ["red","blue","green"];
 this.name = name;
 }
function SubType(name) {
 SuperType.call(this,name);
}
let instance1 = new SuperType('小明')
let instance2 = new SuperType('小白')
instance1.colors .push('yellow')
console.log(instance1) //{name:"小明",colors:["red","blue","green","yellow"]...}
console.log(instance2) //{name:"小白",colors:["red","blue","green"]...}

该方式解决了原型式继承的缺点,但原型链断了,父对象的原型无法继承过来,并且不能通过 instanceOfisPrototypeOf 的检测。

  1. 组合继承:方法放在原型声明,子类构造函数 call 执行父类构造函数,子类构造函数原型指向父类实例。
function SuperType(name){
 this.name = name;
 this.colors = ["red","blue","green"];
}
SuperType.prototype.sayName = function() {
 console.log(this.name);
};
function SubType(name, age){
 // 继承属性 第二次调用
 SuperType.call(this, name);
 this.age = age;
}
// 继承方法 第一次调用
SubType.prototype = new SuperType();
SubType.prototype.sayAge = function() {
 console.log(this.age);
};
let instance1 = new SubType("Nicholas", 29);
instance1.colors.push("black");
console.log(instance1.colors); //["red,blue,green,black"]
instance1.sayName(); // "Nicholas";
instance1.sayAge(); // 29
let instance2 = new SubType("Greg", 27);
console.log(instance2.colors); // ["red,blue,green"]
instance2.sayName(); // "Greg";
instance2.sayAge(); // 27

这种方式可以通过 instanceOfisPrototypeOf 检测,并且父对象的属性在子对象单独创建的,而父对象的方法是共享的,一般方法是不会改变的,因此减少方法多次初始化的性能消耗,但是构造函数执行了两次。

  1. 寄生式继承:在原型式继承的基础上添加方法声明。
function object(person) {
 function F() {}
 F.prototype = person
 return new F()
}
function createAnother(original){
 let clone = object(original); // 通过调用函数创建一个新对象
 clone.sayHi = function() { // 以某种方式增强这个对象
 console.log("hi");
};
 return clone; // 返回这个对象
}

这种方式是在原型式继承基础上添加方法。缺点和原型式继承一样。

  1. 寄生组合式继承:在组合继承上将父类的原型赋值给子类原型。
//核心代码
function object(person) {
 function F(params) {}
 F.prototype = person
 return new F()
}
function inheritPrototype(SubType,SuperType) {
 let prototype = object(SuperType.prototype) //生成一个父类原型的副本

 //重写这个实例的constructor
 prototype.constructor = SubType

 //将这个对象副本赋值给 子类的原型
 SubType.prototype = prototype
}

function SuperType(name) {
 this.name = name;
 this.colors = ["red","blue","green"];
}
SuperType.prototype.sayName = function() {
 console.log(this.name);
};
function SubType(name, age) {
 SuperType.call(this, name);
 this.age = age;
}

//调用inheritPrototype函数给子类原型赋值,修复了组合继承的问题
inheritPrototype(SubType, SuperType);

SubType.prototype.sayAge = function() {
 console.log(this.age);
};

解决组合继承执行两次父构造函数的缺点,但这种方式相对复杂些。

原型的操作方法

构造函数的 prototype 属性

前面已经介绍过了构造函数的 prototype 属性的方式设置对象原型,这是使用给定原型创建对象的最古老的方式。

Object.create

之后标准出了 Object.create 来实现给定原型创建对象的新方式,相对构造函数简单许多,但仍然无法改变已有对象的原型。

let animal = {
  eats: true
};

// 创建一个以 animal 为原型的新对象
let rabbit = Object.create(animal); // 与 {__proto__: animal} 相同

alert(rabbit.eats); // true

alert(Object.getPrototypeOf(rabbit) === animal); // true

Object.create 方法的第二个参数是属性描述,因此它可以克隆所有属性包括可枚举和不可枚举、数据属性和 setter/getter 方法

let clone = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj)
);

__proto__

大部分浏览器还实现了非标准的 __proto____proto__ 其实是 [[Prototype]] 的访问器也就是 getter/setter 方法。

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal

对象的 __proto__ 是可以被赋值修改的,但只接受 nullobject 类型,其他类型的赋值会被忽略。

let obj = {};

obj['__proto__'] = "some value";

alert(obj[key]); // [object Object],并不是 "some value"!

并且这个 getter/setterObject.prototype 的访问器属性。

这里有个疑问:一般我对对象属性的增删改都都是是会操作到自身对象上不会作用到原型对象上的,如下:

let animal = {
  eats: true
};
let rabbit = {
  jumps: true
};

rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal

rabbit.eats=false

animal.eats // true

rabbit 对象设置 eats 属性时在自己对象上添加 eats 属性,并不会修改原型对象 animaleats 属性值,那么我们在设置 __proto__ 的时候为什么会调用到原型链最底层的 Object.prototype 上的 __proto__ 呢?这就涉及到访问器在原型链中调用机制。

我们先来看看 ECMA-262 规范中对内部 [[set]] 的执行流程

code2.png

规范描述是这样:

  • 如果自身该属性描述为undefined,那么
  • 声明 parent 为对象的原型 O.[[GetPrototypeOf]]()
  • 如果 parent 不为 null,那么
  • 执行 parentset 方法
  • 否则在自身对象上添加该属性并且设置其描述为 { [[Value]]: undefined,[[Writable]]: true, [[Enumerable]]: true,[[Configurable]]: true }

转为人话就是如果自身对象没有这个访问器就会找原型上是否有这个访问器,如果有就执行原型的 [[set]] 方法,没有就给自身对象添加这个属性。规范中没有说明会不会从原型链上一种找下去。我们可以试试看

  let obj = {
      set propName(value) {
          // 当执行 obj.propName = value 操作时,setter 起作用
          console.log(`__proto__ set function:${value}`)
      }
  };
  let subObj = Object.create(obj)
  subObj.propName = 2  //__proto__ set function:2

控制台打印出 __proto__ set function:2 说明有执行原型的 set 方法。

我们再来看看原型链上的会不会执行:

  let obj = {
      set propName(value) {
          // 当执行 obj.propName = value 操作时,setter 起作用
          console.log(`__proto__ set function:${value}`)
      }
  };
  let subObj = Object.create(obj)
  let grandsonObj = Object.create(subObj)
  grandsonObj.propName = 2 //__proto__ set function:2

原型链上也能会执行最顶层原型的 set 方法。

因此,虽然 __proto__ 是在 Object.prototype 上的访问器,当 rabbit.__proto__ = animal 的时候仍然会触发__proto__set 方法。

这种 set 执行流程也使得 vue 在响应式 Proxy 对象时要特别处理,下面会讲到。

Object.getPrototypeOf 和 Object.setPrototypeOf

现代设置原型的方法。

let animal = {
  eats: true
};

let rabbit={}

Object.setPrototypeOf(rabbit, animal);// 将 rabbit 的原型修改为 animal

alert(Object.getPrototypeOf(rabbit) === animal); // true

very plain 对象

当我们平时开发时常将对象作为键值对存储数据,但当 __proto__ 作为键时就有问题,__proto__ 键只能被赋值为一个对象或 null,并且对象的原型也被修改,可能导致意想不到的 bug。我们可以通过 Object.create 来解决这个问题。

Object.create(null)

Object.create(null) 创建了一个空对象,这个对象没有原型,因此它没有继承 __proto__getter/setter 方法,但缺点是对象不能使用内置的方法,比如 toString

Proxy 代理原型

ES6 Proxy 作用是包装目标对象并拦截诸如读取/写入属性和其他操作,拦截也就是通过 get/set 捕捉器进行拦截。

let user = {
  name: "John",
};

user = new Proxy(user, {
  get(target, prop, receiver) {
    console.log(`GET ${prop}`)
    return target[prop];
  },
  set(target, prop, val, receiver) {
     console.log(`SET ${prop}`)
    return target[prop]=val; 
  }
});

let name = user.name; // "GET name"
user.name = "Pete"; // "SET name=Pete"

但如果 user 对象的原型也是个代理对象,并且访问原型的属性时会有什么效果呢?

let parent = {
    bar: 'parent'
}

parent = new Proxy(parent, {
    get(target, prop, receiver) {
        console.log(`parent GET ${prop}`);
        return Reflect.get(target, prop, receiver);
    },
    set(target, prop, val, receiver) {
        console.log(`parent SET ${prop}`);
        Reflect.set(target, prop, val, receiver)
    }
})


let child = { name: "John" }
Object.setPrototypeOf(child, parent)
child = new Proxy(child, {
    get(target, prop, receiver) {
        console.log(`child GET ${prop}`);
        return Reflect.get(target, prop, receiver);
    },
    set(target, prop, val, receiver) {
        console.log(`child SET ${prop}`);
        Reflect.set(target, prop, val, receiver)
    }
});

let bar = child.bar // child GET bar  parent GET bar

上面代码的执行过程是: 1.child.bar 先执行 child 代理对象的 get 方法。 2.get 方法返回目标对象的 bar 属性目标对象没有这个属性,于是从原型对象 parent 代理对象上找 3.执行 parent 代理对象的 get 方法,返回 parent 的目标对象 bar 属性。

set 方法也是同样的机制。

这里有个疑问为什么用 Reflect.get 而不用 target[prop],在 get 方法中还看不出明显的差异,两种方式最后返回的结果是一样的。差异比较明显的是 set 方法。

我们先来看看 set 方法中使用 Reflect.set 方式:

 let parent = {
      bar: 'parent'
  }

  parent = new Proxy(parent, {
      get(target, prop, receiver) {
          console.log(`parent GET ${prop}`);
          return Reflect.get(target, prop, receiver);
      },
      set(target, prop, val, receiver) {
          console.log(`parent SET ${prop}`);
          Reflect.set(target, prop, val, receiver)
      }
  })


  let child = { name: "John" }
  Object.setPrototypeOf(child, parent)
  child = new Proxy(child, {
      get(target, prop, receiver) {
          console.log(`child GET ${prop}`);
          return Reflect.get(target, prop, receiver);
      },
      set(target, prop, val, receiver) {
          console.log(`child SET ${prop}`);
          Reflect.set(target, prop, val, receiver)
      }
  });

  child.bar = "Pete";
  console.log(parent)
  console.log(child)

运行结果

code3.png

原型对象的 bar 并没有被修改为 Pete,而且是在自己对象上添加了 bar 属性并且值为 Pete

我们再来看 target[prop] 的方式:

  let parent = {
      bar: 'parent'
  }

  parent = new Proxy(parent, {
      get(target, prop, receiver) {
          console.log(`parent GET ${prop}`);
          return Reflect.get(target, prop, receiver);
      },
      set(target, prop, val, receiver) {
          console.log(`parent SET ${prop}`);
          target[prop] = val
      }
  })


  let child = { name: "John" }
  Object.setPrototypeOf(child, parent)
  child = new Proxy(child, {
      get(target, prop, receiver) {
          console.log(`child GET ${prop}`);
          return Reflect.get(target, prop, receiver);
      },
      set(target, prop, val, receiver) {
          console.log(`child SET ${prop}`);
          target[prop] = val
      }
  });

  child.bar = "Pete";
  console.log(parent)
  console.log(child)

运行结果为:

code4.png

原型对象 parentbar 属性值变为 Pete , child 对象上并未有 bar 属性。

为什么会这样,主要是 Reflect.set 的最后一个参数 receiverreceiver 的详细说明会在下一篇 Proxy 中详细说明,这就直接说结果,receiver 参数可以理解为设置 this 对象,Reflect.set 的操作是作用在 receiver 参数对象上的。而 parentset 方法 receiver 参数是 child 代理对象,因此 Reflect.set(target, prop, val, receiver) 操作就作用在 receiver 对象上的,具体原理下一篇详细说明。

vue 通过 Proxy 实现响应式也有这个问题,代理的对象的原型也是一个代理对象时,触发 set 方法会执行两次派发更新,vue 通过 set 方法的 receiver 对象判断是否是自己的代理对象来避免 parent 对象的 set 方法触发派发更新。

下一篇会详细介绍 Proxy 对象和 vue 通过 Proxy 实现响应遇到的问题。