JavaScript相關的模組機制

RAY發表於2018-01-31

前言

java有類檔案,Python有import機制,Ruby有require等,而Javascript 通過<script>標籤引入程式碼的機制顯得雜亂無章,語言自身毫無組織能力,人們不得不用名稱空間的等方式人為的組織程式碼,以求達到安全易用的目的 《深入淺出Nodejs》--樸靈

模組一直以來都是組織大型軟體的必備的要素,就像建築和磚,“磚”的組織規則更是需要最先明確的事情,一直以來JS在語言層面都沒能給模組機制足夠的重視,直到ES6的module的出現彷彿給出了最終解決的方案,但是畢竟ES6的module還沒能得到良好的支援,其中所面臨的複雜情況可想而知,因為業務場景的多樣性導致似乎哪一種模組機制都感覺到了眾口難調,雖然Node8已經對絕大部分的ES6語法提供了非常好的支援,但是要想使用ES6的模組機制還是必須要使用類似babel的轉義工具才能做到並不是那麼“無畏”的使用。本文從最簡單的模組開始,然後主要從Node的模組規範和ES6的模組機制對模組進行梳理。

“模組化”的基本實現

每次在註冊成為某一個網站或者應用的使用者時最讓人心碎的的就是自己常用的使用者名稱已經存在了,很緊張得換了幾個還能接受的使用者名稱發現自己的想法總是很受歡迎,於是即便放著《不將就》也無奈的選擇了在自己的使用者名稱後面加上了自己的生日數字... 這裡也不太方便討論如果加上了生日數字之後,表單校正還是提示你“該使用者名稱已經存在!”的情況,剪網線就完事了。

使用者名稱已存在!

我想表達的意思實際就是,全域性環境下的變數的命名衝突,變數太多難免詞窮情況很常見,所以這一定是模組化給我們帶來的好處,有了模組你就可以繼續用你喜歡的使用者名稱,只不過你得介紹清楚,你是“村口第五家.Ray"

一把梭

無需多言,上圖表達了一切。良好的模組化,是程式碼複用與工程解耦的關鍵,"一把梭"確實爽,講究一個我不管你裡面怎麼翻滾,你暴露給我乾淨的介面,我還你一個講究的git star。

如果一個包依賴另一個包,你一把梭的時候還要手動先把它依賴的那個包梭進來,過分之,那個它依賴的包有依賴好幾個別的包,甚至有些情況中你甚至還要很在意你手動新增依賴的順序,這種梭法,一旦專案複雜,光是對這些“梭法”的管理都讓人心煩了,所以為了省心,模組機制也務必要面對解析依賴,管理依賴這個本身就很繁瑣的任務。

所以進入正題,針對前面提到的幾點,看一看簡單的模組實現。

  • 最簡單的模組化可以理解成一個一個的封裝函式,每一個封裝的函式去完成特定的功能,呼叫函式的方式進行復用。但是存在著類似於a,b汙染了全域性變數的缺點
const module1 = ()=>{
	// dosomething
}
const module2 = ()=>{
	// dosomething
}
複製程式碼
  • 使用物件封裝
var module1 = new Object({
_count : 0,
m1 : function (){
//...
},
m2 : function (){
//...
}
    
});
// module1.m1
// module1.m2
複製程式碼

缺點:往往存在不想讓外部訪問的變數(module1._count),這種方式就不能滿足了(不考慮使用Object.defineProperty)

  • 立即執行函式的方式
var module1 = (function(){
var _count = 0;
var m1 = function(){
//...
};
var m2 = function(){
//...
};
return {
    m1 : m1,
    m2 : m2
};
})();
複製程式碼

通過自執行函式可以只返回想返回的東西。

如果此模組內想繼承使用類似於jquery等庫則就需要顯示的將庫傳入到自執行函式中了

var module1 = (function ($, axios) {
//...
})(jQuery, axios);
複製程式碼

瀏覽器傳統載入模組規則

1.預設方法

通過<script>標籤載入 JavaScript 指令碼,預設是同步載入執行的,渲染引擎如果遇到<script>會停下來,知道指令碼下載執行完成

2.非同步方法

<script src="/lib/test.js" defer></script>
<script src="/lib/test.js" async></script>
複製程式碼

defer 和 async屬性

  1. defer 會讓該標籤引用的指令碼在DOM完全解析之後,並且引用的其他指令碼執行完成之後,才會執行;多個defer會按照在頁面上出現的順序依次執行
  2. async 類似於非同步回撥函式,載入完成或,渲染引擎就會立即停下來去執行該指令碼,多個async指令碼不能後保證執行的順序

CommonJs

Node 的模組系統就是參照著CommonJs規範所實現的

const path = require('path')
path.join(__dirname,path.sep)

複製程式碼

path.join 必然是依賴於path模組載入完成才能使用的,對於伺服器來說,因為所有的資源都存放在本地,所以各種模組各種模組載入進來之後再執行先關邏輯對於速度的要求來說並不會是那麼明顯問題。

特點

  1. 一個檔案就是一個模組,擁有單獨的作用域;
  2. 普通方式定義的變數、函式、物件都屬於該模組內;
  3. 通過require來載入模組;
  4. 通過exportsmodul.exports來暴露模組中的內容;
  5. 模組載入的順序,按照其在程式碼中出現的順序。
  6. 模組可以多次載入,但只會在第一次載入的時候執行一次,然後執行結果就被快取了,以後再載入,就直接讀取快取結果;模組的載入順序,按照程式碼的出現順序是同步載入的;

require(同步載入)基本功能:讀取並執行一個JS檔案,然後返回該模組的exports物件,如果沒有發現指定模組會報錯;

exports:node為每個模組提供一個exports變數,其指向module.exports,相當於在模組頭部加了這句話:var exports = module.exports,在對外輸出時,可以給exports物件新增方法(exports.xxx等同於module.exports.xxx),不能直接賦值(因為這樣就切斷了exports和module.exports的聯絡);

module變數代表當前模組。這個變數是一個物件,它的exports屬性(即module.exports)是對外的介面。載入某個模組,其實是載入該模組的module.exports屬性

  • module物件的屬性:
    • module.id模組的識別符,通常是帶有絕對路徑的模組檔名。
    • module.filename 模組的檔名,帶有絕對路徑。
    • module.loaded 返回一個布林值,表示模組是否已經完成載入。
    • module.parent 返回一個物件,表示呼叫該模組的模組。
    • module.children 返回一個陣列,表示該模組要用到的其他模組。
    • module.exports 表示模組對外輸出的值。

例子:

  • 注意在這種方式下module.exports被重新賦值了,所以之前使用exports匯出的hello不再有效(模組頭部var exports = module.exports)
exports.hello = function() {
  return 'hello';
};

module.exports = 'Hello world';/
複製程式碼

因此一旦module.exports被賦值了,表明這個模組具有單一出口了

AMD

**Asynchronous Module Definition非同步載入某模組的規範。**試想如果在瀏覽器中(資源不再本地)採用commonjs這種完全依賴於先載入再試用方法,那麼如果一個模組特別大,網速特別慢的情況下就會出現頁面卡頓的情況。便有了非同步載入模組的AMD規範。require.js便是基於此規範

require(['module1','module2'....], callback);
reqire([jquery],function(jquery){
   //do something
})


//定義模組
define(id, [depends], callback); 
//id是模組名,可選的依賴別的模組的陣列,callback是用於return出一個給別的模組用的函式
複製程式碼

熟悉的回撥函式形式。

Node的模組實現

Node 對於模組的實現以commonjs為基礎的同時也增加了許多自身的特性

  • Node模組的引入的三個步驟

    • 路徑分析
    • 檔案定位
      • require引數中如果不寫字尾名,node會按照.js,.node,.json的順序依次補足並try
      • 此過程會呼叫fs模組同步阻塞式的判斷檔案是否存在,因此非js檔案最後加上字尾
    • 編譯執行
      • .js 檔案會被解析為 JavaScript 文字檔案,.json 檔案會被解析為 JSON 文字檔案。 .node 檔案會被解析為通過 dlopen 載入的編譯後的外掛模組.
  • Node的模組分類

    • 核心模組 Node本身提供的模組,比如path,buffer,http等,在Node編譯過程中就載入進記憶體,因此會省掉檔案定位和編譯執行兩個檔案載入步驟
    • 檔案模組 開發人員自己寫的模組,會經歷完整的模組引入步驟
  • Node也會優先從快取中載入引入過的檔案模組,在Node中第一次載入某一個模組的時候,Node就會快取下該模組,之後再載入模組就會直接從快取中取了。這個“潛規則”核心模組和檔案模組都會有。

require('./test.js').message='hello'
console.log(require.cache);
console.log(require('./test.js').message)//hello
複製程式碼

上述程式碼說明第二次載入依舊使用了第一次載入進來之後的模組並沒有重新載入而是讀取了快取中的模組,因為重新載入的某塊中並沒有message。列印出來的require.cache包含了本模組的module資訊和載入進來的模組資訊。

JavaScript相關的模組機制

那麼如果你想要多次執行某一個模組,要麼你手動像下面這樣刪除該模組的快取記錄之後再重新載入使用,要麼應該在模組中暴露一個工廠函式,然後呼叫那個函式多次執行該模組,與vue-ssr的建立應用例項的工廠函式意思相近。

require('./test.js').message='hello'
delete require.cache['/absolute-path/test.js']
console.log(require('./test.js').message)//undifined
複製程式碼

可見當刪除了相關模組的快取,再一次載入時則不再有message了。

// Vue-ssr工廠函式,目的是為每個請求創立一個新的應用例項
const Vue = require('vue')
module.exports = function createApp (context) {
  return new Vue({
    data: {
      url: context.url
    },
    template: `<div>訪問的 URL 是: {{ url }}</div>`
  })
}
複製程式碼
  • 模組包裝器

Node在載入模組之後,執行之前則會使用函式包裝器將模組程式碼包裝,從而實現將頂層變數(var,let,const)作用域限制在模組範圍內提供每一個特定在該模組的頂層全域性變數module,exports,__dirname(所在資料夾的絕對路徑),__filename(絕對路徑加上檔名)

(function(exports, require, module, __filename, __dirname) {
// 模組的程式碼實際上在這裡
});
複製程式碼

關於模組的具體編譯執行過程,這次就不深入討論了,足夠花心思在好好重新深入總結重寫一篇了,順便再次安利樸靈大大的《深入淺出nodejs》

ES6中模組的解決方案

終於,ES6在語言層面上提供了JS一直都沒有的模組功能,使得在繼Commonjs之於服務端,AMD之於瀏覽器之外提供了一個通用的解決方案。

1.設計思想

儘量靜態化(靜態載入),使得編譯時就能確定模組間的依賴關係以及輸入輸出的變數。

2.關鍵語法

  • export
    • export可以輸出變數:export var a = 1

    • 輸出函式:export function sum(x, y) { return x + y; };

    • 輸出類:export class A{}

    • 結尾大括號寫法:export {a , sum , A}

    • 尤為注意的一點就是export所匯出的介面一定要和模組內部的變數建立一一對應的關係

對於一個模組來說,它就是一個預設使用了嚴格模式的檔案('use strict'),而別的檔案要想使用該模組,就必須要求該模組內有export主動匯出的內容

例子:

export 1 //直接匯出一個數字是不可以的

var a= 2
export a //間接匯出數字也是不可以的!
export {a}//正確

export function(){} //錯誤

function sum(){}
export sum //錯誤
export {sum}//正確
複製程式碼

export個人最為重要的一點就是可以通過引入指向從而取到模組內的實時的值

例子:

export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);
複製程式碼

引用該模組的檔案在定時器時間到的時候則會得到改變後的值

  • export default

實質: 匯出一個叫做default(預設的)變數,本質是將後面的值,賦給default變數,所以情況就和export 不同了

不同點:

  1. export 匯出的變數,在import的時候必須要知道變數名,否則無法載入,export default就允許隨意取名直接載入,並且不用使用大括號;
  2. export default 後面不能跟變數宣告語句
// export default匯出
export default function test() {}
	
import test from 'test'; // 輸入
	
// export匯出
export function test() {};
	
import {test} from 'test'; // 輸入


export var a = 1;// 正確


var a = 1;
export default a;// 正確


export default var a = 1;// 錯誤
複製程式碼

export default 每一個模組只允許有一個

  • import

與匯出export對應,引用則是import

export {a,b}
	||
	\/
import { a as A ,b as B} from './test.js';
複製程式碼

主要特點:

使用import載入具有提升的效果,即會提到檔案頭部進行:

foo();

import { foo } from 'my_module';
複製程式碼

該程式碼會正常執行。

*載入預設載入全部匯出的變數

import * as A from './a.js'

import 載入進來的變數是不允許改變的。

瀏覽器對ES6模組的載入

type='module',此時瀏覽器就會知道這是ES6模組,同時會自動給他加上前文提到的defer屬性,即等到所有的渲染操作都執行完成之後,才會執行該模組

<script type="module" src="./test.js"></script>

Node 對ES6模組的載入

由於Node有自己的模組載入機制,所以在Node8.5以上版本將兩種方式的載入分開來處理,對於載入ES6的模組,node要求其字尾名得是.mjs,然後還得加上--experimental-modules引數,然後兩種機制還不能混用。確實還是很麻煩的,所以現在Node端想用import主流還是用babel轉義。

對比ES6 module和Node的commonjs

差異:

  • 靜態載入VS執行時載入

首先看下面一段程式碼:

if (x > 2) {
  import A from './a.js';
}else{
  import B from './b.js';
}
複製程式碼

這段程式碼會報錯,因為JS引擎在處理import是在編譯時期,此時不會去執行條件語句,因此這段程式碼會出現句法錯誤,相反,如果換成:

if (x > 2) {
  const A =require('./a.js');
}else{
  const B =require('./b.js');
}
複製程式碼

commonjs是在執行時載入模組,因此上面程式碼就會成功執行

由於動態載入功能的要求,才會有了import()函式的提案,這裡就不過多贅述。

  • 值的引用VS值的拷貝

commonjs模組在載入之後會把原始型別的值快取,之後該模組的內部變化則不會再影響到其輸出的值

//test.js
var counter = 3;
function incCounter() {
  counter++;
}
module.exports = {
  counter: counter,
  incCounter: incCounter,
};
==================================
//main.js
var test = require('./test');

console.log(test.counter);  // 3
test.incCounter();
console.log(test.counter); // 3
複製程式碼

ES6的模組機制,在引擎靜態分析階段會把import當成是一種只讀引用(地址是隻讀的const,因此不可以在引用該模組的檔案裡給他重新賦值),等到程式碼實際執行時,才會根據引用去取值

// test.js
export let counter = 3;
export function incCounter() {
  counter++;
}

// main.js
import { counter, incCounter } from './test';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
複製程式碼

迴圈載入問題

迴圈載入指的是,a檔案依賴於b檔案,而b檔案又依賴於a檔案

  • commonjs的迴圈載入問題

commonjs是在載入時執行的,他在require的時候就會全部跑一遍,因此他在遇到迴圈載入的情況就會只輸出已經執行的部分,而之後的部分則不會輸出,下面是一個例子:

//parent檔案
exports.flag = 1;
let children = require('./children')//停下來,載入chilren
console.log(`parent檔案中chilren的flag =${children.flag}`);
exports.flag = 2
console.log(`parent檔案執行完畢了`);
=========================================================
//test2檔案
exports.flag = 1;
let parent = require('./parent')//停下來,載入parent,此時parent只執行到了第一行,匯出結果flag ==1
console.log(`children檔案中parent的flag =${parent.flag}`);
exports.flag = 2
console.log(`children檔案執行完畢了`);

複製程式碼

node parent之後執行結果為

Commonjs迴圈載入

執行parent之後會在第一行匯出flag=1,然後去ruquirechildren檔案,此時parent進行等待,等待children檔案執行結束,children開始執行到第二行的時候出現“迴圈載入”parent檔案,此時系統自動去找parent檔案的exports屬性,而parent只執行了一行,但是好在它有exports了flag,所以children檔案加再進來了那個flag並繼續執行,第三行不會報錯,最後在第四行children匯出了flag=2,此時parent再接著執行到結束。

  • ES6中的迴圈載入問題

ES6和commonjs本質上不同!因為ES6是引用取值,即動態引用

引用阮一峰老師ES6標準入門的例子

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
複製程式碼

執行後的結果:

ES6迴圈載入出錯

執行的過程是當a檔案防線import了b檔案之後就會去執行b檔案,到了b檔案這邊看到了他又引用了a檔案,並不會又去執行a檔案發生“張郎送李郎”的故事,而是倔強得認為foo這個介面已經存在了,於是就繼續執行下去,直到在要引用foo的時候發現foo還沒有定義,因為let定義變數會出現"暫時性死區",不可以還沒定義就使用,其實如果改成var宣告,有個變數提升作用就不會報錯了。改成var宣告fooexport let foo = 'foo';

ES6迴圈載入換成var

雖然列印的foo是undifined但是並沒有影響程式執行,但最好的做法是,改成同樣有提升作用的function來宣告。最後去執行函式來獲得值,最後得到了希望的結果

// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
export function foo() { return 'foo' };

// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
export function bar() { return 'bar' };
複製程式碼

ES6迴圈載入正確

結束語

其實關於模組還有很多東西還沒有梳理總結到,比如node模組的載入過程的細節,和編譯過程,再比如如何自己寫一個npm模組釋出等等都是很值得去梳理總結的。最後期待您的評論與指正。

參考文章: ES6標準入門--阮一峰 Nodejs v8.9.4 官方文件 《深入淺出Nodejs》---樸靈 Commonjs規範

相關文章