以淘寶店鋪爲例,談談 TypeScript ESLint 規則

前言


ESLint 在項目中已經是大家見慣不慣的存在,你可能很厭煩動不動跳出來的 ESLint 報錯,也可能很享受經過統一校驗的工工整整的代碼,無論如何,我的意見是,在稍微正式點的項目中都要有 ESLint 的存在,無論是直接使用簡單的 recommend 配置如 extends: ['eslint: recommend'],還是精心研究了一整套適用於自己的規則集,Lint 工具的最大幫助就是保持語法統一,至少項目中的所有 JavaScript 文件應使用統一的單雙引號、分號、縮進等風格(僅靠編輯器並不能保證)。

其次,Lint 幫助你的代碼更加簡潔、有效,如不允許未使用的變量、JSX/TSX 中使用簡寫的 true 屬性(<Comp shouldDisplay /> 而不是 <Comp shouldDisplay={true} />)等、還有一點值得一提,ESLint 並不會一直嘗試去簡化你的代碼,在很多情況下它會要求你寫更多代碼來換取可讀性和安全性的提升,尤其是在 TypeScript 場景下,explicit-module-boundary-types 規則會要求你爲函數與類方法顯式的聲明其返回值,switch-exhaustiveness-check 規則會要求你處理聯合類型變量的所有類型分支。

本文來自於我在所在團隊(淘寶店鋪)內部制定、落地、推廣 ESLint 規則集的收穫,將會簡要的介紹一批我認爲在 TypeScript 分享中非常有必要的規則,通過這篇文章,你會了解到在制定規則時我們考慮的是什麼,對於 TypeScript 代碼進行約束的思考,以及如何在自己的團隊內推廣這一套規則。

另外,淘系技術部前端架構團隊正在淘系內推廣 AppLint,準備將 ESLint 推廣到整個淘系前端作爲 CI/CD 的卡口之一,歡迎集團的同學瞭解並試用。

基礎約束

爲了適應讀者可能有的不同的約束嚴格程度,這裏將規則拆分爲基礎約束與嚴格約束部分,基礎約束的規則以語法統一(包括實際代碼與類型部分)爲主,推薦所有人在所有項目中使用,即使是個人項目——說實在的,都寫 TypeScript 了,還在意這小小的 Lint 規則?而嚴格約束部分更關注類型以及 ECMAScript、TypeScript 的特殊語法,適合對代碼質量要求較高的同學。這裏不會給出推薦的錯誤等級,即使全部是 warn,只要你打開了,至少你也會在以後心情好的時候來修對吧?(對吧?)

array-type

TypeScript 中支持使用 Array<T>T[] 的形式聲明數組類型,此規則約束項目中對這兩種數組類型的聲明。

其支持的配置:

爲什麼?:對於這種效果完全一致的語法,我們需要的只是確定一個規範然後在所有地方使用這一規範。實際上,這一類規則(還有後面的類型斷言語法)就類似於單引號 / 雙引號,加不加分號這種基礎規則,如果你不能接受上一行代碼單引號這一行代碼雙引號,那麼也沒理由能接受這裏一個 Array<number> 那裏一個 number[],另外,我個人推薦統一使用 []

await-thenable

只允許對異步函數、Promise、PromiseLike 使用 await 調用

爲什麼:避免無意義的 await 調用。

ban-ts-comment

禁止 @ts- 指令的使用,或者允許其在提供了說明的情況下被使用,如:

// @ts-expect-error 這裏的類型太複雜,日後補上
// @ts-nocheck 未完成遷移的文件

此規則推薦與 prefer-ts-expect-error 搭配使用,詳見下方。

爲什麼:如果說亂寫 any 叫 AnyScript,那麼亂寫 @ts-ignore 就可以叫 IgnoreScript 了。

ban-types

禁止部分值被作爲類型標註,此規則能夠對每一種被禁用的類型提供特定的說明來在觸發此規則報錯時給到良好的提示,場景如禁用 {}Functionobject 這一類被作爲類型標註,

爲什麼?使用 {} 會讓你寸步難行:類型 {} 上不存在屬性 'foo',所以用了 {} 你大概率在下面還需要類型斷言回去或者變 any,使用 object Function 毫無意義。

consistent-type-assertions

TypeScript 支持通過 as<> 兩種不同的語法進行類型斷言,如:

const foo = {} as Foo;
const foo = <Foo>{};
// 類似的還有常量斷言
const foo = <const>[1, 2];
const foo = [1, 2, 3] as const;

這一規則約束使用統一的類型斷言語法,我個人一般在 Tsx 中使用 as ,在其他時候儘可能的使用 <>,原因則是 <> 更加簡潔。

爲什麼:類似於 array-type,做語法統一,但需要注意的是在 Tsx 項目中使用 <> 斷言會導致報錯,因爲不像泛型可以通過 <T extends Foo> 來顯式告知編譯器這裏是泛型語法而非組件。

explicit-module-boundary-types

函數與類方法的返回值需要被顯式的指定,而不是依賴類型推導,如:

const foo = ()Foo ={};

爲什麼:通過顯式指定來直觀的區分函數的功能,如副作用等,同時顯式指定的函數返回值也能在一定程度上提升 TypeScript Compiler 性能。

no-extra-non-null-assertion

不允許額外的重複非空斷言:

// x
function foo(bar: number | undefined) {
  const bar: number = bar!!!;
}

爲什麼:額,why not?

prefer-for-of

在你使用 for 循環遍歷數組時,如果索引僅僅用來訪問數組成員,則應該替換爲 for...of

爲什麼:如果不是爲了兼容性場景,在這種場景下的確沒有必要使用 for 循環。

prefer-nullish-coalescing && prefer-optional-chain

使用 ?? 而不是 ||,使用 a?.b 而不是 a && a.b

爲什麼:邏輯或 || 會將 0 與 "" 視爲 false 而導致錯誤的應用默認值,而可選鏈相比於邏輯與 && 則能夠帶來更簡潔的語法(尤其是在屬性訪問嵌套多層,或值來自於一個函數時,如 document.querySelector),以及與 ?? 更好的協作:const foo = a?.b?.c?.d ?? 'default';

no-empty-interface

不允許定義空的接口,可配置爲允許單繼承下的空接口:

// x
interface Foo {}

// √
interface Foo extends Bar {}

爲什麼:沒有父類型的空接口實際上就等於 {},雖然我不確定你使用它是爲了什麼,但我能告訴你這是不對的。而單繼承的空接口場景則是較多的,如先確定下繼承關係再在後續添加成員。

no-explicit-any

不允許顯式的 any。

實際上這條規則只被設置爲 warn 等級,因爲真的做到一個 any 不用或是全部替換成 unknown + 類型斷言 的形式成本都非常高。

推薦配合 tsconfig 的 --noImplicitAny (檢查隱式 any)來儘可能的保證類型的完整與覆蓋率。

no-inferrable-types

不允許不必要的類型標註,但可配置爲允許類的屬性成員、函數的屬性成員進行額外標註。

const foo: string = "linbudu";

class Foo {
  prop1: string = "linbudu";
}

function foo(a: number = 5, b: boolean = true) {
  // ...
}

爲什麼:對於普通變量來說,與實際賦值一致的類型標註確實是沒有意義的,TypeScript 的控制流分析能很好地做到這一點,而對於函數參數與類屬性,主要是爲了確保一致性,即函數的所有參數(包括重載的各個聲明)、類的所有屬性都有類型標註,而不是僅爲沒有初始值的參數 / 屬性進行標註。

no-non-null-asserted-nullish-coalescing

不允許非空斷言與空值合併同時使用:bar! ?? tmp

爲什麼:冗餘

no-non-null-asserted-optional-chain

不允許非空斷言與可選鏈同時使用:foo?.bar!

爲什麼:和上一條規則一樣屬於冗餘,同時意味着你對 ! ?? ?. 的理解存在着不當之處。

no-throw-literal

不允許直接 throw 一個字符串如:throw 'err',只能拋出 Error 或基於 Error 派生類的實例,如:throw new Error('Oops!')

爲什麼:拋出的 Error 實例能夠自動的收集調用棧信息,同時藉助 proposal-error-cause[3] 提案還能夠跨越調用棧來附加錯誤原因傳遞上下文信息,不過,真的會有人直接拋出一個字符串嗎??

no-unnecessary-type-arguments

不允許與默認值一致的泛型參數,如:

function foo<T = number>() {}
foo<number>();

爲什麼:出於代碼簡潔考慮。

no-unnecessary-type-assertion

不允許與實際值一致的類型斷言,如:const foo = 'foo' as string

爲什麼:你懂的。

no-unnecessary-type-constraint

不允許與默認約束一致的泛型約束,如:interface FooAny<T extends any> {}

爲什麼:同樣是出於簡化代碼的考慮,在 TS 3.9 版本以後,對於未指定的泛型約束,默認使用 unknown ,在這之前則是 any,知道這一點之後你就沒必要再多寫 extends unknown 了。

non-nullable-type-assertion-style

此規則要求在類型斷言僅起到去空值作用,如對於 string | undefined 類型斷言爲 string時,將其替換爲非空斷言 !

const foo: string | undefined = "foo";

// √
foo!;
// x
foo as string;

爲什麼:當然是因爲簡化代碼了!此規則的本質是檢查經過斷言後的類型子集是否僅剔除了空值部分,因此無需擔心對於多種有實際意義的類型分支的聯合類型誤判。

prefer-as-const

對於常量斷言,使用 as const 而不是 <const>,這一點類似於上面的 consistent-type-assertions 規則。

prefer-literal-enum-member

對於枚舉成員值,只允許使用普通字符串、數字、null、正則,而不允許變量複製、模板字符串等需要計算的操作。

爲什麼:雖然 TypeScript 是允許使用各種合法表達式作爲枚舉成員的,但由於枚舉的編譯結果擁有自己的作用域,因此可能導致錯誤的賦值,如:

const imOutside = 2;
const b = 2;
enum Foo {
  outer = imOutside,
  a = 1,
  b = a,
  c = b,
}

這裏 c == Foo.b == Foo.c == 1,還是 c == b == 2 ? 觀察下編譯結果:

"use strict";
const imOutside = 2;
const b = 2;
var Foo;
(function (Foo) {
  Foo[(Foo["outer"] = imOutside)] = "outer";
  Foo[(Foo["a"] = 1)] = "a";
  Foo[(Foo["b"] = 1)] = "b";
  Foo[(Foo["c"] = 1)] = "c";
})(Foo || (Foo = {}));

懂伐小老弟?

prefer-ts-expect-error

使用 @ts-expect-error 而不是 @ts-ignore

爲什麼:@ts-ignore@ts-expect-error 二者的區別主要在於,前者是 ignore,是直接放棄了下一行的類型檢查而無論下一行是否真的有錯誤,後者則是期望下一行確實存在一個錯誤,並且會在下一行實際不存在錯誤時拋出一個錯誤。

這一類干涉代碼檢查指令的使用本就應該慎之又慎,在任何情況下都不應該被作爲逃生艙門(因爲它真的比 any 還好用),如果你一定要用,也要確保用的恰當。

promise-function-async

返回 Promise 的函數必須被標記爲 async,此規則能夠確保函數的調用方只需要處理 try/catch 或者 rejected promise 的情況。

爲什麼:還用解釋嗎?

嚴格約束

no-unnecessary-boolean-literal-compare

不允許對布爾類型變量與 true / false 的 === 比較,如:

declare const someCondition: boolean;
if (someCondition === true) {
}

爲什麼:首先,記住我們是在寫 TypeScript,所以不要想着你的變量值還有可能是 null 所以需要這樣判斷,如果真的發生了,那麼說明你的 TS 類型標註不對哦。而且,此規則的配置項最多允許 boolean | null 這樣的值與 true / false 進行比較,所以還是讓你的類型更精確一點吧。

consistent-type-definitions

TypeScript 支持通過 type 與 interface 聲明對象類型,此規則可將其收束到統一的聲明方式,即僅使用其中的一種。

爲什麼:先說我是怎麼做得:在絕大部分場景下,使用 interface 來聲明對象類型,type 應當用於聲明聯合類型、函數類型、工具類型等,如:

interface IFoo {}

type Partial<T> = {
  [P in keyof T]?: T[P];
};

type LiteralBool = "true" | "false";

原因主要有這麼幾點:

method-signature-style

方法簽名的聲明方式有 method 與 property 兩種,區別如下:

// method
interface T1 {
  func(arg: string): number;
}

// property
interface T2 {
  func: (arg: string) => number;
}

此規則將聲明方式進行約束,推薦使用第二種的 property 方式。

爲什麼:首先,這兩種方式被稱爲 method 與 property 很明顯是因爲其對應的寫法,method 方式類似於在 Class 中定義方法,而 property 則是就像定義普通的接口屬性,只不過它的值是函數類型。推薦使用 property 的最重要原因是,通過使用 屬性 + 函數值 的方式定義,作爲值的函數的類型能享受到更嚴格的類型校驗( strictFunctionTypes[4]),此配置會使用逆變(contravariance)而非協變(covariance)的方式進行函數參數的檢查,關於協變與逆變我後續會單獨的寫一篇文章,這裏暫時不做展開,如果你有興趣,可以閱讀 TypeScript 類型中的逆變協變

consistent-type-imports

約束使用 import type {} 進行類型的導入,如:

// √
import type { CompilerOptions } from "typescript";

// x
import { CompilerOptions } from "typescript";

爲什麼:import type 能夠幫助你更好的組織你的項目頭部的導入結構(雖然 TypeScript 4.5 支持了類型與值的混合導入:import { foo, type Foo },但還是推薦通過拆分值導入與類型導入語句來獲得更清晰地項目結構)。值導入與類型導入在 TypeScript 中使用不同的堆空間來存放,因此無須擔心循環依賴(所以你可以父組件導入子組件,子組件導入定義在父組件中的類型這樣)。

一個簡單的、良好組織了導入語句的示例:

import { useEffect } from "react";

import { Button, Dialog } from "ui";
import { ChildComp } from "./child";

import { store } from "@/store";
import { useCookie } from "@/hooks/useCookie";
import { SOME_CONSTANTS } from "@/utils/constants";

import type { Foo } from "@/typings/foo";
import type { Shared } from "@/typings/shared";

import styles from "./index.module.scss";

restrict-template-expressions

模板字符串中的計算表達式其返回值必須是字符串,此規則可以被配置爲允許數字、布爾值、可能爲 null 的值以及正則表達式,或者你也可以允許任意的值,但這樣就沒意思了...

爲什麼:在模板表達式中非字符串與數字以外的值很容易帶來潛在的問題,如:

const arr = [1, 2, 3];
const obj = { name: "linbudu" };

// 'arr: 1,2,3'
const str1 = `arr: ${arr}`;
// 'obj: [object Object]'
const str2 = `obj: ${obj}`;

無論哪種情況都不會是你想看到的,因爲這實際上已經脫離了你的掌控。推薦在規則配置中僅開啓 allowNumber 來允許數字,而禁止掉其他的類型,你所需要做得應當是在把這個變量填入模板字符串中時進行一次具有實際邏輯的轉化。

switch-exhaustiveness-check

switch 的判定條件爲 聯合類型 時,其每一個類型分支都需要被處理。如:

type PossibleTypes = "linbudu" | "qiongxin" | "developer";

let value: PossibleTypes;
let result = 0;

switch (value) {
  case "linbudu"{
    result = 1;
    break;
  }
  case "qiongxin"{
    result = 2;
    break;
  }
  case "developer"{
    result = 3;
    break;
  }
}

爲什麼:工程項目中經常出現的,導致問題發生的原因就是有部分功能邏輯點僅通過口口相傳,只看代碼你完全不知道自己還漏了什麼地方。如聯合類型變量中每一條類型分支可能都需要特殊的處理邏輯。

你也可以通過 TypeScript 中的 never 類型來實現實際代碼的檢驗:

const strOrNumOrBool: string | number | boolean = false;

if (typeof strOrNumOrBool === "string") {
  console.log("str!");
} else if (typeof strOrNumOrBool === "number") {
  console.log("num!");
} else if (typeof strOrNumOrBool === "boolean") {
  console.log("bool!");
} else {
  const _exhaustiveCheck: never = strOrNumOrBool;
  throw new Error(`Unknown input type: ${_exhaustiveCheck}`);
}

這裏通過編譯時與運行時做了兩重保障,確保爲聯合類型新增類型分支時也需要被妥善的處理,你可以參考開頭的 never 類型 文章瞭解更多 never 相關的使用。除了聯合類型以外,你還可以通過 never 類型來確保每一個枚舉成員都需要處理。

enum PossibleType {
  Foo = "Foo",
  Bar = "Bar",
  Baz = "Baz",
}

function checker(input: PossibleType) {
  switch (input) {
    case PossibleType.Foo:
      console.log("foo!");
      break;
    case PossibleType.Bar:
      console.log("bar!");
      break;
    case PossibleType.Baz:
      console.log("baz!");
      break;
    default:
      const _exhaustiveCheck: never = input;
      break;
  }
}

以上就是我們目前在使用的部分規則,還有一批規則或是涉及到高度的定製或是適用場景狹窄,這裏就不做列舉了。如果你有什麼想法,歡迎與我一起交流,但請注意:我不是在灌輸你一定要使用什麼規則,我只是在分享我們使用的規則以及考量,因此在留言前請確認不要屬於此類觀點,感謝你的閱讀。

參考資料

[1]

QCon+ 專題:TypeScript 在中大型項目中的落地實踐: https://qconplus.infoq.cn/2021/beijing2nth/track/1240

[2]

淘寶店鋪 TypeScript 研發規約落地: https://qconplus.infoq.cn/2021/beijing2nth/presentation/4161

[3]

proposal-error-cause: https://github.com/tc39/proposal-error-cause

[4]

strictFunctionTypes: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-6.html#strict-function-types

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bY5NQGEllV5IpVKhEYBVjA