子產品化伴随着前端的發展,從無到有,從“僞”到“真”,再到後來的有成熟體系和規範并且适用于浏覽器環境下的子產品化。讓我們來看看子產品化到底經曆了什麼。
什麼是子產品化?為什麼需要子產品化?
在最初的前端,js 隻負責比較簡單的互動,代碼量非常有限,我們将所有代碼都混在一起。但是随着前端技術的發展,js 可以做的事情也越來越多,這就導緻 js 代碼量激增。
這時對于一個複雜的應用程式,與其将所有代碼一股腦地放在一個檔案當中,不如按照一定的文法,遵循特定的規範将一個龐大的檔案拆分為幾個獨立的檔案。
這些檔案應該具有互相獨立和功能邏輯單一的特性,對外暴露資料或接口,在需要的時候再進行導入或引用。這就是子產品化的概念。
前端子產品化發展主要經曆了三個階段:
- 早期“僞”子產品化時代;
- 多種多種規範标準時代;
- 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
,主要有兩個原因:
- 使
又全局變量變成局部變量,當内部代碼通路
window
對象時,不用順着作用域鍊逐級查找,可以更快的通路
window
;
window
- 為了壓縮代碼時更好的優化;
另外傳入 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 詳解
- module.exports:
屬性表示目前子產品對外輸出的接口,當其他檔案加載該子產品,實際上就是讀取module.exports
這個屬性;module.exports
-
exports
node 為每一個子產品提供了一個
對象 ,這個exports
對象的引用指向exports
。這相當于隐式的聲明module.exports
var exports = module.exports;
。
如此一來,在對外輸出時,可以在這個變量上添加屬性方法。
例如:
注意:不能把exports.test = function () { // ... };
直接指向一個值(exports
方式指派),這樣會改變exports = xxx
的引用位址,相當于切斷了exports
和exports
的關系。module.exports
總結下 module.exports 和 exports 的差別就是:
-
,exports = module.exports = {}
是exports
的一個引用module.exports
-
引用子產品後,傳回給調用者的是require
而不是module.exports
exports
;
3.
的方式更新屬性,相當于修改了exports.xxx
,那麼該屬性對調用子產品可見;module.exports
-
的方式相當于給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
導出!
原因很簡單:
- 其一
導出整體對象,不利于 tree shaking;export default
- 其二
導出的結果可以随意命名,不利于代碼管理;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項目的首選。