iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 29
1
自我挑戰組

Typescript 初心者手札系列 第 29

【Day 29】在 React 專案中使用 TypeScript - 宣告檔案(declaration file)(下)

昨天我們探討了宣告檔案的使用情境,知道如何載入第三方宣告檔案,以及如何判斷函式庫使用方式,今天要來探討如何撰寫宣告檔案.d.ts以及如何發布宣告檔案。

在使用情況不同的函式庫,宣告檔案撰寫的内容和方式會有所區別。

1. 全域函式庫(Global Libraries)、全域套件

常見全域宣告語法:

declare var/let/const 宣告全域變數(const為常數)
declare function 宣告全域函式
declare class 宣告全域類別
declare enum 宣告全域列舉型別
declare namespace 宣告(含有子屬性的)全域物件
interface 和 type 宣告全域類別

==declare var/let/const==

舉例來說:

src/jQuery.d.ts檔案
//將jQuery宣告為全域變數(let 和 var 沒什麼差別,一個是ES5語法,一個是ES6語法)
declare let jQuery: (selector: string) => any;
declare var jQuery: (selector: string) => any;
src/index.ts 檔案
//由於是全域變數宣告,不需導入即可在任何地方調用或修改
jQuery('#foo');
jQuery = function(selector) {
    return document.querySelector(selector);
};

倘若是將jQuery宣告為全域常數 const

src/jQuery.d.ts檔案
//將jQuery宣告為全域常數
declare const jQuery: (selector: string) => any;

常數宣告後,就不允許修改了,如果修改就會報錯

src/index.ts 檔案
jQuery = function(selector) {
    return document.querySelector(selector);
};
// ERROR: Cannot assign to 'jQuery' because it is a constant or a read-only property.

==declare function==

declare function 用來定義全域函式型別。全域函式也支援函式重載(function overloading),範例程式碼如下:

src/jQuery.d.ts檔案
declare function jQuery(selector: string): any;
declare function jQuery(domReadyCallback: () => any): any;
src/index.ts 檔案
jQuery('#foo');
jQuery(function() {
    alert('Dom Ready!');
});

==declare class==

declare class 用來定義類別,要注意的是裡面只能定義型別,不可以含有具體的執行程式碼

src/Animal.d.ts 檔案
declare class Animal {
    name: string;
    constructor(name: string);
    sayHi(): string;
    
    callMyName() {
        return `My name is ${this.name}`;
    }; // ERROR: An implementation cannot be declared in ambient contexts.
}
src/index.ts 檔案
let cat = new Animal('Tom');

==declare enum==

和其他全域變數宣告一樣,declare enum 僅用來定義型別,而非具體的值。

src/Directions.d.ts
declare enum Directions {
    Up,
    Down,
    Left,
    Right
}
src/index.ts
let directions = [Directions.Up, Directions.Down, Directions.Left, Directions.Right];

==declare namespace==

前幾天在研究namespace時,其實對於namespace和模組的差異仍有些模糊。今天剛好看到一份資料較清楚說明兩者的差異,簡單來說,namespace可以看作是還沒有ES6模組出現的早期模組化方式,而後 ES6 出現了模組,TS就將早期的模組化方式改名為namespace,以兼容兩者。

現階段 TS 將每個檔案都視為獨立的模組,因此,現在幾乎很少使用 namespace 了,但在宣告文件中仍然會使用 declare namespace,用來表示有很多子屬性的全域物件變數。以jQuery例子來說,jQuery可以定義是一個全域物件變數,提供jQuery.ajax的方法供開發者使用,這時候可以使用 declare namespace jQuery 來宣告這個有多個屬性的全域變數。

src/jQuery.d.ts檔案
declare namespace jQuery {
    function ajax(url: string, settings?: any): void;
    const version: number;
    class Event {
        blur(eventType: EventType): void
    }
    enum EventType {
        CustomClick
    }
}
src/index.ts 檔案
jQuery.ajax('/api/get_something');
console.log(jQuery.version);
const e = new jQuery.Event();
e.blur(jQuery.EventType.CustomClick);

namespace裡面做嵌套,簡單來說,namespace裡面可以宣告另一個namespace,例如:

declare namespace jQuery {
    function ajax(url: string, settings?: any): void;
    namespace fn {
        function extend(object: any): void;
    }
}

==interface/type==

interface和type不需要使用declare就可以在全域使用

src/jQuery.d.ts檔案
interface AjaxSettings {
    method?: 'GET' | 'POST'
    data?: any;
}
src/index.ts 檔案
let settings: AjaxSettings = {
    method: 'POST',
    data: {
        name: 'foo'
    }
};

一般來說,應盡量減少全域變數 interface和 type,以減少可能的命名衝突,因此,通常會在外層使用 namespace 包起來。

declare namespace jQuery {
    interface AjaxSettings {
        method?: 'GET' | 'POST'
        data?: any;
    }
    function ajax(url: string, settings?: AjaxSettings): void;
}

在使用時,就會以jQuery.(運算符)來取得namespace下的屬性或方法

jQuery.ajax('/api/post_something', settings);

混合宣告

上面提到了jQuery宣告,有可能jQuery既是物件又是函式嗎? 答案是可以的。在 TS 中函式、類別和介面都可以多次宣告,它們會不衝突的合併起來。上面有提到的函式重載(function overloading)就是函式多次宣告。舉例來說:

interface Alarm {
    price: number;
}
interface Alarm {
    weight: number;
}

上面 Alarm 介面宣告了兩次,而裡面的屬性會合併。
同樣以jQury範例來說,是可以如此宣告的:

declare function jQuery(selector: string): any;
declare namespace jQuery {
    function ajax(url: string, settings?: any): void;
}

此時,jQuery既可以做全域函式使用,也可以命名空間的物件方式操作裡面的方法。

2. 模組函式庫(Module Libraries)

昨天有提到如何下載別人已寫好的模組函式庫的宣告文件,倘若找不到就得自己寫了,那檔案要放在哪裡呢?
假設有一個 foo 模組,推薦方式會是在跟 src 同層創建一個 types 資料夾,專門放置自己寫的模組宣告檔案,然後將 foo 的宣告檔案 foo.d.ts 放到 types/ 資料夾下,又或者是將 foo 的宣告檔案命名為index.d.ts,放到types/foo/的資料夾下,並搭配tsconfig.json 中的 paths 和 baseUrl 設定。

{
    "compilerOptions": {
        "baseUrl": "./",
        "paths": {
            "*": ["types/*"]
        }
    }
}

如此設定之後,透過 import 導入 foo 時,TS 編譯器也會去 types 資料夾下找尋相對應的模組宣告檔案了。至於 TS 查找檔案的順序請參考Day26文章,有更清楚的說明。上面的範例找尋檔案的順序如下:

  • /types/foo.d.ts
  • /types/foo/package.json (如果指定"types"屬性)
  • /types/foo/foo/index.d.ts

也就是說,如果types資料夾下有一foo.d.ts檔案,也有一個資料夾foo,裡面放置一個index.d.ts檔案,則 TS 編譯器會優先使用foo.d.ts檔案。

模組的宣告方式和全域宣告檔案不太一樣。在模組宣告檔案中,仍然會使用declare進行宣告,但使用declare只能在同一檔案中調用,除非在宣告檔案中使用 export 導出,其他檔案使用import導入後才可以使用。

常見模組導出語法:

export 導出變數
export default 預設導出
export = commonjs 導出模組

==export 導出一或多個模組==

types/foo/index.d.ts 檔案
declare const name: string;
declare function getName(): string;
declare class Animal {
    constructor(name: string);
    sayHi(): string;
}

export { name, getName, Animal };

==export default==

在 ES6 模組系統中,可以使用 export default 導出一個預設值,之後可以用 import foo from 'foo' 而非 import { foo } from 'foo' 來導入。

types/foo/index.d.ts 檔案

export default function foo(): string;

src/index.ts 檔案

import foo from 'foo';
foo();

要注意的是,只有函式、類別和介面才可以直接預設導出,其他型別需要使用declare宣告才能做預設導出,而且通常預設導出的語句會放在檔案的最上方。

//錯誤寫法
export default enum Directions {
    Up,
    Down,
    Left,
    Right // ERROR: Expression expected.
}

//正確寫法
export default Directions;

declare enum Directions {
    Up,
    Down,
    Left,
    Right
}

==export=(等於)==

使用 commonjs 規範的函式庫,我們會用下面方式來導出一個模組:

// 全部導出
module.exports = foo;
// 單一導出
exports.bar = bar;

而導入方式則有三種:
第一種方式是 const ... = require

// 全部導入
const foo = require('foo');
// 單一導入
const bar = require('foo').bar;

第二種方式是import ... from

// 全部導入
import * as foo from 'foo';
// 單一導入
import { bar } from 'foo';

第三種方式是import ... require(官方推薦使用)

// 全部導入
import foo = require('foo');
// 單一導入
import bar = foo.bar;

舉例來說:

types/foo/index.d.ts檔案
export = foo;

declare function foo(): string;
declare namespace foo {
    const bar: number;
}

需要注意的是,使用export = 之後就不能在單一導出 export { bar } ,但可以透過混合宣告,使用 declare namespace foo 把 bar 放進 foo 裡。事實上,import ... require 和 export = 都是 TS 為了兼容 AMD 規範和 commonjs 規範而創建的新語法,並不常使用,也不推薦使用。

3. UMD 模組

可以透過 <script> 標籤導入,也可以使用 import 導入。TS提供了新語法 export as namespace實踐。

一般使用 export as namespace 时,大部分是引入別人已經定義好的第三方宣告檔案,基於此再加上 export as namespace 語句,就可以將宣告好的一個變數變成全域變數。

==export as namespace==

一般使用 export as namespace 時都是函式庫已經有宣告檔案,再基於此宣告檔案下加上 export as namespace 語句,就可以將宣告好的變數變成全域變數。

types/foo/index.d.ts檔案

export as namespace foo;
export = foo;

declare function foo(): string;
declare namespace foo {
    const bar: number;
}

也可以和export default一起使用

export as namespace foo;
export default foo;

declare function foo(): string;
declare namespace foo {
    const bar: number;
}

4. 把模組擴展成全域

對於模組或UMD宣告檔案來說,只有export導出的型別宣告才能被導入,那如果希望宣告檔案能擴展成全域變數型別,就需要使用declare global

types/foo/index.d.ts檔案
declare global {
    interface String {
        prependHello(): string;
    }
}

export {};
src/index.ts檔案
'bar'.prependHello();

要注意的是,即使宣告檔案不需要導出任何東西,仍然需要導出一個空物件,告訴編譯器這是模組宣告檔案而非全域變數宣告檔案。

5. 模組套件

有時透過import 導入一個模組套件,會改變原本模組的結構。假如模組套件沒有宣告檔案,就會導致型別不完整,TS 提供了語法declare module用來宣告擴展原本模組的型別。

==declare module==

要拓展原本模組,就需要先導入原本模組再進行拓展

types/moment-plugin/index.d.ts檔案
import * as moment from 'moment';

declare module 'moment' {
    export function foo(): moment.CalendarKey;
}
src/index.ts檔案
import * as moment from 'moment';
import 'moment-plugin';

moment.foo();

declare module也可以用在同一個檔案中宣告多個模組

declare module 'foo' {
    export interface Foo {
        foo: string;
    }
}

declare module 'bar' {
    export function bar(): string;
}

發布宣告檔案

根據不同使用情境自行撰寫宣告檔案之後,下一步就是把檔案放在正確的地方讓編譯器找到並使用,編譯器會按下面的順序來查找:

  1. 在package.json檔中的 types 指定宣告檔案查找位置
  2. 根目錄下的index.d.ts
  3. 針對package.json中的main指定檔案下的同名不同後綴的.d.ts檔案
    第一種方式:在package.json檔中設定types
{
    "name": "foo",
    "version": "1.0.0",
    "main": "lib/index.js",
    "types": "foo.d.ts",
}

指定了 types 为 foo.d.ts 之後,引入此函式庫時,就會去找 foo.d.ts 宣告檔案,如果没有指定 types ,就會從根目錄下查找 index.d.ts 檔案,如果還是沒有,編譯器就會去 main 下尋找 lib/index.d.ts檔案,如果還是沒有就會被認為沒有宣告檔案。

備註:如果是昨天提到的npm install別人寫好的宣告檔案,則檔案自動存在./node_modules/@types中。

結合昨天探討的內容,先來總結一下撰寫宣告檔案的原則:

  1. 從型別角度來分:原始型別(string、number等)、物件型別、TS 擴充型別(複合型別、列舉、介面等)(可參考Day05型別總覽)。有些型別可以直接宣告導出例如:函式、類別和介面,有些則必須先使用 declare 宣告才可導出。
  2. 從程式碼 scope 來分:全域、模組或是既可以全域也可以模組(UMD),不同的scope會採取不同的宣告方式。
  3. 從檔案來源來分:自己撰寫.d.ts檔案和引用別人寫的.d.ts檔案。若為引用別人寫的宣告檔案,則直接使用 npm 下載並放置在node_modules/@types資料夾中; 若是自行撰寫的宣告檔案,則需放置在特定資料夾位置或在package.json設定檔中進行設定,編譯器才能找到該宣告檔案。

上一篇
【Day 28】在 React 專案中使用 TypeScript - 宣告檔案(declaration file)
下一篇
【Day 30】總結:TypeScript 初心者的學習之路
系列文
Typescript 初心者手札30

1 則留言

0
Maxwell Alexius
iT邦新手 5 級 ‧ 2019-10-14 20:39:09

寫得非常細緻 /images/emoticon/emoticon12.gif

Kira iT邦新手 5 級‧ 2019-10-15 10:28:14 檢舉

謝謝 Maxwell 前輩的鼓勵/images/emoticon/emoticon42.gif

我要留言

立即登入留言