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

前言

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

模塊一直以來都是組織大型軟件的必備的要素,就像建築和磚,「磚」的組織規則更是須要最早明確的事情,一直以來JS在語言層面都沒能給模塊機制足夠的重視,知道ES6的module的出現彷彿給出了最終解決的方案,可是畢竟ES6的module還沒能獲得良好的支持,其中所面臨的複雜狀況可想而知,由於業務場景的多樣性致使彷佛哪種模塊機制都感受到了衆口難調,雖然Node8已經對絕大部分的ES6語法提供了很是好的支持,可是要想使用ES6的模塊機制仍是必需要使用相似babel的轉義工具才能作到並非那麼「無畏」的使用。本文從最簡單的模塊開始,而後主要從Node的模塊規範和ES6的模塊機制對模塊進行梳理。javascript

「模塊化」的基本實現

每次在註冊成爲某一個網站或者應用的用戶時最讓人心碎的的就是本身經常使用的用戶名已經存在了,很緊張得換了幾個還能接受的用戶名發現本身的想法老是很受歡迎,因而即使放着《不將就》也無奈的選擇了在本身的用戶名後面加上了本身的生日數字...
這裏也不太方便討論若是加上了生日數字以後,表單校訂仍是提示你「該用戶名已經存在!」的狀況,剪網線就完事了。html

用戶名已存在!

我想表達的意思實際就是,全局環境下的變量的命名衝突,變量太多不免詞窮狀況很常見,因此這必定是模塊化給咱們帶來的好處,有了模塊你就能夠繼續用你喜歡的用戶名,只不過你得介紹清楚,你是「村口第五家.Ray"vue

一把梭

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

若是一個包依賴另外一個包,你一把梭的時候還要手動先把它依賴的那個包梭進來,過度之,那個它依賴的包有依賴好幾個別的包,甚至有些狀況中你甚至還要很在乎你手動添加依賴的順序,這種梭法,一旦項目複雜,光是對這些「梭法」的管理都讓人心煩了,因此爲了省心,模塊機制也務必要面對解析依賴,管理依賴這個自己就很繁瑣的任務。node

因此進入正題,針對前面提到的幾點,看一看簡單的模塊實現。jquery

  • 最簡單的模塊化能夠理解成一個一個的封裝函數,每個封裝的函數去完成特定的功能,調用函數的方式進行復用。可是存在着相似於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)ios

  • 當即執行函數的方式
  var module1 = (function(){
    var _count = 0;
    var m1 = function(){
      //...
    };
    var m2 = function(){
      //...
    };
    return {
      m1 : m1,
      m2 : m2
    };
  })();

經過自執行函數能夠只返回想返回的東西。git

若是此模塊內想繼承使用相似於jquery等庫則就須要顯示的將庫傳入到自執行函數中了es6

var module1 = (function ($, axios) {
    //...
  })(jQuery, axios);

瀏覽器傳統加載模塊規則

1.默認方法npm

經過<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模塊發佈等等都是很值得去梳理總結的,這一次就先到這吧,總之,第一次在本身的SF正兒八經的寫這麼長的技術總結博客,組織內容上感受比較凌亂,還有不少的不足。但願本身之後多多總結提升吧。最後固然仍是要感謝開源,感謝提供了那麼多優秀資料的前輩們。也歡迎來個人博客網站(https://isliulei.com)指教

參考文章:
ES6標準入門--阮一峯
Nodejs v8.9.4 官方文檔
《深刻淺出Nodejs》---樸靈
Commonjs規範
相關文章
相關標籤/搜索