Nodejs模塊加載與ES6模塊加載實現

原始時代

做爲一門語言的引入代碼方式,相較於其餘如PHP的include和require,Ruby的require,Python的import機制,Javascript是直接使用 <script> 標籤。
由於Javascript是一門單線程語言,GUI渲染線程和Javascript引擎線程是互斥的,代碼執行 <script> 標籤GUI渲染線程會掛起,而後下載資源,執行腳本,完成以後再繼續往下執行。在那段時間內界面是不會響應用戶操做的。用戶體驗至關不友好。同時還帶來一系列的隱患:javascript

  • 引入順序可能會引發代碼無效甚至報錯;
  • 互不瞭解的代碼也許會形成重複命名覆蓋;
  • 難以串聯代碼之間邏輯關係;
  • 執行順序受影響的因素更多;
  • 不易管理維護;

<script> 標籤也提供了 defer 和 async 屬性能夠實現異步加載。渲染引擎遇到這一行命令,就會開始下載外部腳本,但不會等它下載和執行,而是直接執行後面的命令,區別在於:html

  • defer: 等到整個頁面在內存中正常渲染結束(DOM結構徹底生成,以及其餘腳本執行完成),纔會執行,能夠保證順序加載;
  • async: 一旦下載完,渲染引擎就會中斷渲染,執行這個腳本之後,再繼續渲染,不保證順序加載;
<script src="xx.js" defer></script>
<script src="xx.js" async></script>

出於須要社區制定了一些模塊加載方案,最主要的有 CommonJS 和 AMD 兩種。前者用於服務器,後者用於瀏覽器。java

CommonJS規範

CommonJS規範爲Javascript制定的美好願景是但願Javascript可以在任何地方運行,具有跨宿主環境執行的能力,例如:node

  • 富客戶端應用
  • 服務器端Javascript應用程序(如Nodejs )
  • 命令行工具
  • 桌面圖形界面應用程序
  • 混合應用(Titanium和Adobe AIR等形式應用)

這些規範基本覆蓋了模塊,二進制,Buffer,字符集編碼,I/O流,進程環境,文件系統,套接字,單元測試,Web服務器網關接口,包管理等。git

模塊規範

1, 引用
模塊上下文提供 require() 方法引入外部模塊,通常以下es6

var fs = require('fs');

2, 定義
模塊中存在一個上下文 module對象 ,它表明模塊自身, module對象 提供了 exports對象 用於導出當前模塊的變量、函數、類,而且是惟一的導出出口。json

//a.js模塊
exports.a = 1;
//引用a.js模塊
var a = require('a');

3, 標識
require() 方法接受小駝峯命名的字符串,或者相對/絕對路徑,而且能夠省略文件後綴,它有本身一套匹配規則,後面再講。數組

  • "./" 開頭表示相對路徑引用模塊;
  • "/" 開頭表示絕對路徑引用模塊;
  • 不帶上面符號開頭的小駝峯字符串表示默認提供的核心模塊或者 node_modules 下安裝模塊;
  • 不帶上面符號開頭的路徑字符串表示 node_modules 下安裝模塊對應後續路徑;
//a.js模塊
var a = require('./a');
var a = require('/a');
var a = require('a');
var a = require('a/a');

至此看來使用至關簡單,模塊的意義在於將類聚的變量、函數、類等限定在私有做用域中,同時支持引入導出功能鏈接上下游依賴,避免了變量污染等問題。瀏覽器

module.exports 和exports的關係?

exports 是引用 module.exports 的值,而真正導出的是 module.exports ,接着就是基本類型和引用類型的區別。
若是直接替換 module.exports 或者exports至關於切斷了和原有對象之間的關聯,後續二者互不影響了。緩存

CommonJS加載原理

第一次 require() 一個腳本的時候會執行代碼而後在內存中會生成一個模塊對象緩存起來,相似

{
  id: '...',//模塊的識別符,一般是帶有絕對路徑的模塊文件名
  filename: '',//模塊的文件名,帶有絕對路徑
  exports: {...},//導出變量、函數、類
  loaded: true,//模塊是否已經完成加載
  parent: {},//調用該模塊的模塊
  children: [],//該模塊要用到的其餘模塊
  ...
}

例如你建立一個文件腳本代碼執行就能夠查看到這些信息。

exports.a = 1;
console.log(module);
// Module {
//   id: '.',
//   exports: { a: 1 },
//   parent: null,
//
//   filename: 'C:\\project\\test\\module_demo\\test1.js',
//   loaded: false,
//   children: [],
//   paths:
//    [ 'C:\\project\\test\\module_demo\\node_modules',
//      'C:\\project\\test\\node_modules',
//      'C:\\project\\node_modules',
//      'C:\\node_modules' ] }

之後須要引用模塊的變量、函數、類就在這個模塊對象的 exports 取出,即便再次 require() 進來模塊也不會從新執行,只會從緩存獲取。

CommonJS優勢

  • 模塊引用順序決定加載順序;
  • 每一個模塊只會加載一次,而後將運行結果緩存起來二次利用,之後再次加載就直接讀取緩存。要想讓模塊再次運行,必須清除緩存;
  • 每一個模塊都有其單獨的做用域,不會污染全局;

Nodejs 模塊實現

Nodejs 借鋻了 CommonJS 但不徹底按照規範實現了本身的模塊系統。

在Nodejs 引入模塊會經歷三個步驟:

  • 路徑分析;
  • 文件定位;
  • 編譯執行;

在 Nodejs 中有兩種模塊

  • Nodejs 提供的核心模塊;
    這部分模塊在 Nodejs 源代碼編譯過程當中編譯進了二進制執行文件。在 Nodejs 進程啓動時部分核心模塊被直接加載進了內存中,因此在引用的時候能夠省去文件定位和編譯執行的步驟,而且在路徑分析優先判斷,因此加載速度是最快的。
  • 由用戶編寫的文件模塊;
    這部分模塊在運行時動態加載,須要經歷完整步驟。

Nodejs 會對引用過的模塊進行緩存以減小二次引入的開銷。並且緩存的是模塊編譯和執行以後的對象。因此 require() 對相同模塊的再次加載都是優先緩存方式,核心模塊的緩存檢查依然優先於文件模塊。

Nodejs 模塊標識

前面提過的模塊標識,例如:

  • 核心模塊fs等
    優先級僅次於緩存加載,若是直接引用本身編寫的和核心模塊具備相同標識的模塊會引用失敗,必須選擇不一樣標識符或者使用路徑方式加載。
  • 路徑形式文件模塊
    分析過程當中 rerquire() 會將路徑轉換成真實路徑,並以此爲索引將編譯後結果緩存起來,由於指明瞭模塊位置因此查找過程會省點時間,速度慢於核心模塊。
  • 自定義模塊
    多是以包或者文件形式的特殊模塊,查找費時速度最慢的一種,由於他會用到模塊路徑的查找方法。

模塊路徑規則

Nodejs在定位文件模塊有本身的一套查找策略,你能夠隨便一個文件夾執行一個腳本以下看看打印信息,我是 Windows 系統結果以下

console.log(module.paths);
// [ 'C:\\work\\project\\test\\node_modules',
//   'C:\\work\\project\\node_modules',
//   'C:\\work\\node_modules',
//   'C:\\node_modules' ]

從中能夠看出他會從當前執行文件所在目錄下的 node_modules,沿路徑向上逐層遞歸查找 node_modules 直到根目錄爲止。
模塊加載過程會逐個嘗試直到符合條件或者沒有符合爲止,你能夠看出裏面有着很明顯的問題。

  • 層級越深查找起來越費時費力;
  • 可能你衹想查看當前目錄,可是它失敗後會自動嘗試其餘路徑;

這就是自定義模塊最慢的緣由。

文件定位

  • 擴展名分析
    Nodejs 在標識符不包含後綴狀況下會以.js, .json, .node的次序逐個嘗試匹配,並且過程當中須要利用fs模塊以同步阻塞方式去判斷是否匹配,因此在非.js文件狀況指明後綴能減小性能損耗的問題。
  • 目錄分析和包
    還有一種狀況是通過上面步驟以後都匹配不到對應文件可是有符合的目錄,此時Nodejs 會將其做爲一個包的方式處理。
    1)查找包下的 package.json 文件(包描述文件),經過 JSON.parse() 解析出文件讀取裏面的 main 屬性定位對應的文件,省略後綴狀況下須要執行擴展名分析步驟。
    2)若是沒有 package.json 或者 main 屬性不對,會用默認值 index 去查找匹配文件,這一步須要擴展名分析步驟逐個嘗試。
    3)若是仍是失敗就會根據模塊路徑規則往上層路徑尋找,直到所有路徑都沒有匹配文件就拋出失敗。

模塊編譯

這是引入模塊的最後階段,定位到目標文件以後會新建一個模塊對象,而後根據路徑載入進行編譯,不一樣後綴文件載入方式不一樣:

  • js經過fs模塊同步讀取文件以後編譯執行;
  • node是C/C++編寫的擴展文件,經過 dlopen()方法 加載最後編譯生成的對象;
  • json經過fs模塊同步讀取文件以後用 JSON.parse() 解析返回結果;
  • 其他默認js處理方式;

每一個編譯成功以後的模塊都會以其文件路徑做爲索引緩存在 Module_cache。根據不一樣的擴展後綴 Nodejs 有不一樣的讀取方式。

1, Javascript模塊編譯
在編譯過程當中,Nodejs 會對獲取的模塊進行包裝,以下:

(function(exports, require, module, __filename, __dirname) {
    //模塊源碼
})

2, C/C++模塊編譯
Nodejs 調用 process.dlopen() 方法進行加載執行,經過 libuv封裝庫 支持 Windows 和 *nix 平臺下實現,由於.node自己就是C/C++寫的,因此它不須要編譯,衹要加載執行就能夠了,執行效率較高。

3, JSON文件編譯
上面說過經過fs模塊同步讀取文件以後用 JSON.parse() 解析返回結果,賦值給模塊對象的 exports。
除了配置文件,若是你開發中有須要用到json文件的時候能夠不用 fs模塊 去讀取,而是直接 require() 引入更好,由於能享受到緩存加載的便利。

核心模塊

上面說過 Nodejs 模塊分爲核心模塊和文件模塊,剛纔講的都是文件模塊的編譯過程,而 Nodejs 的核心模塊在編譯成可執行文件過程當中會被編譯進二進制文件。核心模塊也分Javascript和C/C++編寫,前者在Node的lib目錄,後者在Node的src目錄。

Javascript核心模塊編譯

轉存爲C/C++代碼

Nodejs 採用V8附帶的 js2c.py工具 將內置的Javascript代碼(src/node.js和lib/*.js)轉成C++的數組,生成 node_natives.h 頭文件,Javascript代碼以字符串形式存儲在nodejs命名空間裏,此時還不能直接執行。等 Nodejs 啓動進程時候才被直接加載進內存中,因此不須要引入就能直接使用。

編譯Javascript核心模塊

和文件模塊同樣也會被包裝成模塊對象,區別在於獲取源代碼的方式以及緩存執行結果的位置。
核心模塊源文件經過 process.binding('natives') 取出,編譯完成後緩存到 NativeModule._cache 對象上,而文件模塊會被緩存到 Module._cache。

C/C++核心模塊編譯(不懂C/C++,這一塊簡短略過)

內建模塊的組織形式

每一個內建模塊在定義以後會經過 NODE_MODULE宏 將模塊定義到nodejs命名空間,模塊的具體初始化方法被掛載在結構的 register_func 成員。
node_extensions.h 文件將散列的內建模塊統一放進 node_module_list數組 中,Nodejs 提供了 get_builtin_module() 方法從中取出。
內建模塊優點在於自己C/C++編寫性能優異,編譯成二進制文件時候被直接加載進內存,無需再作標識符定位,文件定位,編譯等過程。

內建模塊導出

Nodejs 啓動會生成全局變量 process,提供 Binding() 方法協助加載內建模塊。
加載過程當中咱們會先生成 exports空對象 ,而後調用 get_builtin_module() 方法去取內建模塊,經過執行 register_func 填充空對象,最後按模塊名緩存起來並返回給調用方使用。

核心模塊引入流程

圖片描述

模塊調用

至此咱們已經有個大概概念了,梳理一下各類模塊之間的關係:

  • C/C++內建模塊是最底層核心模塊,主要提供API給Javascript核心模塊和第三方Javascript模塊使用;
  • Javascript核心模塊分兩類,一類做爲C/C++內建模塊的封裝層和橋接層,一類純粹的功能模塊;
  • 文件模塊分Javascript模塊和C/C++擴展模塊;

ES6模塊加載

直到ES6標準化模塊功能,統一替代了以前多種模塊實現庫,成爲瀏覽器和服務器通用的模塊解決方案。ES6 模塊的設計思想是儘可能的靜態化,使得編譯時就能肯定模塊的依賴關係,以及輸入和輸出的變量、函數、類。CommonJS 和 AMD 模塊,都只能在運行時肯定這些東西。
ES6 的模塊有幾個須要注意的地方:

  • 自動採用嚴格模式,即便你沒有使用"use strict";
  • 頂層的this指向undefined;
// CommonJS模塊
let {readFile} = require('fs');
// ES6模塊
import {readFile} from 'fs';

以上爲例。
CommonJS加載整個 fs模塊 生成一個模塊對象,而後從對象中導出 readFile方法 。
ES6 模塊經過 import命令 從 fs模塊 加載輸入的變量、函數、類。
結果就是ES6模塊效率高,可是拿不到模塊對象自己。

加載方案 加載 輸出
CommonJS 運行時加載 拷貝
ES6 模塊 編譯時輸出接口 引用

因爲 ES6 模塊是編譯時加載,使得靜態分析成爲可能。好比引入宏(macro)和類型檢驗(type system)這些只能靠靜態分析實現的功能。

ES6 模塊還有如下好處:

  • 再也不須要UMD模塊格式了,未來服務器和瀏覽器都會支持 ES6 模塊格式。目前,經過各類工具庫,其實已經作到了這一點。
  • 未來瀏覽器的新 API 就能用模塊格式提供,再也不必須作成全局變量或者 navigator對象 的屬性。
  • 再也不須要對象做爲命名空間(好比Math對象),將來這些功能能夠經過模塊提供。
  • ES6模塊提供了 export導出命令 和 import導入命令 ,它們一樣具備全局提高的效果,只要在頂層使用便可。

export導出命令

支持輸出變量、函數、類。

//變量
export var a = 1;
//函數
export function log(n) {
  console.log(n);
}
//類
export class Num {}

我習慣寫法是使用對象方式輸出,整個模塊導出什麼一目瞭然。

//變量
var a = 1;
//函數
function log(n) {
  console.log(n);
}
//類
class Num {}

export {a, log, Num};

這種寫法也支持as關鍵字對外重命名

export {a as b, log as cng, Num as Digit};

這裏有一個隱藏比較深的概念性知識,export命令規定的是對外的接口必須與模塊內部的變量、函數、類創建一一對應關係。這種寫法是OK的。

export var a = 1;
//或者
var a = 1;
export {
    a,
    //或者
    a as b,
}

可是你不能這麼寫,儘管看起來沒什麼問題,不過沒有提供對外的接口,只是直接或者間接輸出1。

export  1;
//或者
var a = 1;
export a

特別容易讓人混淆的是這一句,因此要特別注意

//正確
export var a = 1;
//錯誤
var a = 1;
export a

這不只僅是針對變量,包括函數和類也遵循這種寫法,之因此會有這種要求是由於 export語句 輸出的接口,與其對應的值是動態綁定關係,即經過該接口,能夠取到模塊內部實時的值。

export var a = 1
setTimeout(() => a = 2, 3000);
//後續引用a會獲得2

import 導入命令

和expor相對應的按需引入寫法以下

//直接引入寫法
import {a, log, Num} from 'xx';

import也支持使用as關鍵字

import {a as b, log as cng, Num as Digit} from 'xx';

和 export 動態綁定值不一樣,import 是隻讀靜態執行,即你不能修改引用的模塊變量、函數、類等,也不能使用表達式和變量這種運行時才能引入靜態分析階段無法獲得值的寫法。

//修改屬性
import{a} from 'xx'
a = 2//error
//表達式引入
import{'l' + 'og'} from 'xx'
//變量引入
var module = 'xx';
import {} from module//error
//判斷引入
//error
if (true) {
  import {} from 'xx1';
} else {
  import {} from 'xx2';
}

由於屢次引用也只會執行一次,儘管不推薦,可是這種寫法也是能夠的

import {a} from 'xx';
import {log} from 'xx';
//等價於
import {a, log} from 'xx';

import也支持這種寫法,僅僅執行模塊,可是不輸入任何變量、函數、類。

import 'xx';

關鍵字default

export 支持 關鍵字default 設置默認導出的變量、函數、類:
1, 每一個模塊只支持一個關鍵字default默認導出;
2, 可使用函數名或匿名函數導出,即便指定了函數名也不能在模塊外部引用,等同視爲匿名函數加載;

//函數
function log(n) {
    console.log(n);
}
export default log;
//或者
export default function(n) {
    console.log(n);
}
//或者
export default function log(n) {
    console.log(n);
}

其餘模塊加載該模塊時,import命令能夠爲該默認導出函數指定任意名字。

export default function log(n) {
  console.log(n);
}
//加載
import anyName from 'xx';

若是想在一條 import語句 中,同時輸入默認函數和其餘接口,能夠寫成下面這樣。

import log, {a, Num as Digit} from 'xx';

本質上這也只是一種語法糖,與下面寫法等價

export default log;
import log from 'xx';
//==等價==
export { log as default}
import { default as log } from 'xx';

由於default也是變量,因此不能後面再加變量

export default var a = 1;

可是能夠直接輸出

export default 1;
export default a;

關鍵字*

用星號(*)指定一個對象,全部輸出值都加載在這個對象上面。

import * as all from 'xx';
const {a, log, Num} = all;

export 與 import 的複合寫法

這裏提供了兩種寫法,他們之間會有些不一樣。

//引入後導出
import {log} from 'xx';
export {log};
//直接導出
export {log} from 'xx';
//或者
export {log as default} from 'xx';

區別在於第二三種是沒有導入動做,因此不能在該模塊引用對應的變量、函數、類。

須要注意的是下面三種寫法ES6目前還不支持。

export * as all from "xx";
export all from "xx";
export log, {a, Digit as Num} from 'xx';

Nodejs 使用問題

import命令 會被 JavaScript引擎靜態分析,先於模塊內的其餘語句執行,而 Nodejs 的 require() 是運行時加載模塊,import命令沒法取代require的動態加載功能,因此若是在Nodejs 中使用ES6模塊語法要注意這一點。

//成功
var fs = require('f'+'s');
//報錯
import fs from ('f'+'s');

有一個提案,建議引入 import() 函數,完成動態加載,已經有實現方案了,我沒用過就不說了。

參考資料

nodejs 深刻淺出
ES6 標準入門(第3版)

相關文章
相關標籤/搜索