我們先前所練習的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
關鍵字可以被用在最外層的var
、let
、const
、function
、class
、interface
、enum
、type
關鍵字前,使該項目暴露到模組外(換句話說就是可以被模組外的程式使用)。
例如:
export function f() {
console.log("A");
}
export
關鍵字也可以不必與上述的var
、let
等關鍵字搭配使用,而是可以直接在其後接上一組大括號{}
,來放入要暴露的名稱,如果有多個名稱就用逗號,
隔開。
例如:
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中正常使用。
以上程式執行時會輸出:
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.ts
的f
函數的哦!另外,像這樣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.ts
的A
模組名稱之下。
除了函數和類別之外,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.ts
和b.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.ts
和b.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.ts
和b.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的import
與from
關鍵字無法直接引用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)。