混合对象“类”
混入
在继承或者实例化时,JavaScript 的对象机制并不会自动执行复制行为。简单来说,JavaScript 中只有对象,并不存在可以被实例化的“类”。一个对象并不会被复制到其他对象,它们会被关联起来。
【混入】:由于在其他语言中类表现出来的都是复制行为,因此 JavaScript 开发者也通过模拟类的复制行为创造出一个新的方法,名为“混入”。
显式混入
JavaScript 不会自动实现 Vehicle 到 Car 的复制行为,所以我们需要手动实现复制功能。
【示例】:
function mixin(sourceObj, targetObj) {
for(var key in sourceObj) {
// 只会在不存在的情况下复制
if(!(key in targetObj)) {
targetObj[key] = sourceObj[key];
}
}
return targetObj;
}
var Vehicle = {
engines: 1,
ignition: function() {
console.log("Turning on my engine.");
},
drive: function() {
this.ignition();
console.log("Steering and moving forward!");
}
};
var Car = mixin(Vehicle, {
wheels: 4,
drive: function() {
Vehicle.drive.call(this);
console.log("Rolling on all " + this.wheels + " wheels!");
}
});
【注意】:我们处理的已经不再是类了,因为在 JavaScript 中不存在类,Vehicle 和 Car 都是对象,供我们分别进行复制和粘贴。从技术角度来说,函数(ignition)实际上没有被复制,复制的是函数引用。
1. 再说多态
上例中 Vehicle.drive.call(this) 这就是我们所说的显式多态。在 ES6 之前的 JavaScript 没有相对多态的机制。所以,由于 Car 和 Vehicle 中都有 drive() 函数,为了指明调用对象,必须使用绝对(而不是相对)引用,即通过名称显式指定 Vehicle 对象并调用它的 drive() 函数。
【区别】:
- 在支持相对多态的面向类的语言中,Car 和 Vehicle 之间的联系只在类定义的开头被创建,从而只需要在这一个地方维护两个类的联系。
- 在 JavaScript 中由于屏蔽,使用显式伪多态会在所有需要使用(伪)多态引用的地方创建一个函数关联(我的理解是函数在 JavaScript 中是一等公民,而类在面向类的语言中是一等公民),这会极大地增加维护成本。由于显式伪多态可以模拟多重继承,所以它会进一步增加代码的复杂度和维护难度。
【建议】:使用伪多态通常会导致代码变得更加复杂,难以阅读并且难以维护,因此应当尽量避免使用显式伪多态,因为这样做往往得不偿失。
【示例】:显式伪多态模拟多重继承。
var Vehicle = {
engine: 1,
name: function() {
console.log("I am vehicle!");
}
};
var Car = {
wheels: 4,
name: function() {
console.log("I am car!");
}
};
var BaoMa = {
name: function() {
Vehicle.name.call(this);
Car.name.call(this);
console.log("I am BaoMa!");
}
};
BaoMa.name();
// I am vehicle!
// I am car!
// I am BaoMa!
2. 混合复制
【mixin() 工作原理】:遍历 sourceObj(父类)的属性,如果在 targetObj(子类)中没有这个属性就进行复制。由于实在目标对象(子类)初始化之后才进行复制,因此要小心不要覆盖目标对象的原有属性。
【注意】:如果没有存在性检查,可能会有重写风险。
【另外一种方法】:先进行复制后对子类进行特殊化,这么做的好处在于不需要进行存在性检查。
function mixin(sourceObj, targetObj) {
for(var key in sourceObj) {
targetObj[key] = sourceObj[key];
}
return targetObj;
}
var Vehicle = {
// ...
};
// 首先创建一个空对象并把 Vehicle 的内容复制进去
var Car = mixin(Vehicle, {});
// 然后把新内容复制到 Car 中
mixin({
wheels: 4,
drive: function() {
// ...
}
}, Car);
【问题】:由于两个对象引用的是同一个函数,因此这种复制(或者说混入)实际上并不能完全模拟面向类的语言中的复制。也就是说 JavaScript 中的函数无法真正地复制,所以你只能复制对共享函数对象的引用。如果你修改了共享的函数对象,其所有引用该函数的对象都会受到影响。
【说明】:显式混入是 JavaScript 中一个很棒的机制,不过它的功能也没有看起来那么强大。虽然它可以把一个对象的属性复制到另一个对象中,但是这其实并不能带来太多的好处,无非就是少几条定义语句,而且还会带来我们刚才提到的函数对象引用问题。
【注意】:在能够提高代码可读性的前提下使用显式混入,避免使用增加代码理解难度或者让对象关系更加复杂的模式。
3. 寄生继承
显式混入模式的一种变体被称为“寄生继承”,它既是显式的又是隐式的,主要推广者是 Douglas Crockford。
【工作原理】:
function Vehicle() {
this.engines = 1;
}
Vehicle.prototype.ignition = function() {
console.log("Turning on my engine.");
};
Vehicle.prototype.drive = function() {
this.ignition();
console.log("Steering and moving forward!");
};
// “寄生类” Car
function Car() {
// 首先,car 是一个 Vehicle
var car = new Vehicle();
// 接着对 car 进行定制
car.wheels = 4;
// 保存到 Vehicle::drive() 的特殊引用
var vehDrive = car.drive;
// 重写 Vehicle::drive()
car.drive = function() {
vehDrive.call(this);
console.log("Rolling on all " + this.wheels + " wheels!");
};
return car;
}
var myCar = new Car();
myCar.drive();
// Turning on my engine.
// Steering and moving forward!
// Rolling on all 4 wheels!
【解释】:首先复制一份 Vehicle 父类(对象)的定义,然后混入子类(对象)的定义(如果需要的话保留到父类的特殊引用),然后用这个复合对象构建实例。
【注意】:调用 new Car() 时会创建一个新对象并绑定到 Car 的 this 上。但是因为我们没有使用这个对象而是返回了我们自己的 car 对象,所以最初被创建的这个对象会被丢弃,因此可以不使用 new 关键字直接调用 Car()。这样做得到的结果是一样的,但是可以避免创建并丢弃多余的对象。
隐式混入
隐式混入和之前提到的显式伪多态很像,因此也具备同样的问题。
【示例】:
var Something = {
cool: function() {
this.greeting = "Hello World";
this.count = this.count ? this.count + 1 : 1;
}
};
var Another = {
cool: function() {
// 隐式把 Something 混入 Another
Something.cool.call(this);
}
};
【解释】:通过在构造函数调用或者方法调用中使用 Something.cool.call(this),实际上“借用”了函数 Something.cool() 并在 Another 的上下文中调用了它。最终的结果是 Something.cool() 中的赋值操作都会应用在 Another 对象上而不是 Something 对象上。
【注意】:虽然这类技术利用了 this 的重新绑定功能,但是 Something.cool.call(this) 仍然无法变成相对引用,所以使用时千万要小心。通常来说,尽量避免使用这样的结构,以保证代码的整洁和可维护性。
小结
- JavaScript 不会像面向类语言那样自动创建对象的副本。
- 混入模式(无论是显式还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法,比如显式伪多态,这会让代码更加难懂并且难以维护。此外,显式混入实际上无法完全模拟类的复制行为,因为对象只能复制引用,无法复制被引用对象或者函数本身。忽视这一点会导致许多问题。
- 总的来说,在 JavaScript 中模拟类是得不尝试的,虽然能解决当前的问题,但是可能会埋下更多的隐患。