React + Typescript 工程化治理實踐

螞蟻金服資料體驗技術發表於2019-11-14

作者 光生 螞蟻金服·資料體驗技術團隊

最近參與了一個 React + Typescript 元件專案,這個專案後期會開源,對程式碼的質量和工程化上有比較高的要求,因此需要進行工程化治理。通過這次工程化治理,筆者算是梳理清楚了一個  React + Typescript 第三方元件所需要的一些工程化方面的基礎設施,在這裡總結並分享給大家。

這次的工程化治理主要分以下幾個方面:

  • 靜態檢查:TypeScript + ESLint
  • 開發體驗:打包工具和 Mono-repo 管理
  • 程式碼質量:測試

靜態檢查

TS 和 ESLint 這些工具本質上是對程式碼做靜態檢查,儘早發現隱藏的 bug。在 TS 出現之後,TS 有 ESLint 沒有的型別檢查,並且也具備 ESLint 具有的語法錯誤檢查的能力,所以目前我們用 ESLint 主要是利用社群中數量龐大的 Lint 規則來對程式碼風格做一個規範,利用工具的方式去推行一些最佳實踐。TS 則主要負責對程式碼語法和語義上的錯誤進行靜態檢查。另外,TS 本身是一個全新的語言,使用 TS 可以享受到一些 JS 沒有的語言特性。

從 AnyScript 到 TypeScript

用 TS,一個很重要的區別就是有沒有在配置中開啟 strict 選項。如果沒有的話,那其實你用的就是 AnyScript,在型別上基本沒有約束,和 JS 沒有太大的區別。如果是從 JS 遷移到 TS 的專案,這個選項應該關閉,因為老的 JS 程式碼沒有寫型別。但如果是全新的純 TS 專案,strict 是一定要開啟的。現在 CRA 這樣的腳手架建立的專案也是預設開啟了 strict 模式的。

開啟 strict 模式其實很簡單,難的是如何在 strict 模式下優雅的寫 TS 程式碼。下面說說一些 strict 模式下的常見問題以及一些型別的技巧:

noImplicitAny

這個問題出現的最常見的場景就是函式的引數。如果習慣了寫 JS,在寫函式引數的時候很大可能會忘記寫型別。雖然 TS 可以推斷出函式的返回值型別,但不能推斷出函式的引數型別。如果不寫引數型別,那引數的型別預設就是 any,這個時候就會報 noImplictAny 的錯誤,因為 TS 的 strict 模式下不允許這種隱式 any 的存在。

解決這個問題,就要養成給函式引數加型別的習慣,並且不能直接加個顯式的 any 就完事了 ?。該定義新型別就定義,如果已經定義的就引用一下。不是非常規的場景,是不應該出現 any 的,這個後面還會再講到。any 本身是一個繞過型別檢查的 escape hatch,用了 any 就會導致這個地方的型別檢查被繞過,這樣一來使用 TS 的意義就不大了。

話說回來,如果寫程式碼的同學的背景是寫靜態型別語言的,那是絕對不會忘了加型別的。這個問題在習慣寫弱型別的 JS 的前端同學身上比較常見,更多是一個習慣和型別思維的養成問題

讓我們再來看一個場景:

const props = {
  foo: "bar"
};

props["foo"] = "baz"; // Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
複製程式碼

這種場景下也會報 noImplicitAny 的錯誤。這是因為我們沒有顯式宣告這個物件的 index signiture。解決方案就是:

interface Props {
  foo: string;
  [key: string]: Props[keyof Props];
}

const props: Props = {
  foo: "bar"
};

props["foo"] = "baz"; // ok
props["bar"] = "baz"; // error
複製程式碼

這裡本來可以直接寫 [key:string]: any;  的,但如果在 key 確定的情況下,可以用 keyof 來獲取一個介面的所有 key 組成的聯合型別,然後用 index types 獲取 value 的型別。這樣相比 any 能對 value 的型別有一個限制。

strictNullChecks

在預設(非 strict 模式)下,undefined 和 null 可以被賦值給任意的值,所以去呼叫一個可能為 undefined 的屬性上的方法也是被允許的。在 strict 模式下,null 和 undefined 被作為單獨的型別處理,不能賦值給其他的型別。因此如果一個值可能是 null 或者 undefined,我們必須採取措施,為型別檢查提供資訊。

比如有如下的場景:

class Component extends React.Component<{}, {}> {
    graph: Graph?;

    componentDidMount() {
      this.graph = new Graph()
      this.init(this.graph)
    }

    init() {
      this.graph.on("click", () => {})  // Object is possibly 'undefined'
    }

    render() {
        return <div>foo</div>
    }
}

複製程式碼

在 GUI 的場景中,很多的成員變數是會在元件初始化之後才有值的,比如這個場景下的 this.graph 。在變數引數或者屬性的型別後面加一個 ? 是 Optional 的意思。表名這個屬性或者引數有可能是 undefined。

要想繞過這個報錯,我們需要用 type guards

init() {
    if (this.graph) this.graph.on("click", () => {})  // ok
}
複製程式碼

但如果呼叫 this.graph  的地方比較多,寫 if 就很麻煩,而且影響程式碼的閱讀。這個時候在確保 this.graph  一定有值的情況下,我們可以用 type assertion:

init() {
    this.graph!.on("click", () => {})  // ok
}
複製程式碼

在近期的 TS 3.7 版本中新推出的 Optional Chaining,是更好的解決方案:

init() {
   this.graph?.on("click", () => {})  // ok
}
複製程式碼

! 和 ? 這些操作符的用法,寫過 Swift 的同學應該會很熟悉。現在 TS 裡面基本有了一整套的 Nullable Value 的操作方案。

總結一下,對於 strict 模式下的 strictNullChecks,我們可以用 type guards,type assertion,optional chaining 三種辦法去告訴編譯器,這裡的操作是安全的。

關鍵是,Optional 的值在 GUI 程式設計中是很正常的,我們要學會去處理和麵對這些情況,把 undefined 和 null 作為一個單獨的型別來對待。

第三方庫的型別定義

在引用第三方庫時,我們需要注意一點,就是這個庫有沒有提供型別定義檔案。型別定義檔案一般在專案 package.json 的 types 欄位中有說明。對於 JS 寫的庫,型別定義也可能是一個單獨的包,比如  @types/react  這樣的。

有型別定義檔案,那我們在呼叫 API 的時候就可以引入對應的型別定義,呼叫的時候也會有型別檢查和程式碼提示,來提升我們使用第三方庫的效率,提早發現可能的 bug。

如果沒有,就要考慮是否自行維護一個定義檔案,但這樣的成本是很大的。所以,一個沒有定義檔案的第三方庫,我們要仔細的考慮是否要在 TS 專案中使用這個庫。

高階型別

TS 文件中有一章叫 “高階型別”。裡面提到的都是一些高階的型別特性。除了 Type Guard,交叉型別,聯合型別和上文提到的可以為 null 的型別之外,最關鍵的是

  • Mapped types(對映型別)
  • Conditional Types(條件型別)
  • Index types(索引型別)

在使用泛型時,這些技巧可以讓我們對型別進行“程式設計”,想象一下對型別變數使用 ?三元表示式或者 Array.prototype.map  這樣的方法。

舉個例子,條件型別的用法是這樣的:

T extends U ? X : Y
複製程式碼

如果 T 和 U 相容(T 包含 U 有的所有屬性,T 可以被賦值給 U),這個型別就是 X,否則就是 Y。

看一下條件型別的實際用途。比如有如下的函式,可能返回 string,也可能是 null:

function process(text: string | null): string | null {
  return text && text.replace(/f/g, "p");
}
複製程式碼

但這樣的型別寫法是有問題的,因為返回值有可能是 null,沒有 toUpperCase 這個方法。

//            ⌄ Type Error! :(
process("foo").toUpperCase();
複製程式碼

這個時候我們可以用條件型別來解決:

function process<T extends string | null>(
 text: T
): T extends string ? string : null {
 ...
}

process("foo").toUpperCase() // ok
process().toUpperCase() // error
複製程式碼

在寫 TS 程式碼時,我們在掌握了這些高階型別技巧時,就可以適時的去用這些技巧來讓程式碼的型別檢查變的更健壯,避免重複定義型別,寫更優雅的程式碼。

因為本文不是 TS 的專題文章,上文中沒有提到的 TS 使用技巧,比如對映型別等等,還可以參考:巧用 TypeScript  和  巧用 Typescript (二)  以及  TS 學習總結:編譯選項 && 型別相關技巧

ESLint 和 Prettier

ESLint 和 Prettier 是更流行,普及程度更高的工具。這裡就不講太多細節,主要說的是 ESLint 如何支援 TypeScript。

ESLint + TypeScript:取代 TSLint 的新方案

TSLint 在 2019 年宣佈未來專案將會廢棄。TS 官方推薦 ESLint 作為 Linter。我們可以通過  @typescript-eslint/parser 讓 ESLint 支援解析 TS 檔案。配套的還有   @typescript-eslint/eslint-plugin 作為 ESLint 下針對 TS 訂製的 Lint 規則。

ESLint + TS 這方面的資料很多,Using ESLint and Prettier in a TypeScript Project  這篇文章講了如何從 TSLint 遷移到 ESLint。

還有以下的文章,都講解了相關的配置(ESLint + TS):

關於 TSLint 到 ESLint 的切換的背景,可以看  typescript-eslint  這個專案的 README,講的非常詳細

使用 ESLint 的好處就是:可以背靠 ESLint 的生態,像 Airbnb 這樣的規則集就可以直接用於 TS 專案。上面列舉的部落格就有講如何配置 Airbnb + typescript-eslint + prettier 三種規則集。讓專案可以用  typescript-eslint 來規範 TS 程式碼(TS 特有的 Lint 規則),用 Airbnb 來規範 React 和 JS 程式碼(TS 是 JS 的超集),用 Prettier 相關規則來關閉前兩個規則中和 Prettier 程式碼風格衝突的規則。三者集合就是目前比較完善,好用的 Lint 規則了。

Airbnb 中有一些規則,比如要求 React 元件宣告 PropTypes,是不適用於 TS 專案的,所以需要在 ESLint 配置檔案裡關掉。其他類似的配置有很多,我們不用死板的遵守 Lint 規則,而是關閉不合適的規則,只取其精華。

JS + TS 混合專案  ESLint 配置

在又有 JS 又有 TS 檔案的情況下,ESLint 需要只在 TS 檔案上,執行 TS 相關規則的校驗,不然在校驗 JS 時很多 TS 規則也會生效,這樣就造成了困擾。

解決方案就是使用 ESLint 的 override:

"overrides": [
    {
      "files": "**/*.ts",
      "extends": [
        "eslint-config-airbnb",
        "plugin:@typescript-eslint/recommended",
        "prettier/@typescript-eslint",
        "prettier",
        "prettier/react"
      ],
    }
  ],
複製程式碼

只在處理 TS 檔案時才加入 TS 的相關規則。

與這個問題有關的 issue

另外,有一些 JS 規則在 TS 檔案上使用時也會出現問題,比如: github.com/eslint/esli…。解決方案也是使用 override。

Pre commit hook

Pre commit hook 是指設定一個 Git hook,在提交之前執行。前端專案一般利用這個機會執行靜態程式碼檢查和程式碼格式化,比如 ESLint,Prettier。也可以執行測試或者 TS 編譯等等檢查。

這裡特別提到 Commit hook 是因為這個環節是必不可少的。如果沒有 commit hook,那 ESLint 和 Prettier 等於是形同虛設了。

具體設定的流程可以參考:Configuring Pre-commit Hooks for Prettier and Linting on a TypeScript Project

commit hook 也可以用 -n 跳過,所以還應該在 CI 時加上 ESLint,來保證不規範的程式碼提交被立刻發現。


開發體驗

打包工具也好,mono-repo 也好,這些基礎設施其實提升的是開發人員的體驗。開發的時候省心省力,方便快捷,一鍵配置,一鍵升級,這是現在前端開發體驗升級的方向。在選擇一個 React 元件的構建工具鏈時,開發體驗是值得我們關注的一個重要要素。

打包工具

關於模組的格式,我們聽過 AMD,CommonJS,UMD,ES Module 等等。由於模組標準一開始大規模應用是 Nodejs 的 CommonJS,所以幾年前我們寫的 JS 模組都是以 CommonJS 的格式。Webpack 這樣的打包工具也只相容 CommonJS 模組。後來 ES 2015 中提出了 ES Module,這個標準是未來瀏覽器支援的標準,Nodejs 也會支援。並且從功能上來說,ES Module 語法上更簡潔,支援  multiple exports,並且可以讓構建工具進行靜態的依賴分析,讓 Tree-shaking 成為可能。

目前的構建工具都支援原生的 ES Module 格式(之前需要用 babel 轉為 CommonJS)。我們寫的元件原始碼就是 ES Module。在輸出方面,現在的 JS 庫一般都會提供 ES Module 版本。所以我們需要尋找合適的打包方式。

構建 ES module:Rollup/Babel

我們只需要把 package.json 的 module 欄位指向打包出的 es module 格式的檔案,構建工具就會使用 module 欄位而不是 main 欄位進行構建了。

接下來我們要選擇打包工具,Webpack 目前不支援輸出 ES module,可能在 Webpack 5 會支援。所以先排除 Webpack。

Rollup 和 Babel 是可行的兩種方案。

Rollup 是目前最流行 JS 庫打包工具,React,Vue 之類的開源專案都在使用 Rollup。Rollup 支援輸出 CommonJS,UMD,ES Module 在內的主流格式,並可以通過外掛支援 CSS 等靜態資源的處理。Rollup 和 Webpack 的主要區別就是 Rollup 是以構建 JS 為核心的,並且從一開始就是基於 ES Module 的,如果要相容 CommonJS 程式碼,需要引入額外的外掛。Webpack 更關注的是所有資源的構建,並且強調 Code Splitting 的能力,專注於 Web 應用的打包。Rollup 更輕量和專注,而且支援 ES Module 的輸出,所有在 JS 庫打包這個方面 Rollup 是首選。

Babel 其實本身只是一個轉譯工具。但 Babel 可以通過外掛支援 TS 程式碼的轉譯,還有 JSX 的轉譯(老本行),所以如果是簡單的 TS 庫,可以直接用 Babel 進行轉譯,輸出的就是原汁原味的 ES Module(因為 Babel 壓根沒有去解析模組,只是單純的轉譯程式碼)。需要注意的是 Babel 的 TS 轉譯只是轉譯,不是編譯,所以型別錯誤是不會報出的,需要額外跑 tsc 來對 TS 程式碼進行型別校驗。其他的靜態資源也是一樣的,需要單獨跑 task。

專注與 JS 庫打包的 Father

上面兩種工具都可以用,但這裡不打算講如何配置,因為現在的趨勢就是構建工具鏈下沉,封裝為一個統一的入口。只要跑一個命令就可以構建,並且只需要配置一些簡單的必要的引數。底下的工具鏈升級也只要更新一個入口工具就行,不用花時間去維護整個構建體系。Umijs,Create React App,和 Vue-cli 都是這樣的例子。

這裡向大家安利一款專注於 JS 庫打包的工具:Father。Father 可以簡單理解為是 JS 庫領域的 CRA 或者 Umi。Father 封裝了 Rollup 和 Babel 兩套工具鏈。

在最簡單的情況下:我們只需要告訴 Father 需要什麼格式的輸出就可以構建成功,比如:

father build --esm --cjs --umd --file bar src/foo.js
複製程式碼

因此筆者在專案中就使用了 Father 來對 React 元件進行打包。如果對 Rollup 和 Babel 構建流程有興趣的同學,可以看一下 Father 的原始碼,還是很容易看懂的。

mono-repo 管理:Lerna

Lerna 是用於管理擁有多個 npm package 的 mono repo 的工具。mono repo 就是指多個專案的原始碼放在同一個倉庫下進行管理。

簡單的說,Lerna 的功能就是一鍵在多個 package 中同時執行一些命令。而且執行的時候還會根據 package 之間的依賴拓撲關係,對命令的啟動順序進行編排。同時 Lerna 的 bootstrap 命令可以把 package 之間相互的依賴,自動 link 到 package 自己的 node_modules 裡面。這可以說是最大的一個賣點。Lerna 之前如果要在本地開發多個相互依賴的 npm 包,那就要敲一堆的 npm link,而且還容易出問題。

mono-repo 這種方式本身也是為了提升多個 npm package 的情況下,管理原始碼的效率,以及共享基礎設施。因此 Lerna 其實是提升了開發者開發基於 mono repo 的前端專案的體驗。

前端的元件庫一類的專案,用 Lerna 是非常合適的。


程式碼質量

其實之前提到的靜態檢查也是用於保證程式碼質量的,這裡的程式碼質量主要是指測試。

React 元件測試技術選型

React 元件的測試框架有很多,我選的是 Jest。因為這是 FB 自家的工具,也是一個很流行的測試框架。除了測試框架,我們還需要一個 DOM Util,用於元件渲染和 DOM 的操作。比較流行的就是 Enzymereact-testing-library

在 React 16 下,Enzyme 有一些問題,比如 shallow 模式下不支援 useEffect。詳見:github.com/airbnb/enzy…。react testing library 是在 React 官方的 test util 基礎上包裝的,要更輕量一些。他的 FAQ 中寫了對於 Enzyme 的看法:

What about enzyme is "bloated with complexity and features" and "encourage poor testing practices"? Most of the damaging features have to do with encouraging testing implementation details. Primarily, these are shallow rendering, APIs which allow selecting rendered elements by component constructors, and APIs which allow you to get and interact with component instances (and their state/properties) (most of enzyme's wrapper APIs allow this). The guiding principle for this library is:

The more your tests resemble the way your software is used, the more confidence they can give you. - 17 Feb 2018

作者覺得測試應該模仿使用者使用你的產品時的操作,而不應該鼓勵對實現細節進行測試。

綜合各種因素,筆者選用了  react testing library。總的來說其實這種 Util 庫,選用哪一個的差別不大。寫起來更方便的就是好的。

如果對 react testing library 不熟悉,可以看官網和這篇教程。

testingjavascript.com/  這個測試教程網站,可以瞭解到測試相關技術的大圖,如果對測試的分類和作用不太清楚可以看一下這個網站。

常見的測試技巧

react testing library 的測試套路

使用  react testing library 測試是很簡單的,我們只需要呼叫 render ,把元件渲染出來就行了:

const { asFragment, queryByText, rerender } = render(
  <Graphin data={data} layout={layout}>
    <div>foo</div>
  </Graphin>
);
expect(queryByText(/foo/)).toBeTruthy();
複製程式碼

比較有意思的就是 render  之後會返回一個 render result,裡面是一些 DOM query util 和一些其他的 util。比如 queryByText  就是根據元素裡的文字作為選擇器來獲取 DOM 元素。其他的 DOM query API 可以從這裡看到。其中用的比較多的一個是 queryByTestId,在 React 元素上加 data-test-id  之後就可以直接通過 queryByTextId  獲取到這個元素。

可以看出,react testing library 鼓勵的是根據元素的 text 這樣的屬性來獲取元素,進行斷言。這就是這個庫的哲學,希望開發者從使用者怎麼使用產品的角度去測試。而不是通過 DOM 結構之類實現細節的來判斷。

除了對渲染的 UI 進行測試,我們還需要觸發事件,這個過程中需要用 act  和 fireEvent  這樣的 API:

act(() => {
  fireEvent.click(getByText(/Click Me/), {});
});
複製程式碼

需要用 act 包裝的原因是,瀏覽器中 React 的渲染是有一定的週期的,會有 batch update。因此把會修改 state 的呼叫寫在 act 中可以保證這個呼叫會完整的走完渲染週期。

如果想對元件的 props 進行更新,我們需要使用 render  結果裡返回的 rerender :

data = { id: "1" } // update props.data
rerender(<Graphin data={data} layout={layout}>/Graphin>)
複製程式碼

之後就可以繼續使用第一次呼叫返回的那幾個函式進行斷言。

最後一個函式是 asFragment ,呼叫 asFragment  可以返回元件的 DOM 結構。這讓我們可以使用 Jest 的 Snapshot 對元件進行測試:

expect(asFragment()).toMatchSnapShot();
複製程式碼

Mock 瀏覽器事件

測試中,經常會遇到需要 mock 函式或者其他物件的情況。mock 函式可以用 Jest 的 Mock Functions。比較麻煩的是一些瀏覽器事件的 mock。因為  Jest 的 DOM 實現使用的是 JSDom,並不是真實的瀏覽器環境。這裡舉一個例子,如果需要模擬瀏覽器的 resize 事件,可以這麼做:

act(() => {
  // Change the viewport to 500px.
  (window as any).innerWidth = 500;
  (window as any).innerHeight = 500;
});
fireEvent(window, new Event("resize"));
複製程式碼

Canvas 測試

如果測試的目標中有 Canvas,情況分兩種:

  • Canvas 上的內容是元件用到的圖表庫一類的渲染結果,和元件本身的正確性無關
  • Canvas 上的內容就是測試的目標,比如給圖表庫寫測試

如果是前者,我們可以 Mock 掉 Canvas,使用 jest-canvas-mock 可以很方便的一鍵 Mock。

如果是後者,我們可以用 jest-electron 去執行一個真實的瀏覽器,來測試 Canvas 的繪製結果。

使用  jest-canvas-mock 的時候,我們還可以通過 Mock 的 Canvas 物件上附加的 API 來獲取 Canvas 上的繪製呼叫的資訊:

let canvas = getByTestId("custom-element").firstChild as HTMLCanvasElement;
let ctx = canvas.getContext("2d") as any;
ctx.__getPath(); // 獲取路徑資訊
ctx.__getEvents(); // 獲取事件記錄
ctx.__getDrawCalls(); // 獲取繪製呼叫資訊
複製程式碼

這樣我們就可以通過這些資訊來看圖表的繪製介面是否有被呼叫,從而看出呼叫了圖表渲染 API 的 React 元件本身的邏輯是否正確。

覆蓋率

Jest 配置了 collectCoverage: true  之後就會在本地生成測試覆蓋率報表。用 http-server 起一個本地伺服器就可以看到,類似如下的表格:

image.png

覆蓋率分為語句,行,分支,函式四個部分。我們一般說的覆蓋率一般是指行覆蓋率,就是程式碼本身有百分之幾是被測試跑到的。但分支覆蓋率也很重要,這意味著我們有沒有把所有的 case 都測試到。覆蓋率是不是要 100% 要看情況,如果是 lodash 這樣的工具庫,那就要有這樣的指標。如果是比較複雜的 React 元件,那主要先保證核心鏈路是被覆蓋的。

如果測試本身寫的不好,覆蓋率很高其實也沒有用。比如只是把程式碼跑一遍但沒有對結果做任何驗證的話,就算程式碼邏輯出現了問題,測試也是 pass,覆蓋率也很高,但這樣的測試是沒有用的。

總結一下,就是不能一味的追求數字的好看。覆蓋率報表是幫助我們看測試是否漏掉了應該測試的函式,分支等等,起一個輔助的作用。評價測試的標準還是看測試能不能幫助我們在之後每一次提交程式碼時發現是否有 regression 的情況。


結語

本文是對一次 React + TypeScript 元件的工程化治理過程所做的總結。如果你的專案也是 React + TypeScript 元件,並且會發布為 NPM package 給其他人使用,那本文應該可以為工程化方面的建設提供一些參考。

因為篇幅原因,裡面一些具體的流程需要讀者自行看連結中的教程和部落格,那些文章更專注,更有深度。本文主要介紹的還是  React + TypeScript 元件工程化的主要幾個方向(靜態檢查,開發體驗和程式碼質量)和其中一些需要解決的問題。

github blog 原文連線

相關文章