JS模塊化的雜七雜八

本文首先會先按照時間順序初步介紹 CommonJSAMDCMDUMDES6 Module,而後針對 CommonJSES6 Module進行深刻闡述

1、初步介紹

在日常開發的過程當中,全部的這些代碼都是由一個個的模塊構成的,咱們能夠在npm上下載咱們須要的包,而後組裝在咱們的項目中,就好像組裝模塊同樣,隨着mvvm框架的普及,主流的開發方式都變成了模塊化。在以前,js模塊化仍是經過命名空間來實現的,再後來產生了一個模塊化規範(CommonJs),CommonJs是誕生於node社區,但它只能在服務端運行,在瀏覽器端仍是用不了,因而,社區便出了AMD規範,國內又出了CMD,後來又出了UMD,其實這三個規範都是爲了幫助模塊化開發。目前比較流行的是ES6 Module規範。html

CommonJS

CommonJS規範的特色:前端

  • 一個文件即一個模塊。文件中定義的變量、函數、類都是私有的,外界沒法訪問模塊內的內容node

  • 經過module.exports暴露模塊內的常量、函數、文件、模塊等webpack

  • module.exports導出模塊,輸出的是值的拷貝;模塊導入的也是輸出值的拷貝git

    • 也就是說,一旦輸出這個值,這個值在模塊內部的變化是監聽不到的(可對比ES6)
  • 使用require引入模塊es6

  • 同步加載github

    • 模塊是同步加載的,即只有加載完成,才能執行後面的操做;
    • 由於CommonJS是運行在Nodejs服務端的,Node.js是同步模塊加載,所以CommonJS是用同步的方式加載模塊,對服務端來講require()是本地加載,正由於全部文件都在本地,讀取速度很快,同步加載不會形成什麼很差的影響,可是在瀏覽器端,由於網絡的緣由,同步加載就會在必定程度限制資源的加載速度
  • 模塊是運行時加載(運行時加載)web

    • CommonJS規範中,require()是用來加載一個模塊,那require這語句作了什麼事?npm

      運行文件時,Node發現文件中使用require加載一個A模塊,Node首先會執行整個A模塊,而後在內存中生成一個對象,該對象就是A模塊的一個表達載體,接着從該對象的exports屬性中取出A模塊輸出的各個接口,供當前文件使用api

      {
         id: '',  // 惟一的模塊名
         exports: {  // 包含模塊輸出的各個接口
            ...
         },
         loaded: true,  // 模塊的腳本是否執行完畢
         ...
      }
      複製代碼

      若在文件中再次使用require加載A模塊,Node也不會重複執行A模塊,而是直接在內存中找到A模塊的表達對象,取出exports屬性的值。也就是說不管經過require加載多少次相同的模塊,都只會在第一次加載中運行,日後都是從對象中取出exports屬性的值(除非手動清除系統緩存)

而後能夠看一下CommonJS規範的代碼

var foo = require('./foo') // 引入模塊
var foo = require('event').some // 引入模塊的某個屬性

function fn() {
  console.log('fn')
}
module.exports.fn = fn // 經過module.exports將fn函數暴露出去
// exports.fn = fn // 或者經過exports暴露出去
複製代碼

AMD(Async Module Definition )

AMD是異步模塊加載機制,做者以RequireJS實現了AMD規範,因此提及AMD規範就能想到RequireJS,它的特色:

  • 使用define定義模塊: define能夠傳入三個參數,分別是模塊名(string)、依賴模塊(array)、回調函數(function

    • 第一個參數是模塊名,字符串類型,可選參數。若不存在則模塊標識應該默認定義爲在加載器中被請求腳本的標識。若是存在,那麼模塊標識必須爲頂層的或者一個絕對的標識

    • 第二個參數是依賴模塊,數組類型,是一個當前模塊依賴的,已被模塊定義的模塊標識的數組字面量

    • 第三個參數是一個須要進行實例化的函數或者一個對象

      // 定義無依賴的模塊
      define({
        fn1: function(a){
          return a
        },
        arr1: []
      })
      
      // 定義有依賴的模塊
      define(["a"], function(a){
        return {
          fn1: function(){
            return a.num
          }
        }
      })
      
      // 具名模塊
      define("foo", [ "a", "b"], function(a, b){
          ...
      });
      複製代碼
  • 使用require引入模塊:

  • 異步加載模塊:模塊的加載不影響它後面語句的運行,全部依賴這個模塊的語句,都定義在一個回調函數中,等到加載完成以後,這個回調函數纔會運行

  • 依賴前置,提早執行

    • define方法裏傳入的依賴模塊(數組),會在一開始就下載並執行
    • RequireJS從2.0開始,也改爲能夠延遲執行,只是寫法上和提早執行有些不一樣。雖然 AMD也支持CMD寫法,但依賴前置是官方文檔的默認模塊定義寫法

而後能夠看一下AMD規範的代碼

// src/foo.js
// 會在聲明並初始化要用到的全部模塊(a和b),無論後續是否用到
// 參數1 "foo" 是模塊名
// 參數2 ["a", "b"] 是依賴的模塊名
// 參數3 function(){} 是回調函數
define("foo", ["a", "b"], function (a, b) { // 定義foo.js模塊
    const NUM = 2
    const fn = function () {
      console.log('foo')
    }
    return {
        NUM: NUM,
        fn: fn
    }
})


// app.js
require.config({ // 經過require.config()設置每一個模塊路徑和引用名
  baseUrl: "src",
  paths: {
    "foo": "foo", // 實際路徑爲src/foo.js, 指定foo.js的引用名爲foo
  }
})

// 加載foo.js模塊
// 加載模塊時要將模塊名(也能夠是文件路徑)放在[]中做爲reqiure()的第一參數,
require(["foo"],function(foo){
  console.log(foo)
})
複製代碼

CMD

CMD是通用模塊定義,CMDSeaJS在推廣過程當中生產的對模塊定義的規範,SeaJS的做者就是大名鼎鼎的玉伯,它的特色:

  • 一個文件爲一個模塊
  • 使用define定義模塊
  • 使用require引入模塊
  • 依賴就近,延遲執行: 只有執行到require()時,依賴模塊才執行。

AMDCMD其實不少地方類似,可是它們最大區別是執行方式的不一樣,在執行過程當中,通過AMD編譯後,全部require引入的模塊都被前置了,CMD雖然也會將require的代碼下載下來,可是它不會去執行,直到代碼運行到那個模塊的依賴纔會去執行對應模塊

/** AMD寫法 **/
define(['./a','./b'], function (a, b) {
    //依賴一開始就寫好,等於在最前面聲明並初始化了要用到的全部模塊
    a.test() // 即使沒用到某個模塊 b,但 b 仍是提早執行了
})
 
/** CMD寫法 **/
define(function (requie, exports, module) {
    if (false) {
        var b = require('./b') //依賴能夠在須要時申明
        b.doSomething()
    }
    exports.fn = function() {
      console.log('fn')
    };
})
複製代碼

👇是sea.js的使用demo

//a.js
define(function (require, export, module)) {
  // 經過require引入依賴
  var foo = require('./foo')
  const NUM = 2
  // 經過export或者經過module.exports暴露接口
  exports.num = NUM
  // module.exports.num = NUM
}


//b.js
// 加載模塊
// 數組中聲明須要加載的模塊,能夠是模塊名、js文件路徑
seajs.use(['./a'], function(a) {
  a.fn();
});
複製代碼

UMD

webpack打包過程當中就有UMD這個選項,UMD實際上是一個通用解決方案,它自己不是什麼新的規範,就好似一個判斷條件同樣。在模塊定義中,它主要作了三件事

  • 先判斷是否支持AMD。若是是則使用AMD,不然執行下一步
  • 而後判斷是否支持CommonJs。若是是則使用CommonJs,不然執行下一步
  • 若是都不是,則定義爲全局變量
(function (root, fac) {
  if (typeof define === 'function' && define.amd) { // 若是define這個方法被定義,且define.amd是否存在,說明是AMD
    define([], fac) // 以AMD的規範去定義模塊
  } else if (typeof exports === 'object'){ // exports爲一個對象,這個就是判斷是否在node環境中,知足Commonjs規範
    module.exports = fac() // 以commonjs的規範去暴露接口
  } else {
    root.some = fac() // 暴露給瀏覽器的全局環境,root其實就是window
  }
})
複製代碼

ESM(ES6 Module)

ES6 在語言標準的層面上,實現了模塊功能,並且實現得至關簡單,旨在成爲瀏覽器和服務器通用的模塊解決方案。webpack3原生支持

  • 一個文件爲一個模塊

  • 使用export暴露模塊內的常量、函數、文件、模塊等,暴露的是對值的引用

    • ES6 Module暴露出去的是一種靜態定義(引用)
  • export命令規定的是對外的接口,必須與模塊內部的變量創建一一對應關係。

  • 使用import引入模塊

    • ES6 Module模塊對導出模塊,變量,對象是動態引用,遇到模塊加載命令import時不會去執行模塊,只是生成一個指向被加載模塊的引用,不存在緩存值的問題
    • 引用帶來的一個特色是,不會緩存運行結果,而是動態地去被加載的模塊取值,也就是模塊暴露的內容發生變化時,經過import引入的值自己也會相應的變化(在後面循環加載章節會詳細闡述)
  • 模塊是編譯時輸出接口(編譯時加載)

    • ES6 Module暴露出去的是一種靜態定義(引用),在代碼靜態解析階段就會生成

import/export 最終都是編譯爲 require/exports 來執行的

// foo.js 暴露接口
export const NUM = 12
function fn() {
  console.log('fn')
}
export { fn }
export default class myClass {...} // 默認暴露


// app.js 引入模塊
import myClass, { fn, NUM } from 'other_module'
複製代碼

目前webpack支持AMD(requireJS)ES6 Module(官方推薦)、CommonJS

require: node 和 es6 都支持的引入 export / import : 只有es6 支持的導出引入 module.exports / exports: 只有 node 支持的導出

2、理解CommonJS中的exports、module.exports

CommonJS規範中認爲一個文件即一個模塊

CommonJS是運行在Nodejs服務端的,Node爲每一個模塊都建立一個module對象以表明當前模塊,初始值爲{},該對象的exports屬性(即module.exports)是對外的接口,當經過**require加載某個文件時,其實就是加載該文件的module.exports**。下面代碼經過module.exports輸出變量el和函數fn1。

// foo1.js
const el = 5
const fn1 = function (val) {
  return `val: ${val}`
}
module.exports.el = el
module.exports.fn1 = fn1
複製代碼
// index.js
var foo1 = require('./foo1') // 加載模塊

console.log(foo1) // { a: 5, fn1: [Function: fn1] }
console.log(foo1.el) // 5
console.log(foo1.fn1(4)) // val: 4
複製代碼

前面說了,require加載某個文件時,其實就是加載該文件的module.exports,那exports對象是什麼?

爲了方便,Node爲每一個模塊提供一個exports對象,並讓exports對象指向module.exports,如此一來,exportsmodule.exports都指向同一塊內存區域,這樣咱們就能夠直接在exports對象上添加變量、函數、類等,以表示對外輸出的接口,如同添加在module.exports同樣。其做用就像👇這行代碼同樣,

var exports = module.exports; 
複製代碼

來看一下經過exports暴露接口的代碼:

// foo1.js
const el = 5
const fn1 = function (val) {
  return `val: ${val}`
}
// 與module.exports上方寫法相同效果
exports.el = el
exports.fn1 = fn1
複製代碼

可是須要注意的是,不能直接將exports變量指向一個值(直接被覆蓋取值 例如:export = xxx),由於這樣等於切斷了exportsmodule.exports的聯繫;也不能修改module.exports值,這樣也會讓exports切斷聯繫

掏個🌰感覺下這句話的意思

// index.js
var foo1 = require('./foo1');
console.log(foo1)

// fool1.js 
// eg1: 在第一次賦值以後,若是單獨修改module.exports指向,export仍是停留在上一次賦值的值,而且此時經過exports暴露任何變量、函數等是沒有用的,由於export已經不指向module.exports了
var exports = module.exports = {a: 1}
module.exports = {a: 2} // 此時exports值爲{a: 1}
const el = 5
exports.el = el // index.js輸出的值:{a: 2}

// fool1.js 
// eg2:修改exports值,一樣致使export斷開與module.exports的聯繫
var exports = module.exports
const el = 5
exports = {} // export指向新的對象
exports.el = el // index.js輸出的值:{},由於module.exports默認是{}
複製代碼

從上面能夠看出,require永遠只會導出module.exports的內容,exports就好似一個代駕,當斷開與module.exports的聯繫,他就沒用了,它的做用就是方便用戶使用module.exports這個對象

總結一下exportsmodule.exports的區別了:

  • module.exports 初始值爲一個空對象 {}
  • exports是指向的 module.exports的引用
  • require()返回的值是被加載文件的module.exports

若是有人有疑問,既然是引用關係,爲啥修改exports不會同時修改module.exports的值?能夠看看👇代碼理解下引用的意思

var a = {name: 'one'}
var b = a
console.log(a) // {name: 'one'}
console.log(b) // {name: 'one'}

b.name = 'two'
console.log(a) // {name: 'two'}
console.log(b) // {name: 'two'}

var b = {name: 'three'}
console.log(a) // {name: 'two'}
console.log(b) // {name: 'three'}
複製代碼

a 是一個對象,b 是對 a 的引用,即 a 和 b 指向同一塊內存,因此前兩個輸出同樣。當對 b 做修改時,即 a 和 b 指向同一塊內存地址的內容發生了改變,因此 a 也會體現出來,因此第三四個輸出同樣。當 b 被覆蓋時,b 指向了一塊新的內存,a 仍是指向原來的內存,因此最後兩個輸出不同

3、理解ES6 Module的export、export default

ES6 module是經過exportexport default來暴露接口,那它們的聯繫和區別是什麼?

先說明exportexport default的區別:

  • 在一個文件或模塊中,export能夠有多個,export default僅有一個

  • 經過export方式導出,在導入時要加{ },export default則不須要

  • export可直接導出變量表達式,export default不可

// foo.js
// export導出
export const NUM1 = 1 // 直接導出
function fn () {
  console.log('res')
}
const NUM2 = 2
export { fn, NUM2 } // 間接導出

// export default導出
export default function fn1 () { // 直接導出
  console.log('res1')
}
// const NUM2 = 2
// export default NUM2 // 間接導出
// export defult const NUM2 = 2 // export default不可導出變量表達式
複製代碼
// index.js
import { NUM1, fn, NUM2 } from './foo' // 導出了export
import fn1 from './foo'  // 導出了export default
// import fn1, { NUM1, fn, NUM2 } from './foo' // 等價於上面兩行

import * as all from './foo' // 將export和export default暴露出的接口都用對象all表示

fn1() // res1
fn() // res
console.log(NUM1) // 1
console.log(NUM2) // 2

console.log(all.NUM) // 1
console.log(all.default) // res1 export default暴露出來的在all的default屬性裏
複製代碼

另外提一下,暴露的方式還有一些拓展的寫法

// 第二種方式:先定義,後暴露
const NUM = 12
function fn() {...}

export { NUM, fn } // 暴露上面定義的NUM變量和fn函數
export { NUM as NUM_NEW, fn as fn1 } // 以NUM_NEW代替NUM,被暴露出去;fn同理,import也要用NUM_NEW和fn1來引入

// 能夠將其餘模塊的內容引入進來,而後再暴露出去,做一箇中轉站
export {foo, bar} from 'other_module' // 引入foo和bar接口,並將其暴露出去
export * from 'src/other_module' // 表示先引入other_module模塊的全部接口,而後所有暴露出去
export {foo as foo1, bar} from 'other_module' // 也能夠進行重命名,將foo以foo1的名字暴露出去
複製代碼

4、CommonJS和ES6 Module應對」循環加載「

「循環加載」指的是,a腳本的執行依賴b腳本,而b腳本的執行又依賴a腳本

// a.js
var b = require('b');

// b.js
var a = require('a');
複製代碼

循環加載最容易發生的狀況就是遞歸加載,致使無限循環,可是複雜項目中模塊衆多,模塊互相依賴的狀況也時常發生,所以,主流的兩個模塊規範CommonJSES6 Module也給出了各自的解決方案

CommonJS的循環加載

前面討論過CommonJS的加載原理,require第一次加載模塊會生成一個對象,此後重複加載該模塊都是從對象中的exports屬性中取值。針對循環加載

👉CommonJS的方案是:一旦出現某個模塊被「循環加載」,就只輸出已經執行的部分,沒有執行的部分不會輸出

結合🌰進行理解,下面🌰來自於Node官方文檔

//main.js
var a = require('./a.js');
var b = require('./b.js');

console.log('在main.js中,a.done = %j, b.done = %j', a.done, b.done);
複製代碼
//a.js
exports.done = false;

var b = require('./b.js');
console.log('在a.js中,b.done = %j', b.done);

exports.done = true;
console.log('a.js執行完畢!')
複製代碼
//b.js
exports.done = false;

var a = require('./a.js');
console.log('在b.js中,a.done = %j', a.done);

exports.done = true;
console.log('b.js執行完畢!')
複製代碼
node main.js // 運行main.js

輸出結果:
在b.js中,a.done = false
b.js執行完畢!
在a.js中,b.done = true
a.js執行完畢!
在main.js中,a.done = true, b.done = true
複製代碼
  • 執行過程以下:
    • 首先加載a模塊,a模塊暴露done=false,而後在a模塊中加載b模塊,b模塊暴露done=false,而後加載a模塊,此時發生循環加載
    • 此時,系統不會去繼續從新加載a模塊,而是從以前加載a模塊時建立的對象中取值,而其中的值也僅僅是已經執行完的部分,系統從對象的exports屬性中取值,done=false,而後繼續往下執行,輸出在b.js中,a.done = false,而後輸出b.js執行完畢!
    • b模塊執行結束後,a模塊就能夠繼續執行了,此時b模塊的done=true,所以輸出在a.js中,b.done = true,而後輸出a.js執行完畢!
    • 此時a,b都執行結束,且都在內存建立對應的對象,在main.js中加載a、b模塊,則直接從對象中獲取exports屬性

ES6 Module的循環加載

前面介紹了ES6 Module兩個特色:

一、使用export暴露接口,暴露的是對值的引用

二、使用import動態引入模塊,遇到import時不會去執行模塊,只是生成一個指向被加載模塊的引用,等到真的須要用到模塊時,再到模塊裏面去取值。

舉個🌰,例子來源於阮一峯

// a.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

// b.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);

// 執行b.js,輸出爲:
bar
baz
複製代碼

從結果能夠看出,a.js的變量foo,在剛加載時等於bar,過了500毫秒,又變爲等於baz,b.js一開始獲取到foo的值時bar,500毫秒後獲取的值時baz,這就說明ES6 Module 不會去緩存運行結果,而是動態地去取被加載模塊暴露的值,由於import生成的引用,其實就是一個地址引用,指向那塊數據的內存

👉ES6 Module不會關心import是否發生」循環加載「,由於它僅僅生成一個引用,須要開發者本身保證,在文件中真正使用被加載模塊時,引用是能夠取到值

也就是說,在文件中使用import加載A模塊時,僅僅是生成一個引用(相似指針),該引用指向的是A模塊暴露接口的地址,所以,開發者必須保證在真正使用A模塊時,是能夠取到A模塊的值,不然就會報錯

// a.js
import {resB} from './b'
console.log('a')
export const resA = 2

// b.js
import {resA} from './a'
console.log('b')
export const resB = 3
console.log(resA)

// 輸出結果
b
undefined
a
複製代碼

上述代碼,a.js加載b.js,b.js又加載a.js,構成了循環加載。按照CommonJS規範,上面的代碼是無法執行的。a先加載b,而後b又加載a,這時a尚未任何執行結果,因此輸出結果爲null,即對於b.js來講,變量resA的值等於null,後面的foo()就會報錯。

讓咱們一行行來看,ES6 循環加載是怎麼處理的。首先,執行a.js之後,引擎發現它加載了b.js,所以會優先執行b.js,而後再執行a.js。接着,執行b.js的時候,已知它從a.js輸入了resA接口,這時不會去執行a.js,而是認爲這個接口已經存在了,繼續往下執行,執行到console.log(resA)時,發現這個接口沒有定義,而後輸出undefined。接着繼續執行a.js,輸出a

補充知識點:在代碼運行前,函數聲明和變量定義一般會被解釋器移動到其所在做用域的最頂部。若是把函數寫成函數表達式,就不具備提高做用了

那如何解決這個問題?能夠利用函數提高來解決,在a.js中經過函數包裹NUM常量,而後將該值進行返回,因爲函數會被提高,因此當b.js執行resA()的時候,a.js的resA已經被聲明瞭,此時執行console.log(resA())就能獲得正常的結果

// a.js
import {resB} from './b'
console.log('a')
function resA() {
  const NUM = 2
  return NUM
}
export { resA }

// b.js
import {resA} from './a'
console.log('b')
export const Foo2 = 3
console.log(resA())

// 輸出結果
b
2
a
複製代碼

5、CommonJS與ES6 Module的差別

  • CommonJS暴露出的是一個值的拷貝,一旦暴露接口,這個接口在模塊內部的變化是監聽不到的;ES6 Module暴露的是內容的引用,模塊內部被暴露的接口改變會影響引用的改變
  • 若遇到重複加載的狀況,CommonJS會直接從第一次加載時生成對象的exports屬性中取值;ES6 Module則會經過引用找到模塊暴露接口的內存位置,並從中取值
  • 若出現循環加載狀況,CommonJS只輸出已經執行的部分,還未執行的部分不會輸出;ES6 Module須要開發者本身保證,真正取值的時候可以取到值
  • CommonJS是加載時執行,若出現循環加載狀況,則從已執行的內容中取值;ES6 Module是動態引用,加載模塊時不執行代碼,只是生成一個指向被加載模塊的引用
  • CommonJS模塊是運行時加載,ES6 Module模塊是編譯時輸出接口
  • CommonJS是加載整個模塊,ES6 Module能夠按需加載部分接口

但願看完本篇文章能對你有所幫助,

文中若有錯誤,歡迎在評論區指正,若是這篇文章幫助到了你,歡迎點贊和關注。

參考資料 📖

相關文章
相關標籤/搜索