前端模塊化一——規範詳述
來自專欄百度外賣前端19 人贊了文章
前言
模塊化——這個東西可以說在研發圈子裡,耳熟能詳,家喻戶曉。
都快說爛了,今天為什麼又拿出來講?
作為一個開發經驗剛滿一年的前端,
接觸過es6 的import,nodejs 的require,webpack下的import或者require。那麼他們之間到底有什麼區別?
CommonJS 聽說過,nodejs的模塊化實現?AMD聽說過,也聽說過CMD,還有個ES6 模塊化、webpack 模塊化。
當項目里使用的時候,我們是否真正知道,你用的到底基於哪個規範的模塊化?
最重要的是,當我們require( )或者import的時候,我們要知道底發生了什麼?
文章很長,需要一點耐心,但是關於前端的模塊化的東西從萌芽時代到現在的ES6 、webpack實戰中所有模塊化的東西都有詳盡的講解。
1 為何前端需要模塊化?
ps:基礎比較好的同學可以跳過第一章節
為了徹底弄清楚模塊化這個東西,我們要從最開始模塊化的起源說起。
1.1 無模塊化的原始時代
最開始js只是作為一個腳本語言來使用,做一些簡單的表單校驗,動畫實現等等。
代碼都是這樣的,直接把代碼寫進<script>標籤里,代碼量非常少。
<script> if(true) { ... } else { ... } for(var i=0; i< 100; i++){ ... } document.getElementById(button).onClick = function () { ... }</script>
1.2 代碼量劇增帶來的災難性問題
後來隨著ajax非同步請求的出現,前端能做的事情越來越多,代碼量飛速增長。
也暴露出了一些問題。
全局變數的災難
這個非常好理解,就是大家的代碼都在一個作用域,不同的人定義的變數可能會重複從而產生覆蓋。
//試想彭彭定義了一個變數 name = pengpeng;//後來,丁滿後面又定義了name = dingman;//再後來, 彭彭開始使用他定義的變數if(name === pengpeng){ ...}
這就杯具了。
依賴關係管理的災難
<script type="text/javascript" src="a.js"></script><script type="text/javascript" src="b.js"></script><script type="text/javascript" src="c.js"></script>
如果c依賴了b,b依賴了c,則script引入的順序必須被依賴的放在前面,試想要是有幾十個文件,我們都要弄清楚文件依賴關係然後手動,按順序引入,無疑這是非常痛苦的事情。
1.3 早期的解決方式
(1)閉包
moduleA = function() { var a,b; return { add: function (c){ return a + b + c; }; }}()
這樣function內部的變數就對全局隱藏了,達到了封裝的目的,但是最外層模塊名還是暴露在全局,要是模快越來越多,依然會存在模塊名衝突的問題。
(2)命名空間
Yahoo的YUI早起的做法
app.tools.moduleA.add = function(c){ return app.tools.moduleA.a + c;}
毫無疑問以上兩種方法都不夠優雅。
那麼,模塊化到底需要解決什麼問題提呢?我們先設想一下可能有以下幾點
- 安全的包裝一個模塊的代碼,避免全局污染
- 唯一標識一個模塊
- 優雅的將模塊api暴露出去
- 方便的使用模塊
2 服務端模塊化
Nodejs出現開創了一個新的紀元,使得我們可以使用javascript寫伺服器代碼,對於服務端而言必然是需要模塊化的。
2.1 Nodejs和CommonJS的關係
這裡要說一下Nodejs和CommonJS的關係。
- Nodejs的模塊化能一種成熟的姿態出現離不開CommonJS的規範的影響
- 在伺服器端CommonJS能以一種尋常的姿態寫進各個公司的項目代碼中,離不開Node的優異表現
- Node並非完全按照規範實現,針對模塊規範進行了一定的取捨,同時也增加了少許自身特性
以上三點是摘自朴靈的《深入淺出Nodejs》
2.2 CommonJS規範簡介
CommonJS對模塊的定義非常簡單,主要分為模塊引用,模塊定義和模塊標識3部分
(1)模塊引用
var add = require(./add.js);var config = require(config.js);var http = require(http);
(2)模塊定義
module.exports.add = function () { ...}module.exports = function () { return ...}
可以在一個文件中引入模塊並導出另一個模塊
var add = require(./add.js);module.exports.increment = function () { return add(val, 1);}
大家可能會疑惑,並沒有定義module,require 這兩個屬性是怎麼來的呢??(後面在介紹Nodejs模塊化——模塊編譯部分會給大家詳細介紹,這裡先簡單說一下)。
其實,一個文件代表一個模塊,一個模塊除了自己的函數作用域之外,最外層還有一個模塊作用域,module就是代表這個模塊,exports是module的屬性。require也在這個模塊的上下文中,用來引入外部模塊。
(3)模塊標識
模塊標識就是require( )函數的參數,規範是這樣的:
- 必須是字元串
- 可以是以./ ../開頭的相對路徑
- 可以是絕對路徑
- 可以省略後綴名
CommonJS的模塊規範定義比較簡單,意義在於將類聚的方法和變數等限定在私有的作用域中,同時支持引入和導出將上下游模塊無縫銜接,每個模塊具有獨立的空間,它們互不干擾。
2.3 Nodejs的模塊化實現
Node模塊在實現中並非完全按照CommonJS來,進行了取捨,增加了一些自身的的特性。
Node中一個文件是一個模塊——module
一個模塊就是一個Module的實例
Node中Module構造函數:
function Module(id, parent){ this.id = id; this.exports = {}; this.parent = parent; if(parent && parent.children) { parent.children.push(this); } this.filename = null; this.loaded = false; this.children = [];}//實例化一個模塊 var module = new Module(filename, parent);
其中id 是模塊id,exports是這個模塊要暴露出來的api,parent是父級模塊,loaded表示這個模塊是否載入完成,因為CommonJS是運行時載入,loaded表示文件是否已經執行完畢返回一個對象。
2.4 Node模塊分類
如圖所示Node模塊一般分為兩種核心模塊和文件模塊。
核心模塊——就是Node內置的模塊比如http, path等。在Node的源碼的編譯時,核心模塊就一起被編譯進了二進位執行文件,部分核心模塊(內建模塊)被直接載入進內存中。
在Node模塊的引入過程中,一般要經過一下三個步驟
- 路徑分析
- 文件定位
- 編譯執行
核心模塊會省略文件定位和編譯執行這兩步,並且在路徑分析中會優先判斷,載入速度比一般模塊更快。
文件模塊——就是外部引入的模塊如node_modules里通過npm安裝的模塊,或者我們項目工程里自己寫的一個js文件或者json文件。
文件模塊引入過程以上三個步驟都要經歷。
2.5 那麼NodeJS require的時候是怎麼路徑分析,文件定位並且編譯執行的?
2.5.1 路徑分析
前面已經說過,不論核心模塊還是文件模塊都需要經歷路徑分析這一步,當我們require一個模塊的時候,Node是怎麼區分是核心模塊還是文件模塊,並且進行查找定位呢?
Node支持如下幾種形式的模塊標識符,來引入模塊:
//核心模塊require(http)----------------------------//文件模塊//以.開頭的相對路徑,(可以不帶擴展名)require(./a.js) //以..開頭的相對路徑,(可以不帶擴展名)require(../b.js)//以/開始的絕對路徑,(可以不帶擴展名)require(/c.js)//外部模塊名稱require(express)//外部模塊某一個文件require(codemirror/addon/merge/merge.js);
那麼對於這個都是字元串的引入方式,
- Node 會優先去內存中查找匹配核心模塊,如果匹配成功便不會再繼續查找
(1)比如require http 模塊的時候,會優先從核心模塊里去成功匹配
- 如果核心模塊沒有匹配成功,便歸類為文件模塊
(2) 以.、..和/開頭的標識符,require都會根據當前文件路徑將這個相對路徑或者絕對路徑轉化為真實路徑,也就是我們平時最常見的一種路徑解析
(3)非路徑形式的文件模塊 如上面的express 和codemirror/addon/merge/merge.js,這種模塊是一種特殊的文件模塊,一般稱為自定義模塊。
自定義模塊的查找最費時,因為對於自定義模塊有一個模塊路徑,Node會根據這個模塊路徑依次遞歸查找。
模塊路徑——Node的模塊路徑是一個數組,模塊路徑存放在module.paths屬性上。
我們可以找一個基於npm或者yarn管理項目,在根目錄下創建一個test.js文件,內容為console.log(module.paths),如下:
//test.jsconsole.log(module.paths);
然後在根目錄下用Node執行
node test.js
可以看到我們已經將模塊路徑列印出來。
可以看到模塊路徑的生成規則如下:
- 當前路文件下的node_modules目錄
- 父目錄下的node_modules目錄
- 父目錄的父目錄下的node_modules目錄
- 沿路徑向上逐級遞歸,直到根目錄下的node_modules目錄
對於自定義文件比如express,就會根據模塊路徑依次遞歸查找。
在查找同時並進行文件定位。
2.5.2 文件定位
- 擴展名分析
我們在使用require的時候有時候會省略擴展名,那麼Node怎麼定位到具體的文件呢?
這種情況下,Node會依次按照.js、.json、.node的次序一次匹配。(.node是C++擴展文件編譯之後生成的文件)
若擴展名匹配失敗,則會將其當成一個包來處理,我這裡直接理解為npm包
- 包處理
對於包Node會首先在當前包目錄下查找package.json(CommonJS包規範)通過JSON.parse( )解析出包描述對象,根據main屬性指定的入口文件名進行下一步定位。
如果文件缺少擴展名,將根據擴展名分析規則定位。
若main指定文件名錯誤或者壓根沒有package.json,Node會將包目錄下的index當做默認文件名。
再依次匹配index.js、index.json、index.node。
若以上步驟都沒有定位成功將,進入下一個模塊路徑——父目錄下的node_modules目錄下查找,直到查找到根目錄下的node_modules,若都沒有定位到,將拋出查找失敗的異常。
2.5.3 模塊編譯
- .js文件——通過fs模塊同步讀取文件後編譯執行
- .node文件——用C/C++編寫的擴展文件,通過dlopen( )方法載入最後編譯生成的文件。
- .json——通過fs模塊同步讀取文件後,用JSON.parse( ) 解析返回結果。
- 其餘擴展名文件。它們都是被當做.js文件載入。
每一個編譯成功的文件都會將其文件路徑作為索引緩存在Module._cache對象上,以提高二次引入的性能。
這裡我們只講解一下JavaScript模塊的編譯過程,以解答前面所說的CommonJS模塊中的require、exports、module變數的來源。
我們還知道Node的每個模塊中都有__filename、__dirname 這兩個變數,是怎麼來的的呢?
其實JavaScript模塊在編譯過程中,Node對獲取的JavaScript文件內容進行了頭部和尾部的包裝。在頭部添加了(function (exports, require, module,__filename, __dirname){
,而在尾部添加了
}); 。
因此一個JS模塊經過編譯之後會被包裝成下面的樣子:
(function(exports, require, module, __filename, __dirname){ var express = require(express) ; exports.method = function (params){ ... };});
3、前端模塊化
前面我們所說的CommonJS規範,都是基於node來說的,所以前面說的CommonJS都是針對服務端的實現。
3.1 前端模塊化和服務端模塊化有什麼區別?
- 服務端載入一個模塊,直接就從硬碟或者內存中讀取了,消耗時間可以忽略不計
- 瀏覽器需要從服務端下載這個文件,所以說如果用CommonJS的require方式載入模塊,需要等代碼模塊下載完畢,並運行之後才能得到所需要的API。
3.2 為什麼CommonJS不適用於前端模塊?
如果我們在某個代碼模塊里使用CommonJS的方法require了一個模塊,而這個模塊需要通過http請求從伺服器去取,如果網速很慢,而CommonJS又是同步的,所以將阻塞後面代碼的執行,從而阻塞瀏覽器渲染頁面,使得頁面出現假死狀態。
因此後面AMD規範隨著RequireJS的推廣被提出,非同步模塊載入,不阻塞後面代碼執行的模塊引入方式,就是解決了前端模塊非同步模塊載入的問題。
3.3 AMD(Asynchronous Module Definition) & RequireJS
AMD——非同步模塊載入規範 與CommonJS的主要區別就是非同步模塊載入,就是模塊載入過程中即使require的模塊還沒有獲取到,也不會影響後面代碼的執行。
RequireJS——AMD規範的實現。其實也可以說AMD是RequireJS在推廣過程中對模塊定義的規範化產出。
模塊定義:
(1)獨立模塊的定義——不依賴其它模塊的模塊定義
//獨立模塊定義define({ method1: function() {} method2: function() {}}); //或者define(function(){ return { method1: function() {}, method2: function() {}, }});
(2)非獨立模塊——依賴其他模塊的模塊定義
define([math, graph], function(math, graph){ ...});
模塊引用:
require([a, b], function(a, b){ a.method(); b.method();})
3.4 CommonJS 和AMD的對比:
- CommonJS一般用於服務端,AMD一般用於瀏覽器客戶端
- CommonJS和AMD都是運行時載入
3.5 什麼是運行時載入?
我覺得要從兩個點上去理解:
- CommonJS 和AMD模塊都只能在運行時確定模塊之間的依賴關係
- require一個模塊的時候,模塊會先被執行,並返回一個對象,並且這個對象是整體載入的
//CommonJS 模塊let { basename, dirname, parse } = require(path);//等價於let _path = require(path);let basename = _path.basename, dirname = _path.dirname, parse = _path.parse;
上面代碼實質是整體載入path模塊,即載入了path所有方法,生成一個對象,然後再從這個對象上面讀取3個方法。這種載入就稱為"運行時載入"。
再看下面一個AMD的例子:
//a.jsdefine(function(){ console.log(a.js執行); return { hello: function(){ console.log(hello, a.js); } }});//b.jsrequire([a], function(a){ console.log(b.js 執行); a.hello(); $(#b).click(function(){ b.hello(); });});
運行b.js時得到結果:
//a.js執行//b.js執行//hello, a.js
可以看到當運行b.js時,因為b.js require a.js模塊的時候後a.js模塊會先執行。驗證了前面所說的"require一個模塊的時候,模塊會先被執行"。
3.6 CMD(Common Module Definition) & SeaJS
CMD——通用模塊規範,由國內的玉伯提出。
SeaJS——CMD的實現,其實也可以說CMD是SeaJS在推廣過程中對模塊定義的規範化產出。
與AMD規範的主要區別在於定義模塊和依賴引入的部分。AMD需要在聲明模塊的時候指定所有的依賴,通過形參傳遞依賴到模塊內容中:
define([dep1, dep2], function(dep1, dep2){ return function(){};})
與AMD模塊規範相比,CMD模塊更接近於Node對CommonJS規範的定義:
define(factory);
在依賴示例部分,CMD支持動態引入,require、exports和module通過形參傳遞給模塊,在需要依賴模塊時,隨時調用require( )引入即可,示例如下:
define(function(require, exports, module){ //依賴模塊a var a = require(./a); //調用模塊a的方法 a.method();})
也就是說與AMD相比,CMD推崇依賴就近, AMD推崇依賴前置。
3.7 UMD(Universal Module Definition) 通用模塊規範
如下是codemirror模塊lib/codemirror.js模塊的定義方式:
(function (global, factory) { typeof exports === object && typeof module !== undefined ? module.exports = factory() // Node , CommonJS : typeof define === function && define.amd ? define(factory) //AMD CMD : (global.CodeMirror = factory()); //模塊掛載到全局}(this, (function () { ...})
可以看說所謂的兼容模式是將幾種常見模塊定義方式都兼容處理。
目前為止,前端常用的幾種模塊化規範都已經提到,還有一種我們項目里用得非常多的模塊化引入和導出,就是ES6的模塊化。
3.8 ES6模塊
如前面所述,CommonJS和AMD都是運行時載入。ES6在語言規格層面上實現了模塊功能,是編譯時載入,完全可以取代現有的CommonJS和AMD規範,可以成為瀏覽器和伺服器通用的模塊解決方案。這裡關於ES6模塊我們項目里使用非常多,所以詳細講解。
ES6模塊使用——export
(1)導出一個變數
export var name = pengpeng;
(2)導出一個函數
export function foo(x, y){}
(3)常用導出方式(推薦)
// person.jsconst name = dingman;const age = 18;const addr = 卡爾斯特森林;export { firstName, lastName, year };
(4)As用法
const s = 1;export { s as t, s as m, }
可以利用as將模塊輸出多次。
ES6模塊使用——import
(1)一般用法
import { name, age } from ./person.js;
(2)As用法
import { name as personName } from ./person.js;
import命令具有提升效果,會提升到整個模塊的頭部,首先執行,如下也不會報錯:
getName();import { getName } from person_module;
(3)整體模塊載入 *
//person.jsexport name = xixi;export age = 23;//逐一載入import { age, name } from ./person.js;//整體載入import * as person from ./person.js;console.log(person.name);console.log(person.age);
ES6模塊使用——export default
其實export default,在項目里用的非常多,一般一個Vue組件或者React組件我們都是使用export default命令,需要注意的是使用export default命令時,import是不需要加{}的。而不使用export default時,import是必須加{},示例如下:
//person.jsexport function getName() { ...}//my_moduleimport {getName} from ./person.js;-----------------對比---------------------//person.jsexport default function getName(){ ...}//my_moduleimport getName from ./person.js;
export default其實是導出一個叫做default的變數,所以其後面不能跟變數聲明語句。
//錯誤export default var a = 1;
值得注意的是我們可以同時使用export 和export default
//person.jsexport name = dingman;export default function getName(){ ...}//my_moduleimport getName, { name } from ./person.js;
前面一直提到,CommonJS是運行時載入,ES6時編譯時載入,那麼兩個有什麼本質的區別呢?
3.9 ES6模塊與CommonJS模塊載入區別
ES6模塊的設計思想,是盡量的靜態化,使得編譯時就能確定模塊的依賴關係,以及輸入和輸出的變數。所以說ES6是編譯時載入,不同於CommonJS的運行時載入(實際載入的是一整個對象),ES6模塊不是對象,而是通過export命令顯式指定輸出的代碼,輸入時也採用靜態命令的形式:
//ES6模塊import { basename, dirname, parse } from path;//CommonJS模塊let { basename, dirname, parse } = require(path);
以上這種寫法與CommonJS的模塊載入有什麼不同?
- 當require path模塊時,其實 CommonJS會將path模塊運行一遍,並返回一個對象,並將這個對象緩存起來,這個對象包含path這個模塊的所有API。以後無論多少次載入這個模塊都是取這個緩存的值,也就是第一次運行的結果,除非手動清除。
- ES6會從path模塊只載入3個方法,其他不會載入,這就是編譯時載入。ES6可以在編譯時就完成模塊載入,當ES6遇到import時,不會像CommonJS一樣去執行模塊,而是生成一個動態的只讀引用,當真正需要的時候再到模塊里去取值,所以ES6模塊是動態引用,並且不會緩存值。
因為CommonJS模塊輸出的是值的拷貝,所以當模塊內值變化時,不會影響到輸出的值。基於Node做以下嘗試:
//person.jsvar age = 18;module.exports ={ age: age, addAge: function () { age++; }} //my_modulevar person = require(./person.js);console.log(person.age);person.addAge();console.log(person.age);//輸出結果1818
可以看到內部age的變化並不會影響person.age的值,這是因為person.age的值始終是第一次運行時的結果的拷貝。
再看ES6
//person.jsexport let age = 18;export function addAge(){ age++;}//my_moduleimport { age, addAge } from ./person.js;console.log(age);addAge();console.log(age);//輸出結果1819
總結
前端模塊化規範包括CommonJS/ AMD/CMD/ES6模塊化,平時我們可能只知其中一種但不能全面了解他們的發展歷史、用法和區別,以及當我們使用require 和import的時候到底發生了什麼,這篇文章給大家算是比較全面的做了一次總結(我只是搬運工)。
PS: 由於文章太長為了方便閱讀,前端模塊化webpack實際項目講解,放在續篇前端模塊化二——webpack實際項目中的模塊化
參考
1. ECMAScript 6入門
2. 朴靈 (2013) 深入淺出Node.js. 人民郵電出版社, 北京。
3. JavaScript 標準參考教程(alpha)
推薦閱讀:
※員工餐廳廚房管理規範
※新形勢下加強和規範黨內政治生活必須以黨章為根本遵循 舒啟明
※【新標準】靜脈治療護理技術操作規範
※規範刑事附帶民事訴訟調解若干實務問題探討