学习TypeScript:避免传统的OOP模式

325 阅读6分钟

这次我们看的是POOP,即 "面向对象的编程模式"。对于传统的OOP,我主要是指基于类的OOP,我想绝大多数的开发者在谈论OOP时都会想到它。如果你来自Java或C#,你可能会在TypeScript中看到很多熟悉的结构,这些结构最终可能会成为错误的朋友。

避免静态类#

我从那些经常使用Java的人那里看到了一件事,那就是他们想把所有东西都包在一个类里。在Java中,你没有任何其他选择,因为类是结构化代码的唯一方式。在JavaScript(也就是TypeScript)中,有很多其他的可能性,不需要任何额外的步骤就能达到你想要的效果。其中之一是静态类或具有静态方法的类,这是一种真正的Java模式。

// Environment.ts

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { /* ... */ }
  static setVariable(key: string, value: any): void  { /* ... */ }
  static getValue(key: string): unknown  { /* ... */ }
}

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

虽然这很有效,甚至是--没有类型注释--有效的JavaScript,但对于那些可以很容易地成为普通的、无聊的函数的东西来说,它的仪式太多。

// Environment.ts
const variableList: string = []

export function variables(): string[] { /* ... */ }
export function setVariable(key: string, value: any): void  { /* ... */ }
export function getValue(key: string): unknown  { /* ... */ }

// Usage in another file
import * as Environment from "./Environment";

console.log(Environment.variables());

对你的用户来说,界面是完全一样的。你可以像访问类中的静态属性一样访问模块范围内的变量,但你会让它们自动进入模块范围。你决定导出什么,使什么可见,而不是一些TypeScript字段修改器。另外,你不会最终创建一个什么都不做的Environment 实例。

甚至实现也变得更容易。请看variables() 的类版本。

export default class Environment {
  private static variableList: string[] = []
  static variables(): string[] { 
    return this.variableList;
   }
}
As opposed to

相对于模块版本。

const variableList: string = []

export function variables(): string[] {
  return variableList;
}

没有this 意味着要考虑的事情更少。作为一个额外的好处,你的捆绑程序可以更容易地进行树形摇动,所以你最终只能得到你真正使用的东西。

// Only the variables function and variablesList 
// end up in the bundle
import { variables } from "./Environment";

console.log(variables());

这就是为什么一个合适的模块总是比一个有静态字段和方法的类要好。那只是一个附加的模板,没有任何额外的好处。

避免命名空间#

和静态类一样,我看到有Java或C#背景的人坚持使用命名空间。命名空间是TypeScript在ECMAScript模块标准化之前就引入的一个组织代码的功能。它们允许你在不同的文件中分割东西,用参考标记将它们再次合并。

// file users/models.ts
namespace Users {
  export interface Person {
    name: string;
    age: number;
  }
}

// file users/controller.ts

/// <reference path="./models.ts" />
namespace Users {
  export function updateUser(p: Person) {
    // do the rest
  }
}

在那时,TypeScript甚至有一个捆绑功能。直到今天,它应该还能工作。但正如所说,这是在ECMAScript引入模块之前。现在有了模块,我们有了一种组织和结构代码的方法,可以与JavaScript生态系统的其他部分兼容。所以这是个优点。

那么我们需要命名空间做什么呢?

扩展声明#

如果你想扩展来自第三方依赖的定义,例如生活在节点模块中的定义,命名空间仍然有效。我的一些文章中大量使用了这一点。例如,如果你想扩展全局JSX 命名空间并确保img 元素具有alt文本。

declare namespace JSX {
  interface IntrinsicElements {
    "img": HTMLAttributes & {
      alt: string,
      src: string,
      loading?: 'lazy' | 'eager' | 'auto';
    }
  }
}

或者如果你想在环境模块中写出详细的类型定义。但除此之外呢?它已经没有什么用处了。

没有必要的命名空间#

命名空间将你的定义包装成一个对象。写这样的东西。

export namespace Users {
  type User = {
    name: string;
    age: number;
  }

  export function createUser(name: string, age: number): User {
    return { name, age }
  }
}

就会产生一些非常复杂的东西。

export var Users;
(function (Users) {
    function createUser(name, age) {
        return {
            name, age
        };
    }
    Users.createUser = createUser;
})(Users || (Users = {}));

这不仅增加了麻烦,而且还使你的捆绑器不能正确地进行树形摇动!另外,使用它们也会变得有点费话。

import * as Users from "./users";

Users.Users.createUser("Stefan", "39");

丢掉它们会让事情变得简单很多。坚持使用JavaScript为你提供的东西。不在声明文件之外使用命名空间,使你的代码清晰、简单、整洁。

避免使用抽象类#

抽象类是一种构造更复杂的类层次结构的方式,你预先定义了一些行为,但把一些功能的实际实现留给从你的抽象类扩展出来的类。

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }

  abstract move(): string;
}

class Human extends Lifeform {
  move() {
    return "Walking, mostly..."
  }
}

这是为Lifeform 的所有子类实现move 。这是一个基本上存在于每一种基于类的编程语言中的概念。问题是,JavaScript在传统上并不是基于类的。例如,像下面这样的抽象类会生成一个有效的JavaScript类,但不允许在TypeScript中被实例化。

abstract class Lifeform {
  age: number;
  constructor(age: number) {
    this.age = age;
  }
}

const lifeform = new Lifeform(20);
//               ^ 💥 Cannot create an instance of an abstract class.(2511)

如果你正在编写常规的JavaScript,但却依靠TypeScript以隐式文档的形式为你提供信息,这可能会导致一些不必要的情况。例如,如果一个函数定义看起来像这样。

declare function moveLifeform(lifeform: Lifeform);
  • 你或你的用户可能会将此理解为邀请你将一个Lifeform 对象传递给moveLifeform 。在内部,它调用lifeform.move()
  • Lifeform 可以在JavaScript中进行实例化,因为它是一个有效的类。
  • move 方法在Lifeform 中不存在,从而破坏了你的应用程序!

这是由于一种错误的安全感造成的。你实际上想要的是把一些预定义的实现放在原型链中,并且有一个合同,肯定会告诉你该期待什么。

interface Lifeform {
  move(): string
}

class BasicLifeForm {
  age: number;
  constructor(age: number) {
    this.age = age
  }
}

class Human extends BasicLifeForm implements Lifeform {
  move() {
    return "Walking"
  }
}

当你查阅Lifeform ,你可以看到接口和它所期望的一切,但你几乎不会遇到意外实例化错误类的情况。

底线#

TypeScript在语言的早期就包含了定制机制,当时JavaScript中严重缺乏结构化。现在,JavaScript达到了不同的语言成熟度,它给你足够的手段来结构化你的代码。所以,利用原生的和习惯性的东西,确实是个好主意。模块、对象和函数。偶尔的类。