TypeScript 在開發應用中的實踐總結

福祿網路技術團隊發表於2021-06-23

背景

以前 hybrid app 的移動端開發模式下,H5 和客戶端通訊的 js sdk 程式碼使用 js 編寫,sdk 方法的說明使用文件輸出。對於開發的使用來說,在 IDE 中不能得到友好的引數型別提示。於是我們維護一個型別定義包進行 sdk 方法的型別定義。但這樣對於維護 sdk 的同學來說,維護原始碼的同時需要同步更新型別定義,更新如果不及時,開發需要通過型別合併臨時解決。加上以前的程式碼 api 方法越來越多,全部寫在一個檔案中快一千行了,急需重構。

如果原始碼使用 ts編寫,打包後自動生成.d.ts 檔案,不需要維護額外的型別定義檔案了,開發者在編輯器中也可以獲得引數提示。既然這樣,不如動手試試使用 ts 重構。

準備工作

因為程式碼是純 js 庫,我們使用 rollup+babel 來打包。把原來的程式碼做了一個簡單的梳理,整理出一個初步的專案結構如下:

│  babel.config.js
│  package.json
│  README.md
│  rollup.config.js
│  tsconfig.build.json
│  tsconfig.json
│  typings.d.ts
├─dist  // 最終的輸出
└─src
    │  global.d.ts
    │  index.ts // 入口檔案,輸出最終對外暴露的變數和api方法
    ├─api // api方法
    │  ├─media
    │  ├─tool
    │  └─ui
    ├─lib
    │      sdk.ts   //輸出sdk建構函式
    └─utils // 工具函式

如何宣告回撥函式

最常用的是泛型,在下面的例子中,invoke 方法最終會返回我們傳入的任何型別的 promise。在'global.user.get'方法中,呼叫 invoke 方法,就可以獲得返回值的 data 有 user_id,is_admin 兩個屬性。

1623854207231

如何修改原生型別變數

我們在 window 物件上掛載了一些新的屬性和方法, ts 報錯如下。

1622532435156-7b65457a-e304-4527-971f-645b93d75cff

先看一下 ts 是如何定義這些物件的,我們安裝 typescript 包時,會順帶安裝一個 lib.d.ts,包含 js 執行時以及 DOM 中各種常見的環境宣告。我們開啟 lib.dom.d.ts,發現了 window 的型別定義如下:

1622538459076-d0caa2c9-20b1-44f4-83cf-bea0da077e19

解決方案很簡單,建立一個 global.d.ts 的全域性模組使這些介面與 lib.d.ts 相關聯,利用介面的合併特性,重新定義 Window 介面新增需要的屬性方法即可。

如果不想汙染原始變數型別呢。

比如,我們現在向 sdk 新增一個檔案上傳方法並且可以取消上傳,那麼 invoke 方法最終會返回一個擴充套件了 abort 取消方法的 promise。但像上面這樣擴充套件 promise 時,就會汙染型別。更好的寫法是建立一個新的 AbortablePromise 型別,像下面這樣:

interface AbortablePromise<T extends {}> extends Promise<T> {
  abort: () => void;
}

const invoke = <T extends {}>(method: string): AbortablePromise<T> => {
  let promise = new Promise(resolve => {
    window.WebViewJavascriptBridge.registerHandler(method, (res: T) => resolve(res));
  }) as AbortablePromise<T>;

  promise.abort = () => {
    window.WebViewJavascriptBridge.registerHandler('media.upload.abort');
  };

  return promise;
};

那麼像現在 invoke 方法傳入的引數不同,返回值型別也不同的情況下,想要根據不同的引數約定不同的返回值型別,可以採用函式過載。

function invoke<T extends {}>(method: 'media.file.upload'): AbortablePromise<T>;

function invoke<T extends {}>(method: Method): Promise<T>;

const x1 = invoke('media.file.upload'); // => AbortablePromise
const x2 = invoke('global.user.get'); // => Promise

使用關鍵字 is 進行型別保護

在 utils 資料夾下,我們通常會定義一些工具函式,當我們把它轉換成 ts 的寫法時,可能會這樣寫:

export function isString(arg: any) {
    return Object.prototype.toString.call(arg) === '[object String]';
}

如下程式碼在編譯過程中不會報錯,因為 a 的型別是 any。但是,如果我們使用 is 進行型別保護,此時,在 if 的判斷條件下,型別從 any 縮小至 string,會提示 String 上不存在 join 屬性。

let a: any = 2;
if (isString(a)) {
    a.join();
}

export function isString(arg:any): arg is string {
  return Object.prototype.toString.call(arg) === '[object String]';
}

遇到的問題

使用 alias 配置了'@'指向'./src'目錄,打包後 d.ts 檔案不工作

在開發中,配置了'@'指向'./src'目錄下,但是打包後檢視 dist 資料夾發現.d.ts 檔案中的'@'都未被正確編譯。

1622532587939-63665f53-59b8-4f39-bdfa-d1629f2f42d1

我們查到一個轉換檔案路徑的外掛'@zerollup/ts-transform-paths',用來轉換 npm 打包後.d.ts 中不工作的絕對路徑,npm 上也介紹瞭如何使用,配合 ttypescript(Transformer Typescript,支援在編譯過程中使用在 tsconfig.json 中配置的自定義轉換器),需要用 ttsc 代替 tsc 命令。我們修改 package.json 的命令如下:

"build:types": "ttsc -p tsconfig.build.json"

重新構建試一下,看到'@'已經被正確編譯了。

1622532665017-395b593c-ce49-4e4d-96b2-4b39381a43fd

其他實踐場景

在應用開發中,如何定義和後端通訊返回的資料型別

通常在專案中會有一個 api 資料夾存放各個模組的 service api,現在可以新增一個 types 資料夾,用於存放對應模組的型別,比如 ass.d.ts 對應 ass.ts。在 ass.d.ts 中,使用 declare namespace 宣告名稱空間,可以提取分頁等常用介面。

// api/types/ass.d.ts

declare namespace ass {  // declare namespace後面的全域性變數ass是一個物件
  interface PageParams {
    page: number;
    size: number;
  }

  interface CheckinRuleSearchProps {
     /** 規則型別 */
    rule_type?: string;
    ...
  }

  interface CheckinRuleListBody extends PageParams, CheckinRuleSearchProps {}
}

在 api 中使用:

// api/ass.ts

export const getLocalCheckinRuleList = (data: ass.CheckinRuleListBody) =>
    post < ass.CheckinRuleListResponse > ('/api/v2.0/rule/search', { data });

總結

在重構sdk和專案應用的實際開發過程中,使用 ts 可以直觀地獲取到元件的介面定義,還能對屬性進行自動檢測提示,許多低階 bug 在開發階段就能被發現,對於應用的維護和修改,也不用太擔心型別出錯。
但是無疑會造成初期開發成本的增加,特別是快速迭代的專案,介面定義耗費大量時間,可能還是寫註釋變數名更適合。

參考資料:

https://jkchao.github.io/typescript-book-chinese/

福祿·研發中心 福小球

相關文章