深入 JavaScript 中的对象以及继承原理
ES6引入了一个很甜的语法糖就是 class, class 可以帮助开发者回归到 Java 时代的面向对象编程而不是 ES5 中被诟病的面向原型编程. 我也在工作的业务代码中大量的使用 class, 很少去触及 prototype 了.
两面性:
-
class 语法糖的本质还是prototype, 所以应该回归到写 prototype 上.
-
既然有 class 的写法了, 而且继承上也相比原型好写, 好理解许多, 所以应该向前看, 摒弃掉以前的 prototype 写法.
睿智而理性的读者, 你的理解是其中之一还是二者兼备? 我的看法是: 语法糖存在即合理, 语法糖不仅仅是更高层级的封装, 它可以避免写出没有语法糖时候的 bug, 但是语法糖不是语言本身的特性, 所以也一定要理解背后的成因, 加上原型是 JavaScript 里面特别特别重要的知识点, 不能不去深究. 你可以不用, 但不能不懂.
好了, 来看看 ES5 的面向对象编程
什么是对象
ECMA的官方解释: 无序属性的集合, 属性可包括基本值, 函数, 对象
注意无序二字, 可理解为包含一定属性或方法的键值对. 是的, 本质上, 对象就是包含键值对映射的集合, 键是属性的名字, 值就是属性
属性的类型
-
数据属性
- configurable
- 表示是否可以被配置(除了 enumerable 和 writable 之外), 包含但不限于属性属性转化为访问器属性, 主要是用于 delete 的限制
- enumerable
- 是否可以被 for in 或 Object.keys 到
- value
- 属性具体的值, 默认 undefined
- writable
- 可修改 value
- configurable
-
访问器属性
- configurable
- 表示是否可以被配置(除了 enumerable 和 writable 之外), 包含但不限于属性属性转化为访问器属性, 主要是用于 delete 的限制
- enumerable
- 是否可以被 for in 或 Object.keys 到
- get
- 获取属性的值, 默认 undefined
- set
- 设置属性的值, 默认 undefined
- configurable
注意, 第二个访问器属性就是被著名的 React 和 Vue 实现数据响应的原理之一. 再注意, 以上所有的 bool 属性在没有进行配置的时候都默认为 false.
如何实现这两者的转化呢
在 configurable 为 true 的情况下, 凡是包含 value 或 writable 的会默认为数据属性, 会将原有的 get 和 set 属性删除, 反之如果设置了 get 或 set, 那么就会认为为访问器属性, 将 value 和 writable 删除
const o = {}
Object.defineProperty(o, 'name', {
configurable: true,
enumerable: false, // 可不写, 默认为 false
value: 'lorry',
writable: false // 可不写, 默认为 false
})
console.log(o)//{name: "lorry"}
o.name = 'jiang'// 不会改变, 因为 writable 为 false
console.log(o)//{name: "lorry"}
Object.keys(o)// []
// 转化为访问器属性
o['_name'] = 'lorry'; // 设置私有属性
Object.defineProperty(o, 'name', {
get: function(){return this._name},
set: function(newName){this._name = newName},
configurable: false,
enumerable: true
})
console.log(o); // {_name: "lorry"}
o.name = 'setted jiang'
console.log(o.name); // setted jiang
Object.keys(o); // ["_name", "name"]
其他的方式
除了Object.defineProperty
之外, 还有其他的跟对象属性相关的原生方法
-
Object.defineProperties(o, {attr1:{}, attr2:{}})
, 批量设置一个对象多个属性 -
Object.getOwnPropertyDescriptor(o, attrName)
, 获取对象某个属性的配置 -
Object.getOwnPropertyDescriptors(o)
, 获取对象所有属性的配置
对象的创建
工厂模式
function createObject(name) {
var o = new Object();
o.name = name;
o.sayName = function() {console.log(this.name)};
return o;
}
const p1 = createObject('lorry')
优点: 简单直观 缺点: 无法进行对象识别, 没有 instanceof 可以去追溯.
构造函数模式
function Person(name) {
this.name = name;
this.sayName = function() {console.log(this.name)};
}
const p1 = new Person('lorry')
注意, 凡是构造函数都应该首字母大写 优点:
-
不显式创建对象(实质还是有创建新对象)
-
使用 this 的上下文对象
-
不用 return(默认隐式创建的新对象)
-
能够使用
p1 instanceof Person
进行对象识别
缺点:
- 每个实例都会生成新的属性和方法, 会造成内存的浪费(在当今性能过剩的年代, 这个实质上不算什么问题, 只是显得代码不是很规范和专业)
原型模式
function Person() {};
Person.prototype.name = 'Lorry';
Person.prototype.sayName = function() {
console.log(this.name);
}
const p1 = new Person();
const p2 = new Person();
p1.sayName(); // lorry;
console.log(p1.sayName === p2.sayName) // true
可以看到两个实例p1 和 p2 共享同一个 name 属性和 sayName 的方法, 会节省内存.
注意, 在原型上的方法和属性是不会被 hasOwnProperty()
检测出来的(Object.keys()同样如此), 但是在in
中是有的.比如
p1.hasOwnProperty('name'); // false
Object.keys(p1); // []
'name' in p1; // true
一种更简单的定义方法
function Person(){};
Person.prototype = {
name: 'lorry',
sayName: function(){
console.log(this.name)
},
//ES6
sayName2() {
console.log(this.name)
}
}
这种方式完全重写了 prototype, 包括其原有的 constructor 属性(指向了字面量对象即 Object)
解决办法就是手动指定一下
Person.prototype = {
constructor: Person
}
原型对象的问题:
-
实例无法给构造函数传值
-
共享既是优点也是缺点, 有些属性希望各个实例各自保持自己的, 就无法通过此方法实现
组合模式
看到了吗? 构造函数模式和原型模式实质上是两个极端, 一个是每个实例都是各自为营, 一个是每个实例都步调一致, 所以, 两者的结合就是更好的解决方案.也是现在最常用的方案.
function Person(name) {
// 每个实例各有的
this.name = name
}
// 每个实例共享的
Person.prototype.sayName = function() {
console.log(this.name)
}
还有一种动态原型的变体
function Person(name) {
this.name = name;
// 只会在构造函数初始化时创建一次
if (typeof this.sayName !== 'function') {
Person.prototype.sayName = function() {
console.log(this.name)
}
}
}
寄生构造函数以及稳妥寄生构造函数模式
首先什么叫寄生? 之前我只知道这个模式叫寄生, 但是不知道为什么叫寄生. 现在我的理解是: 寄生是一种相互独立的状态, 就像寄居蟹, 它可以爬到任何一个的壳中生活.看下面的例子
function Person(name) {
const o = new Object();
o.name = name;
o.sayName = function() {
console.log(this.name)
}
return o;
}
const p1 = new Person('lorry')
const p2 = Person('lorry')
// p1和p2所拥有的属性和方法是一样的.
上述代码中, 壳就是 function Person(name){}
这部分, 寄居蟹就是剩余的部分, 调用 new Person()返回的对象跟 Person 没有任何原型上的关系(p1 instanceof Person = false
).
这样有什么好处呢? 私有变量
function Person(name) {
const o = new Object()
o.sayName = function() {
console.log(name)
}
return o;
}
const p1 = new Person('lorry')
p1中就保存了一个稳定对象, 除了调用 sayName 之外没有任何办法可以获取到构造函数中的数据成员.
对象的和继承
OO 的语言通常有两种继承
-
接口的继承, 只继承方法签名
-
实现的继承, 继承实际的方法
ECMA 只支持实现的继承, 也就是具体的方法, 当然一些JavaScript 的超集, 比如 typescript 可以支持接口的继承.
interface A {
name: string
}
interface B extends A {
age: number
}
var b: B = {
name: 'lorry',
age: 26
}
原型链继承
原理就是将 SubType 的[[ prototype ]] 属性指向了 SuperType 的prototype, 本质就是重写了 prototype.
function SuperType() {
this.property = false;
}
SuperType.prototype.getSuperValue = function() {
return this.property;
}
function SubType() {
this.subProperty = true;
}
// 实现了原型继承, 拥有 SuperType 的所有实例属性和方法
SubType.prototype = new SuperType();
SubType.prototype.getSubValue = function() {
return this.subProperty;
}
const subIns = new SubType();
console.log(subIns.getSuperValue());
描述继承关系: SubType 继承 SuperType, SuperType 继承默认的原型 Object. 所以
console.log(subIns instanceof SubType) // true
console.log(subIns instanceof SuperType) // true
console.log(subIns instanceof Object) // true
console.log(Object.prototype.isPrototypeOf(subIns))//true
console.log(SuperType.prototype.isPrototypeOf(subIns))//true
console.log(SubType.prototype.isPrototypeOf(subIns))//true
问题:
-
引用类型(比如数组)的原型属性会被所有实例共享.但实质上之所以在 SuperType 的构造函数中定义属性就是不希望所有实例共享.
-
创建子类的实例时(上例中的 subIns ), 无法向父类构造函数中传参.因为继承不发生在构造函数中
借用构造函数
为了解决上述的第二个问题, 有了这个构造函数继承方式
function SuperType(name) {
this.name = name
}
function SubType(name) {
SuperType.call(this, name);
}
const subIns = new SubType('lorry')
subIns.name;// lorry
就跟构造函数的问题一样, 无法实现函数的复用.
组合继承
跟组合创建对象模式一样, 将借用构造函数和原型链继承的方式组合起来就形成了组合继承的方式.
function SuperType(name) {
this.name = name
}
SuperType.prototype.sayName = function () {
console.log(this.name)
}
function SubType(name) {
// 继承属性
SuperType.call(this, name);
}
// 继承方法
SubType.prototype = new SuperType()
const subIns = new SubType('lorry')
subIns.sayName() // lorry
注意: 其实在继承方法的时候也继承了实例的属性, 但是在查找原型链的时候, 因为实例本身就有其属性了, 不会再向上到超类中查找, 所以相当于只继承了方法. 这两者的结合就形成了最常用的继承方式.
原型式继承
这种方式是临时创建一个对象, 然后使该对象的原型指向超类, 最后返回该临时对象的实例. 所以该实例的 [[ prototype ]] 便指向了超类, 即类似对超类进行了一次浅复制.
function object(o) {
function F(){};
F.prototype = o;
return new F();
}
const Person = {
name: 'lorry',
friends: ['A', 'B']
}
const anotherPerson = object(Person);
anotherPerson.name = 'Lourance'
anotherPerson.friends.push('C')
console.log(Person.name, Person.friends) // 'lorry', ['A', 'B', 'C']
上述的 object 函数就是 ES5 中的 Object.create()函数不传第二个参数的情况, 即Object.create(o)
等价于object(o)
寄生式继承
还记得寄生吗? 就是那只寄居蟹.
function createAnother(origin) {
const clone = object(origin)
clone.sayName = function() {
console.log(this.name)
}
return clone
}
const anotherPerson = createAnother(Person)
anotherPerson.sayName() // lorry
这种方式有两个弊端:
- 与构造函数类似, 这种方式无法复用函数.
- 寄生的通病不知道其继承的谁. instanceof 会失效.
寄生组合式继承
之前说过最常用的继承模式为组合式继承, 但是组合式继承有一个问题就是会重复调用超类两次, 为了解决这个问题就可以使用寄生组合式继承.
// 寄生模式
function inheritPrototype(child, parent) {
const prototype = Object(parent.prototype);
// 恢复 instanceof 原型链的追溯
prototype.constructor = child;
child.prototype = prototype;
}
function SuperType(name) {
this.name = name;
this.friends = ['A', 'B']
}
SuperType.prototype.sayName = function() {
console.log(this.name)
}
function SubType(name, age) {
SuperType.call(this, name);
this.age = age;
}
inheritPrototype(SubType, SuperType);
const subIns1 = new SubType('lorry', 26);
const subIns2 = new SubType('lorry', 26);
subIns1.sayName(); // lorry
subIns1.friends.push('C');
console.log(subIns1.friends); // ['A', 'B', 'C']
console.log(subIns2.friends); // ['A', 'B']
上述就是最合理的继承方式, 集寄生式继承和组合继承的优点于一身. YUI 这个库就采用了上述的继承方式.
由此, 整个关于对象的内容就说完了.总结一下 创建对象的方式:
- 工厂模式
- 构造函数模式
- 组合模式
继承的方式:
- 原型模式
- 寄生式模式
- 寄生组合式模式