巧妙利用TypeScript模組宣告幫助你解決宣告拓展

語言: CN / TW / HK

theme: awesome-green

寫在開頭

網路上大部分 Typescript 教程都在告訴大家如何使用型別體操更好的組織你的程式碼。

但是針對於宣告檔案(Declaration Files)的相關內容卻是少之又少。

這篇文章中,我會帶你著重講述 TypeScript Declaration Files 的用法讓你的 TS 功底更上一層。

TypeScript 模組解析規則

在開始之前,我們先來聊聊 TS 檔案的載入策略。

掌握載入策略才會讓我們實實在在的避免一些看起來毫無頭緒的問題。

TS 中的載入策略分為兩種方式,分別為相對路徑絕對路徑兩種方式。

首先我們來看看相對模組的載入方式:

TypeScript 將 TypeScript 原始檔副檔名(.ts.tsx.d.ts)覆蓋在 Node 的解析邏輯上。同時TypeScript 還將使用package.jsonnamed中的一個欄位types來映象目的"main"- 編譯器將使用它來查詢“主”定義檔案以進行查閱。

比如這樣一段程式碼:

```ts // 假設當前執行路徑為 /root/src/modulea

import { b } from './moduleb' ```

此時,TS 對於 ./moduleb 的載入方式其實是和 node 的模組載入機制比較類似:

  • 首先尋找 /root/src/moduleb.ts 是否存在,如果存在使用該檔案。

  • 其次尋找 /root/src/moduleb.tsx 是否存在,如果存在使用該檔案。

  • 其次尋找 /root/src/moduleb.d.ts 是否存在,如果存在使用該檔案。

  • 其次尋找 /root/src/moduleB/package.json,如果 package.json 中指定了一個types屬性的話那麼會返回該檔案。

  • 如果上述仍然沒有找到,之後會查詢 /root/src/moduleB/index.ts

  • 如果上述仍然沒有找到,之後會查詢 /root/src/moduleB/index.tsx

  • 如果上述仍然沒有找到,之後會查詢 /root/src/moduleB/index.d.ts

可以看到 TS 中針對於相對路徑查詢的規範是和 nodejs 比較相似的,需要注意我在上邊已經額外加粗了。

Ts 在尋找檔案路徑時,在某些條件下是會按照目錄去查詢 .d.ts 的。

非相對匯入

在瞭解了相對路徑的載入方式之後,我們來看看關於所謂的非相對匯入是 TS 是如何解析的。

我們可以稍微回想一下平常在 nodejs 中對於非相對匯入的模組是如何被 nodejs 解析的。沒錯,它們的規則大同小異。

比如下面這段程式碼:

```ts // 假設當前檔案所在路徑為 /root/src/modulea

import { b } from 'moduleb' ```

  • /root/src/node_modules/moduleB.ts
  • /root/src/node_modules/moduleB.tsx
  • /root/src/node_modules/moduleB.d.ts
  • /root/src/node_modules/moduleB/package.json(如果它指定了一個types屬性)
  • /root/src/node_modules/@types/moduleB.d.ts
  • /root/src/node_modules/moduleB/index.ts
  • /root/src/node_modules/moduleB/index.tsx
  • /root/src/node_modules/moduleB/index.d.ts

typescript 針對於非相對匯入的 moduleb 會按照以上路徑去當前路徑的 node_modules 中去查詢,如果上述仍然未找到。

此時,TS 仍然會按照 node 的模組解析規則,繼續向上進行目錄查詢,比如又會進入上層目錄 /root/node_modules/moduleb.ts ...進行查詢,直到查詢到頂層 node_modules 也就是最後一個查詢的路徑為 /node_modules/moduleB/index.d.ts 如果未找到則會丟擲異常 can't find module 'moduleb'

上述查詢規則是基於 tsconfig.json 中指定的 moduleResolution:node,當然還有 classic 不過 classic 規則是 TS 為了相容老舊版本,現代程式碼中基本可以忽略這個模組查詢規則。

解析 *.d.ts 宣告

上邊我們聊了聊 TS 中對於載入兩種不同模組的方式,可是日常開發中,經常有這樣一種場景。

比如,在 TS 專案中我們需要引入一些字尾為 png 的圖片資源,那麼此時 TS 是無法識別此模組的。

image.png

解決方法也非常簡單,通常我們會在專案的根目錄中也就是和 TsConfig.json 平級的任意目錄中新增對應的宣告檔案 image.d.ts

image.png

可以看到,通過定義宣告檔案的方式解決了我們的問題。

可是,你有思考過按照上邊的 typescript 對於模組的載入方式,它是怎麼載入到我們宣告的 image.d.ts 的嗎?

這是一個有意思的問題,按照上邊我們提到的模組載入機制要麼按照相對模組機制查詢,要麼按照對應的 node 模組解析機制進行查詢。

怎麼會查詢到定義在專案目錄中的 image.d.ts 呢?


本質上我們引入任何模組時,載入機制無非就是我們上邊提到的兩種載入方式。

不過,這裡有一個細小的點即是 ts 編譯器會處理 tsconfig.json 的 file、include、exclude 對應目錄下的所有 .d.ts 檔案:

簡單來說,ts 編譯器首先會根據 tsconfig.json 中的上述三個欄位來載入專案內的 d.ts 全域性模組宣告檔案,自然由於 '.png' 檔案會命中全域性載入的 image.d.ts 中的 宣告的 module 所以會找到對應的檔案。

include 在未指定 file 配置下預設為 **,表示 tsc 解析的目錄為當前 tsconfig.json 所在的專案資料夾。

關於 file、include、exclude 三者的區別我就不詳細展開了,本質上都是針對於 TSC 編譯器處理的範圍。後續如果大夥有興趣,我可以單獨開一個 tsconfig.json 的文章去詳細解釋配置。

詳解 typescript 宣告檔案

上邊我們講述了 TypeScript 是如何來載入我們的模組的,在瞭解了上述前置知識後。

讓我們一起來看看編寫一份宣告檔案必備的知識儲備吧!

大多數同學的想法可能是“我又不編寫庫宣告,學這個沒什麼用處。”

其實不是這樣的,學會型別宣告檔案的編寫並不僅僅是為了編寫庫宣告。大多數時候,我們在日常業務中對於第三方庫需要做一些自定一的擴充套件擴充。

大多數時候一些庫提供的泛型引數其實並不能很好的滿足我們的需求,所以利用 *.d.ts 擴充套件第三方庫在業務中是非常常見的需求。

廢話不多說了~我們正式進入正文。

什麼是宣告檔案

為了照顧一些接觸 TS 並不是很多的小夥伴,我們簡單聊聊什麼是 Typescript 宣告檔案。

通常我們將有關於一些全域性變數或者引入的模組對應的型別宣告語句存在一個單獨的檔案,這樣的檔案就被成為宣告檔案。

注意,宣告檔案一定要以 [name].d.ts 結尾。

比如我們在專案內定義一個 jquery.d.ts 時:

```ts // src/jQuery.d.ts

// 定義全域性變數 jQuery,它是一個方法 declare var jQuery: (selector: string) => any; ```

之後我們在專案內的 TS 檔案中就可以在全域性自由的使用宣告的 jQuery 了:

ts jQuery('#root')

正常來說,ts 會解析專案中所有的 *.ts 檔案,當然也包含以 .d.ts 結尾的檔案。所以當我們將 jQuery.d.ts 放到專案中時,其他所有 *.ts 檔案就都可以獲得 jQuery 的型別定義了。

當然,上邊我們提過到關於 tsc 檔案的編譯範圍。所以如果找不到情況可以自行檢查對應的 filesinclude 和 exclude 配置。

全域性變數

上述羅列了 6 中全域性宣告的語句,我們可以通過 declare 關鍵字結合對應的型別,從而在任意 .d.ts 中進行全域性型別的宣告。

比如我們以 namespace 舉例:

假設我們的業務程式碼中存在一個全域性的模組物件 MyLib,它擁有一個名為 makeGreeting 的方法以及一個 numberOfGreetings 數字型別屬性。

當我們想在 TS 檔案中使用該 global 物件時:

image.png

TS 會告訴我們找不到 myLib

原因其實非常簡單,typescript 檔案中本質上是對於我們的程式碼進行靜態型別檢查。當我們使用一個沒有型別定義的全域性變數時,TS 會明確告知找不到該模組。

當然,我們可以選擇在該檔案內部對於該模組進行定義並且進行匯出,Like this:

```ts export namespace myLib { export let makeGreeting: (string: string) => void export let numberOfGreetings: number }

let result = myLib.makeGreeting("hello, world"); console.log("The computed greeting is:" + result); let count = myLib.numberOfGreetings; ```

上述的程式碼的確在模組檔案內部定義了一個 myLib 的名稱空間,在該檔案中我們的確可以正常的使用 myLib。

可是,在別的模組檔案中我們如果仍要使用 myLib 的話,也就意味著我們需要手動再次 import 該 namespace

這顯然是不合理的,所以 TS 為我們提供了全域性的檔案宣告 .d.ts 來解決這個問題。

我們可以通過在 ts 的編譯範圍內宣告 [name].d.ts 來定義全域性的物件的名稱空間。 比如:

image.png

可以看到上圖的右邊,此時當我們使用 myLib 時, TS 可以正確的識別到他是 myLib 的名稱空間 。

如果你的 [name].d.ts 不生效,那麼仔細檢查你的 tsconfig.json -> include 設定~

雖然說隨著 ES6 的普及,ts 檔案中的 namespcae 已經逐漸被淘汰掉了。

但是在型別宣告檔案中使用 declare namespace xxx 宣告類似全域性物件仍然是非常實用的方法。

宣告合併

上邊我們講述瞭如何在型別宣告檔案中進行全域性變數的宣告,接下來其他部分之前我們先來聊聊 TS 中的宣告合併。

介面自動合併

```ts interface Props { name: string; }

interface Props { age: 18; }

const me: Props = { name: 'wang.haoyu', age: 18 } ```

上述的程式碼一目瞭然,在多個相同名稱的 interface 中同名的 interface 宣告會被自動合併

但是需要注意的是,無論哪種宣告合併必須遵循合併的屬性的型別必須是唯一的,比如:

ts interface Props { name: string; } // 後續屬性宣告必須屬於同一型別。屬性“name”的型別必須為“string”,但此處卻為型別“18” interface Props { name: 18; }

declare 合併

image.png

這裡可以看到在右邊的宣告檔案中進行了名為 axios 全域性名稱空間宣告,同時在左邊的檔案中我們使用了 axios.Props 型別。

其實本質上就是相同名稱空間內的介面合併,當然我們可以利用 declare 宣告合併達到更多的效果。後續我們會詳細提到。

Npm 包型別宣告

接下來我們來看看關於 Npm 包型別的宣告檔案如何編寫。

上述我們提到過 TS 是如何載入對應 npm 包的宣告檔案的。

現在我們假設一種場景下,我們目前使用了 axios 這個庫。假設目前這個庫並沒有對應的型別宣告檔案,顯然當我們在程式碼中引入這個庫時候一定是會報錯的。

此時,關於 Npm 包型別的宣告會很好的幫助我們來解決這個問題:

首先我們在上述說到的,當我們在程式碼中執行

ts import axios from 'axios'

它會按照路徑依次去查詢,正常來說它會去 node_modules 下的各個路徑區查詢對應的模組。那麼我們需要將自定義的宣告檔案書寫在 node_modules 中去嗎?

這顯然是不合理的,因為 node_modules 中的目錄是非常不穩定的。

此時,我們可以首先在 tsconfig.json 中配置對應的 alias 別名配置,達到引入 axios 時自動幫我們找到對應的 .d.ts 檔案宣告檔案:

ts { "compilerOptions": { "baseUrl": "./", "paths": { "axios": [ "types/axios.d.ts" ] } } }

這裡我們配置了尋找的別名。

之後,我們在專案的根目錄(tsconfig.json)平級新建一個 types/axios.d.ts

ts // axios.d.ts // 利用 export 關鍵字匯出 name 變數 export const name: string;

此時在專案中的任意檔案,我們就可以使用匯出的 name 變數:

ts import { name } from 'axios' console.log(name) // string 型別的 name 變數

當然你可以為模組內新增對應各種各樣的型別宣告。

上述我們就實現了一個簡單的模組定義檔案,關於 npm 包型別的宣告有以下幾種語法需要和大家強調下:

export 關鍵字

需要額外留意的是npm 包的宣告檔案與全域性變數的宣告檔案有很大區別。

在 npm 包的宣告檔案中,使用 declare 不再會宣告一個全域性變數,而只會在當前檔案中宣告一個區域性變數。只有在宣告檔案中使用 export 匯出,然後在使用方 import 匯入後,才會應用到這些型別宣告。

export 的語法與普通的 ts 中的語法類似,需要注意的是d.ts的宣告檔案中禁止定義具體的實現。

比如:

```ts // types/axios/index.d.ts

// 匯入變數 export const name: string; // 匯出函式 export function createInstance(): AxiosInstance; // 匯出介面 介面匯出省略 export interface AxiosInstance { // ... data: any; } // 匯出 Class export class Axios { constructor(baseURL: string); } // 匯出列舉 export enum Directions { Up, Down, Left, Right } ```

此時我們在 TS 檔案中就可以自由的使用這些匯出的變數和型別了:

```ts import { name, createInstance, AxiosInstance, Axios, Directions } from 'axios'

console.log(name) // string // 通過 createInstance 返回 AxiosInstance 例項 const instance: AxiosInstance = createInstance()

new Axios('/')

const a = Directions.Up ```

混用 declare 和 export

上邊我們提到過,在 npm 包的宣告檔案中,使用 declare 不再會宣告一個全域性變數,而只會在當前檔案中宣告一個區域性變數。

同樣上邊的宣告我們可以改成通過 declare + export 宣告:

```ts // types/axios/index.d.ts

// 變數 declare const name: string; // 函式 declare function createInstance(): AxiosInstance; // 介面 介面可以省略 export interface AxiosInstance { // ... data: any; } // Class declare class Axios { constructor(baseURL: string); } // 列舉 enum Directions { Up, Down, Left, Right }

export { name, createInstance, AxiosInstance, Axios, Directions } ```

export namespace

與 declare namespace 類似,export namespace 用來匯出一個擁有子屬性的物件:

```ts // types/foo/index.d.ts

// 匯出一個 Axios 的名稱空間 export namespace Axios { const name: string; namespace AxiosInstance { function getUrl(): string; } }

// xx.ts import { Axios } from 'axios'

Axios.AxiosInstance.getUrl() ```

export default

在 ES6 模組系統中,使用 export default 可以匯出一個預設值,使用方可以用 import foo from 'foo' 而不是 import { foo } from 'foo' 來匯入這個預設值。

同樣,在型別宣告檔案中,我們可以通過 export default 用來匯出預設值的型別。比如:

image.png

需要額外注意的是隻有 functionclass 和 interface 可以直接預設匯出,其他的變數需要先定義出來,再預設匯出。

export =

當然,我們上述提到的都是關於 ESM 相關的型別宣告檔案。

TS 中的型別宣告檔案同樣為我們提供了使用 export =的 CJS 模組相關語法:

ts // types/axios.d.ts export = axios declare function axios(): void

ts import axios = require('axios')

可以看到上述的程式碼,我們通過 export = axios 定義了一個相關的 CJS 模組語法。

需要額外注意的是在 ts 中若要匯入一個使用了export =的模組時,必須使用TypeScript提供的特定語法import module = require("module")

在日常業務中,不可避免我們會碰到一些相關 commonjs 規範語法的模組,那麼當我們需要擴充對應的模組或者為該模組宣告定義檔案時,就需要使用到上述的 export = 這種語法了。

當然,export = 這種語法不僅僅可以支援 cjs 模組。它也同樣是 ts 為了 ADM 提出的模組相容宣告。有興趣的朋友可以詳細查閱官方文件

擴充套件全域性變數

在型別宣告檔案中對於全域性變數的擴充套件非常簡單,我們僅僅需要利用宣告合併的方式即可對於全域性變數進行擴充套件。

舉個例子,假設我們想為 string 型別的變數擴充套件一個 hello 的方法。正常擴充套件後全域性呼叫該方法 TS 是會提示錯誤的。

此時就需要我們通過型別定義檔案來進行全域性變數的擴充套件: ts // types/index.d.ts 利用介面合併,擴充套件全域性的 String 型別 // 為它新增一個名為 hello 的方法定義 interface String { hello: () => void; }

此後,我們就可以直接在全域性中自由的呼叫該 hello 方法了:

ts 'a'.hello()

在 Npm 包、UMD 中擴充套件全域性變數

在宣告檔案中擴充套件全域性變數利用合併宣告的方式可以非常容易的進行擴充套件。

而在 Npm 包、UMD 的宣告檔案中如果我們想擴充套件全域性變數那應該如何做呢。

上邊我們說到過,任何宣告檔案中只要存在 export/import 關鍵字的話,該宣告檔案中的 declare 都會變成模組內的宣告而非全域性宣告。

比如,我們在自己定義的 axios.d.ts 中:

```ts // types/axios.d.ts

declare function axios(): string;

// 此時宣告的 interface 為模組內部的String宣告 declare interface String { hello: () => void; }

export default axios;

// index.ts 'a'.hello() // 型別“"a"”上不存在屬性“hello” ```

此時內部宣告的 String 介面擴充套件被認為是模組內部的介面拓展,我們在全域性中使用是會提示錯誤的。

針對於 Npm 包中需要進行全域性宣告的話,TS 同樣為我們提供了 declare global 來解決這個問題:

```ts // types/axios.d.ts

declare function axios(): string;

// 模組內部通過 declare global 進行全域性宣告 // declare global 內部的宣告語句相當於在全域性進行宣告 declare global { interface String { hello: () => void; } }

export default axios;

// index.ts 'a'.hello() // correct ```

擴充套件 Npm 包型別

大多數時候我們使用一些現成的第三方庫時都已經有對應的型別宣告檔案了,但有些情況下我們需要對於第三方庫中某些屬性進行額外的擴充套件或者修改。

直接去修改 node_modules 中的第三方 TS 型別宣告檔案顯然是不合理的,那麼此時就需要我們通過型別宣告檔案擴充套件第三方庫的宣告。

同樣 TypeScript 提供給了我們一種 declare module 的語法來進行模組的宣告。

通常在我們可以利用 declare module 語法在進行新模組的宣告的同時,也可以使用它來對於已有第三方庫進行型別定義檔案的擴充套件。

在進行模組擴充套件時,需要額外注意如果是需要擴充套件原有模組的話,需要在型別宣告檔案中先引用原有模組,再使用 declare module 擴充套件原有模組

比如,通常我們在專案中使用 axios 庫時,希望在請求的 config 中支援傳遞一些自定義的引數,從而在全域性攔截器中進行拿到我們的自定義引數。

如果直接在 TS 檔案下進行屬性賦值和取值的話,TS 會丟擲異常的:

image.png

同樣,我們可以利用 declare module 來進行第三方 NPM 包的擴充套件,我們可以看到 axios 請求中第二個引數的型別為 AxiosRequestConfig 型別。 image.png

那麼我們僅僅需要對於這個型別進行擴充套件就 OK 了:

image.png

此時,我們在回到剛才的程式碼中可以發現無論我們是取值還是賦值,TS 都可以很好的幫我們進行出型別推斷。

當然,這只是一個非常簡單的例子。但是這個場景我相信對於大家來說都非常常見,不過模組的擴充套件本質上大同小異~

三斜線指令

其實三斜線指令在是 TS 在早期版本中為了描述多個模組之間的相互依賴關係產生的語法。

目前,隨著 ESM 模組語法的推廣,官方也不再建議使用三斜線指令來宣告模組依賴了。

但是目前來說三斜線指令的存在仍然有它獨特的作用,接下來我們一起來看看。

/// <reference types="..." />

所謂 /// <reference types="..." /> 是三斜線指令的一種宣告方式,這個指令是用來宣告依賴的。

表示該宣告檔案依賴了 types='...' 中對於 ... 的依賴,在進行了上述的聲明後我們就可以在自己的宣告檔案中使用types='...'中宣告的變量了。

比如:

ts /// <reference types="jquery" />

上述程式碼中,我們在宣告檔案的開頭使用了三斜線指令。那麼此時我們就可以在接下來的檔案中使用 jquery 宣告檔案中宣告的變量了。

比如 jquery 中聲明瞭對應的 declare namespace JQuery ,那麼我們同樣可以在自己的宣告檔案中使用這個依賴:

``` ///

declare function foo(options: JQuery.AjaxSettings): string; ```

通常,我們可以利用三斜線指令的 types 來宣告對於全域性變數的依賴,從而避免使用import語句將宣告檔案變為區域性模組。

主要特別注意的是,如果使用了三斜線指令引入一個模組時,比如:

ts /// <reference types="axios" />

因為 Axios 是一個模組,所以我們無法直接在宣告檔案中使用任何模組內部宣告的變數。

之所以上邊的用例能通過三斜線指令正常的使用 JQuery 全域性變數,是因為在 jquery 的宣告檔案中聲明瞭全域性的 namespcae JQuery

/// <reference path="JQueryStatic.d.ts" />

當我們的全域性變數的宣告檔案太大時,同樣我們可以通過三斜線指令將該宣告檔案拆分為多個檔案。

然後在一個入口檔案中將它們一一引入,來提高程式碼的可維護性。

比如 jQuery 的宣告檔案就是這樣:

``` // node_modules/@types/jquery/index.d.ts

/// /// /// /// ///

export = jQuery; ```

其中用到了 types 和 path 兩種不同的指令。它們的區別是:types 用於宣告對另一個庫的依賴,而 path 用於宣告對另一個檔案的依賴。

同時需要額外留意的是,在使用 path 進行檔案拆分時每個單獨的檔案都是一個獨立的檔案模組系統

比如上述的 JQuery 宣告檔案中,我們可以明顯的看到 export = jQuery 在最終將 JQuery 以 CJS 的形式進行了匯出,表示它是一個模組。

但是由於 /// <reference path="misc.d.ts" /> 模組檔案中聲明瞭全域性的 namespace JQuery

所以我們在程式碼中才可以正常的使用 JQuery 這個全域性變數。

簡單來說 jquery 根宣告檔案是一個模組,而它內部使用的三斜線指令引入的 /// <reference path="misc.d.ts" /> 並非是一個模組而是聲明瞭一個全域性名稱空間。

所以三斜線指令並不會引入入口是模組檔案,而將依賴的模組也變為模組宣告。

結尾

斷斷續續這篇文章也寫了好久,希望這篇文章可以讓大家有所收穫。

對於模組宣告檔案我個人也是一直在一種摸索的階段,之前其實沒有特意關心這塊內容。

之後如果有時間,我會詳細和大家談談這部分內容其實坑點還挺多的。當然,大家對於文章中的內容有什麼疑惑或者建議都可以在評論區留言給我。