背景
以前 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 兩個屬性。
如何修改原生型別變數
我們在 window 物件上掛載了一些新的屬性和方法, ts 報錯如下。
先看一下 ts 是如何定義這些物件的,我們安裝 typescript 包時,會順帶安裝一個 lib.d.ts,包含 js 執行時以及 DOM 中各種常見的環境宣告。我們開啟 lib.dom.d.ts,發現了 window 的型別定義如下:
解決方案很簡單,建立一個 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 檔案中的'@'都未被正確編譯。
我們查到一個轉換檔案路徑的外掛'@zerollup/ts-transform-paths',用來轉換 npm 打包後.d.ts 中不工作的絕對路徑,npm 上也介紹瞭如何使用,配合 ttypescript(Transformer Typescript,支援在編譯過程中使用在 tsconfig.json 中配置的自定義轉換器),需要用 ttsc 代替 tsc 命令。我們修改 package.json 的命令如下:
"build:types": "ttsc -p tsconfig.build.json"
重新構建試一下,看到'@'已經被正確編譯了。
其他實踐場景
在應用開發中,如何定義和後端通訊返回的資料型別
通常在專案中會有一個 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 在開發階段就能被發現,對於應用的維護和修改,也不用太擔心型別出錯。
但是無疑會造成初期開發成本的增加,特別是快速迭代的專案,介面定義耗費大量時間,可能還是寫註釋變數名更適合。