天天看點

【譯】精通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工具,例如TSLint和ESLint,這些工具都可以強迫你在代碼裡面遵循一緻的代碼風格來避免一些潛在的錯誤。這些工具的一些具體的場景可以是:檢測漏掉的分号,定義了而又沒有使用到的變量以及一些其它常見的問題。

最佳實踐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的過程中,可能想要確定某些資料是不能被更改的,這個時候就要用到我們要說的Readonly或ReadonlyArray類型了。

Readonly類型用來将某個對象類型的屬性變成隻讀的,所謂的隻讀就是說該屬性在建立後是不可以被再次修改的。隻讀限制對于定義一些配置資訊或者是常量是十分有用的,舉個例子:

interface Point {
    x: number;
    y: number;
}
let point: Readonly<Point> = {x: 0, y: 0};
point.x = 1; // 由于 "point.x" 是隻讀的,是以這裡TypeScript會抛出錯誤
           

ReadonlyArray和Readonly很像,不過它是面向數組的,它可以讓某個數組變成隻讀的:

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" 是一個數字的
}
           

除了上面的自定義函數外,我們還可以使用in,typeof和instanceof操作符來做類型守衛。

最佳實踐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開發者。最後祝大家程式設計愉快!