JavaScript 的格局日新月異,網站和應用的依賴關係也隨之而變。
這篇文章適合那些大量使用 script 標籤來載入 JS 的程式設計師,隨著網頁數目增多和專案規模的擴大,他們感覺到依賴管理變得越來越笨重。
如果想要深入瞭解每一個細節,同時想對 CommonJS 和 AMD 規範做比較的話,請參考 Axel Rauschmayer 的 Exploring ES6 book , 尤其是第 17 章。
什麼是 JavaScript 模組?
JavaScript 模組允許我們把專案中的程式碼分散成一個個單獨的檔案,或者使用通過 npm
安裝的開源模組。用模組化的方式寫程式碼有助於(專案的)組織、維護、測試,以及最重要的依賴管理。
當我們編寫 JavaScript 時,理想情況是保障每個模組都專注一件事並把這件事做好。這種分工可以讓我們在需要某個模組時再去做相應的載入。模組化是是 npm
背後的核心原則。當需要某個特定的功能時,我們能安裝相應的模組並載入到應用當中。
隨著時間推移,我們發現那種大而全的框架變少了,看到更多的是 專注一件事並把這件事做好 的小型模組。
舉個例子,大部分人都用過 jQuery。這個庫包含了從 CSS 操作到 ajax 呼叫幾乎全部的方法。現如今,很多人開始遷移到 React 這類庫上,我們常常需要載入額外的模組來完成一些任務如 ajax 或路由。
這篇文章將會帶你大致瞭解 npm
和 ES6 模組的使用。對於其它一些的包管理(如 Bower) 和模組載入工具(如 CommonJS 和 AMD),已經有大量的文章和話題在討論了。
不管你是做 Node 開發還是前端開發,我相信 ES6 模組和 npm
是未來的方向。如果你去看當下流行的開源專案,像 React 或 lodash,你會發現他們都採用了 ES6 模組 + npm
。
當前的開發流程
很多 JavaScript 的開發流程是這樣的:
- 找一個符合要求的外掛或庫然後從 GitHub 上下載。
- 通過 script 標籤載入到網站。
- 用全域性變數呼叫或以 jQuery 外掛的方式呼叫。
這類開發流程這麼多年來表現一直不錯,除了這幾個問題:
- 外掛必須手動升級 - 很難及時得知(該外掛)何時修復了嚴重的bug或是有哪些新功能可用。
- 混亂的原始碼版本控制 - 所有依賴都要加入原始碼,當庫更新時會導致非常不愉快的結果(版本問題)。
- 基本沒有依賴管理 - 很多指令碼的功能是重複的,如果分成小模組則可以很輕鬆的實現共享。
- 程式碼汙染和潛在的全域性名稱空間衝突。
編寫模組化的 JavaScript 這種理念並不新鮮,不過隨著 ES6 的到來和業界將 npm 作為 JavaScript 的首選包管理工具,我們看到大量的開發摒棄了從前的工作流程,遷移到使用 ES6 和 npm 的標準化流程上來。
等等,npm?那不是 Node 專用的嗎?
很久以前,npm 是作為 Node.js 的包管理工具起步的,如今它已經進化為JavaScript和前端開發的包管理工具。現在,我們可以把庫的安裝過程簡化為 2 個步驟:
- 從 npm 安裝依賴,例如:
1npm install lodash --save - 在當前檔案中匯入剛才的依賴,例如:
1import _ from 'lodash';
這套開發流程還需要很多的工作要做,同時關於模組的匯入匯出也有很多需要學習,現在讓我們來深入瞭解一下吧。
模組背後的理念
我們使用匯入和匯出語句來在檔案之間共享程式碼(變數、函式、資料、任何程式碼),而不是把所有程式碼載入到全域性名稱空間下。每一個模組匯入需要的依賴,也可以為其它檔案匯出需要的程式碼。
讓程式碼在瀏覽器執行還需要一個打包的步驟,我們會在文章的後面加以討論,現在,讓我們專注於 JavaScript 模組背後的核心理念。
建立自己的模組
假設我們正在構建一個線上購物的應用,需要一個檔案存放所有的輔助函式。我們可以建立一個模組命名為 helpers.js ,檔案包含一些輔助函式 - formatPrice(price)、 addTax(price) 和 discountPrice(price, percentage),還有一些關於該線上商城的變數。
我們的 helpers.js 檔案看起來如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const taxRate = 0.13; const couponCodes = ['BLACKFRIDAY', 'FREESHIP', 'HOHOHO']; function formatPrice(price) { // .. 做一些格式化 return formattedPrice; } function addTax(price) { return price * (1 + taxRate); } function discountPrice(price, percentage) { return price * (1 - percentage); } |
現在,每個檔案都持有自己的本地函式和變數,只要沒有明確地匯出,它們絕不會滲透到其它檔案的作用域中。在上面的例子裡,我們可能不想讓其它模組訪問 taxRate 變數,但在模組內部確實需要這個變數。
我們怎麼讓其它模組訪問到這些函式和變數呢?答案是匯出它們。在 ES6 中有兩種匯出方式,命名匯出和預設匯出。由於需要讓多個函式和 couponCodes 變數被訪問到,我們使用命名匯出。稍後我們會加以詳述。
從模組匯出程式碼,最簡單直白的方式就是在行首加上 export 關鍵字,像這樣:
1 2 3 4 5 6 7 8 9 10 |
const taxRate = 0.13; export const couponCodes = ['BLACKFRIDAY', 'FREESHIP', 'HOHOHO']; export function formatPrice(price) { // .. 做一些格式化 return formattedPrice; } // ... |
我們也可以在最後再匯出:
1 2 3 4 |
export couponCodes; export formatPrice; export addTax; export discountPrice; |
或者裡一次性匯出:
1 |
export { couponCodes, formatPrice, addTax, discountPrice }; |
還有很多其它很便捷的匯出方式,如果碰到有些情形無法滿足你的工作需求,請檢視 MDN 的文件。
預設匯出
前面提到,從模組中有兩種匯出方式 - 命名或預設。上面的例子用了命名匯出。如果要讓其它模組匯入這些出口,我們必須要知道我們希望匯入的變數/函式的名稱 - 接下來會有一個例子說明。使用命名匯出的好處是你可以從一個模組中匯出多項內容。
另一種匯出方式是預設匯出。當需要匯出多個變數/函式時,可以用命名匯出,當你的模組只需要匯出一個變數/函式的時候,使用預設匯出就行了。儘管你可以在一個模組中同時使用預設匯出和命名匯出,我還是建議你每個模組只採用一種方式。
預設匯出的例子是一個單獨的 StorePicker React 元件,或者是某個陣列。例如,下面這個陣列需要讓其它元件訪問到,我們可以用預設匯出。
1 2 3 4 5 6 |
// people.js const fullNames = ['Drew Minns', 'Heather Payne', 'Kristen Spencer', 'Wes Bos', 'Ryan Christiani']; const firstNames = fullNames.map(name => name.split(' ').shift()); export default firstNames; // ["Drew", "Heather", "Kristen", "Wes", "Ryan"] |
和上面一樣,你可以在想要匯出的函式到前面加上 export default 關鍵字。
1 |
export default function yell(name) { return `HEY ${name.toUpperCase()}!!` } |
匯入自己建立的模組
既然我們已經把程式碼分成了小模組並且按需求匯出了,現在可以向前一步,把這些模組匯入到應用的其它部分了。
如果要匯入的模組是程式碼庫的一部分,我們使用 import 語句,然後指定檔案相對於當前模組的路徑 - 跟你平時在 HTML 中匯入資源路徑或 CSS 背景圖是一樣的。你會發現我們去掉了 .js 字尾,因為字尾不是必需的。
需要注意並不是匯入一次模組,整個應用就能像全域性變數一樣去訪問了。每當模組依賴其它模組時 - 比如我們上面的程式碼需要一個 lodash 方法 - 我們必須將它匯入進來。如果我們有5個模組都需要同樣的 lodash 函式,我們需要匯入 5 次。這有助於保持作用域清晰,同時讓模組更加輕便且可重複使用。
匯入命名匯出
我們最先匯出的時我們的輔助模組。這裡使用的是 命名匯出,現在,有很多種方式將它們匯入:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 把所有內容作為一個物件的屬性或方法匯入 import * as h from './helpers'; // 然後使用 const displayTotal = h.formatPrice(5000); // 或者直接把所有內容匯入到當前模組的作用域 import * from './helpers'; const displayTotal = addTax(1000); //我不推薦這種風格,因為不夠明確,可能會導致程式碼難以維護 // 或者分組挑選需要的匯入 import { couponCodes, discountPrice } from './helpers'; const discount = discountPrice(500, 0.33); |
匯入預設匯出
如果你回想一下,我們還從 people.js 中匯出了一個 first names 的陣列,這是那個模組唯一需要匯出的部分。
預設匯出可以用任何名稱匯入 - 不需要知道匯出的變數、函式、或類的名稱。
1 2 3 4 5 6 7 8 9 10 |
import firstNames from './people'; // 或 import names from './people'; // 或 import elephants from './people'; // 這些都可以匯入 first names 陣列 // 你還可以這樣匯入 預設匯出 import * as stuff from './people' const theNames = stuff.default |
從 npm 匯入模組
我們使用的大量模組來自於 npm。不管你需要一個像 jQuery 這種完整庫,還是 lodash 這種只有工具函式的庫,又或是 superagent 這種提供 Ajax 請求的庫,我們都可以用 npm 來安裝。
1 2 3 4 5 |
npm install jquery --save npm install lodash --save npm install superagent --save // or all in one go: npm i jquery lodash superagent -S |
一旦這些包存在在 node_modules/ 目錄下了,就可把它們匯入到我們的程式碼。預設情況下,Babel 會把 ES6 的匯入語句編譯成 CommonJS。所以,只要使用一個可以解析模組語法的打包工具(webpack 或者 browserify),你就可以玩轉 node_modules/ 目錄了。我們打匯入語句只需要包含模組的名稱就可以了。其它打包工具可能需要外掛或配置才能從 node_modules/ 目錄匯入。
1 2 3 4 5 6 |
// 匯入整個庫或外掛 import $ from 'jquery'; // 然後正常使用就可以了 $('.cta').on('click',function() { alert('Ya clicked it!'); }); |
上面的程式碼能正常工作是因為 jQuery 是按 CommonJS 模式匯出的, Babel 將 ES6 的 import 語句轉譯使其適配 jQuery 的 CommonJS 匯出(語句)。
我們再試試 superagent。和 jQuery 一樣,superagent 對整個庫使用了CommonJS規範的預設匯出,所以我們可以任意命名 - 一般命名成 request。
1 2 3 4 5 6 7 8 |
// 將模組匯入 import request from 'superagent'; // 然後使用! request .get('https://api.github.com/users/wesbos') .end(function(err, res){ console.log(res.body); }); |
“精挑細選”——部分匯入
我最喜歡 ES6 模組的一個特性就是,有大量的庫允許你只選擇你需要的程式碼。lodash 是個非常棒的工具庫,裡面有很多有用的 JavaScript 方法。
我們可以將整個庫用 _(下劃線) 變數匯入,這是因為 lodash 將整個庫作為一個主模組來匯出(同樣Babel 會將我們的 import 進行轉譯,效果等同於 lodash 使用了預設匯出)
1 2 3 4 5 6 7 8 |
// 將整個庫匯入成一個 _ 變數 import _ from 'lodash'; const dogs = [ { 'name': 'snickers', 'age': 2, breed : 'King Charles'}, { 'name': 'prudence', 'age': 5, breed : 'Poodle'} ]; _.findWhere(dogs, { 'breed': 'King Charles' }); |
不過,更常見的是你只想匯入一兩個方法,而不是整個庫。因為 lodash 已經將每個方法都作為一個模組匯出了,所以我們可以選擇只匯入我們想要的部分! 同樣Babel 的轉譯保證了這種做法的可行性。
1 2 3 4 |
import { throttle } from 'lodash'; $('.click-me').on('click', throttle(function() { console.count('ouch!'); }, 1000)); |
確保模組是最新的
一些反對“微模組”編碼的人們認為,這麼做會導致 npm 中的庫存在大量的相互依賴。
JavaScript 生態發展非常迅速,要讓你的依賴維持最新狀態非常困難。我們都知道不管是程式碼還是依賴都存在 bug、安全漏洞或者某些程式碼不再易用。 我們需要清楚的瞭解專案的各種不安全、被棄用的、過時的或無用的程式碼。
要解決這個問題,bitHound 公司提供了一項很棒的服務,這項服務能夠持續監控你的程式碼,當你的依賴出現了任何錯誤都會及時向你報告,同時還會提供一個專案的綜合統計評分。通過這項服務可以看出你的專案到底有多健壯,它對所有開源專案都是免費的。
bitHound 整合了 GitHub 和 BitBucket 並且推出了自動提交分析功能,這樣當你的程式碼庫發生變更時會通知 bitHound。一旦依賴庫過期,你的 Slack 或 HipChat 就會收到通知,或收到一份包含詳細資訊的郵件。
bitHound 還包含了 Pull Requests 的分支狀態 - 設定 通過/失敗 標準,之後 bitHound 會將這些狀態資訊提交到 GitHub 或者 Bitbucket。
有個叫 npm-check-updates 的工具可以非常好地搭配 bitHound 工作。使用 npm install npm-check-updates -g 在你開發機器上全域性安裝,然後執行 ncu 命令,可以快速檢查你的程式碼包是否需要升級。如果需要,你可以執行 ncu –upgradeAll 命令自動升級 package.json 裡面的所有包。之後一定要執行 npm install 命令來從 NPM 上獲取最新程式碼。
打包操作
因為瀏覽器現在還不支援 ES6 模組,我們需要一些工具讓它們能正常工作。 JavaScript 打包工具可以把這些模組轉譯成一個單獨的 JavaScript 檔案,或者把應用程式的各個部分打包成多個檔案。
最終我們不再需要打包工具,HTTP/2 載入時會自動請求所有的 import 語句。
這有一些流行的打包工具,大部分都使用 Babel 將 ES6 模組轉譯成 CommonJS。
- Browserify 最初用來在瀏覽器中使用 nodejs 風格的程式碼。同樣支援 ES6 模組
- webpack 在 React 社群非常流行。不僅支援 ES6 ,還支援很多其它格式的模組程式碼。
- Rollup 為 ES6 而生,但是source maps(一個記錄程式碼轉換前和轉換後的位置資訊檔案)似乎有些問題 - 我會在一兩個月後再看看。
- JSPM 基於
npm
SystemJS. - Ember CLI 為Ember使用者提供的與 webpack 類似的簡單命令列工具。使用了 Broccoli 引擎。
該怎麼選呢?選最適合你的。我是 Browserify 和 webpack 的忠實粉絲,因為前者上手簡單,後者容易與 React 整合。編寫 ES6 模組的迷人之處在於你寫的不是 Browserify 或 webpack 模組 - 你可以隨時切換打包工具。有很多選用哪個工具的觀點,快速搜尋一下你會發現每個工具都有大量的論述。
如果你已經在執行任務時,使用了gulp, grunt, 或npm對現有的JavaScript和CSS檔案進行操作,那麼將模組引入工作流程非常簡單。
實現打包非常簡單 - 你可以將它作為一個 gulp 的子任務來執行,或者通過 webpack 配置、npm 指令碼 或是直接使用命令列。
我建立了一個程式碼庫,你可以通過幾個簡單模組的程式碼詳細瞭解如何使用 webpack 和 Browserify。
匯入非模組程式碼
如果你正在致力於將程式碼庫模組化,但又很難一步做到,你可以簡單的通過匯入“檔名”來載入並執行檔案中的程式碼。這不算 ES6,但它是打包工具的一個功能。
這個概念與連續執行多個 js 檔案並無不同,除非你匯入的程式碼作用域限制在匯入的模組中。
需要全域性變數的程式碼
有些庫,比如說 jQuery 外掛,不太適合 JavaScript 的模組化系統。整個 jQuery 外掛系統都假設有一個叫window.jQuery的全域性變數 ,每個外掛可以附加在該變數之上。但是我們剛剛學過 ES6 模組沒有全域性變數。所有程式碼的作用域都侷限於當前模組,除非你把一些程式碼明確地設定在window物件上。
要想解決這個問題,首先要問問自己是否真得需要這個外掛,還是可以自己寫一個。很多 JavaScript 外掛系統已經重寫並移除 jQuery 依賴,成為獨立的 JavaScript 模組。
如果不是,你需要檢查你的構建流程,看看能不能幫助解決問題。Browserify 有 Browserify Shim(用於解決不相容Browserify或不相容CommonJS的問題),webpack 也有相關文件。
陷阱
當匯出一個函式時,不要在最後包含分號。很多打包工具仍然允許使用額外的分號,但是在函式宣告後面去掉分號是最佳實踐,這樣當你切換其它打包工具時可以避免碰到一些奇葩的問題。
1 2 3 4 |
// 錯誤: export function yell(name) { return `HEY ${name}`; }; // 正確: export function yell(name) { return `HEY ${name}`; } |
延伸閱讀
希望這是一篇關於使用 npm
和 ES6 模組化不錯的介紹。還有更多需要學習的內容,我認為,最好的學習方式就是在你的下一個專案中使用它。下面有一些些非常棒的學習資源,可以在你的前進道路上提供幫助。
- Exploring ES6 book
- Brief Overview of ES6 Module syntax
- ES6 Features
- ES6 Modules on Rollup’s Wiki
- Browserify vs webpack hot drama
- webpack & ES6
- ES6 features & webpack workshop (complete with repo and video recordings: ES6 featuresMigrating with Webpack)
Thanks + Updates
非常感謝 Stephen Margheim、 Kent C. Dodds、Mike Chen、 Ben Berman 和 Juan Pablo Osorio Ospina 幫助校對並且提供了高質量的反饋意見。
如果你有任何意見、程式碼示例、技術升級或者說明,想加進來,請提交 pull request。