本系列已集結成書從 0 到 Webpack:學習 Modern Web 專案的建置方式,這是一本完整介紹 Webpack 的專書,如有學習 Webpack 相關的問題可以參考此書。
本文講述 JavaScript 模組化的發展,以及這樣的發展讓現代 JavaScript 開發遭遇了什麼樣的困難。
本文範例放於 GitHub 。
我有一隻愛用的滑鼠,那是個無線滑鼠,在電腦上插上接收器就可以使用了,非常的方便。因為在家裡用習慣了,因此我去上班時都會帶著它,到公司後只要插上接收器就可以直接上工,讓我的工作效率大幅的提升...。
大家有沒有想過一個問題:為什麼我們可以在電腦上接上不同的滑鼠,而不需要把整台電腦換掉呢?這是因為電腦是以模組化的概念所設計的。那所謂的模組化又是什麼呢?
模組化(modular)是個將一個大功能拆分成各個獨立小功能的概念,而每個小功能就叫做模組(module)。
模組擁有介面,介面會有輸入及輸出的資源定義,因此擁有相同介面的模組是可以互相做抽換的。
以電腦來說,滑鼠、鍵盤、硬碟、記憶體...等都是模組,他們都以固定的介面接入主機板中,以此發揮他們的功用。
因為介面相同的關係,所以就算換成不同廠牌的滑鼠、鍵盤,電腦依然可以正常運作。
因為模組化的概念可以使編程變得更有結構,因此大多數的語言都支援模組化的語意。
將電腦的例子寫成代碼,會像下面這樣:
// computer
import Mouse from "mouse";
import Keyboard from "keyboard";
Mouse.addEventListener("move", () => {
/* Move mouse */
});
Keyboard.addEventListener("click", () => {
/* Enter word */
});
電腦引入了滑鼠跟鍵盤這兩個模組,然後監聽他們的訊號,並做相對應的處理。
而模組是可以做抽換的,假設今天我要換支新的滑鼠:
// computer
import mouse from "newMouse"; // Switch to new mouse
import Keyboard from "keyboard";
mouse.addEventListener("move", () => {
/* Move mouse */
});
Keyboard.addEventListener("click", () => {
/* Enter word */
});
我只要改成引入 newMouse
就可以做到了。
這樣的編程方式就叫做模組化編程,模組化編程讓開發者可以把程式拆成多個模組,每個模組都負責一部分的功能,將全部的模組依照介面組合起來,就是一個完整的程式。如果寫得好,每個模組都會是高內聚,而對於模組間會是低耦合的。因此模組化編程可以封裝模組,讓除錯、測試、使用上更加容易。
有寫過程式的讀者應該都會發現到,自己有使用過模組化編程的的經驗。開發時,我們會借助第三方模組來減輕開發的負擔,或使用內建模組來處理底層的程序,而這樣的動作就代表你在做模組化編程。
雖然模組化編程是這麼的自然,但是 JavaScript 被發明時是沒有模組化的,這使得開發上遇到了許多的問題。而之後為了解決這些問題,第三方的模組化語意被發明,而這些第三方的模組化語意又產生了其他問題。這段時期究竟是發生了什麼事情,導致 JavaScript 的模組化之路如此顛簸呢?接下來就帶大家來了解這段歷史。
在 Webpack 這類的工具出現之前,開發者會直接在 html
檔案中藉由 <script>
, <link>
標籤引入 JavaScript 以及 CSS 檔案:
<!-- ./demos/before-webpack/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Webpack Demos: Before Webpack</title>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.20/lodash.min.js"></script>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<script src="./index.js"></script>
</body>
</html>
我們甚至不需要引入外部 js
及 css
檔案,在 html
檔案內直接將 JavaScript 寫在 <script>
、 CSS 寫在 <style>
標籤就好了,一個檔案就可以完成所有的工作。
JavaScript 被發明時,只是要做一些簡單的腳本工作而已,因此這樣做是沒有問題的。
但是隨著時代的變遷,網路成了人們生活的重心,越來越多複雜的服務將目標放到了網頁上,伴隨而來的就是網頁應用的代碼量增加,這使得沒有模組化的 JavaScript 深陷於泥沼中。
以前,在寫程式庫的時候,雖然內部變數可以使用 IIFE 避免污染外部變數,但是當要匯出的時候,我們還是需要將變數放到 window
這個全域物件裡,使其他的 script 可以使用目標的變數。在 lodash, JQuery 之類的庫中看到這樣的注入方式。
但是這時候如果其他的 js
代碼覆蓋了 window
中的變數,問題就會發生:
<!-- demos/before-webpack/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Webpack Demos: Before Webpack</title>
<script src="https://cdn.jsdelivr.net/npm/lodash@4.17.20/lodash.min.js"></script>
<!-- window._ === lodash library -->
<script>
_ = "wrong lodash";
</script>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<script src="./index.js"></script>
</body>
</html>
Uncaught TypeError: _.join is not a function
由於依賴的目標庫是注入到全域物件中,因此在要引用的 js
中只能假設目標是存在的:
// demos/before-webpack/index.js
const demoName = "Before Webpack";
function component() {
const element = document.createElement("div");
// assume _ is exist
element.innerHTML = _.join(["Webpack Demo", demoName], ": ");
element.classList.add("demo");
return element;
}
document.body.appendChild(component());
這樣方式會造成就算工具庫沒有引入,我們只能在執行時才能發現錯誤。
腳本語言是由上而下執行的, JavaSctipt 也不例外,而由於都只是注入 window
,並沒有實際的相依關係,因此依賴的順序只能靠開發者手動調整,只要引入順序有誤,就會造成問題:
<!-- demos/wrong-import/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Webpack Demos: Wrong Import</title>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<!-- error -->
<script src="https://code.jquery.com/jquery-1.12.4.js"></script>
</head>
<body></body>
</html>
Uncaught ReferenceError: jQuery is not defined
因為 JQuery UI 是相依於 JQuery 的,如果先引入 JQuery UI ,它會因為找不到 JQuery 而報錯。
有的時候我們會引入外部庫去嘗試它的功能,但發現不符合需求就將檔案中相關的程式刪除,但常常會忘記要去刪除 <script>
標籤,這時我們就會引入不必要的程式碼,增加載入的時間。
大部分的專案都會引入第三方的庫或是框架來減少開發的負擔,但這樣做會增加要引入的檔案,造成請求次數增加,拖慢效能。
由於專案越趨龐大,沒有模組化的 JavaScript 在開發上遇到了瓶頸,也使開發者發想了許多模組化解決方案。
Node.js 是 JavaScript 伺服器端的執行環境,它使得 JavaScript 由網頁前端跨至後端的領域,成為一個網頁全端的程式語言。 Node.js 使用 CommonJS (簡稱 CJS) 規格實作模組系統,作為 JavaScript 語言的模塊化解決方案。
// ./demos/common-js/add.js
function add(a, b) {
return a + b;
}
module.exports = add;
// ./demos/common-js/index.js
const add = require("./add");
console.log(add(1, 2));
// 3
module.exports
導出模塊require
引入模塊在檔案都在本地的後端環境下,同步加載的模組系統是可行的,但是在資源分散的前端環境下,為了增進效能,能擁有異步加載的模組系統是必須的。
RequireJS 做為前端模塊的解決方案,它實作了 Asynchronous Module Definition 規格,使模塊可以異步加載。
<!-- ./demos/require-js/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Webpack Demo: RequireJS</title>
<script
data-main="index"
src="https://unpkg.com/requirejs@2.3.6/bin/r.js"
></script>
</head>
<body></body>
</html>
// ./demos/require-js/add.js
define(function () {
return function (a, b) {
return a + b;
};
});
// ./demos/require-js/index.js
requirejs(["add"], function (add) {
console.log(add(1, 2));
// 3
});
define
定義導出模塊requirejs
導入模塊,在加載完成後叫用 callback
函數,執行程式使用 RequireJS 後,我們終於可以在瀏覽器上以模組開發 JavaScript 。接著最後的一哩路就是原生的模組系統。
ESM 是 ES2015 中定義的模組化語意所實作的模組系統,各瀏覽器實作了 ES2015 標準後,使得 ESM 成為 JavaScript 在前端的原生模組系統,為 JavaScript 帶來真正模組話語意。
<!-- ./demos/es-module/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>Webpack Demo: ES Module</title>
<script src="index.js" type="module"></script>
<script src="add.js" type="module"></script>
</head>
<body></body>
</html>
// ./demos/es-module/add.js
export default function (a, b) {
return a + b;
}
// ./demos/es-module/index.js
import add from "./add.js";
console.log(add(1, 2));
// 3
export
導出模塊import
導入模塊雖然擁有了原生的模塊系統,但是前端環境受制於終端(EX: Chrome、Firefox、Edge...)的實作程度,並不是全部都已經支援 ESM 。而因為歷史的關係, JavaScript 的模組實作方式多樣(CJS, AMD, ESM) ,這也造成模組整合的困難。
由於歷史的原因, JavaScript 的模組化語意有好幾種,由於實作方式及語法都不相同,因此要引用會需要個別處理。如果是自己要開發工具庫讓別人使用的話,也需要分別轉換成不同的模組定義,才能確保使用者不會有引用上的問題發生。
ES Module 這個原生模組語意的出現,確實讓 JavaScript 的模組化問題出現了一道曙光,但是 JavaScript 的執行環境太過複雜,有前端,有後端,有瀏覽器、Electron、React Native...等不同的環境,各個環境對於 ES Module 的支援度都不一樣,造成使用 ES Module 時還是必須要經過轉換。
JavaScript 的模組化之路坎坷,確實使得開發變得複雜困難,但是危機就是轉機, Webpack 的發明就完美的解決了這些問題,使前端工程迎來了全面的模組化時代。