React + Typescript 工程化治理實踐

做者 光生 螞蟻金服·數據體驗技術團隊javascript

最近參與了一個 React + Typescript 組件項目,這個項目後期會開源,對代碼的質量和工程化上有比較高的要求,所以須要進行工程化治理。經過此次工程化治理,筆者算是梳理清楚了一個  React + Typescript 第三方組件所須要的一些工程化方面的基礎設施,在這裏總結並分享給你們。html

此次的工程化治理主要分如下幾個方面:前端

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

靜態檢查

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

從 AnyScript 到 TypeScript

用 TS,一個很重要的區別就是有沒有在配置中打開 strict 選項。若是沒有的話,那其實你用的就是 AnyScript,在類型上基本沒有約束,和 JS 沒有太大的區別。若是是從 JS 遷移到 TS 的項目,這個選項應該關閉,由於老的 JS 代碼沒有寫類型。但若是是全新的純 TS 項目,strict 是必定要打開的。如今 CRA 這樣的腳手架建立的項目也是默認開啓了 strict 模式的。node

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

noImplicitAny

這個問題出現的最多見的場景就是函數的參數。若是習慣了寫 JS,在寫函數參數的時候很大可能會忘記寫類型。雖然 TS 能夠推斷出函數的返回值類型,但不能推斷出函數的參數類型。若是不寫參數類型,那參數的類型默認就是 any,這個時候就會報 noImplictAny 的錯誤,由於 TS 的 strict 模式下不容許這種隱式 any 的存在。webpack

解決這個問題,就要養成給函數參數加類型的習慣,而且不能直接加個顯式的 any 就完事了 😂。該定義新類型就定義,若是已經定義的就引用一下。不是很是規的場景,是不該該出現 any 的,這個後面還會再講到。any 自己是一個繞過類型檢查的 escape hatch,用了 any 就會致使這個地方的類型檢查被繞過,這樣一來使用 TS 的意義就不大了。git

話說回來,若是寫代碼的同窗的背景是寫靜態類型語言的,那是絕對不會忘了加類型的。這個問題在習慣寫弱類型的 JS 的前端同窗身上比較常見,更可能是一個習慣和類型思惟的養成問題github

讓咱們再來看一個場景:web

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 原文鏈接

相關文章
相關標籤/搜索