JavaScript 和 TypeScript 交叉口 —— 型別定義檔案(*.d.ts)

linkFly發表於2019-02-16

《從 JavaScript 到 TypeScript 系列》 文章我們已經學習了 TypeScript 相關的知識。
TypeScript 的核心在於靜態型別,我們在編寫 TS 的時候會定義很多的型別,但是主流的庫都是 JavaScript 編寫的,並不支援型別系統。那麼如何讓這些第三方庫也可以進行型別推導呢?

這篇文章我們來講解 JavaScript 和 TypeScript 的靜態型別交叉口 —— 型別定義檔案。

這篇文章首發於我的個人部落格 《聽說》。

前端開發 QQ 群:377786580

型別定義檔案

在 TypeScript 中,我們可以很簡單的,在程式碼編寫中定義型別:

interface IBaseModel {
  say(keys: string[] | null): object
}

class User implements IBaseModel {
  name: string
  constructor (name: string) {
    this.name = name
  }
}複製程式碼

但是主流的庫都是 JavaScript 編寫的,TypeScript 身為 JavaScript 的超集,自然需要考慮到如何讓 JS 庫也能定義靜態型別。

TypeScript 經過了一系列的摸索,先後提出了 tsd(已廢棄)、typings(已廢棄),最終在 TypeScript 2.0 的時候重新整理了型別定義,提出了 DefinitelyTyped

DefinitelyTyped 就是讓你把 "型別定義檔案(*.d.ts)",釋出到 npm 中,配合編輯器(或外掛),就能夠檢測到 JS 庫中的靜態型別。

型別定義檔案的以 .d.ts 結尾,裡面主要用來定義型別。

例如這是 jQuery 的型別定義檔案 中一段程式碼(為了方便理解做了一些改動)

// 定義 jQuery 需要用到的型別名稱空間
declare namespace JQuery {
    // 定義基本使用的型別
    type Selector = string;
    type TypeOrArray<T> = T | T[];
    type htmlString = string;
}

// 定義 jQuery 介面,jquery 是一個 包含 Element 的集合
interface JQuery<TElement extends Node = HTMLElement> extends Iterable<TElement> {
    length: number;
    eq(index: number): this;

    // 過載
    add(selector: JQuery.Selector, context: Element): this;
    add(selector: JQuery.Selector | JQuery.TypeOrArray<Element> | JQuery.htmlString | JQuery): this;

    children(selector?: JQuery.Selector): this;
    css(propertyName: string): string;
    html(): string;
}

// 對模組 jquery 輸出介面
declare module 'jquery' {
    // module 中要使用 export = 而不是 export default
    export = jQuery;
}複製程式碼

型別定義

*.d.ts 編寫起來非常簡單,經過 TypeScript 良好的靜態型別系統洗禮過後,語法學習成本非常低。

我們可以使用 type 用來定義型別變數:

// 基本型別
type UserName = string

// 型別賦值
type WebSite = string
type Tsaid = WebSite複製程式碼

可以看到 type 其實可以定義各種格式的型別,也可以和其他型別進行組合。

// 物件
type User = {
  name: string;
  age: number;
  website: WebSite;
}

// 方法
type say = (age: number) => string

// 類
class TaSaid {
  website: string;
  say: (age: number) => string;
}複製程式碼

當然,我們也可以使用 interface 定義我們的複雜型別,在 TS 中我們也可以直接定義 interface

interface Application {
    init(): void
    get(key: string): object
}複製程式碼

interfacetype(或者說 class) 很像。

但是 type 的含義是定義自定義型別,當 TS 提供給你的基礎型別都不滿足的時候,可以使用 type 自由組合出你的新型別,而 interface 應該是對外輸出的介面。

type 不可以被繼承,但 interface 可以:

interface BaseApplication {
    appId: number
}

export interface Application extends BaseApplication {
  init(): void
    get(key: string): object
}複製程式碼

declare

declare 可以建立 *.d.ts 檔案中的變數,declare 只能作用域最外層:

declare var foo: number;
declare function greet(greeting: string): void;

declare namespace tasaid {
  // 這裡不能 declare
  interface blog {
    website: 'http://tasaid.com'
  } 
}複製程式碼

基本上頂層的定義都需要使用 declareclass 也是:

declare class User {
  name: string
}複製程式碼

namespace

為防止型別重複,使用 namespace 用於劃分割槽域塊,分離重複的型別,頂層的 namespace 需要 declare 輸出到外部環境,子名稱空間不需要 declare

// 名稱空間
declare namespace Models {
  type A = number
  // 子名稱空間
  namespace Config {
    type A = object
    type B = string
  }
}

type C = Models.Config.A複製程式碼

組合定義

上面我們只演示了一些簡單的型別組合,生產環境中會包含許多複雜的型別定義,這時候我們就需要各種組合出強大的型別定義:

動態屬性

有些型別的屬性名是動態而未知的,例如:

{
  '10086': {
    name: '中國移動',
    website: 'http://www.10086.cn',
  },
  '10010': {
    name: '中國聯通',
    website: 'http://www.10010.com',
  },
  '10000': {
    name: '中國電信',
    website: 'http://www.189.cn'
  }
}複製程式碼

我們可以使用動態屬性名來定義型別:

interface ChinaMobile {
  name: string;
  website: string;
}

interface ChinaMobileList {
  // 動態屬性
  [phone: string]: ChinaMobile
}複製程式碼

型別遍歷

當你已知某個型別範圍的時候,可以使用 inkeyof 來遍歷型別,例如上面的 ChinaMobile 例子,我們可以使用 in 來約束屬性名必須為三家運營商之一:

type ChinaMobilePhones = '10086' | '10010' | '10000'

interface ChinaMobile {
  name: string;
  website: string;
}

// 只能 type 使用, interface 無法使用
type ChinaMobileList = {
  // 遍歷屬性
  [phone in ChinaMobilePhones]: ChinaMobile
}複製程式碼

我們也可以用 keyof 來約定方法的引數


export type keys = {
  name: string;
  appId: number;
  config: object;
}

class Application {
  // 引數和值約束範圍
  set<T extends keyof keys>(key: T, val: keys[T])
  get<T extends keyof keys>(key: T): keys[T]
}複製程式碼

整合釋出

有兩種主要方式用來發布型別定義檔案到 npm

  1. 與你的 npm 包捆綁在一起(內建型別定義檔案)
  2. 釋出到 npm 上的 @types organization

前者,安裝完了包之後會自動檢測並識別型別定義檔案。
後者,則需要通過 npm i @types/xxxx 安裝,這就是我們前面所說的 DefinitelyTyped ,用於擴充套件 JS 庫的型別宣告。

內建型別定義檔案

內建型別定義就是把你的型別定義檔案和 npm 包一起釋出,一般來說,型別定義檔案都放在包根目錄的 types 目錄裡,例如 vue

如果你的包有一個主 .js 檔案,需要在 package.json 裡指定主型別定義檔案。

設定 typestypeings 屬性指向捆綁在一起的型別定義檔案。 例如包目錄如下:

├── lib
│   ├── main.js
│   └── main.d.ts # 型別定義檔案
└── package.json複製程式碼
// pageage.json
{
    "name": "demo",
    "author": "demo project",
    "version": "1.0.0",
    "main": "./lib/main.js",
    // 定義主型別定義檔案
    "types": "./lib/main.d.ts"
}複製程式碼

如果主型別定義檔名是 index.d.ts 並且位置在包的根目錄裡,就不需要使用 types 屬性指定了。

├── lib
│   └── main.js
├── index.d.ts # 型別定義檔案
└── package.json複製程式碼

如果你發的包中,package.json 中使用了 files 欄位的話(npm 會根據 files 配置的規則決定釋出哪些檔案),則需要手動把型別定義檔案加入:

// pageage.json
{
  "files": [
    "index.js",
    "*.d.ts"
  ]
}複製程式碼

如果只發二級目錄的話,把型別定義檔案放到對應的二級目錄下即可:

import { default as App } from 'demo/app'複製程式碼

釋出到 @types organizatio

釋出到 @types organizatio 的包表示源包沒有包含型別定義檔案,第三方/或原作者定義好型別定義檔案之後,釋出到 @types 中。例如 @types/express

根據 DefinitelyTyped 的規則,和編輯器(和外掛) 自動檢測靜態型別。

@types 下面的包是從 DefinitelyTyped 裡自動釋出的,通過 types-publisher 工具。

如果想讓你的包釋出為 @types 包,需要提交一個 pull request 到 github.com/DefinitelyT…

在這裡檢視詳細資訊 contribution guidelines page

如果你正在使用 TypeScript,而使用了一些 JS 包並沒有對應的型別定義檔案,可以編寫一份然後提交到 @types

贈人玫瑰,手留餘香。

釋出到 @types organizatio 的包可以通過 TypeSearch 搜尋檢索,使用 npm install --save-dev @types/xxxx 安裝:

更多細節請參閱 DefinitelyTyped

其他

module

通常來說,如果這份型別定義檔案是 JS 庫自帶的,那麼我們可以直接匯出模組:

interface User {}
export = User複製程式碼

而如果這份型別定義檔案不是 JS 庫自帶的,而是第三方的,則需要使用 module 進行關聯。

例如 jquery 釋出的 npm 包中不包含 *.d.ts 型別定義檔案,jquery 的型別定義檔案釋出在了 @types/jquery,所以型別定義檔案中匯出型別的時候,需要關聯模組 jquery,意思就是我專門針對這個包做的型別定義:

interface jQuery {}
declare module 'jquery' {
    // module 中要使用 export = 而不是 export default
    export = jQuery;
}複製程式碼

從而解決了一些主流的 JS 庫釋出的 npm 包中沒有型別定義檔案,但是我們可以用第三方型別定義檔案為這些庫補充型別。

風格

經過一系列探索,個人比較推薦下面的編寫風格,先看目錄:

types
├── application.d.ts
├── config.d.ts
├── index.d.ts # 入口模組
└── user.d.ts複製程式碼

入口模組主要做這些事情:

  1. 定義名稱空間
  2. 匯出和聚合子模組

主出口檔案 index.d.ts

import * as UserModel from './user'
import * as AppModel from './application'
import * as ConfigModel from './config'

declare namespace Models {
  export type User = UserModel.User;
  export type Application = AppModel.Application;
  // 利用 as 抹平爭議性變數名
  export type Config = ConfigModel.Config;
}複製程式碼

子模組無需定義名稱空間,這樣外部環境 (types 資料夾之外) 則無法獲取子模組型別,達到了型別封閉的效果:

export interface User {
  name: string;
  age: number
}複製程式碼

相關文章