第四章: 混合(淆)“类”的对象 - 混合(Mixin)

优质
小牛编辑
130浏览
2023-12-01

混合(Mixin)

当你“继承”或是“实例化”时,JavaScript 的对象机制不会 自动地 执行拷贝行为。很简单,在 JavaScript 中没有“类”可以拿来实例化,只有对象。而且对象也不会被拷贝到另一个对象中,而是被 链接在一起(详见第五章)。

因为在其他语言中观察到的类的行为意味着拷贝,让我们来看看 JS 开发者如何在 JavaScript 中 模拟 这种 缺失 的类的拷贝行为:mixins(混合)。我们会看到两种“mixin”:明确的(explicit)隐含的(implicit)

明确的 Mixin(Explicit Mixins)

让我们再次回顾前面的 VehicleCar 的例子。因为 JavaScript 不会自动地将行为从 Vehicle 拷贝到 Car,我们可以建造一个工具来手动拷贝。这样的工具经常被许多库/框架称为 extend(..),但为了便于说明,我们在这里叫它 mixin(..)

  1. // 大幅简化的 `mixin(..)` 示例:
  2. function mixin( sourceObj, targetObj ) {
  3. for (var key in sourceObj) {
  4. // 仅拷贝非既存内容
  5. if (!(key in targetObj)) {
  6. targetObj[key] = sourceObj[key];
  7. }
  8. }
  9. return targetObj;
  10. }
  11. var Vehicle = {
  12. engines: 1,
  13. ignition: function() {
  14. console.log( "Turning on my engine." );
  15. },
  16. drive: function() {
  17. this.ignition();
  18. console.log( "Steering and moving forward!" );
  19. }
  20. };
  21. var Car = mixin( Vehicle, {
  22. wheels: 4,
  23. drive: function() {
  24. Vehicle.drive.call( this );
  25. console.log( "Rolling on all " + this.wheels + " wheels!" );
  26. }
  27. } );

注意: 重要的细节:我们谈论的不再是类,因为在 JavaScript 中没有类。VehicleCar 分别只是我们实施拷贝的源和目标对象。

Car 现在拥有了一份从 Vehicle 得到的属性和函数的拷贝。技术上讲,函数实际上没有被复制,而是指向函数的 引用 被复制了。所以,Car 现在有一个称为 ignition 的属性,它是一个 ignition() 函数引用的拷贝;而且它还有一个称为 engines 的属性,持有从 Vehicle 拷贝来的值 1

Car已经 有了 drive 属性(函数),所以这个属性引用没有被覆盖(参见上面 mixin(..)if 语句)。

重温”多态(Polymorphism)”

我们来考察一下这个语句:Vehicle.drive.call( this )。我将之称为“显式假想多态(explicit pseudo-polymorphism)”。回想一下,我们前一段假想代码的这一行是我们称之为“相对多态(relative polymorphism)”的 inherited:drive()

JavaScript 没有能力实现相对多态(ES6 之前,见附录A)。所以,因为 CarVehicle 都有一个名为 drive() 的函数,为了在它们之间区别调用,我们必须使用绝对(不是相对)引用。我们明确地用名称指出 Vehicle 对象,然后在它上面调用 drive() 函数。

但如果我们说 Vehicle.drive(),那么这个函数调用的 this 绑定将会是 Vehicle 对象,而不是 Car 对象(见第二章),那不是我们想要的。所以,我们使用 .call( this )(见第二章)来保证 drive()Car 对象的环境中被执行。

注意: 如果 Car.drive() 的函数名称标识符没有与 Vehicle.drive() 的重叠(也就是“遮蔽(shadowed)”;见第五章),我们就不会有机会演示“方法多态(method polymorphism)”。因为那样的话,一个指向 Vehicle.drive() 的引用会被 mixin(..) 调用拷贝,而我们可以使用 this.drive() 直接访问它。被选用的标识符重叠 遮蔽 就是为什么我们不得不使用更复杂的 显式假想多态(explicit pseudo-polymorphism) 的原因。

在拥有相对多态的面向类的语言中,CarVehicle 间的连接在类定义的顶端被建立一次,那里是维护这种关系的唯一场所。

但是由于 JavaScript 的特殊性,显式假想多态(因为遮蔽!) 在每一个你需要这种(假想)多态引用的函数中 建立了一种脆弱的手动/显式链接。这可能会显著地增加维护成本。而且,虽然显式假想多态可以模拟“多重继承”的行为,但这只会增加复杂性和代码脆弱性。

这种方法的结果通常是更加复杂,更难读懂,而且 更难维护的代码。应当尽可能地避免使用显式假想多态,因为在大部分层面上它的代价要高于利益。

混合拷贝(Mixing Copies)

回忆一下上面的 mixin(..) 工具:

  1. // 大幅简化的 `mixin()` 示例:
  2. function mixin( sourceObj, targetObj ) {
  3. for (var key in sourceObj) {
  4. // 仅拷贝不存在的属性
  5. if (!(key in targetObj)) {
  6. targetObj[key] = sourceObj[key];
  7. }
  8. }
  9. return targetObj;
  10. }

现在,我们考察一下 mixin(..) 如何工作。它迭代 sourceObj(在我们的例子中是 Vehicle)的所有属性,如果在 targetObj(在我们的例子中是 Car)中没有名称与之匹配的属性,它就进行拷贝。因为我们是在初始对象存在的情况下进行拷贝,所以我们要小心不要将目标属性覆盖掉。

如果在指明 Car 的具体内容之前,我们先进行拷贝,那么我们就可以省略对 targetObj 检查,但是这样做有些笨拙且低效,所以通常不优先选用:

  1. // 另一种 mixin,对覆盖不太“安全”
  2. function mixin( sourceObj, targetObj ) {
  3. for (var key in sourceObj) {
  4. targetObj[key] = sourceObj[key];
  5. }
  6. return targetObj;
  7. }
  8. var Vehicle = {
  9. // ...
  10. };
  11. // 首先,创建一个空对象
  12. // 将 Vehicle 的内容拷贝进去
  13. var Car = mixin( Vehicle, { } );
  14. // 现在拷贝 Car 的具体内容
  15. mixin( {
  16. wheels: 4,
  17. drive: function() {
  18. // ...
  19. }
  20. }, Car );

不论哪种方法,我们都明确地将 Vehicle 中的非重叠内容拷贝到 Car 中。“mixin”这个名称来自于解释这个任务的另一种方法:Car 混入 Vehicle 的内容,就像你吧巧克力碎片混入你最喜欢的曲奇饼面团。

这个拷贝操作的结果,是 Car 将会独立于 Vehicle 运行。如果你在 Car 上添加属性,它不会影响到 Vehicle,反之亦然。

注意: 这里有几个小细节被忽略了。仍然有一些微妙的方法使两个对象在拷贝完成后还能互相“影响”对方,比如它们共享一个共通对象(比如数组)的引用。

由于两个对象还共享它们的共通函数的引用,这意味着 即便手动将函数从一个对象拷贝(也就是混入)到另一个对象中,也不能 实际上模拟 发生在面向类的语言中的从类到实例的真正的复制

JavaScript 函数不能真正意义上地(以标准,可靠的方式)被复制,所以你最终得到的是同一个共享的函数对象(函数是对象;见第三章)的 被复制的引用。举例来说,如果你在一个共享的函数对象(比如 ignition())上添加属性来修改它,VehicleCar 都会通过这个共享的引用而受“影响”。

在 JavaScript 中明确的 mixin 是一种不错的机制。但是它们显得言过其实。和将一个属性定义两次相比,将属性从一个对象拷贝到另一个对象并不会产生多少 实际的 好处。而且由于我们刚才提到的函数对象引用的微妙之处,这显得尤为正确。

如果你明确地将两个或更多对象混入你的目标对象,你可以 某种程度上模拟 “多重继承”的行为,但是在将方法或属性从多于一个源对象那里拷贝过来时,没有直接的办法可以解决名称的冲突。有些开发者/库使用“延迟绑定(late binding)”和其他诡异的替代方法来解决问题,但从根本上讲,这些“技巧” 通常 得不偿失(而且低效!)。

要小心的是,仅在明确的 mixin 能够实际提高代码可读性时使用它,而如果你发现它使代码变得更很难追溯,或在对象间建立了不必要或笨重的依赖性时,要避免使用这种模式。

如果正确使用 mixin 使你的问题变得比以前 困难,那么你可能应当停止使用 mixin。事实上,如果你不得不使用复杂的库/工具来处理这些细节,那么这可能标志着你正走在更困难,也许没必要的道路上。在第六章中,我们将试着提取一种更简单的方法来实现我们期望的结果,同时免去这些周折。

寄生继承(Parasitic Inheritance)

明确的 mixin 模式的一个变种,在某种意义上是明确的而在某种意义上是隐含的,称为“寄生继承(Parasitic Inheritance)”,它主要是由 Douglas Crockford 推广的。

这是它如何工作:

  1. // “传统的 JS 类” `Vehicle`
  2. function Vehicle() {
  3. this.engines = 1;
  4. }
  5. Vehicle.prototype.ignition = function() {
  6. console.log( "Turning on my engine." );
  7. };
  8. Vehicle.prototype.drive = function() {
  9. this.ignition();
  10. console.log( "Steering and moving forward!" );
  11. };
  12. // “寄生类” `Car`
  13. function Car() {
  14. // 首先, `car` 是一个 `Vehicle`
  15. var car = new Vehicle();
  16. // 现在, 我们修改 `car` 使它特化
  17. car.wheels = 4;
  18. // 保存一个 `Vehicle::drive()` 的引用
  19. var vehDrive = car.drive;
  20. // 覆盖 `Vehicle::drive()`
  21. car.drive = function() {
  22. vehDrive.call( this );
  23. console.log( "Rolling on all " + this.wheels + " wheels!" );
  24. };
  25. return car;
  26. }
  27. var myCar = new Car();
  28. myCar.drive();
  29. // Turning on my engine.
  30. // Steering and moving forward!
  31. // Rolling on all 4 wheels!

如你所见,我们一开始从“父类”(对象)Vehicle 制造了一个定义的拷贝,之后将我们的“子类”(对象)定义混入其中(按照需要保留父类的引用),最后将组合好的对象 car 作为子类实例传递出去。

注意: 当我们调用 new Car() 时,一个新对象被创建并被 Carthis 所引用(见第二章)。但是由于我们没有使用这个对象,而是返回我们自己的 car 对象,所以这个初始化创建的对象就被丢弃了。因此,Car() 可以不用 new 关键字调用,就可以实现和上面代码相同的功能,而且还可以省去对象的创建和回收。

隐含的 Mixin(Implicit Mixins)

隐含的 mixin 和前面解释的 显式假想多态 是紧密相关的。所以它们需要注意相同的事项。

考虑这段代码:

  1. var Something = {
  2. cool: function() {
  3. this.greeting = "Hello World";
  4. this.count = this.count ? this.count + 1 : 1;
  5. }
  6. };
  7. Something.cool();
  8. Something.greeting; // "Hello World"
  9. Something.count; // 1
  10. var Another = {
  11. cool: function() {
  12. // 隐式地将 `Something` 混入 `Another`
  13. Something.cool.call( this );
  14. }
  15. };
  16. Another.cool();
  17. Another.greeting; // "Hello World"
  18. Another.count; // 1 (不会和 `Something` 共享状态)

Something.cool.call( this ) 既可以在“构造器”调用中使用(最常见的情况),也可以在方法调用中使用(如这里所示),我们实质上“借用”了 Something.cool() 函数并在 Another 环境下,而非 Something 环境下调用它(通过 this 绑定,见第二章)。结果是,Something.cool() 中进行的赋值被实施到了 Another 对象而非 Something 对象。

那么,这就是说我们将 Something 的行为“混入”了 Another

虽然这种技术看起来有效利用了 this 再绑定的功能,也就是生硬地调用 Something.cool.call( this ),但是这种调用不能被作为相对(也更灵活的)引用,所以你应当 提高警惕。一般来说,应当尽量避免使用这种结构 以保持代码干净而且易于维护。