帶你了解JavaScript相關的模塊機制

前言

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信息和加載進來的模塊信息。

那么如果你想要多次執行某一個模塊,要么你手動像下面這樣刪除該模塊的緩存記錄之后再重新加載使用,要么應該在模塊中暴露一個工廠函數,然后調用那個函數多次執行該模塊,與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 function crc32() {}
    
import crc32 from 'crc32'; // 輸入
    
// 第二組
export function crc32() {};
    
import {crc32} from 'crc32'; // 輸入


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模塊發布等等都是很值得去梳理總結的,這一次就先到這吧,總之,第一次在自己的博客站正兒八經的寫這么長的技術總結博客,組織內容上感覺比較凌亂,還有很多的不足。希望自己以后多多總結提高吧。最后當然還是要感謝開源,感謝提供了那么多優秀資料的前輩們。也歡迎來我的博客網站(https://isliulei.com)指教。

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。