前端模塊化

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模塊一般分為兩種核心模塊和文件模塊。

前端模塊化

圖1 模塊分類

核心模塊——就是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.js
console.log(module.paths);

然後在根目錄下用Node執行

node test.js

可以看到我們已經將模塊路徑打印出來。

前端模塊化

圖2 模塊路徑

可以看到模塊路徑的生成規則如下:

  • 當前路文件下的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){\n,而在尾部添加了\n}); 。

因此一個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.js
define(function(){
console.log('a.js執行');
return {
hello: function(){
console.log('hello, a.js');
}
}
});
//b.js
require(['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.js
const 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.js
export 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.js
export function getName() {
...
}
//my_module
import {getName} from './person.js';
-----------------對比---------------------
//person.js
export default function getName(){
...
}
//my_module
import getName from './person.js';

export default其實是導出一個叫做default的變量,所以其後面不能跟變量聲明語句。

//錯誤 

export default var a = 1;

值得注意的是我們可以同時使用export 和export default

//person.js
export name = 'dingman';
export default function getName(){
...
}
//my_module
import 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.js
var age = 18;
module.exports ={
age: age,
addAge: function () {
age++;
}
}
//my_module
var person = require('./person.js');
console.log(person.age);
person.addAge();
console.log(person.age);
//輸出結果
18
18

可以看到內部age的變化並不會影響person.age的值,這是因為person.age的值始終是第一次運行時的結果的拷貝。

再看ES6

//person.js
export let age = 18;
export function addAge(){
age++;
}
//my_module
import { age, addAge } from './person.js';
console.log(age);
addAge();
console.log(age);
//輸出結果
18
19

總結

前端模塊化規範包括CommonJS/ AMD/CMD/ES6模塊化,平時我們可能只知其中一種但不能全面瞭解他們的發展歷史、用法和區別,以及當我們使用require 和import的時候到底發生了什麼,這篇文章給大家算是比較全面的做了一次總結(我只是搬運工)。


https://zhuanlan.zhihu.com/p/41568986


分享到:


相關文章: