跳转到主要内容

这篇文章将帮助你避免以后很难(或只是令人厌烦)纠正的错误。如果你打算创建一个新项目,并想让它变得令人惊叹——继续阅读!

纯函数

这是最重要的:保持你的函数的纯粹性,尽可能多地使用它们。

维基百科就是这样定义纯函数的:

  • 对于相同的参数,函数返回值是相同的(局部静态变量、非局部变量、可变引用参数或输入流没有变化),并且
  • 该函数没有副作用(没有局部静态变量、非局部变量、可变引用参数或输入/输出流的突变)。

使用关键字this的函数并不纯粹——它们使用的信息超出了它们的作用域,因此它们可以为相同的参数返回不同的结果。

尽管如此,我们的一些函数显然必须使用这一点——尽管如此,还是要尽可能多地将代码从不纯函数转移到纯函数。

变异其论点的函数是邪恶的最大来源——不惜一切代价避免它们。

不可变动性

数据的意外突变通常会导致危险的错误,这就是JS社区创建工具为数据结构提供不变性的原因。如果你愿意的话,你可以找到它们并阅读它们的文档(至少阅读它们是个好主意)。

我将向您展示一个在许多情况下“足够好”的技巧-它不会像不变性工具那样将您从每一个错误中拯救出来,但它将涵盖绝大多数情况,并且不会花费您任何费用:

export type UserModel = {
  readonly name: string;
  readonly email?: string;
  readonly age?: string;
}

通过在数据结构的字段中添加readonly关键字,每次尝试对作为参数接收的数据进行变异时,都会出现TS错误。所有这些都只会在编译时产生成本——它将在编译后被删除,并且不会在运行时进行检查(0性能成本)。

只需创建一个浅拷贝就可以消除该错误,并创建一个具有修改字段的新对象:

updatedUser = {
  ...user,
  age: 25
}

此外,它不像可变性工具那样具有限制性——如果你知道自己在做什么,并且想在特定情况下忽略这种限制,你可以应用-readonly修饰符或fest或ts本质类型中的Writeable类型。

在这些库中,您还可以找到使数据结构递归(深层)只读的类型,但在实践中,它并不像预期的那样完美和可预测-如果您愿意,可以尝试它们,但我发现最好手动将字段声明为只读(并对嵌套结构递归),或者只使用不变性库。

不变性是一个大话题,对于一篇文章的一部分来说太大了,你可以找到更多的信息和观点。

如果你还没有使用它,我鼓励你尝试一下:一开始,这将是一条有点崎岖的道路,但最终,你会开始应用规则和编程模式,并考虑到不变性,它将变得和常规编程一样容易。此外,它特别适用于纯函数;)

可见性和可变性修饰符

有一条众所周知的规则:使用const而不是let来声明变量。

我建议您也使用这个:将只读修饰符添加到您不打算修改的每个字段(在类、类型、接口中)。它将带来0成本——如果你不打算修改它,那么编译器会捕捉到任何意外的尝试。如果您以后决定将此字段设为可写字段,则可以删除只读修饰符。

export class HealthyComponent {
   
   // do not modify this!
   private readonly stream$: Observable<UserModel>;
   
   // it's ok to modify this
   protected isInitialized: boolean = false;

   constructor() {
     // you can initialize readonly fields in the constructor
     this.stream$ = of({name: example});
   }
}

对于类的字段和方法(包括组件、管道、服务和指令),请使用可见性修饰符:private、protected和public。

稍后您将为此感谢自己:当某个字段的方法是私有的,并且在某个时刻您看到您的类可以删除它(或者您需要修改它)时,您可以确保没有其他代码在使用它,所以可以重构它。

受保护的字段和方法不仅对继承的类可见,而且在Angular模板中也是可见的,因此,对模板应该访问的字段和方式使用Protected修饰符是一个很好的理由,而对模板不需要的字段和方法使用private修饰符则是一个非常好的理由。

我不添加公共修饰符——默认情况下,字段和方法是公共的,没有修饰符——但这是您个人的选择。由于输入和输出应该是公共的,所以我不会为它们添加修饰符,以免重载语法。

与私有修饰符一样,protected会让你知道你可以安全地删除或修改一些字段或方法,而不用担心模板。

任何代码都会随着时间的推移而增加复杂性,人类无法记住所有内容:这就是为什么这些小的修饰符在重构过程中会产生很大的影响。

类型别名

我希望有人早点告诉我:使用类型别名,而不是模型和其他数据结构的接口。

export type Model = {
  readonly field: string;
  readonly isExample?: boolean;
}

在另一个文件中:

import type { Model } from '@example/models';

通过这样做,当您只需要几个模型时,可以避免加载整个库,因为在TypeScript编译:documentation链接期间,像这样的导入将被完全删除。

此外,您将避免隐式接口声明合并,并将使用显式交集类型(如果您愿意的话)。没有其他显著差异,因此类型别名是最佳选择。

品牌类型

另一个技巧最好在项目开始时就开始使用。

“品牌类型”类似于常规类型,但有一些附加信息。代码可以忽略此添加,编译器将使用此添加来帮助您。

export type UUID = string & { __type: 'UUID' };

在上面的例子中,UUID仍然像字符串一样工作,但它有一条附加信息(“品牌”),这将有助于将其与普通字符串区分开来。

当您将用户的密码而不是电子邮件传递给某个功能时,品牌类型将使您免受这种情况的影响。当你发送错误的ID时,它会捕捉到错误——如果没有品牌类型,这是最难捕捉的情况,因为ID通常存储在具有类似名称和类型的变量和字段中。

我们可以使用一个独特的符号,也可以不使用:

// Method with additional fields:

export type UUID = string & { 
  readonly __type: 'UUID' 
};

export type DomainUUID = UUID & {
  readonly __model: 'Domain'
}

export type Domain = {
  readonly uuId: DomainUUID;
  readonly isActive: boolean;
  readonly name: string;
}

export type UserUUID = UUID & {
  readonly __model: 'User'
}

export type User = {
  readonly uuId: UserUUID;
  readonly name: string; 
}

// Method with a unique symbol:
declare const brand: unique symbol;

export type Brand<T, TBrand extends string> = T & {
  readonly [brand]: TBrand;
}

export type UUID = Brand<string, 'UUID'>;

// you can extend not only primitive types
export type DomainUUID = Brand<UUID, 'Domain'>;

export type Domain = {
  readonly uuId: DomainUUID;
  readonly isActive: boolean;
  readonly name: string;
}

export type UserUUID = Brand<UUID, 'User'>;

export type User = {
  readonly uuId: UserUUID;
  readonly name: string; 
}

你可以从代码中看到,这里唯一的符号优雅地取代了我们的人工字段__model。

关于品牌类型及其实现方法的更多信息,您可以在这个Twitter帖子中阅读。

类型化函数

请键入您的函数:它们的参数和返回的结果。

当你创建它们时,它们应该得到什么以及应该返回什么对你来说是显而易见的。但添加类型有两个原因:

  • 对于代码的其余部分,它们应该向该函数发送什么以及它保证返回什么并不明显;
  • 对你来说,几个月后,这也不会很明显。

如果我们应该声明返回类型,有不同的意见:我建议您声明它们,排除(如果您愿意的话)那些不返回任何内容的类型。

最初的主要好处并不明显,但在重构过程中会非常有帮助:

  • 如果您更改了函数的代码,并且不小心更改了它的返回类型,它将被编译器捕获;
  • 如果您有意更改函数的返回类型,而使用此函数的某些代码还没有准备好,那么它将被编译器捕获。

在推断类型的情况下,一些代码很可能会接受返回的结果,但会改变行为:

// before refactoring
function isAuthenticated(user: User) { 
  //...
  return true;
  // inferred type: boolean
}

if (isAuthenticated(user)) {
  markItemAsPaid();
} else {
  redirectToLogin();
}

// after refactoring
function isAuthenticated(user: User) {  
  //...
  return someApiRequest(user);
  // inferred type: Observable<boolean>
}

在这个例子中,编译器不会引发任何错误:“Observable”是一个对象,if(isAuthenticated(user))可以工作,但它总是返回true。

这是一个简单的例子,但对于更复杂的代码,发生这种情况的几率更高。

此外,它还显著提高了代码的可读性,这是一个非常重要的指标,比保存几个符号进行键入更重要。

继承

使用组合而非继承原则。

上面链接的文本解释了Angular上下文之外的内容,我将解释为什么在Angular中使用抽象类和继承组件和指令不是一个好主意。

除了继承带来的常见问题外,父类中声明的输入和输出也将被继承,因此您必须在子类中支持它们,即使您在特定的子类中不需要它们。

此外,在组件的情况下,每个Angular组件都应该有一个模板。这里有两种方法:

  • 链接到父母的模板:你不能覆盖任何东西,所以父母的模板会有很多分支来处理每个孩子的需求和特殊情况;
  • 创建一个子模板:您将不得不复制整个模板,这会降低代码的可重用性——这也是继承的最初原因。

与本文中的其他建议一样,这一建议带来了一些额外的动作和思考,但没有什么好的东西是免费的。这篇文章是为了帮助你,而不是争论或批评:尽可能多地使用本文中的建议✌️

阅读“掌握角度”: