【译】精通TypeScript:掌握20个提高代码质量的最佳实践!

本文正在参加「金石计划」

声明:本文是翻译文章,原文为🔥 Mastering TypeScript: 20 Best Practices for Improved Code Quality,作者是Vitalii Shevchuk

介绍

TypeScript是一门使用十分广泛的非常适合开发现代应用的开源语言。得益于它先进的类型系统,开发者可以使用它来编写高鲁棒性,高可维护性和高可扩展性的代码。虽然是这样说,不过如果真的想发挥它真正的威力来编写出高质量的项目代码的话,理解和遵循一些最佳实践是必不可少的。因此在本篇文章中我将会带大家一起深入到TypeScript的世界来学习21个关于它的最佳实践,最后让大家可以精通这门语言。这些最佳实践会涵盖很多不同的话题,不过你放心,对于每个最佳实践,我都会提供一些具体的例子来帮助大家理解,希望你们在实际项目开发里面使用起来了。

所以现在请端起一杯咖啡,让我们开始驾驭TypeScript的旅程吧!

最佳实践1:强类型检测(Strict Type Checking)

本篇文章中,我们将会从那些最基础的最佳实践开始说起,循序渐进由浅入深

试想一下你可以在错误真正发生之前就可以提前捕获到错误,是不是听起来有点美好得难以置信?很好,因为这正是TypeScript的强类型检查为你做的事情。这个检测机制可以让你提前发现那些一般很难发现到的问题,从而防止它们在你的代码里面为非作歹进而导致更严重的问题。

那么强类型检测是什么意思呢?用通俗易懂的话来说就是这个检测会确保你定义的变量类型和你预料的一样。听起来有点饶,我们看个例子,假如你定义了一个string类型的变量,这个检测会确保你后面给这个变量赋值的时候一定是一个string类型的值而不能是number这种其它类型的值。听起来很不错,那么如何在项目里面开启强类型检测呢?其实很简单,你只需要在tsconfig.json文件里面将 “strict” 的值设置为true就可以了(这个配置的默认值其实也是true)。当你设置完这个值后,TypeScript将会开启一系列检测来捕获那些你可能注意不到的特定错误。

下面是一个具体的强制类型检测可以帮助你发现一些常规错误的例子:

let userName: string = "John";
userName = 123; // 由于 "123" 不是一个字符串,所以这里TypeScript会报错

你看,当你遵循了这个最佳实践后,你就可以在早期开发的时候发现错误并且确保你的代码是按照你的预期执行的了,毫无疑问,这将会节约你很多后面定位奇怪问题的时间。

最佳实践2:类型推断(Type Inference)

其实TypeScript和JavaScript最大的区别就是,TypeScript会想办法明确你变量的类型,可是这并不意味着你需要给每一个变量显式声明类型。

这个时候就到了我们的第二个最佳实践出场了,那就是类型推断。所谓TypeScript的类型推断做的事情就是TypeScript的编译器会根据你给某个变量赋的值的类型来推断出该变量的类型。因此这也就意味着你不用在每次声明变量的时候都要显式声明它的类型了。

举个具体的例子,在下面的代码片段中,TypeScript会自动推断出name这个变量的类型是string,你是不需要具体写出来的:

let name = "John";

类型推断在你要处理复杂类型或者使用某个函数的返回值来初始化某个变量的时候是极其有用的。不过你也要记住,类型推断并不是万能药,有时候你更好的做法其实是显式地声明变量的类型,特别是处理某些复杂的类型或者你想要确保变量的类型是某个特定类型的时候。

最佳实践3:Linters

Linters是一些通过强迫你在代码里面遵循一些规范和约定以写出更好的代码的工具。它们可以帮助你提前发现问题以提高你代码的整体质量。

对于TypeScript有几个可用的Linters工具,例如TSLintESLint,这些工具都可以强迫你在代码里面遵循一致的代码风格来避免一些潜在的错误。这些工具的一些具体的场景可以是:检测漏掉的分号,定义了而又没有使用到的变量以及一些其它常见的问题。

最佳实践4:接口(Interfaces)

当我们说到要写出简洁而且高可维护性的代码时,接口一直都是你最好的朋友。所谓的接口就是,你代码里面对象的蓝图(blueprint)或者是模板,它用来表示你要处理的数据的结构。

在TypeScript的世界中,一个接口就像一个合同一样定义了一个对象的形状。它显式地声明了某个对象应该拥有的方法和属性。这也就是说当你给拥有某个接口类型的变量赋值一个对象类型的值时,TypeScript会检查这个对象是否拥有该接口定义的所有属性和方法。

下面是一个具体的定义和使用TypeScript接口的例子:

interface User {
    name: string;
    age: number;
}
let user: User = {name: "John", age: 25};

另外一方面,接口还可以帮助你更容易地重构代码,因为它可以确保当接口的定义发生改变时,你的代码里面所有使用了该接口类型对象的地方都要一次性地更新代码,否则编译器就会报错。

最佳实践5:类型别名(Type Alias)

TypeScript允许你使用类型别名的方式来创建一个新的自定义类型。这里要说一下类型别名和上面接口的区别:类型别名(type alias)其实是给一个已经存在的类型创建一个新的名字,然而接口(interface)则是为对象的形状创建一个名字。

我们接着来看一个类型别名的例子,使用type alias来给一个二维空间的点创建一个自定义类型:

type Point = { x: number, y: number };
let point: Point = { x: 0, y: 0 };

类型别名也可以用来创建一些复杂类型,例如联合类型(union type)交叉类型(intersection type)。例如下面这个例子:

type User = { name: string, age: number };
type Admin = { name: string, age: number, privileges: string[] };
type SuperUser = User & Admin

最佳实践6:使用元组(Using Tuples)

元组可以用来表示一个固定长度的拥有不同类型元素的数组,它们可以用来表示一个各个位置的元素的类型是固定的集合。

例如你可以使用元组来表示二维空间的一个点:

let point: [number, number] = [1, 2];

你也可以使用元组来表示不同类型的数据组成的集合:

let user: [string, number, boolean] = ["Bob", 25, true];

另外你可以使用析构表达式来将元组里面的元素赋值给不同的变量:

let point: [number, number] = [1, 2];
let [x, y] = point;
console.log(x, y);

最佳实践7:使用any类型(Using any Type)

有些时候,我们确实需要在代码里面使用到某个确实不知道类型的变量。在这种情况下,我们就可以使用any类型了。不过,和其它任何强大的工具一样,我们在使用any时一定要十分谨慎并且清楚地知道使用它的目的。

关于使用any的一个最佳实践就是只有在万不得已的时候才使用它。举个例子当我们需要使用某个第三方没有类型定义的包时或者处理一些随机生成的数据时我们就可以考虑使用any类型了。另外我们还要使用类型断言(type assertions)和类型守卫(type guards)来保证any类型的变量被正确使用。换句话来说,在可能的情况下,我们要尝试将变量的类型限制到某个范围之内。

下面是一个使用any的例子:

function logData(data: any) {
    console.log(data);
}

const user = { name: "John", age: 30 };
const numbers = [1, 2, 3];

logData(user); // { name: "John", age: 30 }
logData(numbers); // [1, 2, 3]

any另外一个最佳实践是我们要避免将函数的返回类型或参数类型设置为any,因为这样会减弱你代码的安全性。另外一个建议,相对于any,你可以使用那些像unknown或者object这种更加确定的类型,因为它们还可以提供某种程度的类型安全。

最佳实践8:使用unknown类型(Using the unknown Type)

unknown类型是TypeScript3.0引入的一个强大的限制性类型。因为它比any类型有更强的限制性,所以它可以帮助你预防一些无意的类型错误。

不像any类型,当你使用某个unknown类型的变量时,除非你先检查这个变量的类型,否则TypeScript将不会允许你对这个变量进行任何操作。这样就可以帮助你在代码编译的时候捕获到代码的类型错误,而不用等到代码实际运行的时候。

举个例子,你可以使用unknown类型定义一个类型更加安全的函数:

function printValue(value: unknown) {
    if (typeof value === 'string') {
        console.log(value);
    } else {
        console.log('Not a string');
    }
}

你还可以使用unknown类型来定义类型更加安全的变量:

let value: unknown = "hello";
let str: string = value; // 错误:'unknown'类型不可以赋值给'string'类型

最佳实践9:”never”

在TypeScript中,never是一个很特别的类型,它用来表示某些值永远不会出现。例如它可以用来表示某个方法永远都不会正常返回值,因为这个方法在执行的时候会抛出异常。正因如此,它可以用来告诉其他开发者(或者编译器)某个函数不能以某些方式被使用,进而用来帮助捕获一些潜在的错误。

举个例子,下面的函数在输入值小于0时会抛出错误:

function divide(numerator: number, denominator: number): number {
    if (denominator === 0) {
        throw new Error('Cannot divide by zero');
    }
    
    return numerator / denominator;
}

在上面的代码中,divide函数的返回值是数字,可是当传入的分母是零的时候,它将会抛出错误。这也就意味着这个函数在这种情况下是不会正常返回的,因此你可以使用never作为这种情况下这个函数的返回值:

function divide(numerator: number, denominator: number): number | never {
    if (denominator === 0) {
        throw new Error('Cannot divide by zero');
    }
    
    return numerator / denominator;
}

最佳实践10:使用keyof操作符(Using the keyof operator)

keyof是一个可以让你创建代表某个对象所有keys集合类型的强大操作符。

例如,你可以使用keyof操作符来创建一个包含某个接口类型所有key集合的类型:

interface User {
    name: string;
    age: number;
}
type UserKeys = keyof User; // "name" | "age"

下面是一个更加具体的用法,通过keyof你可以限制某个函数的参数一定是某个对象的其中一个key:

function getValue<T, K extends keyof T>(obj: T, key: K) {
    return obj[key];
}
let user: User = {name: 'John', age: 30};
console.log(getValue(user, 'name')); // 'John'
console.log(getValue(user, 'gender')); // 由于"gender"不是user的key,所以这里TypeScript会报错

最佳实践11:使用枚举类型(Using Enums)

枚举(enumerations)是在TypeScript中定义一组命名变量的方法。通过给一组相关的值起一个有意义的名字,你就可以编写出可读性和可维护性都更高的代码。

举个例子,你可以使用枚举来定义订单所有可能的状态:

enum OrderStatus {
    Pending,
    Processing,
    Shipped,
    Delivered,
    Cancelled
}
let orderStatus: OrderStatus = OrderStatus.Pending;

枚举变量的值是可以自定义的,可以是数字或字符串:

enum OrderStatus {
    Pending = 1,
    Processing = 2,
    Shipped = 3,
    Delivered = 4,
    Cancelled = 5,
}
let orderStatus: OrderStatus = OrderStatus.Pending;

按照常规的命名习惯,枚举变量的第一个字母要大写,并且该名字一定是单数形式

最佳实践12:使用命名空间(Using Namespaces)

命名空间可以帮助你更好地组织代码和避免命名冲突。简单来说,通过命名空间,你可以为你的代码创建一个容器,然后在这个容器里面放置你定义的变量,类,函数或者是接口。

举个例子,你可以使用命名空间来将所有和某个特定功能相关的代码放到同一个分组里面:

namespace OrderModule {
    export class Order { /* ... */ }
    export function cancelOrder(order: Order) { /* ... */ }
    export function processOrder(order: Order) { /* ... */ }
}
let order = new OrderModule.Order();
OrderModule.cancel(order);

另外你还可以通过为不同的代码绑定不同的命名空间来防止它们的命名冲突。

namespace MyCompany.MyModule {
    export class MyClass { /* ... */ }
}
let myClass = new MyCompany.MyModule.MyClass();

这里值的一提的是虽然命名空间和模块(modules)很像,不过它是用来组织代码和防止命名冲突的,而模块是用来加载和执行代码的。

最佳实践13:使用工具类型(Using Utility Types)

工具类型是TypeScript提前定义好的内置类型,它们可以帮助你写出类型更加安全的代码。简单来说,工具类型允许你以更加便捷的方式对某个类型进行一些常规操作。

举个例子,你可以使用Pick工具类型来从一个对象类型生成一个新的对象类型,新的对象类型的属性是原对象类型的子集:

type User = { name: string, age: number, email: string };
type UserInfo = Pick<User, "name" | "email">;

Pick相反,你可以使用Exclude工具类型来从某个对象类型里面剔除某些属性然后生成一个新的对象类型:

type User = { name: string, age: number, email: string };
type UserWithoutAge = Exclude<User, "age">

你还可以使用Partial工具类型来生成一个某个对象类型所有属性都是可选的(optional)新类型:

type User = { name: string, age: number, email: string };
type PartialUser = Partial<User>;

最佳实践14:”只读类型”和”只读数组类型”(”Readonly” and “ReadonlyArray”)

当你在使用TypeScript的过程中,可能想要确保某些数据是不能被更改的,这个时候就要用到我们要说的ReadonlyReadonlyArray类型了。

Readonly类型用来将某个对象类型的属性变成只读的,所谓的只读就是说该属性在创建后是不可以被再次修改的。只读限制对于定义一些配置信息或者是常量是十分有用的,举个例子:

interface Point {
    x: number;
    y: number;
}
let point: Readonly<Point> = {x: 0, y: 0};
point.x = 1; // 由于 "point.x" 是只读的,所以这里TypeScript会抛出错误

ReadonlyArrayReadonly很像,不过它是面向数组的,它可以让某个数组变成只读的:

let numbers: ReadonlyArray<number> = [1, 2, 3];
numbers.push(4); // 由于 "numbers" 是只读的,所以这里TypeScript会抛出错误

最佳实践15:类型守卫(Type Guards)

当我们使用TypeScript来处理复杂类型时,往往很难预料某个变量是属于哪个类型的。这个时候,你就可以利用类型守卫这个强大的工具来帮助你根据某些特定的条件缩小变量类型的范围了。

下面是一个使用自定义函数来作为类型守卫去判断某个变量是不是一个数字的例子:

function isNumber(x: any): x is number {
    return typeof x === 'number';
}

let value = 3;
if (isNumber(value)) {
    value.toFixed(2); // 由于你使用了类型守卫,所以这里TypeScript是知道 "value" 是一个数字的
}

除了上面的自定义函数外,我们还可以使用intypeofinstanceof操作符来做类型守卫。

最佳实践16:使用泛型(Using Generics)

泛型是TypeScript的一个很强大的属性,它可以让你写出适用于任何类型的代码,从而提高代码的可复用度。换句话来说,泛型可以让你只需要编写一份关于函数,类型或者接口的代码,该代码就可以自动适用于多个类型,这样你就不用为每个类型都维护一个独立的代码了。

举个例子,你可以使用泛型来定义一个可以返回拥有任何类型子元素的数组的函数:

function createArray<T>(length: number, value: T): Array<T> {
    let result = [];
    for (let i = 0; i < length; i++) {
        result[i] = value;
    }
    return result;
}
let names = createArray<string>(3, "Bob");
let numbers = createArray<number>(3, 0);

你还可以使用泛型来创建一个可以和任何类型的数据一起工作的类:

class GenericNumber<T> {
    zeroValue: T;
    add: (x: T, y: T) => T;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function(x, y) { return x + y; };

最佳实践17:使用infer关键字(Using the infer keyword)

infer关键字可以用来从某个复杂类型里面提取新的类型。

举个例子,你可以使用infer关键字从一个数组类型里面提取出它子元素的类型:

type ArrayType<T> = T extends (infer U)[] ? U : never;
type MyArray = ArrayType<string[]>; // MyArray is of type string

你可以使用infer类型从对象类型里面提取新的类型:

type ObjectType<T> = T extends { [key: string]: infer U } ? U : never;
type MyObject = ObjectType<{ name: string, age: number }>; // MyObject的类型是string|number

最佳实践18:使用条件类型(Using Conditional Types)

条件类型可以让你表示更加复杂的类型关系。它们可以让你根据类型满足的条件创建新的类型。

举个例子,你可以使用条件类型提取出函数返回值的类型:

type ReturnType<T> = T extends (…args: any[]) => infer R ? R : any;  
type R1 = ReturnType<() => string>; // string  
type R2 = ReturnType<() => void>; // void

你可以使用条件类型从某个对象类型里面提取出满足某些条件的属性作为新的类型:

type PickProperties<T, U> = { [K in keyof T]: T[K] extends U ? K : never }[keyof T];  
type P1 = PickProperties<{ a: number, b: string, c: boolean }, string | number>; // "a" | "b"

最佳实践19:使用映射类型(Using Mapped Types)

映射类型是一种让我们根据现有类型去创建新类型的方法。它们允许你对现有类型进行一系列的操作然后生成新的类型。

举个例子,你可以使用映射类型来为现存类型创建一个只读版本的新类型:

type Readonly<T> = { readonly [P in keyof T]: T[P] };
let obj: { a: number, b: string } = { a: 1, b: "hello" };
let readonlyObj: Readonly<typeof obj> = { a: 1, b: "hello" };

你也可以使用映射类型来基于现存类型创建一个所有属性都是可选的新类型:

type Optional<T> = { [P in keyof T]?: T[P] };
let obj: { a: number, b: string } = { a: 1, b: "hello" };
let optionalObj: Optional<typeof obj> = { a: 1 };

总的来说,映射类型可以被使用来:

  • 创建新的类型
  • 从现存对象类型里面移除或者添加某些属性
  • 更改现存对象类型里面某些属性的类型

最佳实践20:使用装饰器(Using Decorators)

装饰器是一种给类,方法或者属性添加额外功能的一种简单语法。它们可以在不魔改类本身实现的基础上增强它的能力。

举个例子,你可以使用装饰器给类的一个属性方法添加打日志的能力:

function logMethod(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    let originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
        console.log(`Calling ${propertyKey} with args: ${JSON.stringify(args)}`);
        let result = originalMethod.apply(this, args);
        console.log(`Called ${propertyKey}, result: ${result}`);
        return result;
    }
}

class Calculator {
    @logMethod
    add(x: number, y: number): number {
        return x + y;
    }
}

你也可以使用装饰器来给某个类,方法或者属性添加元信息,这些信息会在运行时被使用。

function setApiPath(path: string) {
    return function(target: any) {
        target.prototype.apiPath = path;
    }
}

@setApiPath('/users')
class UserService {
    // ...
}
console.log(new UserService().apiPath); // users

结论

无论你是初学者或者是资深的TypeScript开发者,我希望这篇文章都可以给你提供到一些可以帮助你写出更加简洁和高效代码的有用建议。

不过记住一点,最佳实践只是指导性的东西,不是一定要遵循的规则。因此在你写代码的时候,一定要有自己的判断能力和而不能盲目遵守各种规则。另外还要记住的一点是TypeScript是一门不断进化完善的语言,会不断有新的功能出现,所以它对应的最佳实践也可能会跟着变化,因此我们一定要保持开放的态度时刻学习新的知识。

我希望你在看完了这篇文章后能学习到新的知识并且激励你成为一个更好的TypeScript开发者。最后祝大家编程愉快!

原文链接:https://juejin.cn/post/7214858677173502009 作者:进击的大葱

(0)
上一篇 2023年3月27日 上午10:53
下一篇 2023年3月27日 上午11:04

相关推荐

发表回复

登录后才能评论