JavaScript 語言誕生至今,模塊規範化之路曲曲折折。社區前後出現了各類解決方案,包括 AMD、CMD、CommonJS 等,然後 ECMA 組織在 JavaScript 語言標準層面,增長了模塊功能(由於該功能是在 ES2015 版本引入的,因此在下文中將之稱爲 ES6 module)。
今天咱們就來聊聊,爲何會出現這些不一樣的模塊規範,它們在所處的歷史節點解決了哪些問題?html
或根據功能、或根據數據、或根據業務,將一個大程序拆分紅互相依賴的小文件,再用簡單的方式拼裝起來。前端
爲了更好的理解各個模塊規範,先增長一個簡單的項目用於演示。node
# 項目目錄: ├─ js # js文件夾 │ ├─ main.js # 入口 │ ├─ config.js # 項目配置 │ └─ utils.js # 工具 └─ index.html # 頁面html
在刀耕火種的前端原始社會,JS 文件之間的通訊基本徹底依靠window
對象(藉助 HTML、CSS 或後端等狀況除外)。git
// config.js var api = 'https://github.com/ronffy'; var config = { api: api, }
// utils.js var utils = { request() { console.log(window.config.api); } }
// main.js window.utils.request();
<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>小賊先生:【深度全面】JS模塊規範進化論</title> </head> <body> <!-- 全部 script 標籤必須保證順序正確,不然會依賴報錯 --> <script src="./js/config.js"></script> <script src="./js/utils.js"></script> <script src="./js/main.js"></script> </body> </html>
瀏覽器環境下,在全局做用域聲明的變量都是全局變量。全局變量存在命名衝突、佔用內存沒法被回收、代碼可讀性低等諸多問題。es6
這時,IIFE(匿名當即執行函數)出現了:github
;(function () { ... }());
用IIFE重構 config.js:typescript
;(function (root) { var api = 'https://github.com/ronffy'; var config = { api: api, }; root.config = config; }(window));
IIFE的出現,使全局變量的聲明數量獲得了有效的控制。npm
依靠window
對象承載數據的方式是「不可靠」的,如window.config.api
,若是window.config
不存在,則window.config.api
就會報錯,因此爲了不這樣的錯誤,代碼裏會大量的充斥var api = window.config && window.config.api;
這樣的代碼。後端
這時,namespace
登場了,簡約版本的namespace
函數的實現(只爲演示,不要用於生產):api
function namespace(tpl, value) { return tpl.split('.').reduce((pre, curr, i) => { return (pre[curr] = i === tpl.split('.').length - 1 ? (value || pre[curr]) : (pre[curr] || {})) }, window); }
用namespace
設置window.app.a.b
的值:
namespace('app.a.b', 3); // window.app.a.b 值爲 3
用namespace
獲取window.app.a.b
的值:
var b = namespace('app.a.b'); // b 的值爲 3 var d = namespace('app.a.c.d'); // d 的值爲 undefined
app.a.c
值爲undefined
,但由於使用了namespace
, 因此app.a.c.d
不會報錯,變量d
的值爲undefined
。
隨着前端業務增重,代碼愈來愈複雜,靠全局變量通訊的方式開始捉襟見肘,前端急需一種更清晰、更簡單的處理代碼依賴的方式,將 JS 模塊化的實現及規範陸續出現,其中被應用較廣的模塊規範有 AMD 和 CMD。
面對一種模塊化方案,咱們首先要了解的是:1. 如何導出接口;2. 如何導入接口。
異步模塊定義規範(AMD)制定了定義模塊的規則,這樣模塊和模塊的依賴能夠被異步加載。這和瀏覽器的異步加載模塊的環境恰好適應(瀏覽器同步加載模塊會致使性能、可用性、調試和跨域訪問等問題)。
本規範只定義了一個函數define
,它是全局變量。
/** * @param {string} id 模塊名稱 * @param {string[]} dependencies 模塊所依賴模塊的數組 * @param {function} factory 模塊初始化要執行的函數或對象 * @return {any} 模塊導出的接口 */ function define(id?, dependencies?, factory): any
AMD 是一種異步模塊規範,RequireJS 是 AMD 規範的實現。
接下來,咱們用 RequireJS 重構上面的項目。
在原項目 js 文件夾下增長 require.js 文件:
# 項目目錄: ├─ js # js文件夾 │ ├─ ... │ └─ require.js # RequireJS 的 JS 庫 └─ ...
// config.js define(function() { var api = 'https://github.com/ronffy'; var config = { api: api, }; return config; });
// utils.js define(['./config'], function(config) { var utils = { request() { console.log(config.api); } }; return utils; });
// main.js require(['./utils'], function(utils) { utils.request(); });
<!-- index.html --> <!-- ...省略其餘 --> <body> <script data-main="./js/main" src="./js/require.js"></script> </body> </html>
能夠看到,使用 RequireJS 後,每一個文件均可以做爲一個模塊來管理,通訊方式也是以模塊的形式,這樣既能夠清晰的管理模塊依賴,又能夠避免聲明全局變量。
更多 AMD 介紹,請查看文檔。
更多 RequireJS 介紹,請查看文檔。
特別說明:
先有 RequireJS,後有 AMD 規範,隨着 RequireJS 的推廣和普及,AMD 規範才被建立出來。
CMD 和 AMD 同樣,都是 JS 的模塊化規範,也主要應用於瀏覽器端。
AMD 是 RequireJS 在的推廣和普及過程當中被創造出來。
CMD 是 SeaJS 在的推廣和普及過程當中被創造出來。
兩者的的主要區別是 CMD 推崇依賴就近,AMD 推崇依賴前置:
// AMD // 依賴必須一開始就寫好 define(['./utils'], function(utils) { utils.request(); }); // CMD define(function(require) { // 依賴能夠就近書寫 var utils = require('./utils'); utils.request(); });
AMD 也支持依賴就近,但 RequireJS 做者和官方文檔都是優先推薦依賴前置寫法。
考慮到目前主流項目中對 AMD 和 CMD 的使用愈來愈少,你們對 AMD 和 CMD 有大體的認識就好,此處再也不過多贅述。
更多 CMD 規範,請查看文檔。
更多 SeaJS 文檔,請查看文檔。
隨着 ES6 模塊規範的出現,AMD/CMD 終將成爲過去,但毋庸置疑的是,AMD/CMD 的出現,是前端模塊化進程中重要的一步。
前面說了, AMD、CMD 主要用於瀏覽器端,隨着 node 誕生,服務器端的模塊規範 CommonJS 被建立出來。
仍是以上面介紹到的 config.js、utils.js、main.js 爲例,看看 CommonJS 的寫法:
// config.js var api = 'https://github.com/ronffy'; var config = { api: api, }; module.exports = config;
// utils.js var config = require('./config'); var utils = { request() { console.log(config.api); } }; module.exports = utils;
// main.js var utils = require('./utils'); utils.request(); console.log(global.api)
執行node main.js
,https://github.com/ronffy
被打印了出來。
在 main.js 中打印global.api
,打印結果是undefined
。node 用global
管理全局變量,與瀏覽器的window
相似。與瀏覽器不一樣的是,瀏覽器中頂層做用域是全局做用域,在頂層做用域中聲明的變量都是全局變量,而 node 中頂層做用域不是全局做用域,因此在頂層做用域中聲明的變量非全局變量。
咱們在看 node 代碼時,應該會發現,關於接口導出,有的地方使用module.exports
,而有的地方使用exports
,這兩個有什麼區別呢?
CommonJS 規範僅定義了exports
,但exports
存在一些問題(下面會說到),因此module.exports
被創造了出來,它被稱爲 CommonJS2 。
每個文件都是一個模塊,每一個模塊都有一個module
對象,這個module
對象的exports
屬性用來導出接口,外部模塊導入當前模塊時,使用的也是module
對象,這些都是 node 基於 CommonJS2 規範作的處理。
// a.js var s = 'i am ronffy' module.exports = s; console.log(module);
執行node a.js
,看看打印的module
對象:
{ exports: 'i am ronffy', id: '.', // 模塊id filename: '/Users/apple/Desktop/a.js', // 文件路徑名稱 loaded: false, // 模塊是否加載完成 parent: null, // 父級模塊 children: [], // 子級模塊 paths: [ /* ... */ ], // 執行 node a.js 後 node 搜索模塊的路徑 }
其餘模塊導入該模塊時:
// b.js var a = require('./a.js'); // a --> i am ronffy
當在 a.js 裏這樣寫時:
// a.js var s = 'i am ronffy' exports = s;
a.js 模塊的module.exports
是一個空對象。
// b.js var a = require('./a.js'); // a --> {}
把module.exports
和exports
放到「明面」上來寫,可能就更清楚了:
var module = { exports: {} } var exports = module.exports; console.log(module.exports === exports); // true var s = 'i am ronffy' exports = s; // module.exports 不受影響 console.log(module.exports === exports); // false
模塊初始化時,exports
和module.exports
指向同一塊內存,exports
被從新賦值後,就切斷了跟原內存地址的關係。
因此,exports
要這樣使用:
// a.js exports.s = 'i am ronffy'; // b.js var a = require('./a.js'); console.log(a.s); // i am ronffy
CommonJS 和 CommonJS2 常常被混淆概念,通常你們常常提到的 CommonJS 實際上是指 CommonJS2,本文也是如此,不過無論怎樣,你們知曉它們的區別和如何應用就好。
CommonJS 和 AMD 都是運行時加載,換言之:都是在運行時肯定模塊之間的依賴關係。
兩者有何不一樣點:
var a = require('./a.js');
時,在 a.js 文件加載完成後,才執行後面的代碼。AMD 加載模塊是異步的,全部依賴加載完成後以回調函數的形式執行代碼。fs
和chalk
都是模塊,不一樣的是,fs
是 node 內置模塊,chalk
是一個 npm 包。這兩種狀況在 CommonJS 中才有,AMD 不支持。var fs = require('fs'); var chalk = require('chalk');
Universal Module Definition.
存在這麼多模塊規範,若是產出一個模塊給其餘人用,但願支持全局變量的形式,也符合 AMD 規範,還能符合 CommonJS 規範,能這麼全能嗎?
是的,能夠如此全能,UMD 閃亮登場。
UMD 是一種通用模塊定義規範,代碼大概這樣(假如咱們的模塊名稱是 myLibName):
!function (root, factory) { if (typeof exports === 'object' && typeof module === 'object') { // CommonJS2 module.exports = factory() // define.amd 用來判斷項目是否應用 require.js。 // 更多 define.amd 介紹,請[查看文檔](https://github.com/amdjs/amdjs-api/wiki/AMD#defineamd-property-) } else if (typeof define === 'function' && define.amd) { // AMD define([], factory) } else if (typeof exports === 'object') { // CommonJS exports.myLibName = factory() } else { // 全局變量 root.myLibName = factory() } }(window, function () { // 模塊初始化要執行的代碼 });
UMD 解決了 JS 模塊跨模塊規範、跨平臺使用的問題,它是很是好的解決方案。
AMD 、 CMD 等都是在原有JS語法的基礎上二次封裝的一些方法來解決模塊化的方案,ES6 module(在不少地方被簡寫爲 ESM)是語言層面的規範,ES6 module 旨在爲瀏覽器和服務器提供通用的模塊解決方案。長遠來看,將來不管是基於 JS 的 WEB 端,仍是基於 node 的服務器端或桌面應用,模塊規範都會統一使用 ES6 module。
目前,不管是瀏覽器端仍是 node ,都沒有徹底原生支持 ES6 module,若是使用 ES6 module ,可藉助 babel 等編譯器。本文只討論 ES6 module 語法,故不對 babel 或 typescript 等可編譯 ES6 的方式展開討論。
CommonJS 中頂層做用域不是全局做用域,一樣的,ES6 module 中,一個文件就是一個模塊,文件的頂層做用域也不是全局做用域。導出接口使用export
關鍵字,導入接口使用import
關鍵字。
export
導出接口有如下方式:
export const prefix = 'https://github.com'; export const api = `${prefix}/ronffy`;
const prefix = 'https://github.com'; const api = `${prefix}/ronffy`; export { prefix, api, }
方式1和方式2只是寫法不一樣,結果是同樣的,都是把prefix
和api
分別導出。
// foo.js export default function foo() {} // 等同於: function foo() {} export { foo as default }
export default
用來導出模塊默認的接口,它等同於導出一個名爲default
的接口。配合export
使用的as
關鍵字用來在導出接口時爲接口重命名。
export { api } from './config.js'; // 等同於: import { api } from './config.js'; export { api }
若是須要在一個模塊中先導入一個接口,再導出,可使用export ... from 'module'
這樣的簡便寫法。
ES6 module 使用import
導入模塊接口。
導出接口的模塊代碼1:
// config.js const prefix = 'https://github.com'; const api = `${prefix}/ronffy`; export { prefix, api, }
接口已經導出,如何導入呢:
import { api } from './config.js'; // or // 配合`import`使用的`as`關鍵字用來爲導入的接口重命名。 import { api as myApi } from './config.js';
import * as config from './config.js'; const api = config.api;
將 config.js 模塊導出的全部接口都掛載在config
對象上。
// foo.js export const conut = 0; export default function myFoo() {}
// index.js // 默認導入的接口此處刻意命名爲cusFoo,旨在說明該命名可徹底自定義。 import cusFoo, { count } from './foo.js'; // 等同於: import { default as cusFoo, count } from './foo.js';
export default
導出的接口,可使用import name from 'module'
導入。這種方式,使導入默認接口很便捷。
import './config.js';
這樣會加載整個 config.js 模塊,但未導入該模塊的任何接口。
上面介紹了 ES6 module 各類導入接口的方式,但有一種場景未被涵蓋:動態加載模塊。好比用戶點擊某個按鈕後才彈出彈窗,彈窗裏功能涉及的模塊的代碼量比較重,因此這些相關模塊若是在頁面初始化時就加載,實在浪費資源,import()
能夠解決這個問題,從語言層面實現模塊代碼的按需加載。
ES6 module 在處理以上幾種導入模塊接口的方式時都是編譯時處理,因此import
和export
命令只能用在模塊的頂層,如下方式都會報錯:
// 報錯 if (/* ... */) { import { api } from './config.js'; } // 報錯 function foo() { import { api } from './config.js'; } // 報錯 const modulePath = './utils' + '/api.js'; import modulePath;
使用import()
實現按需加載:
function foo() { import('./config.js') .then(({ api }) => { }); } const modulePath = './utils' + '/api.js'; import(modulePath);
特別說明:
該功能的提議目前處於 TC39 流程的第4階段。更多說明,請查看TC39/proposal-dynamic-import。
CommonJS 和 AMD 是運行時加載,在運行時肯定模塊的依賴關係。
ES6 module 是在編譯時(import()
是運行時加載)處理模塊依賴關係,。
CommonJS 在導入模塊時,會加載該模塊,所謂「CommonJS 是運行時加載」,正因代碼在運行完成後生成module.exports
的緣故。固然,CommonJS 對模塊作了緩存處理,某個模塊即便被屢次多處導入,也只加載一次。
// o.js let num = 0; function getNum() { return num; } function setNum(n) { num = n; } console.log('o init'); module.exports = { num, getNum, setNum, }
// a.js const o = require('./o.js'); o.setNum(1);
// b.js const o = require('./o.js'); // 注意:此處只是演示,項目裏不要這樣修改模塊 o.num = 2;
// main.js const o = require('./o.js'); require('./a.js'); console.log('a o.num:', o.num); require('./b.js'); console.log('b o.num:', o.num); console.log('b o.getNum:', o.getNum());
命令行執行node main.js
,打印結果以下:
o init
module.exports
屬性上。a o.num: 0
module.exports
。b o.num: 2
module.exports
。b o.getNum: 1
// o.js let num = 0; function getNum() { return num; } function setNum(n) { num = n; } console.log('o init'); export { num, getNum, setNum, }
// main.js import { num, getNum, setNum } from './o.js'; console.log('o.num:', num); setNum(1); console.log('o.num:', num); console.log('o.getNum:', getNum());
咱們增長一個 index.js 用於在 node 端支持 ES6 module:
// index.js require("@babel/register")({ presets: ["@babel/preset-env"] }); module.exports = require('./main.js')
命令行執行npm install @babel/core @babel/register @babel/preset-env -D
安裝 ES6 相關 npm 包。
命令行執行node index.js
,打印結果以下:
o init
o.num: 0
o.num: 1
import
導入的接口只是值的引用,因此num
纔會有兩次不一樣打印結果。o.getNum: 1
對於打印結果3,知曉其結果,在項目中注意這一點就好。這塊會涉及到「Module Records(模塊記錄)」、「module instance(模快實例)」 「linking(連接)」等諸多概念和原理,你們可查看ES modules: A cartoon deep-dive進行深刻的研究,本文再也不展開。
ES6 module 是編譯時加載(或叫作「靜態加載」),利用這一點,能夠對代碼作不少以前沒法完成的優化:
你們在平常開發中都在使用 CommonJS 和 ES6 module,但不少人只知其然而不知其因此然,甚至不少人對 AMD、CMD、IIFE 等概覽還比較陌生,但願經過本篇文章,你們對 JS 模塊化之路可以有清晰完整的認識。
JS 模塊化之路目前趨於穩定,但確定不會止步於此,讓咱們一塊兒學習,一塊兒進步,一塊兒見證,也但願能有機會爲將來的模塊化規範貢獻本身的一點力量。
本人能力有限,文中可能不免有一些謬誤,歡迎你們幫助改進,文章github地址,我是小賊先生。