【深度全面】前端JavaScript模塊化規範進化論

image

前言

JavaScript 語言誕生至今,模塊規範化之路曲曲折折。社區前後出現了各類解決方案,包括 AMD、CMD、CommonJS 等,然後 ECMA 組織在 JavaScript 語言標準層面,增長了模塊功能(由於該功能是在 ES2015 版本引入的,因此在下文中將之稱爲 ES6 module)。
今天咱們就來聊聊,爲何會出現這些不一樣的模塊規範,它們在所處的歷史節點解決了哪些問題?html

何謂模塊化?

或根據功能、或根據數據、或根據業務,將一個大程序拆分紅互相依賴的小文件,再用簡單的方式拼裝起來。前端

全局變量

演示項目

爲了更好的理解各個模塊規範,先增長一個簡單的項目用於演示。node

# 項目目錄:
├─ js              # js文件夾
│  ├─ main.js      # 入口
│  ├─ config.js    # 項目配置
│  └─ utils.js     # 工具
└─  index.html     # 頁面html

Window

在刀耕火種的前端原始社會,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>

IIFE

瀏覽器環境下,在全局做用域聲明的變量都是全局變量。全局變量存在命名衝突、佔用內存沒法被回收、代碼可讀性低等諸多問題。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

AMD/CMD

隨着前端業務增重,代碼愈來愈複雜,靠全局變量通訊的方式開始捉襟見肘,前端急需一種更清晰、更簡單的處理代碼依賴的方式,將 JS 模塊化的實現及規範陸續出現,其中被應用較廣的模塊規範有 AMD 和 CMD。

面對一種模塊化方案,咱們首先要了解的是:1. 如何導出接口;2. 如何導入接口。

AMD

異步模塊定義規範(AMD)制定了定義模塊的規則,這樣模塊和模塊的依賴能夠被異步加載。這和瀏覽器的異步加載模塊的環境恰好適應(瀏覽器同步加載模塊會致使性能、可用性、調試和跨域訪問等問題)。

本規範只定義了一個函數define,它是全局變量。

/**
 * @param {string} id 模塊名稱
 * @param {string[]} dependencies 模塊所依賴模塊的數組
 * @param {function} factory 模塊初始化要執行的函數或對象
 * @return {any} 模塊導出的接口
 */
function define(id?, dependencies?, factory): any

RequireJS

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

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 的出現,是前端模塊化進程中重要的一步。

小賊先生-文章原址

CommonJS

前面說了, 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.jshttps://github.com/ronffy被打印了出來。
在 main.js 中打印global.api,打印結果是undefined。node 用global管理全局變量,與瀏覽器的window相似。與瀏覽器不一樣的是,瀏覽器中頂層做用域是全局做用域,在頂層做用域中聲明的變量都是全局變量,而 node 中頂層做用域不是全局做用域,因此在頂層做用域中聲明的變量非全局變量。

module.exports和exports

咱們在看 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.exportsexports放到「明面」上來寫,可能就更清楚了:

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

模塊初始化時,exportsmodule.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

CommonJS 和 AMD 都是運行時加載,換言之:都是在運行時肯定模塊之間的依賴關係。

兩者有何不一樣點:

  1. CommonJS 是服務器端模塊規範,AMD 是瀏覽器端模塊規範。
  2. CommonJS 加載模塊是同步的,即執行var a = require('./a.js');時,在 a.js 文件加載完成後,才執行後面的代碼。AMD 加載模塊是異步的,全部依賴加載完成後以回調函數的形式執行代碼。
  3. [以下代碼]fschalk都是模塊,不一樣的是,fs是 node 內置模塊,chalk是一個 npm 包。這兩種狀況在 CommonJS 中才有,AMD 不支持。
var fs = require('fs');
var chalk = require('chalk');

UMD

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 模塊跨模塊規範、跨平臺使用的問題,它是很是好的解決方案。

小賊先生-文章原址

ES6 module

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導出接口有如下方式:

方式1

export const prefix = 'https://github.com';
export const api = `${prefix}/ronffy`;

方式2

const prefix = 'https://github.com';
const api = `${prefix}/ronffy`;
export {
  prefix,
  api,
}

方式1和方式2只是寫法不一樣,結果是同樣的,都是把prefixapi分別導出。

方式3(默認導出)

// foo.js
export default function foo() {}

// 等同於:
function foo() {}
export {
  foo as default
}

export default用來導出模塊默認的接口,它等同於導出一個名爲default的接口。配合export使用的as關鍵字用來在導出接口時爲接口重命名。

方式4(先導入再導出簡寫)

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,
}

接口已經導出,如何導入呢:

方式1

import { api } from './config.js';

// or
// 配合`import`使用的`as`關鍵字用來爲導入的接口重命名。
import { api as myApi } from './config.js';

方式2(總體導入)

import * as config from './config.js';
const api = config.api;

將 config.js 模塊導出的全部接口都掛載在config對象上。

方式3(默認導出的導入)

// 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'導入。這種方式,使導入默認接口很便捷。

方式4(總體加載)

import './config.js';

這樣會加載整個 config.js 模塊,但未導入該模塊的任何接口。

方式5(動態加載模塊)

上面介紹了 ES6 module 各類導入接口的方式,但有一種場景未被涵蓋:動態加載模塊。好比用戶點擊某個按鈕後才彈出彈窗,彈窗裏功能涉及的模塊的代碼量比較重,因此這些相關模塊若是在頁面初始化時就加載,實在浪費資源,import()能夠解決這個問題,從語言層面實現模塊代碼的按需加載。

ES6 module 在處理以上幾種導入模塊接口的方式時都是編譯時處理,因此importexport命令只能用在模塊的頂層,如下方式都會報錯:

// 報錯
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 和 ES6 module

CommonJS 和 AMD 是運行時加載,在運行時肯定模塊的依賴關係。
ES6 module 是在編譯時(import()是運行時加載)處理模塊依賴關係,。

CommonJS

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,打印結果以下:

  1. o init
    模塊即便被其餘多個模塊導入,也只會加載一次,而且在代碼運行完成後將接口賦值到module.exports屬性上。
  2. a o.num: 0
    模塊在加載完成後,模塊內部的變量變化不會反應到模塊的module.exports
  3. b o.num: 2
    對導入模塊的直接修改會反應到該模塊的module.exports
  4. b o.getNum: 1
    模塊在加載完成後即造成一個閉包。

ES6 module

// 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,打印結果以下:

  1. o init
    模塊即便被其餘多個模塊導入,也只會加載一次。
  2. o.num: 0
  3. o.num: 1
    編譯時肯定模塊依賴的 ES6 module,經過import導入的接口只是值的引用,因此num纔會有兩次不一樣打印結果。
  4. o.getNum: 1

對於打印結果3,知曉其結果,在項目中注意這一點就好。這塊會涉及到「Module Records(模塊記錄)」、「module instance(模快實例)」 「linking(連接)」等諸多概念和原理,你們可查看ES modules: A cartoon deep-dive進行深刻的研究,本文再也不展開。

ES6 module 是編譯時加載(或叫作「靜態加載」),利用這一點,能夠對代碼作不少以前沒法完成的優化:

  1. 在開發階段就能夠作導入和導出模塊相關的代碼檢查。
  2. 結合 Webpack、Babel 等工具能夠在打包階段移除上下文中未引用的代碼(dead-code),這種技術被稱做「tree shaking」,能夠極大的減少代碼體積、縮短程序運行時間、提高程序性能。

後記

你們在平常開發中都在使用 CommonJS 和 ES6 module,但不少人只知其然而不知其因此然,甚至不少人對 AMD、CMD、IIFE 等概覽還比較陌生,但願經過本篇文章,你們對 JS 模塊化之路可以有清晰完整的認識。
JS 模塊化之路目前趨於穩定,但確定不會止步於此,讓咱們一塊兒學習,一塊兒進步,一塊兒見證,也但願能有機會爲將來的模塊化規範貢獻本身的一點力量。
本人能力有限,文中可能不免有一些謬誤,歡迎你們幫助改進,文章github地址,我是小賊先生。

參考

AMD 官方文檔
阮一峯:Module 的加載實現

相關文章
相關標籤/搜索