做爲前端開發者,你是否也曾有過疑惑,爲何能夠代碼中能夠直接使用 require
方法加載模塊,爲何加載第三方包的時候 Node 會知道選擇哪一個文件做爲入口,以及常被問到的,爲何 ES6 Module export 基礎數據類型的時候會有【引用類型】的效果?html
帶着這些疑問和好奇,但願閱讀這篇文章能解答你的疑惑。前端
在 ES6 以前,ECMAScript 並無提供代碼組織的方式,那時候一般是基於 IIFE 來實現「模塊化」,隨着 JavaScript 在前端大規模的應用,以及服務端 Javascript 的推進,原先瀏覽器端的模塊規範不利於大規模應用。因而早期便有了 CommonJS 規範,其目標是爲了定義模塊,提供通用的模塊組織方式。node
在 Commonjs 中,一個文件就是一個模塊。定義一個模塊導出經過 exports
或者 module.exports
掛載便可。git
exports.count = 1;
複製代碼
導入一個模塊也很簡單,經過 require
對應模塊拿到 exports
對象。github
const counter = require('./counter');
console.log(counter.count);
複製代碼
CommonJS
的模塊主要由原生模塊 module
來實現,這個類上的一些屬性對咱們理解模塊機制有很大幫助。json
Module {
id: '.', // 若是是 mainModule id 固定爲 '.',若是不是則爲模塊絕對路徑
exports: {}, // 模塊最終 exports
filename: '/absolute/path/to/entry.js', // 當前模塊的絕對路徑
loaded: false, // 模塊是否已加載完畢
children: [], // 被該模塊引用的模塊
parent: '', // 第一個引用該模塊的模塊
paths: [ // 模塊的搜索路徑
'/absolute/path/to/node_modules',
'/absolute/path/node_modules',
'/absolute/node_modules',
'/node_modules'
]
}
複製代碼
在編寫 CommonJS 模塊的時候,咱們會使用 require
來加載模塊,使用 exports
來作模塊輸出,還有 module
,__filename
, __dirname
這些變量,爲何它們不須要引入就能使用?segmentfault
緣由是 Node 在解析 JS 模塊時,會先按文本讀取內容,而後將模塊內容進行包裹,在外層裹了一個 function,傳入變量。再經過 vm.runInThisContext
將字符串轉成 Function
造成做用域,避免全局污染。api
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
複製代碼
因而在 CommmonJS 的模塊中能夠不須要 require,直接訪問到這些方法,變量。瀏覽器
參數中的 module
是當前模塊的的 module
實例(儘管這個時候模塊代碼還沒編譯執行),exports
是 module.exports
的別名,最終被 require
的時候是輸出 module.exports
的值。require
最終調用的也是 Module._load
方法。__filename
,__dirname
則分別是當前模塊在系統中的絕對路徑和當前文件夾路徑。緩存
開發者在使用 require 時很是簡單,但實際上爲了兼顧各類寫法,不一樣類型的模塊,node_modules
packages 等模塊的查找過程稍微有點麻煩。
首先,在建立模塊對象時,會有 paths 屬性,其值是由當前文件路徑計算獲得的,從當前目錄一直到系統根目錄的 node_modules
。能夠在模塊中打印 module.paths
看看。
[
'/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules'
]
複製代碼
除此以外,還會查找全局路徑(若是存在的話)
[
execPath/../../lib/node_modules, // 當前 node 執行文件相對路徑下的 lib/node_modules
NODE_PATH, // 全局變量 NODE_PATH
HOME/.node_modules, // HOME 目錄下的 .node_module
HOME/.node_libraries' // HOME 目錄下的 .node-libraries
]
複製代碼
按照官方文檔給出的查找過程已經足夠詳細,這裏只給出大概流程。
從 Y 路徑運行 require(X)
1. 若是 X 是內置模塊(好比 require('http'))
  a. 返回該模塊。
  b. 再也不繼續執行。
2. 若是 X 是以 '/' 開頭、
a. 設置 Y 爲 '/'
3. 若是 X 是以 './' 或 '/' 或 '../' 開頭
a. 依次嘗試加載文件,若是找到則再也不執行
- (Y + X)
- (Y + X).js
- (Y + X).json
- (Y + X).node
b. 依次嘗試加載目錄,若是找到則再也不執行
- (Y + X + package.json 中的 main 字段).js
- (Y + X + package.json 中的 main 字段).json
- (Y + X + package.json 中的 main 字段).node
  c. 拋出 "not found"
4. 遍歷 module paths 查找,若是找到則再也不執行
5. 拋出 "not found"
複製代碼
模塊查找過程會將軟鏈替換爲系統中的真實路徑,例如 lib/foo/node_moduels/bar
軟鏈到 lib/bar
,bar
包中又 require('quux')
,最終運行 foo
module 時,require('quux')
的查找路徑是 lib/bar/node_moduels/quux
而不是 lib/foo/node_moduels/quux
。
當運行 node index.js
時,Node 調用 Module 類上的靜態方法 _load(process.argv[1])
加載這個模塊,並標記爲主模塊,賦值給 process.mainModule
和 require.main
,能夠經過這兩個字段判斷當前模塊是主模塊仍是被 require
進來的。
CommonJS
規範是在代碼運行時同步阻塞性地加載模塊,在執行代碼過程當中遇到 require(X)
時會停下來等待,直到新的模塊加載完成以後再繼續執行接下去的代碼。
雖然說是同步阻塞性,但這一步實際上很是快,和瀏覽器上阻塞性下載、解析、執行 js
文件不是一個級別,硬盤上讀文件比網絡請求快得多。
文件模塊查找挺耗時的,若是每次 require 都須要從新遍歷文件夾查找,性能會比較差;還有在實際開發中,模塊可能包含反作用代碼,例如在模塊頂層執行 addEventListener
,若是 require 過程當中被重複執行屢次可能會出現問題。
CommonJS
中的緩存能夠解決重複查找和重複執行的問題。模塊加載過程當中會以模塊絕對路徑爲 key
, module
對象爲 value
寫入 cache
。在讀取模塊的時候會優先判斷是否已在緩存中,若是在,直接返回 module.exports
;若是不在,則會進入模塊查找的流程,找到模塊以後再寫入 cache
。
// a.js
module.exports = {
foo: 1,
};
// main.js
const a1 = require('./a.js');
a1.foo = 2;
const a2 = require('./a.js');
console.log(a2.foo); // 2
console.log(a1 === a2); // true
複製代碼
以上例子中,require a.js
並修改其中的 foo
屬性,接着再次 require a.js
能夠看到兩次 require
結果是同樣的。
模塊緩存能夠打印 require.cache
進行查看。
{
'/Users/evan/Desktop/demo/main.js':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/evan/Desktop/demo/main.js',
loaded: false,
children: [ [Object] ],
paths:
[ '/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules'
]
},
'/Users/evan/Desktop/demo/a.js':
Module {
id: '/Users/evan/Desktop/demo/a.js',
exports: { foo: 1 },
parent:
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/evan/Desktop/demo/main.js',
loaded: false,
children: [Array],
paths: [Array] },
filename: '/Users/evan/Desktop/demo/a.js',
loaded: true,
children: [],
paths:
[ '/Users/evan/Desktop/demo/node_modules',
'/Users/evan/Desktop/node_modules',
'/Users/evan/node_modules',
'/Users/node_modules',
'/node_modules' ] } }
複製代碼
緩存還解決了循環引用的問題。舉個例子,如今有模塊 a require 模塊 b;而模塊 b 又 require 了模塊 a。
// main.js
const a = require('./a');
console.log('in main, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
// a.js
exports.a1 = true;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.a2 = true;
// b.js
const a = require('./a.js');
console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
複製代碼
程序執行結果以下:
in b, a.a1 = true, a.a2 = undefined
in main, a.a1 = true, a.a2 = true
複製代碼
實際上在模塊 a 代碼執行以前就已經建立了 Module 實例寫入了緩存,此時代碼還沒執行,exports 是個空對象。
'/Users/evan/Desktop/module/a.js':
Module {
exports: {},
//...
}
}
複製代碼
代碼 exports.a1 = true;
修改了 module.exports
上的 a1
爲 true
, 這時候 a2
代碼還沒執行。
'/Users/evan/Desktop/module/a.js':
Module {
exports: {
a1: true
}
//...
}
}
複製代碼
進入 b
模塊,require a.js
時發現緩存上已經存在了,獲取 a
模塊上的 exports
。打印 a1, a2
分別是 true
,和 undefined
。
運行完 b
模塊,繼續執行 a
模塊剩餘的代碼,exports.a2 = true;
又往 exports
對象上增長了 a2
屬性,此時 module a
的 export
對象 a1, a2
均爲 true
。
exports: {
a1: true,
a2: true
}
複製代碼
再回到 main
模塊,因爲 require('./a.js')
獲得的是 module a
export
對象的引用,這時候打印 a1, a2
就都爲 true
。
小結:
CommonJS
模塊加載過程是同步阻塞性地加載,在模塊代碼被運行前就已經寫入了 cache
,同一個模塊被屢次 require
時只會執行一次,重複的 require
獲得的是相同的 exports
引用。
值得留意: cache key
使用的是模塊在系統中的絕對位置,因爲模塊調用位置的不一樣,相同的 require('foo')
代碼並不能保證返回的是統一個對象引用。我以前恰巧就遇到過,兩次 require('egg-core') 可是他們並不相等。
ES6
模塊是前端開發同窗更爲熟悉的方式,使用 import
, export
關鍵字來進行模塊輸入輸出。ES6
再也不是使用閉包和函數封裝的方式進行模塊化,而是從語法層面提供了模塊化的功能。
ES6
模塊中不存在 require
, module.exports
, __filename
等變量,CommonJS
中也不能使用 import
。兩種規範是不兼容的,通常來講平日裏寫的 ES6
模塊代碼最終都會經由 Babel
, Typescript
等工具處理成 CommonJS
代碼。
使用 Node
原生 ES6
模塊須要將 js
文件後綴改爲 mjs
,或者 package.json
"type" 字段改成 "module",經過這種形式告知 Node
使用 ES Module
的形式加載模塊。
ES6 模塊的加載過程分爲三步:
ES6 模塊會在程序開始前先根據模塊關係查找到全部模塊,生成一個無環關係圖,並將全部模塊實例都建立好,這種方式自然地避免了循環引用的問題,固然也有模塊加載緩存,重複 import 同一個模塊,只會執行一次代碼。
這一步完成的工做是 living binding import export
,藉助下面的例子來幫助理解。
// counter.js
let count = 1;
function increment () {
count++;
}
module.exports = {
count,
increment
}
// main.js
const counter = require('counter.cjs');
counter.increment();
console.log(counter.count); // 1
複製代碼
上面 CommonJS
的例子執行結果很好理解,修改 count++
修改的是模塊內的基礎數據類型變量,不會改變 exports.count
,因此打印結果認爲 1
。
// counter.mjs
export let count = 1;
export function increment () {
count++;
}
// main.mjs
import { increment, count } from './counter.mjs'
increment();
console.log(count); // 2
複製代碼
從結果上看使用 ES6
模塊的寫法,當 export
的變量被修改時,會影響 import
的結果。這個功能的實現就是 living binding
,具體規範底層如何實現能夠暫時無論,可是知道 living binding
比網上文章描述爲 "ES6 模塊輸出的是值的引用" 更好理解。
更接近 ES6
模塊的 CommonJS
代碼能夠是下面這樣:
exports.counter = 1;
exports.increment = function () {
exports.counter++;
}
複製代碼
到第三步,會基於第一步生成的無環圖進行深度優前後遍歷填值,若是這個過程當中訪問了還沒有初始化完成的空間,會拋出異常。
// a.mjs
export const a1 = true;
import * as b from './b.mjs';
export const a2 = true;
// b.mjs
import { a1, a2 } from './a.mjs'
console.log(a1, a2);
複製代碼
上面的例子會在運行時拋出異常 ReferenceError: Cannot access 'a1' before initialization
。若是改爲 import * as a from 'a.mjs'
能夠看到 a
模塊中 export
的對象已經佔好坑了。
// b.mjs
import * as a from './a.mjs'
console.log(a);
複製代碼
將輸出 { a1: <uninitialized>, a2: <uninitialized> }
能夠看出,ES6 模塊爲 export 的變量預留了空間,不過還沒有賦值。這裏和 CommonJS
不同,CommonJS
到這裏是知道 a1
爲 true
, a2
爲 undefined
除此以外,咱們還能推導出一些 ES6 模塊和 CommonJS
的差別點:
CommonJS
能夠在運行時使用變量進行 require, 例如 require(path.join('xxxx', 'xxx.js'))
,而靜態 import
語法(還有動態 import
,返回 Promise
)不行,由於 ES6 模塊會先解析全部模塊再執行代碼。require
會將完整的 exports
對象引入,import
能夠只 import
部分必要的內容,這也是爲何使用 Tree Shaking
時必須使用 ES6 模塊 的寫法。import
另外一個模塊沒有 export
的變量,在代碼執行前就會報錯,而 CommonJS
是在模塊運行時才報錯。前面提到 ES6
模塊和 CommonJS
模塊有很大差別,不能直接混着寫。這和開發中表現是不同的,緣由是開發中寫的 ES6 模塊最終都會被打包工具處理成 CommonJS
模塊,以便兼容更多環境,同時也能和當前社區普通的 CommonJS
模塊融合。
在轉換的過程當中會產生一些困惑,好比說:
__esModule
是什麼?幹嗎用的?使用轉換工具處理 ES6 模塊的時候,常看到打包以後出現 __esModule
屬性,字面意思就是將其標記爲 ES6 Module
。這個變量存在的做用是爲了方便在引用模塊的時候加以處理。
例如 ES6 模塊中的 export default
在轉化成 CommonJS
時會被掛載到 exports['default']
上,當運行 require('./a.js')
時 是不能直接讀取到 default
上的值的,爲了和 ES6 中 import a from './a.js'
的行爲一致,會基於 __esModule
判斷處理。
// a.js
export default 1;
// main.js
import a from './a';
console.log(a);
複製代碼
轉化後
// a.js
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = 1;
// main.js
'use strict';
var _a = require('./a');
var _a2 = _interopRequireDefault(_a);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
console.log(_a2.default);
複製代碼
a
模塊 export defualt
會被轉換成 exports.default = 1;
,這也是平時前端項目開發中使用 require
爲何還經常須要 .default
才能取到目標值的緣由。
接着當運行 import a from './a.js'
時,es module
預期的是返回 export
的內容。工具會將代碼轉換爲 _interopRequireDefault
包裹,在裏面判斷是否爲 esModule
,是的話直接返回,若是是 commonjs
模塊的話則包裹一層 {default: obj}
,最後獲取 a 的值時,也會被裝換成 _a1.default
。