我們先前所練習的TypeScript程式幾乎都只是把程式敘述寫在index.ts檔案中,雖然我們已經會使用函數、類別來分割不同功能的程式,但當程式愈寫愈多的時候,這樣的作法還是會讓程式變得難以維護。這時就需要用到TypeScript提供的「模組」系統了。



淺談JavaScript的模組系統

試想一下,若要在一個網頁中加入很多個JavaScript檔案進來,每個JavaScript檔案都是一個JavaScript模組,都擁有獨立的功能。這些JavaScript檔案要怎麼樣撰寫才不會被彼此影響?

舉例來說,a.js檔案提供了一個f函數,b.js檔案也提供了一個f函數。

function f() {
    console.log("A");
}
function f() {
    console.log("B");
}

那麼以下網頁中的JavaScript程式應該會怎麼執行?

<!DOCTYPE html>
<html>
    <head>
        <meta charset=UTF-8>
        <script src="a.js"></script>
        <script src="b.js"></script>
    </head>
    <body>
        <script>
            f();
        </script>
    </body>
</html>

答案是,當瀏覽器載入以上HTML的第10行時,最後(最近)被載入的f函數會被執行到。以上網頁,b.js檔案會比a.js檔案還要晚被載入,所以會執行到b.js檔案的f函數。

但如果將網頁改成:

<!DOCTYPE html>
<html>
    <head>
        <meta charset=UTF-8>
        <script src="a.js"></script>
        <script src="b.js" defer></script>
    </head>
    <body>
        <script>
            f();
        </script>
    </body>
</html>

以上網頁,當瀏覽器載入以上HTML的第10行時,最後(最近)被載入的f函數是a.js檔案的f函數,因為b.js檔案被加上defer屬性,它需要等到整個網頁都讀取完成後才會被載入。

由此可見,以上的a.js檔案和b.js檔案是會互相影響的,所以我們需要用不同的方式來實作它們的程式才行。最直覺的方式,就是將每個模組中的內容都用一層函數包起來,然後把要暴露(export)出去的屬性都放進一個物件回傳出來,再把這個物件被指派給一個變數來儲存,該變數的名稱便是模組名稱。

例如:

function aFunction() {
    return {
        f: function f() {
            console.log("A");
        }
    };
}

var A = aFunction();
function bFunction() {
    return {
        f: function f() {
            console.log("B");
        }
    };
}

var B = bFunction();
<!DOCTYPE html>
<html>
    <head>
        <meta charset=UTF-8>
        <script src="a.js"></script>
        <script src="b.js"></script>
    </head>
    <body>
        <script>
            A.f();
            B.f();
        </script>
    </body>
</html>

以上的作法,可不理會a.js檔案和b.js檔案的載入順序,因為兩個f函數此時已有各自的「命名空間」(namespace),並不會發生衝突。

事實上,我們還可以將aFunction函數和bFunction函數「匿名化」,如下:

var A = (function () {
    return {
        f: function f() {
            console.log("A");
        }
    };
})();
var B = (function () {
    return {
        f: function f() {
            console.log("B");
        }
    };
})();

這樣的函數又被稱為「立即被呼叫的函式」(Immediately Invoked Function Expression, IIFE),可以確保原先的aFunction函數和bFunction函數不能直接被外部程式呼叫,也不會與外部程式發生衝突。這種JavaScript的模組撰寫方式稱為「模組樣式」(Module Pattern)。

不過「模組樣式」頂多就是切分命名空間罷了,並無法處理模組與模組之間的相依關係(如重複引用等)。所以後來JavaScript生態圈又演化出了CommonJS和AMD(Asynchronous Module Definition)兩種主要的模組系統,前者直接被Node.js內建(我們在前面的章節用的require函數就是CommonJS的東西),後者則是可以在網頁瀏覽器引用額外的JavaScript檔案來支援。

ES6出來之後,使用TypeScript來開發JavaScript程式,可以完全拋開模組樣式、CommonJS和AMD,因為TypeScript可以直接將ES6的模組系統編譯成其它的模組系統。換句話說,我們只需要學會ES6的模組系統就好了!

ES6的模組系統

export關鍵字

利用ES6之後加入的export關鍵字,我們可以將一個TypeScript的.ts檔案變成一個TypeScript模組。export關鍵字可以被用在最外層的varletconstfunctionclassinterfaceenumtype關鍵字前,使該項目暴露到模組外(換句話說就是可以被模組外的程式使用)。

例如:

export function f() {
    console.log("A");
}

export關鍵字也可以不必與上述的varlet等關鍵字搭配使用,而是可以直接在其後接上一組大括號{},來放入要暴露的名稱,如果有多個名稱就用逗號,隔開。

例如:

export function f() {
    console.log("A1");
}

function f2() {
    console.log("A2");
}

function f3() {
    console.log("A3");
}

function f4() {
    console.log("A4");
}

export {
    f2
};

export {
    f3,
    f4,
};

這邊要注意的是,export關鍵字在一個.ts檔案中是可以被使用多次的!

如果要改變暴露出去的名稱,可以在大括號{}中用as關鍵字來設定,設定方式如下:

export function f() {
    console.log("A1");
}

function f2() {
    console.log("A2");
}

function f3() {
    console.log("A3");
}

function f4() {
    console.log("A4");
}

export {
    f2 as ff
};

export {
    f3 as fff,
    f4 as ffff,
};

import關鍵字

我們在前面的章節學的import關鍵字用法其實並不屬於ES6的用法,而是TypeScript針對ES5以下使用CommonJS模組系統提供的用法,姑且先把它忘了吧!

利用ES6之後加入的import關鍵字,我們可以在TypeScript中引用TypeScript(或是具有TypeScript的.d.ts定義檔的JavaScript)的模組。

引用方式如下:

import * as 自定義模組名稱 from "模組檔案路徑(不含.ts副檔名)";

注意這邊的路徑,如果是相對路徑的話,要用./../來開頭,不然會被當作是要引用JavaScript環境中的模組。

例如以下這個a.ts

export function f() {
    console.log("A1");
}

function f2() {
    console.log("A2");
}

function f3() {
    console.log("A3");
}

function f4() {
    console.log("A4");
}

export { f2 as ff };

export {
    f3 as fff,
    f4 as ffff,
};

可以這樣來引用:

import * as A from "./a";

A.f();
A.ff();
A.fff();
A.ffff();

由於import尚未完整支援ES6的模組系統,因此如果TypeScript的編譯目標是設定為ES6或是以上的版本的話,還需要在tsconfig.json設定檔中的compilerOptions欄位中加上module欄位,並將欄位值設為CommonJS字串,才能夠讓編譯出來的JavaScript模組在Node.js中正常使用。

以上程式執行時會輸出:

A1
A2
A3
A4

提醒一下,這邊的引用a.ts檔案的路徑是填./a,而不是a。另外,我們不需要將a.ts加進tsconfig.json設定檔中的files欄位的陣列中,因為TypeScript會自動編譯import關鍵字引用的.ts檔案。

如果我們只是想引用模組中的部份項目的話,可以將* as 自定義模組名稱改寫為一組大括號{},裡面填入要引用的名稱,如果有多個名稱就用逗號,隔開。

例如:

import { f, ff } from "./a";

f();
ff();

如果想要改變名稱,就使用as關鍵字,用法如下:

import { f as f1, ff as f2 } from "./a";

f1();
f2();

import關鍵字也可以用來引用非模組的TypeScript檔案。

例如以下這個b.ts

function f() {
    console.log("B");
}

f();

可以這樣來引用:

import "./b";

這邊要注意的是,在index.ts中,是無法呼叫b.tsf函數的哦!另外,像這樣import關鍵字沒有搭配from關鍵字來使用時,除了能夠引用任何的(模組或是非模組)TypeScript程式外,還可以引用任何的JavaScript程式。

重複暴露(Re-export)

export關鍵字也可以搭配from關鍵字來用,可以直接將指定的模組提供的名稱再暴露出去,無法直接用* as 自定義模組名稱。例如:

import { f as f1, ff as f2 } from "./a";
export { fff as f3, ffff as f4 } from "./a";

f1();
f2();

如果想要將整個模組更換名稱後再暴露,要寫成以下這個樣子:

import { f as f1, ff as f2 } from "./a";
import * as A from "./a";

f1();

export { A };

預設暴露(Default Export)

作用在function關鍵字前或是class關鍵字前的export關鍵字,其後可以加上default關鍵字,來將該函數或是類別當作預設的暴露對象。一個TypeScript模組只能有一個預設的暴露對象。

例如:

export function f() {
    console.log("A");
}

替函數f加上default關鍵字,如下:

export default function f() {
    console.log("A");
}

當我們要引用TypeScript模組的預設暴露對象時,就用如下的import關鍵字語法:

import f from "./a";

f();

再舉一個例子:

export default function f() {
    console.log("A1");
}

function f2() {
    console.log("A2");
}

function f3() {
    console.log("A3");
}

function f4() {
    console.log("A4");
}

export { f2 as ff };

export {
    f3 as fff,
    f4 as ffff,
};
import * as A from "./a";
import f from "./a";

f();
A.ff();
A.fff();
A.ffff();

注意這邊由於a.ts的函數f是用export default來暴露,因此它並不會包含在index.tsA模組名稱之下。

除了函數和類別之外,export default關鍵字也可以用在定數或者任意名稱上。

例如:

export default "magiclen.org";
import m from "./c";

console.log(m);

以上程式會輸出magiclen.org

再例如:

const n = 123456;

export default n;
import m from "./c";

console.log(m);

以上程式會輸出magiclen.org

在網頁上使用TypeScript編譯出來的JavaScript模組

現在有以下兩個檔案:

export function f() {
    console.log("A");
}
export function f() {
    console.log("B");
}

ES6 模組系統

若將a.tsb.ts檔案編譯成ES6的JavaScript模組,要如何直接在網頁瀏覽器上使用它們呢?請看以下的HTML網頁範例:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
    </head>
    <body>
        <script type="module">
            import * as A from "./a.js";
            import * as B from "./b.js";
            
            A.f();
            B.f();
        </script>
    </body>
</html>

在HTML網頁中新增script元素,並加上type="module"屬性,接著就可以在這個script元素中撰寫ES6的JavaScript程式了。直接用import關鍵字即可引用JavaScript模組。

AMD 模組系統

若要使網頁相容比較舊,不支援ES6的網頁瀏覽器,可以將a.tsb.ts檔案編譯成AMD的JavaScript模組,只要修改tsconfig.json設定檔案中compilerOptions欄位的module欄位的值為AMD字串即可。當然,記得也要將編譯目標改為ES5或是以下的版本。

如何在網頁瀏覽器上使用AMD的JavaScript模組呢?請看以下的HTML網頁範例:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"></script>
    </head>
    <body>
        <script>
            requirejs(["./a.js", "./b.js"], function(A, B) {
                A.f();
                B.f();
            });
        </script>
    </body>
</html>

利用require.min.js提供的requirejs函數,可以幫我們引用AMD的JavaScript模組。

UMD(Universal Module Definition) 模組系統

理想的UMD模組應該要可以同時支援CommonJS、AMD以及root(即網頁瀏覽器的window或Node.js的global),所以若將a.tsb.ts檔案編譯成UMD的JavaScript模組,它們應該要可以像以下這樣來被使用:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <script src="a.js"></script>
        <script src="b.js"></script>
    </head>
    <body>
        <script>
            A.f();
            B.f();
        </script>
    </body>
</html>

不過現階段的TypeScript尚未實作這部份的功能,這個issue也還在討論當中,咱們就先等等吧!

替任意JavaScript模組加上TypeScript的.d.ts檔案

TypeScript的importfrom關鍵字無法直接引用JavaScript模組,它必須要有.d.ts檔案才行。

例如以下這個JavaScript模組:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function f() {
    console.log('A');
}
exports.f = f;

我們若想要將a.js引用至TypeScript程式中,就必須要先撰寫正確的a.d.ts檔案。內容如下:

export declare function f(): void;

TypeScript官方有提供.d.ts檔案的自動產生工具,有興趣的讀者們可以參考看看。

替TypeScript編譯出來的JavaScript模組加上.d.ts檔案

如果需要讓TypeScript在編譯程式的時候順便產生出.d.ts檔案,可以修改tsconfig.json設定檔案中compilerOptions欄位的declaration欄位的值為true。如此一來,被編譯到的.ts檔案都會產生出.d.ts檔案。

用ES6的import關鍵字來引用Node.js的模組

至此我們已經學會了ES6的import關鍵字的用法了!來看看上一章提供的例子:

import fs = require("node:fs");
import path = require("node:path");
import util = require("node:util");

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

const pStat = util.promisify(fs.stat);

const pMkdir = util.promisify(fs.mkdir);

const pCopyFile = util.promisify(fs.copyFile);

const pUnlink = util.promisify(fs.unlink);

async function main() {
    try {
        const stats = await pStat(INPUT_PATH);
        console.log(stats);

        await pMkdir(OUTPUT_FOLDER, { recursive: true });

        const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);
        await pCopyFile(INPUT_PATH, outputPath);

        await pUnlink(INPUT_PATH);

        console.log("Done!");
    } catch (err) {
        console.error(err);
    }
}

main();

在先前的章節有提到,import fs = require("node:fs");這樣的import關鍵字用法用於TypeScript的編譯目標是設為ES6或是以上的版本時會有問題,這是因為當我們把TypeScript的編譯目標設為ES6或是以上的版本時,module欄位的預設值會變成ES6。由於ES6的import關鍵字並不能被這樣使用,所以就會導致程式編譯失敗。若要解決這個問題,只需要明確設定module欄位的值為CommonJS等別種模組系統就好了。

不過我們既然已經學會ES6的模組系統了,就應該要儘量去用它。以上程式可以用ES6的import關鍵字修改成這樣:

import * as fs from "node:fs";
import * as path from "node:path";
import * as util from "node:util";

const INPUT_PATH = "input";
const OUTPUT_FOLDER = "folder";
const OUTPUT_NAME = "output";

const pStat = util.promisify(fs.stat);

const pMkdir = util.promisify(fs.mkdir);

const pCopyFile = util.promisify(fs.copyFile);

const pUnlink = util.promisify(fs.unlink);

async function main() {
    try {
        const stats = await pStat(INPUT_PATH);
        console.log(stats);

        await pMkdir(OUTPUT_FOLDER, { recursive: true });

        const outputPath = path.join(OUTPUT_FOLDER, OUTPUT_NAME);
        await pCopyFile(INPUT_PATH, outputPath);

        await pUnlink(INPUT_PATH);

        console.log("Done!");
    } catch (err) {
        console.error(err);
    }
}

main();

總結

在這個章節中我們學會了TypeScript的模組製作和引用的方式。下一個章節要來介紹TypeScript提供的命名空間(namespace)功能。

下一章:命名空間(namespace)