天天看點

JS子產品化淺談【CommonJS、AMD、CMD、UMD、ESM】

子產品化伴随着前端的發展,從無到有,從“僞”到“真”,再到後來的有成熟體系和規範并且适用于浏覽器環境下的子產品化。讓我們來看看子產品化到底經曆了什麼。

什麼是子產品化?為什麼需要子產品化?

在最初的前端,js 隻負責比較簡單的互動,代碼量非常有限,我們将所有代碼都混在一起。但是随着前端技術的發展,js 可以做的事情也越來越多,這就導緻 js 代碼量激增。

這時對于一個複雜的應用程式,與其将所有代碼一股腦地放在一個檔案當中,不如按照一定的文法,遵循特定的規範将一個龐大的檔案拆分為幾個獨立的檔案。

這些檔案應該具有互相獨立和功能邏輯單一的特性,對外暴露資料或接口,在需要的時候再進行導入或引用。這就是子產品化的概念。

前端子產品化發展主要經曆了三個階段:

  1. 早期“僞”子產品化時代;
  2. 多種多種規範标準時代;
  3. ES 原生時代。

“僞”子產品化時代

借助函數作用域來模拟實作“僞”子產品化,我稱其為函數模式,即将不同功能封裝成不同的函數:

function fn1() {
  //...
}
function fn2() {
  //...
}
           

其實這樣的方式根本連“僞”都不算,各個函數在同一個檔案中,混亂地互相調用,而且存在命名沖突和變量污染的問題,緻命的缺點讓開發者很快就将其抛棄。

很快就出現了第二種方式,姑且稱它為對象模式,即利用對象實作“僞”子產品化:

const module1 = {
  data1: "data1",
  fn1: function () {
    //...
  },
};

const module2 = {
  data2: "data2",
  fn2: function () {
    //...
  },
};
           

這種方式稍微有了那麼一點子產品的雛形,可是這樣的方式也帶來一個大的問題,資料安全性非常低,對象内部成員可以随意被改寫。

如:

module2.data2 = "data1";
           

資料被随意改寫會造成很多的問題,首先就是極容易造成 bug,勤勞的前端開發者怎麼會任由 bug 橫行呢。

在之前關于閉包的文章裡有這樣一句話“閉包簡直就是為解決資料通路性問題而生的”。

我們通過立即執行函數構造一個私有的作用域,再通過閉包的特性,将需要對外暴露的資料和接口輸出。

代碼如下:

(function (window) {
  var data = "data";

  function showData() {
    console.log(`data is ${data}`);
  }
  function updateData() {
    data = "newData";
    console.log(`data is ${data} `);
  }
  window.module1 = { showData, updateData };
})(window);
           

這樣的實作,資料

data

完全做到了私有和獨立,不會受到外界任何變量的幹擾,外界無法随意修改

data

值,

隻能通過調用子產品

module1

暴露給外界(

window

)的函數修改

data

值。

module1.showData(); // data is data
           

修改 data 值的途徑,也隻能由子產品 module1 提供:

module1.updateData(); // data is newData
           

jQuery

庫也是如此方式實作的。

其實

jQuery

的做法就是使用了一個匿名函數形成一個閉包,然後自執行,所有邏輯都在這個閉包中完成,這樣不會污染全局變量,也無法在其他地方通路閉包内的變量。最後将

jQuery

對象進行暴露,這樣在外部就可以通過

jQuery

或者

$

通路閉包内的其他變量了。

代碼片段如下:

(function (window, undefined) {
  //...
  if (typeof window === "object" && typeof window.document === "object") {
    window.jQuery = window.$ = jQuery;
  }
})(window);
           

很多人(包括我)最開始不能了解為什麼自執行函數要傳入

window

,主要有兩個原因:

  1. 使

    window

    又全局變量變成局部變量,當内部代碼通路

    window

    對象時,不用順着作用域鍊逐級查找,可以更快的通路

    window

  2. 為了壓縮代碼時更好的優化;

另外傳入 undefined 一部分原因是因為壓縮優化,另一部分是由于一些低版本浏覽器的相容需要,不展開說了。

此時,子產品化已經初具規模,已經可以實作一些基礎功能。事實上,這就是現代子產品化方案的基石。

多種規範标準時代 —— CommonJS

Node.js 無疑對前端的發展具有極大的促進作用,其中 CommonJS 子產品化規範更是颠覆了人們對于子產品化的認知:

Node.js應用由子產品(采用的 CommonJS 子產品規範)組成。即一個檔案就是一個子產品,擁有自己獨立的作用域,變量和方法都是存在獨立作用域内。

Node.js 中的 CommonJS 規範在浏覽器端實作依靠的就是

module.exports

require

方法。

CommonJS 規範規定,每個子產品内部,

module

變量代表目前子產品。這個變量是一個對象,它的

exports

屬性(即

module.exports

)是對外的接口。

加載某個子產品,其實是加載該子產品的

module.exports

屬性。使用

require

方法加載子產品。

CommonJS 子產品的特點如下:

  • 所有代碼都運作在子產品作用域内,不會污染全局作用域;
  • 子產品加載的順序,按照其在代碼中引入的順序;
  • 子產品可以多次加載,但是隻會在第一次加載時運作一次,然後運作結果會被緩存,之後不論加載幾次,都會直接讀取緩存。清除緩存後方可再次運作;
  • module.exports

    屬性輸出的是值的拷貝,一旦輸出操作完成,子產品内發生的任何變化不會影響到已輸出的值;
  • 注意

    module.exports

    exports

    的用法以及差別;

module.exports && exports 詳解

  1. module.exports:

    module.exports

    屬性表示目前子產品對外輸出的接口,當其他檔案加載該子產品,實際上就是讀取

    module.exports

    這個屬性;
  2. exports

    node 為每一個子產品提供了一個

    exports

    對象 ,這個

    exports

    對象的引用指向

    module.exports

    。這相當于隐式的聲明

    var exports = module.exports;

    如此一來,在對外輸出時,可以在這個變量上添加屬性方法。

    例如:

    exports.test = function () { // ... };

    注意:不能把

    exports

    直接指向一個值(

    exports = xxx

    方式指派),這樣會改變

    exports

    的引用位址,相當于切斷了

    exports

    module.exports

    的關系。

總結下 module.exports 和 exports 的差別就是:

  1. exports = module.exports = {}

    exports

    module.exports

    的一個引用
  2. require

    引用子產品後,傳回給調用者的是

    module.exports

    而不是

    exports

    3.

    exports.xxx

    的方式更新屬性,相當于修改了

    module.exports

    ,那麼該屬性對調用子產品可見;
  3. exprots = xxx

    的方式相當于給

    exports

    重新指派,改變引用,失去了之前的

    module.exports

    引用,該屬性對調用子產品不可見;

如果你還是分不清,那麼就使用

module.exports

多種規範标準時代 —— AMD

AMD 規範,全稱為:Asynchronous Module Definition。存在即合理,從 Node.js 搬過來的 CommonJS 已經可以幫助前端實作子產品化了,那 AMD 存在的意義又是什麼呢?

這還要從 Node.js 自身說起,Node.js 運作于伺服器端,檔案都存在本地磁盤中,不需要去發起網絡請求異步加載,是以 CommonJS 規範加載子產品是同步的,對于 Node.js 來說自然沒有問題,但是應用到浏覽器環境中就顯然不太合适了。 AMD 規範就是解決這一問題的。

AMD 不同于 CommonJS 規範,是異步的,可以說是專為浏覽器環境定制的。AMD 規範中定義了如何建立子產品、如何輸出、如何導入依賴。

更加友好的是,require.js 庫為我們準備好了一切,我們隻需要通過

define

方法,定義為子產品;再通過

require

方法,加載子產品。

因為是異步的,子產品的加載不影響它後面語句的運作。所有依賴這個子產品的語句,都定義在一個回調函數中,等到加載完成之後,這個回調函數才會運作。

define 定義子產品

define 方法的第一個參數可以注入一些依賴的其他子產品,如 jQuery 等

define([], function () {
  // 子產品可以直接傳回函數,也可傳回對象
  return {
    fn() {
      // ...
    },
  };
});
           

AMD 規範也采用 require 方法加載子產品

但是不同于 CommonJS 規範,它要求兩個參數:

第一個參數就是要加載的子產品的數組集合,第二個參數就是加載成功後的回調函數。

有精力的同學可以看看 require.js 的源碼。

從源碼中可以看到,require.js 在全局定義了

define

require

。并且在最外層包裹的是一個自執行函數,将

global

,

setTimeout

傳入其中。

以下為截取

define

方法内的一小段代碼:

if (!deps && isFunction(callback)) {
  deps = [];

  if (callback.length) {
    callback
      .toString()
      .replace(commentRegExp, commentReplace)
      .replace(cjsRequireRegExp, function (match, dep) {
        deps.push(dep);
      });

    deps = (callback.length === 1
      ? ["require"]
      : ["require", "exports", "module"]
    ).concat(deps);
  }
}
           

define

方法内部可以大緻了解為對依賴的收集,

deps.push(dep)

require

的主要作用是根據依賴建立 script 标簽,請求子產品,對子產品進行加載和執行。值得注意的是所有子產品在加載完成後都會執行

removeScript

方法。

該方法會将加載完成後的 script 标簽移除,這也就是為什麼

require

中生成 script 标簽加載子產品,但是在代碼中并沒有出現這些标簽,奧秘就在

removeScript

中。

require.js 的源碼非常繞,推薦有一些源碼閱讀經驗的同學再嘗試閱讀。

多種規範标準時代 —— CMD

CMD 規範全稱為:Common Module Definition,綜合了 CommonJS 和 AMD 規範的特點,推崇 as lazy as possible。代表庫為 sea.js 。

CMD 規範和 CMD 規範不同之處:

  • AMD 需要異步加載子產品,而 CMD 可以同步可以異步;
  • CMD 推崇依賴就近,AMD 推崇依賴前置。

多種規範标準時代 —— UMD

UMD 叫做通用子產品定義規範(Universal Module Definition)。

它可以通過運作編譯時讓同一個代碼子產品在使用 CommonJs、CMD 甚至是 AMD 的項目中運作。

這樣就使得 JavaScript 包運作在浏覽器端、服務區端甚至是 APP 端都隻需要遵守同一個寫法就行了。

他的規範就是綜合其他的規範,沒有自己專有得規範。

代碼如下:

(function (root, factory) {
  if (typeof define === "function" && define.amd) {
    // AMD 規範
    define(["b"], factory);
  } else if (typeof module === "object" && module.exports) {
    // 類 Node 環境,并不支援完全嚴格的 CommonJS 規範
    // 但是屬于 CommonJS-like 環境,支援 module.exports 用法
    module.exports = factory(require("b"));
  } else {
    // 浏覽器環境
    root.returnExports = factory(root.b);
  }
})(this, function (b) {
  // 傳回值作為 export 内容
  return {};
});
           

在定義子產品得時候會檢測目前得環境,将不同的子產品定義方式轉換為同一種寫法。

ES 原生子產品化

ES 子產品化最大的兩個特點是:

1.ES 子產品化規範中子產品輸出的是值的引用

複習下 CommonJS 規範下的使用:

module1.js 中:

var data = "data";
function updateData() {
  data = "newData";
}

module.exports = {
  data: data,
  updateData: updateData,
};
           

index.js 中:

var myData = require("./module1").data;
var updateData = require("./module1").updateData;
console.log(myData); // data
updateData();
console.log(myData); // data
           

因為 CommonJS 規範下,輸出的值隻是拷貝,通過

updateData

方法改變了子產品内的

data

的值,但是

data

myData

并沒有任何關聯,隻是一份拷貝,是以子產品内的變量值修改,也就不會影響到修改之前就已經拷貝過來的

myData

啦。

再看 ES 子產品化規範的表現

module1.js:

let data = "data";
function updateData() {
  data = "newData";
}
export { data, updateData };
           

index.js:

import { data, updateData } from "./module1.js";
console.log(data); // data
updateData();
console.log(data); // newData
           

由于 ES 子產品化規範中導出的值是引用,是以不論何時修改子產品中的變量,在外部都會有展現。

2.靜态化,編譯時就确定子產品之間的關系,每個子產品的輸入和輸出變量也是确定的

ES 子產品化設計成靜态的目的何在?

首要目的就是為了實作 tree shaking 提升運作性能(下面會簡單說 tree shaking)。

ES 子產品化的靜态特性也帶來了局限:

  • import

    依賴必須在檔案頂部;
  • export

    導出的變量類型嚴格限制;
  • 依賴不可以動态确定。

ES 的

export

export default

要用誰?

ES 子產品化導出有

export

export default

兩種。這裡我們建議減少使用

export default

導出!

原因很簡單:

  • 其一

    export default

    導出整體對象,不利于 tree shaking;
  • 其二

    export default

    導出的結果可以随意命名,不利于代碼管理;

tree shaking

tree shaking 就是通過減少web項目中 JavaScript 的無用代碼,以達到減少使用者打開頁面所需的等待時間,來增強使用者體驗。對于消除無用代碼,并不是 JavaScript 專利,事實上業界對于該項操作有一個名字,叫做 DCE(dead code elemination) ,然而與其說 tree shaking 是 DCE 的一種實作,不如說 tree shaking 從另外一個思路達到了DCE的目的。

無用代碼的減少意味着更小的代碼體積,縮減 bundle size,進而獲得更好的使用者體驗。

如何實作 tree shaking?

兩個先決條件:

  • 首先既然要實作的是減少浏覽器下載下傳的資源大小,是以要 tree shaking 的環境必然不能是浏覽器,一般宿主環境是 Node;
  • 其次,如果 JavaScript 是子產品化的,那麼必須遵從的是 ES 子產品化規範,原因上面已經提到過了。

另外需要注意的是,對于單個檔案和子產品化來說 webpack 要實作 tree-shaking 必須依賴 uglifyJs。這裡就不展開過多的闡述了,想了解更多内容可以閱讀這篇文章《Tree-Shaking性能優化實踐 - 原理篇》

目前各大浏覽器早已在新版本中支援 ES 子產品化了。如果我們想在浏覽器中使用原生 ES 子產品方案,隻需要在 script 标簽上添加一個

type="module"

屬性。通過該屬性,浏覽器知道這個檔案是以子產品化的方式運作的。

<script type="module">
    import module1 from './module1'
</script>
           

而對于不支援的浏覽器,需要通過 nomodule 屬性來指定某腳本為 fallback 方案:

<script nomodule>
        alert('你的浏覽器不支援 ES Module,請先更新!')
</script>
           

Node 也從 9.0 版本開始支援 ES 子產品,可見 ES 子產品化由于它的開箱即用的 tree shaking 和未來浏覽器相容性支援等優點,已經漸漸成為web項目的首選。

繼續閱讀