在 JavaScript 中可以通过原型实现继承,JavaScript 只有一种结构:对象,因此 JavaScript 的继承和其他面向对象的类继承不同,它是对象的继承。
对象原型和对象继承
在 JavaScript 对象中有个特殊的内部属性 [[Prototype]]
,该属性指向的对象叫'原型',该属性值只能是 null 或者对象。
当读取对象一个属性时,会先在该对象上找是否有这个属性,没有的话会从原型对象上找是否有这个属性。通过这种方式实现对象继承。
原型链
原型链就该对象的原型对象内部也有 [[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");
数组的 map
,filter
等返回的新数组也是通过 new
调用 this
原型对象的 constructor
来创建的对象。
早期基于构造函数的 prototype
属性有6种继承模式:
注:以下模式名称中提到的原型都是指构造函数的 prototype
属性,因为在早期,构造函数的 prototype
是唯一可靠的设置原型的方法。
- 原型式继承:将父对象赋值给构造函数的
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']
这种方式能通过 instanceOf
和 isPrototypeOf
的检测,而缺点是 JavaScript 中对象是引用的,也就是所有子对象的原型 [[Prototype]]
都指向同一个父对象,而往往我们开发的时候对象的属性值会经常改变,如果父对象属性值改变将会导致其所有的子对象的该属性值也跟着改变,有时这是一个潜藏比较深的隐患。
- 原型链继承:模拟类继承的方式。
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
这种方式的优缺点和原型式继承一样。
- 借用构造函数继承:利用
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"]...}
该方式解决了原型式继承的缺点,但原型链断了,父对象的原型无法继承过来,并且不能通过 instanceOf
和 isPrototypeOf
的检测。
- 组合继承:方法放在原型声明,子类构造函数
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
这种方式可以通过 instanceOf
和 isPrototypeOf
检测,并且父对象的属性在子对象单独创建的,而父对象的方法是共享的,一般方法是不会改变的,因此减少方法多次初始化的性能消耗,但是构造函数执行了两次。
- 寄生式继承:在原型式继承的基础上添加方法声明。
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; // 返回这个对象
}
这种方式是在原型式继承基础上添加方法。缺点和原型式继承一样。
- 寄生组合式继承:在组合继承上将父类的原型赋值给子类原型。
//核心代码
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__
是可以被赋值修改的,但只接受 null
和 object
类型,其他类型的赋值会被忽略。
let obj = {};
obj['__proto__'] = "some value";
alert(obj[key]); // [object Object],并不是 "some value"!
并且这个 getter/setter
是 Object.prototype
的访问器属性。
这里有个疑问:一般我对对象属性的增删改都都是是会操作到自身对象上不会作用到原型对象上的,如下:
let animal = {
eats: true
};
let rabbit = {
jumps: true
};
rabbit.__proto__ = animal; // 设置 rabbit.[[Prototype]] = animal
rabbit.eats=false
animal.eats // true
rabbit
对象设置 eats
属性时在自己对象上添加 eats
属性,并不会修改原型对象 animal
的 eats
属性值,那么我们在设置 __proto__
的时候为什么会调用到原型链最底层的 Object.prototype
上的 __proto__
呢?这就涉及到访问器在原型链中调用机制。
我们先来看看 ECMA-262 规范中对内部 [[set]]
的执行流程
规范描述是这样:
- 如果自身该属性描述为undefined,那么
- 声明
parent
为对象的原型O.[[GetPrototypeOf]]()
- 如果
parent
不为null
,那么 - 执行
parent
的set
方法 - 否则在自身对象上添加该属性并且设置其描述为
{ [[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)
运行结果
原型对象的 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)
运行结果为:
原型对象 parent
的 bar
属性值变为 Pete
, child
对象上并未有 bar
属性。
为什么会这样,主要是 Reflect.set
的最后一个参数 receiver
,receiver
的详细说明会在下一篇 Proxy
中详细说明,这就直接说结果,receiver
参数可以理解为设置 this
对象,Reflect.set
的操作是作用在 receiver
参数对象上的。而 parent
的 set
方法 receiver
参数是 child
代理对象,因此 Reflect.set(target, prop, val, receiver)
操作就作用在 receiver
对象上的,具体原理下一篇详细说明。
vue 通过 Proxy
实现响应式也有这个问题,代理的对象的原型也是一个代理对象时,触发 set
方法会执行两次派发更新,vue 通过 set
方法的 receiver
对象判断是否是自己的代理对象来避免 parent
对象的 set
方法触发派发更新。
下一篇会详细介绍 Proxy
对象和 vue 通过 Proxy
实现响应遇到的问题。