嘿,CSS-Tricksters!Bryan Hughes 友好地从 他发布的关于转换为 TypeScript 的现有文章 中获取了一个概念,并在本文中将其再深入几个步骤,以详细说明如何创建可重用基类。虽然本文不需要阅读另一篇文章,但它绝对值得一看,因为它涵盖了重写代码库和编写单元测试以帮助该过程的困难任务。
Johnny-Five 是一个针对 Node.js 的 物联网 和机器人库。Johnny-Five 通过采用与 jQuery 十年前对 Web 采取的类似方法来简化与硬件的交互:它规范了各种硬件平台之间的差异。与 jQuery 一样,Johnny-Five 提供了更高级别的抽象,使其更易于使用平台。
Johnny-Five 通过 IO 插件 支持各种平台,从 Arduino 系列到 Tessel,再到 Raspberry Pi 等等。每个 IO 插件都实现了用于与更低级硬件外围设备交互的标准化接口。 Raspi IO(我五年前首次创建的)是实现了对 Raspberry Pi 支持的 IO 插件。
任何时候,只要有多个符合一个事物的实现,就可能共享代码。IO 插件也不例外,但是,到目前为止,我们还没有在 IO 插件之间共享任何代码。最近,我们作为一个团队决定改变这种情况。时机恰到,因为我计划重新用 TypeScript 编写 Raspi IO,所以我同意承担这项任务。
基类的目标
对于那些可能不熟悉使用基于类的继承来启用代码共享的人,让我们快速浏览一下我说“创建基类以重用代码”时我的意思是什么。
基类提供结构和通用代码,其他类可以 _扩展_。让我们来看一个简化的 TypeScript 示例基类,我们将在稍后扩展它
abstract class Automobile {
private _speed: number = 0;
private _direction: number = 0;
public drive(speed: number): void {
this._speed = speed;
}
public turn(direction: number): void {
if (direction > 90 || direction < -90) {
throw new Error(`Invalid direction "${direction}"`);
}
this._direction = direction;
}
public abstract getNumDoors(): number;
}
在这里,我们创建了一个名为 Automobile
的基类。它提供所有类型的汽车之间共享的一些基本功能,无论是轿车、皮卡车等等。注意这个类是如何被标记为抽象的,以及如何有一个名为 getNumDoors
的抽象方法。此方法取决于汽车的具体类型。通过将其定义为一个 抽象类,我们是在说我们要求另一个类扩展这个类并实现这个方法。我们可以使用以下代码来实现
class Sedan extends Automobile {
public getNumDoors(): number {
return 4;
}
}
const myCar = new Sedan();
myCar.getNumDoors(); // prints 4
myCar.drive(35); // sets _speed to 35
myCar.turn(20); // sets _direction to 20
我们正在 _扩展_ Automobile
类以创建一个四门轿车类。我们现在可以调用这两个类上的所有方法,就好像它是一个类一样。
对于这个项目,我们将创建一个名为 AbstractIO
的基类,任何 IO 插件作者都可以扩展它来实现特定于平台的 IO 插件。
创建抽象 IO 基类
抽象 IO 是我创建的用于所有 IO 插件作者的基类,用于实现 IO 插件规范,它 今天已在 npm 上发布。
每个作者都有自己的独特风格,有些人更喜欢 TypeScript,有些人更喜欢原生 JavaScript。这意味着,首先也是最重要的是,这个基类必须符合 TypeScript 最佳实践和 JavaScript 最佳实践的交集。然而,找到最佳实践的交集并不总是显而易见的。为了说明这一点,让我们考虑 IO 插件规范的两个部分:MODES
属性和 digitalWrite
方法,它们都是 IO 插件中必需的。
MODES
属性是一个对象,每个键都有一个给定模式类型的人类可读名称,例如 INPUT
,其值为与模式类型关联的数值常量,例如 0。这些数值会出现在 API 中的其他地方,例如 pinMode
方法。在 TypeScript 中,我们希望使用一个 enum
来表示这些值,但 JavaScript 不支持它。在 JavaScript 中,最佳实践是定义一系列常量并将它们放在一个对象“容器”中。
我们如何解决这种最佳实践之间的明显差异?使用另一个来创建!我们首先在 TypeScript 中定义一个 enum
,并为每个值定义一个显式初始化程序
export enum Mode {
INPUT = 0,
OUTPUT = 1,
ANALOG = 2,
PWM = 3,
SERVO = 4,
STEPPER = 5,
UNKNOWN = 99
}
每个值都经过精心选择,以显式映射到规范中定义的 MODES
属性,该属性作为 Abstract IO 类的一部分实现,使用以下代码
public get MODES() {
return {
INPUT: Mode.INPUT,
OUTPUT: Mode.OUTPUT,
ANALOG: Mode.ANALOG,
PWM: Mode.PWM,
SERVO: Mode.SERVO,
UNKNOWN: Mode.UNKNOWN
}
}
有了它,当我们在 TypeScript 世界中时,我们就有了不错的枚举,并且可以完全忽略数值。当我们在纯 JavaScript 世界中时,我们仍然拥有可以以典型的 JavaScript 方式传递的 MODES
属性,因此我们不必直接使用数值。
但是基类中的方法又如何呢?派生类必须覆盖这些方法?在 TypeScript 世界中,我们将使用抽象类,正如我们在上面的示例中所做的那样。太棒了!但是 JavaScript 方面呢?抽象方法被编译掉,在输出的 JavaScript 中甚至不存在,因此我们没有办法确保方法在那里被覆盖。另一个矛盾!
不幸的是,我无法想出一种方法来同时尊重这两种语言,因此我默认使用 JavaScript 方法:在基类中创建一个抛出异常的方法
public digitalWrite(pin: string | number, value: number): void {
throw new Error(`digitalWrite is not supported by ${this.name}`);
}
这并不理想,因为 TypeScript 编译器不会在不覆盖抽象基方法的情况下发出警告。但是,它在 TypeScript 中确实有效(我们仍然在运行时获得异常),同时也是 JavaScript 的最佳实践。
经验教训
在探索为 TypeScript 和原生 JavaScript 设计最佳方法的过程中,我发现解决差异的最佳方法是
- 使用两种语言共同的最佳实践
- 如果这不起作用,请尝试“内部”使用 TypeScript 最佳实践,并将其映射到“外部”的单独的原生 JavaScript 最佳实践,就像我们对
MODES
属性所做的那样。 - 如果这种方法也不起作用,请默认使用原生 JavaScript 最佳实践,就像我们对
digitalWrite
方法所做的那样,并忽略 TypeScript 最佳实践。
这些步骤在创建 Abstract IO 时效果很好,但这仅仅是我发现的。如果您有任何更好的想法,我很乐意在评论中听到它们!
我发现抽象类在原生 JavaScript 项目中没有用,因为 JavaScript 没有这样的概念。这意味着它们在这里是一种反模式,即使抽象类是 TypeScript 最佳实践。TypeScript 也没有提供机制来确保在覆盖方法时进行类型安全,例如 override
C# 中的关键字。这里的教训是,当也面向原生 JavaScript 用户时,应该假装抽象类不存在。
我还了解到,为跨模块共享的接口拥有一个单一的事实来源非常重要。TypeScript 使用 鸭子类型 作为其核心类型系统模式,它在任何给定的 TypeScript 项目中都非常有用。但是,使用鸭子类型在不同模块之间同步类型却比从一个模块导出接口并将其导入到另一个模块更麻烦。我不得不不断地发布对模块的小改动,直到它们的类型保持一致,从而导致了过多的版本号波动。
我学到的最后一点教训并不是什么新鲜事:在整个设计过程中从库使用者那里获得反馈。在开始的时候就知道这一点,我设置了两个主要的反馈回合。第一轮是在 Johnny-Five 协作者峰会上,我们在那里作为一个团队集思广益。第二轮是我 针对自己的拉取请求,尽管我是 Abstract IO 上唯一有权合并拉取请求的协作者。拉取请求被证明非常宝贵,因为我们作为一个团队筛选了细节。PR 开始时的代码与合并时的代码相差很大,这是件好事!
总结
生成旨在供 TypeScript 和原生 JavaScript 用户使用的基类是一个相当直接的过程,但有一些需要注意的地方。由于 TypeScript 是 JavaScript 的超集,因此两者在很大程度上共享相同的最佳实践。当最佳实践的差异出现时,需要一些创造力才能找到最佳的折衷方案。
我成功地实现了 Abstract IO 的目标,并实现了一个对 TypeScript 和原生 JavaScript IO 插件作者都非常有用的基类。事实证明,(大多数情况下)可以做到鱼与熊掌兼得!
作者显然不太了解 Typescript。枚举被编译成一个 Javascript 对象,这正是他在另一个函数中所做的。那个函数和额外的对象是多余的。您只需要使用“Mode”对象。
IO 插件规范中关于冻结对象以及可以/不可以包含的内容有一些奇怪的地方,这阻止了我采用你建议的方法,出于简洁考虑,我在文章中省略了这些内容。在大多数应用程序中,你绝对可以使用枚举,但你应该知道,它会创建一个比我代码中更复杂的對象。
重要的是要记住,枚举被编译成不仅仅是我上面提到的原始对象,以便支持枚举名称的反向查找等,用户也应该意识到这一点。