課程:JavaScript 與 React 底層原理 第 9 堂:ES6+ 語法收尾
27 ES 模組系統
想像你正在建造一座巨大的樂高城堡。如果你把所有的積木都堆在客廳地板上,雖然你最終還是能蓋出城堡,但過程會極其痛苦:你會找不到特定形狀的零件,不小心踩到積木會受傷,而且當你想把城堡的一部分分給朋友看時,你必須拆掉整座建築。
在 JavaScript 的早期,我們就是這樣寫程式的。所有的變數都擠在全域作用域(Global Scope)裡,這就像是把所有積木堆在一起。隨著應用程式變得越來越複雜,我們迫切需要一種「分類盒」系統,讓程式碼可以被拆分成獨立、可重用的單元。這就是 ES 模組系統(ESM, ECMAScript Modules) 誕生的原因。
在上一節中,我們學會了如何定義精簡且強大的箭頭函數。現在,我們要學習如何將這些函數封裝進「分類盒」中,並在需要時精確地取出它們。
模組化的演進:為什麼我們需要 ESM?
在 ESM 出現之前,JavaScript 社群嘗試過許多方案,例如 CommonJS(Node.js 使用的 require)或 AMD。但 ESM 是官方制定的標準,它與以往方案最大的不同在於:它是靜態的(Static)。
這聽起來可能有點抽象,但「靜態」是現代前端工具(如 Vite, Webpack)能夠如此高效的核心原因。在進入語法細節前,我們先來看看 ESM 如何處理「匯出(Export)」與「匯入(Import)」。
具名匯出(Named Export):精確的零件清單
具名匯出就像是一個標註清楚的零件盒,你可以從中導出多個變數、函數或物件。它的核心規則是:匯出的名稱必須與匯入時的名稱匹配。
// mathUtils.js
// 1. 宣告時直接導出
export const add = (a, b) => a + b;
export const PI = 3.14159;
// 2. 統一在底部導出
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
export { subtract, multiply };
當你想使用這些零件時,你需要使用大括號 {} 來進行解構式的匯入:
// app.js
import { add, PI, subtract } from './mathUtils.js';
console.log(add(5, 5)); // 10
console.log(PI); // 3.14159
進階技巧:重新命名(Aliasing)
有時候,你從不同模組匯入的函數可能撞名。這時可以使用 as 關鍵字來重新命名:
import { add as internalAdd } from './mathUtils.js';
const add = (a, b) => a + b + 100; // 本地的 add 函數
console.log(internalAdd(1, 1)); // 2
預設匯出(Default Export):模組的主角
每個檔案只能有一個預設匯出。這通常用於當一個模組「只做一件事」或「有一個核心功能」時。在 React 中,我們最常看到將整個元件作為預設匯出。
// Button.js
const Button = ({ label }) => {
return `<button>${label}</button>`;
};
export default Button;
匯入預設匯出時,不需要大括號,且你可以自由為它命名:
// Dashboard.js
import MyCustomButton from './Button.js'; // 這裡命名的 MyCustomButton 對應的是 Button.js 的 default
混合使用與選用原則
一個檔案可以同時擁有一個預設匯出和多個具名匯出。
// UserProfile.js
export const API_URL = 'https://api.example.com/user'; // 具名
const UserProfile = () => { /* ... */ };
export default UserProfile; // 預設
該選哪一種?
- 具名匯出:適合工具函式庫(如
lodash、mathUtils),因為使用者可以只取所需的零件,這對效能優化(Tree Shaking)非常友好。 - 預設匯出:適合大型元件或類別。然而,許多資深開發者傾向於「全部使用具名匯出」,因為具名匯出在 IDE(如 VS Code)中能提供更好的自動補完支援,且能避免隨意命名導致的混淆。
靜態 Binding:ESM 的靈魂
這是 ESM 最深層且最重要的特性。在 CommonJS(require)中,你可以這樣寫:
// CommonJS 風格 (動態)
if (userIsLoggedIn) {
const adminTools = require('./admin.js');
}
這叫做「動態載入」。JS 引擎必須等到程式碼「執行」到那一行,才知道需要載入哪個模組。
但 ESM 不允許這樣做。import 必須出現在檔案的最頂層,且不能放在 if 或 for 區塊中(除非使用特殊異步語法 import())。
為什麼要這麼嚴格?
因為 ESM 在程式碼執行之前的「編譯階段(Static Time)」就已經決定了模組之間的依賴關係。這帶來了兩個巨大的好處:
- 即時錯誤檢查:如果你匯入了一個不存在的具名變數,JS 引擎在執行任何一行程式碼之前就會噴錯。
- 效能優化(Tree Shaking):因為依賴關係是固定的,打包工具(Bundler)可以像搖晃一棵樹一樣,把那些「雖然被定義了但從未被 import」的枯葉(程式碼)搖掉,不讓它們進入最終的產出檔案中。
Live Binding(動態綁定)
另一個有趣的細節是,ESM 匯出的是「值的引用」而非「值的複印」。
請預測以下行為:
如果你有一個 counter.js 匯出了一個變數和一個增加該變數的函數,當你在 app.js 呼叫增加函數後,匯入的變數會變嗎?
// counter.js
export let count = 0;
export const increment = () => count++;
// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 這裡會是多少?
答案是:1。
在 ESM 中,匯入的 count 始終指向 counter.js 內部的那個記憶體位址。這與 CommonJS 不同(CommonJS 在 require 時會把值複製一份)。這確保了模組間資料的同步性。
模組的單例性(Singleton)
這是一個關於「執行時機」的重要概念。同一個模組,不論被匯入多少次,內部的程式碼都只會執行一次。
想像你有一個負責資料庫連線的模組:
// dbConnection.js
console.log('正在初始化資料庫連線...');
export const connection = { id: Math.random() };
如果你在 Header.js、Footer.js 和 Sidebar.js 都匯入了這個 connection:
- 控制台只會印出一次 「正在初始化資料庫連線...」。
- 這三個元件拿到的
connection.id都會是同一個隨機數。
為什麼這對 React 很重要?
在 React 專案中,我們經常利用這個特性來共享「非狀態(Non-state)」的資料,例如 API 配置、環境變數或自定義的事件中心(Event Bus)。因為模組是單例的,我們可以確保整個應用程式存取的都是同一個實例。
這也意味著,如果你在模組頂層寫了會產生副作用(Side Effects)的程式碼,它們只會發生一次。
Tree Shaking:讓你的 Bundle 瘦身
「Tree Shaking」這個詞在現代前端開發中幾乎與 ESM 劃上等號。它的運作原理完全基於我們前面提到的「靜態結構」。
假設你使用了一個超大型的圖示庫,裡面包含 5000 個圖示,但你的專案只用了其中一個「垃圾桶」圖示。
- 如果是 CommonJS:打包工具很難判斷你到底會不會在執行時動態去
require那些圖示,所以保險起見,它可能會把 5000 個圖示全部打包進去。 - 如果是 ESM:打包工具掃描你的
import { TrashIcon } from 'icon-library'。它清清楚楚地看到你「只」用了這一個。於是,剩下的 4999 個圖示的程式碼就會被直接丟棄。
這對於 React 應用的載入速度至關重要。這也是為什麼當我們使用 lodash 時,會被強烈建議安裝 lodash-es(ESM 版本),因為只有這樣,Tree Shaking 才能發揮作用,避免把整個 lodash 函式庫塞進使用者的瀏覽器。
在 React 中的實戰範例
讓我們看看在典型的 React 專案中,我們是如何組織這些概念的。
步驟 1:定義一個功能元件
我們通常會使用箭頭函數定義元件,並使用具名匯出,這樣在同一個檔案中我們還可以導出相關的類型或常數。
// components/UserCard.js
export const CARD_WIDTH = '300px';
const UserCard = ({ name, role }) => {
return `
<div style="width: ${CARD_WIDTH}">
<h3>${name}</h3>
<p>${role}</p>
</div>
`;
};
export default UserCard;
步驟 2:封裝導出(Index Pattern)
為了讓匯入路徑更乾淨,我們常在資料夾下建立一個 index.js 作為「轉運站」。
// components/index.js
export { default as UserCard } from './UserCard.js';
export { default as Button } from './Button.js';
export * from './Constants.js'; // 導出 Constants 裡所有的具名變數
步驟 3:在主程式匯入
現在,我們可以用非常簡潔的方式一次匯入多個元件:
// App.js
import { UserCard, Button } from './components';
const App = () => {
// 使用元件...
};
這種結構化的方式,正是歸功於 ESM 的靈活性與靜態分析能力。
關於路徑與環境的小提醒
雖然我們在開發 React 時,底層的 Vite 或 Webpack 會幫我們處理很多細節,但有幾點是身為專家必須知道的:
- 瀏覽器原生支援:現代瀏覽器可以直接執行
<script type="module">,但在原生環境下,import的路徑必須包含副檔名(例如./math.js而不能只寫./math)。 - Strict Mode:所有 ESM 模組自動運行在「嚴格模式(Strict Mode)」下。這意味著你不能使用未宣告的變數,且
this在模組頂層是undefined而非window。 - 相對路徑:
import路徑必須以./、../或/開頭,除非是從node_modules匯入的套件。
模組化是維護性的起點
掌握了 ESM,你就不再只是在寫「程式碼」,而是在設計「系統」。你能清晰地定義每個檔案的職責(透過導出什麼、隱藏什麼),並利用靜態綁定的特性讓工具幫你檢查錯誤與優化效能。
在 React 的世界裡,模組系統與元件化(Component-based)的思維是完美契合的。每個元件都是一個模組,擁有自己的輸入(Props)、輸出(JSX/Export)以及私有的內部邏輯。
通往安全存取之路
現在我們已經能流暢地在模組間傳遞資料與函數了。但當你從一個模組匯入一個龐大的 API 回傳物件時,往往會面臨一個頭痛的問題:如果這個物件的深層屬性是 undefined 怎麼辦?
例如,你匯入了一個 userConfig,想讀取 userConfig.theme.colors.primary。如果 theme 根本不存在,你的程式碼就會直接噴錯崩潰。為了優雅地處理這種情況,我們需要下一節的武器:選擇性鏈結(Optional Chaining)與空值合併(Nullish Coalescing)。
ESM 讓我們能安全地「搬運」資料,而下一節的語法將讓我們能安全地「讀取」資料。讓我們繼續前進。