学习和使用 JavaScript 和 TypeScript 中的组合
由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!
欢迎在推特上关注我,我很乐意接受您对话题或改进方面的建议。/克里斯
“composition”(作曲)一词是什么意思?我们先从动词“ compose”(作曲)说起。查阅字典,你会发现很多关于作曲的解释 :) 你还会找到这样的定义:
由各种物质构成
以上内容可能更接近我接下来要谈的内容——构图。
作品
其理念是用其他部件创造新事物。我们为什么要这样做呢?嗯,如果一个复杂的东西由许多我们能够理解的小部件组成,那么构建起来就容易得多。另一个重要原因是可重复使用性。更大更复杂的事物有时可以与其他更大更复杂的事物共享其组成部件。听说过宜家吗? ;)
编程中居然不止一种代码构成方式,谁能想到呢? ;)
组成与继承
我们来快速解释一下继承。继承的概念是从父类继承特性、字段和方法。继承自父类的类称为子类。继承使得我们可以用相同的方式处理一组对象。请看下面的例子:
class Shape {
constructor(x, y) {
this.x = x;
this.y = y;
}
}
class Movable extends Shape {
move(dx, dy) {
this.x += dx;
this.y += dy;
}
}
class Hero extends Movable {
constructor(x, y) {
super(x, y);
this.name = 'hero';
}
}
class Monster extends Movable {
constructor(x, y) {
super(x, y);
this.name = 'monster';
}
}
const movables = [new Hero(1,1), new Monster(2,2)];
movables.forEach(m => m.move(2, 3));
上面我们可以用相同的方式处理 AHero和 B的实例,因为它们有一个共同的祖先,这使得它们可以通过该方法移动。这一切都基于一个关系原则:A是 A ,B是A。MonsterMovablemove()IS-AHeroMovableMonsterMovable
最近有很多关于应该优先使用组合而不是继承的讨论,这是为什么呢?让我们来看看继承的一些缺点:
- 里氏替换原则被错误地应用了。该原则的核心思想是,如果我用一个共同的祖先对象替换另一个对象,代码应该仍然能够正常工作,例如,用一个
Hero对象替换另一个Monster对象,它们都属于同一个类型Movable。在上面的示例代码中,这种替换应该是有效的。然而,上面的代码代表的是理想情况。现实情况要糟糕得多。实际上,可能存在大型代码库,其中包含 20 层以上的继承,并且继承的方法可能没有正确实现,这意味着某些对象无法相互替换。请看以下代码:
class NPC extends Movable {
move(dx, dy) {
console.log('I wont move')
}
}
上述操作违反了替换原则。现在,我们的代码库很小,所以很容易发现这个问题。但在更大的代码库中,可能就很难发现这种情况了。想象一下,如果这种情况发生在继承级别 3,而你的代码库有 20 层继承,会发生什么呢?
- 缺乏灵活性,很多时候你会建立一种
HAS-A关系IS-A。人们更容易把不同的组件看作是执行各种操作的个体,而不是拥有共同点或共同祖先。这可能导致我们创建大量额外的类和继承链,而组合可能更合适。
功能组成
这是一个数学术语,根据维基百科的定义,函数复合是指将两个函数 f 和 g 组合起来,生成一个函数 h,使得 h(x) = g(f(x))。在编程中,它类似于将至少两个函数应用于某个对象,例如:
let list = [1,2,3];
take(orderByAscending(list), 3)
上图假设listx 为输入参数,xf(x)为orderByAscending(list)输入参数。g(x)take()f(x)
关键在于,你对同一段数据依次应用了许多操作。本质上,你是将不同的部分组合起来,创建一个更复杂的算法,该算法在调用时会计算出更复杂的结果。我们不会花太多时间讨论这种组合方式,但你需要知道它的确存在。
物体构成
这种组合方式是指将对象或其他数据类型组合起来,创建比初始状态更复杂的事物。根据所使用的编程语言,实现方式也各不相同。在 Java 和 C# 中,创建对象只有一种方法,即使用类。而在 JavaScript 等其他语言中,对象的创建方式多种多样,从而为组合提供了更多可能性。
使用类进行对象组合
使用类是指一个类通过实例变量引用一个或多个其他类。它描述了一种“拥有”关系。这究竟意味着什么呢?一个人有四肢,一辆车可能有四个轮子,等等。你可以把这些被引用的类想象成零件或组合体,它们赋予你执行某些操作的能力。我们来看一个例子:
class SteeringWheel {
steer(){}
}
class Engine {
run(){}
}
class Car {
constructor(steeringWheel, engine) {
this.steeringWheel = steeringWheel;
this.engine = engine;
}
steer() {
this.steeringWheel.steer();
}
run() {
this.engine.run();
}
}
class Tractor {
constructor(steeringWheel) {
this.steeringWheel = steeringWheel;
}
steer() {
this.steeringWheel.steer();
}
}
我们上面得到的首先是一个更复杂的类,它Car由许多部分组成steeringWheel,通过它,我们获得了控制车辆行驶engine的能力。我们还获得了可重用性,因为我们可以在另一个类中使用它。SteeringWheelTractor
对象组合(不包含类)
JavaScript 与 C# 和 Java 略有不同,它可以通过多种方式创建对象,例如以下几种:
- 对象字面量,你可以像这样直接输入来创建一个对象:
let myObject { name: 'a name' }`
Object.create()你可以直接传入一个对象,它会使用该对象作为模板。例如:
const template = {
a: 1,
print() { return this.a }
}
const test = Object.create(template);
test.a = 2
console.log(test.print()); // 2
- 使用 `
new.` 运算符,您可以将其new应用于类和函数,如下所示:
class Plane {
constructor() {
this.name = 'a plane'
}
}
function AlsoAPlane() {
this.name = 'a plane';
}
const plane = new Plane();
console.log(plane.name); // a plane
const anotherPlane = new AlsoAPlane();
console.log(anotherPlane) // a plane
这两种方法之间存在差异。如果你想让继承在函数式编程中发挥作用,你需要做更多的工作。目前,我们很高兴知道可以使用`new`以不同的方式创建对象。
那么我们究竟该如何进行组合呢?要进行组合,我们需要一种表达行为的方式。如果我们不想使用类,完全可以不用,而是直接使用对象。我们可以用以下方式表达组合:
const steer = {
steer() {
console.log(`steering ${this.name}`)
}
}
const run = {
run() {
console.log(`${this.name} is running`)
}
}
const fly = {
fly() {
console.log(`${this.name} is flying`)
}
}
并按如下方式排列:
const steerAndRun = { ...steer, ...run };
const flyingAndRunning = { ...run, ...fly };
上面我们使用扩展运算符将不同类中的不同属性合并到一个类中。合并后的类steerAndRun现在包含两个{ steer(){ ... }, run() { ... } }属性。flyingAndRunning{ fly(){...}, run() {...} }
然后,我们使用这种方法createVehicle()创建所需内容,如下所示:
function createVehicle(name, behaviors) {
return {
name,
...behaviors
}
}
const car = createVehicle('Car', steerAndRun)
car.steer();
car.run();
const crappyDelorean = createVehicle('Crappy Delorean', flyingAndRunning)
crappyDelorean.run();
crappyDelorean.fly();
最终得到的是两个功能不同的物体。
但我用的是 TypeScript,那该怎么办?
TypeScript 大量使用类和接口,这是使用类来实现对象组合的一种方式。
我能否以类似于使用扩展运算符和对象的方式来实现合成?
是的,当然可以。稍等片刻。我们将使用一种叫做 MixIns 的概念。让我们开始吧:
- 首先,我们需要这样的结构:
type Constructor<T = {}> = new (...args: any[]) => T
我们用这种结构来表达它Constructor是可以被实例化的事物。
- 接下来,声明以下函数:
function Warrior<TBase extends Constructor>(Base: TBase) {
return class extends Base {
say: string = 'Attaaack';
attack() { console.log("attacking...") }
}
}
返回的是一个继承自 的类Base。Base是函数的输入参数,类型为TBase,它使用Constructor我们刚刚创建的类型。
- 让我们定义一个将使用上述函数的类:
class Player {
constructor( private name: string ) {}
}
- 现在,
Warrior像这样调用该函数:
const WarriorPlayerType = Warrior(Player);
const warrior = new WarriorPlayerType("Conan");
console.log(warrior.say); // 'Attaaack'
warrior.attack(); // 'attacking...'
- 我们可以继续组合,创建一个新函数来保存我们可能需要的其他行为:
function Wings<TBase extends Constructor>(Base: TBase) {
return class extends Base {
fly() { console.log("flying...") }
}
}
- 让我们把它应用到现有的作品中。
const WingsWarriorPlayerType = Wings(WarriorPlayerType);
const flyingWarrior = new WingsWarriorPlayerType("Flying Conan");
flyingWarrior.fly();
概括
本文介绍了组合的概念,并阐述了组合相对于继承的优势。此外,文章还介绍了实现组合的不同方法,最后还介绍了一种可在 TypeScript 中使用的组合实现方式。
延伸阅读
关于这个话题,有一些很棒的文章,我觉得你应该读一读。请看以下这些:
文章来源:https://dev.to/itnext/learn-and-use-composition-in-javascript-and-typescript-3f17