天天看點

再唠叨JS子產品化加載之CommonJS、AMD、CMD、ES6

Javascript子產品化程式設計,已經成為一個迫切的需求。理想情況下,開發者隻需要實作核心的業務邏輯,其他都可以加載别人已經寫好的子產品。

Javascript社群做了很多努力,在現有的運作環境中,實作”子產品”的效果。

CommonJS

CommonJS定義的子產品分為:  子產品引用(require)    子產品輸出(exports)       子產品辨別(module)

CommonJS Modules有1.0、1.1、1.1.1三個版本:

  • Node.js、SproutCore實作了 Modules 1.0
  • SeaJS、AvocadoDB、CouchDB等實作了Modules 1.1.1
  • SeaJS、FlyScript實作了Modules/Wrappings

這裡的CommonJS規範指的是CommonJS Modules/1.0規範。

CommonJS是一個更偏向于伺服器端的規範。NodeJS采用了這個規範。CommonJS的一個子產品就是一個腳本檔案。require指令第一次加載該腳本時就會執行整個腳本,然後在記憶體中生成一個對象。

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}      

id是子產品名,exports是該子產品導出的接口,loaded表示子產品是否加載完畢。此外還有很多屬性,這裡省略了。

以後需要用到這個子產品時,就會到exports屬性上取值。即使再次執行require指令,也不會再次執行該子產品,而是到緩存中取值。

// math.js
exports.add = function(a, b) {
  return a + b;
}      
var math = require('math');
math.add(2, 3); // 512      

由于CommonJS是同步加載子產品,這對于伺服器端不是一個問題,因為所有的子產品都放在本地硬碟。等待子產品時間就是硬碟讀取檔案時間,很小。但是,對于浏覽器而言,它需要從伺服器加載子產品,涉及到網速,代理等原因,一旦等待時間過長,浏覽器處于”假死”狀态。

是以在浏覽器端,不适合于CommonJS規範。是以在浏覽器端又出現了一個規範—AMD(AMD是RequireJs在推廣過程中對子產品定義的規範化産出)。

AMD

CommonJS解決了子產品化的問題,但這種同步加載方式并不适合于浏覽器端。

AMD是”Asynchronous Module Definition”的縮寫,即”異步子產品定義”。它采用異步方式加載子產品,子產品的加載不影響它後面語句的運作。 

這裡異步指的是不堵塞浏覽器其他任務(dom建構,css渲染等),而加載内部是同步的(加載完子產品後立即執行回調)。

AMD也采用require指令加載子產品,但是不同于CommonJS,它要求兩個參數:

require([module], callback);1      

第一個參數[module],是一個數組,裡面的成員是要加載的子產品,callback是加載完成後的回調函數。如果将上述的代碼改成AMD方式:

require(['math'], function(math) {
  math.add(2, 3);
})      

其中,回調函數中參數對應數組中的成員(子產品)。

requireJS加載子產品,采用的是AMD規範。也就是說,子產品必須按照AMD規定的方式來寫。

具體來說,就是子產品書寫必須使用特定的define()函數來定義。如果一個子產品不依賴其他子產品,那麼可以直接寫在define()函數之中。

define(id?, dependencies?, factory);12      
  • id:子產品的名字,如果沒有提供該參數,子產品的名字應該預設為子產品加載器請求的指定腳本的名字;
  • dependencies:子產品的依賴,已被子產品定義的子產品辨別的數組字面量。依賴參數是可選的,如果忽略此參數,它應該預設為 

    ["require", "exports", "module"]

    。然而,如果工廠方法的長度屬性小于3,加載器會選擇以函數的長度屬性指定的參數個數調用工廠方法。
  • factory:子產品的工廠函數,子產品初始化要執行的函數或對象。如果為函數,它應該隻被執行一次。如果是對象,此對象應該為子產品的輸出值。

假定現在有一個math.js檔案,定義了一個math子產品。那麼,math.js書寫方式如下:

// math.js
define(function() {
  var add = function(x, y) {
    return x + y;
  }

  return  {
    add: add
  }
})      

加載方法如下:

// main.js
require(['math'], function(math) {
  alert(math.add(1, 1));
})      

如果math子產品還依賴其他子產品,寫法如下:

// math.js
define(['dependenceModule'], function(dependenceModule) {
    // ...
})      

當require()函數加載math子產品的時候,就會先加載dependenceModule子產品。當有多個依賴時,就将所有的依賴都寫在define()函數第一個參數數組中,是以說AMD是依賴前置的。這不同于CMD規範,它是依賴就近的。

CMD

CMD推崇依賴就近,延遲執行。可以把你的依賴寫進代碼的任意一行,如下:

define(factory)      

factory

為函數時,表示是子產品的構造方法。執行該構造方法,可以得到子產品向外提供的接口。factory 方法在執行時,預設會傳入三個參數:require、exports 和 module.

// CMD
define(function(require, exports, module) {
  var a = require('./a');
  a.doSomething();
  var b = require('./b');
  b.doSomething();
})      

如果使用AMD寫法,如下:

// AMDdefine(['a', 'b'], function(a, b) {
  a.doSomething();
  b.doSomething();
})      

這個規範實際上是為了Seajs的推廣然後搞出來的。那麼看看SeaJS是怎麼回事兒吧,基本就是知道這個規範了。

同樣Seajs也是預加載依賴js跟AMD的規範在預加載這一點上是相同的,明顯不同的地方是調用,和聲明依賴的地方。AMD和CMD都是用difine和require,但是CMD标準傾向于在使用過程中提出依賴,就是不管代碼寫到哪突然發現需要依賴另一個子產品,那就在目前代碼用require引入就可以了,規範會幫你搞定預加載,你随便寫就可以了。但是AMD标準讓你必須提前在頭部依賴參數部分寫好(沒有寫好? 倒回去寫好咯)。這就是最明顯的差別。

sea.js通過

sea.use()

來加載子產品。

seajs.use(id, callback?)      

ES6

es6子產品特性,推薦參看阮一峰老師的:ECMAScript 6 入門 - Module 的文法

說起 ES6 子產品特性,那麼就先說說 ES6 子產品跟 CommonJS 子產品的不同之處。

  • ES6 子產品輸出的是值的引用,輸出接口動态綁定,而 CommonJS 輸出的是值的拷貝
  • ES6 子產品編譯時執行,而 CommonJS 子產品總是在運作時加載

CommonJS 輸出值的拷貝

CommonJS 子產品輸出的是值的拷貝(原始值的拷貝),也就是說,一旦輸出一個值,子產品内部的變化就影響不到這個值。

// a.js
var b = require('./b');
console.log(b.foo);
setTimeout(() => {
  console.log(b.foo);
  console.log(require('./b').foo);
}, 1000);

// b.js
let foo = 1;
setTimeout(() => {
  foo = 2;
}, 500);
module.exports = {
  foo: foo,
};
// 執行:node a.js
// 執行結果:
// 1
// 1
// 1      

上面代碼說明,b 子產品加載以後,它的内部 foo 變化就影響不到輸出的 exports.foo 了。這是因為 foo 是一個原始類型的值,會被緩存。是以如果你想要在 CommonJS 中動态擷取子產品中的值,那麼就需要借助于函數延時執行的特性。

// a.js
var b = require('./b');
console.log(b.foo());
setTimeout(() => {
  console.log(b.foo());
  console.log(require('./b').foo());
}, 1000);

// b.js
let foo = 1;
setTimeout(() => {
  foo = 2;
}, 500);
module.exports = {
  foo: () => {
    return foo;
  },
};
// 執行:node a.js
// 執行結果:
// 1
// 2
// 2      

是以我們可以總結一下:

  • CommonJS 子產品重複引入的子產品并不會重複執行,再次擷取子產品直接獲得暴露的 module.exports 對象
  • 如果你要處處擷取到子產品内的最新值的話,也可以你每次更新資料的時候每次都要去更新 module.exports 上的值
  • 如果你暴露的 module.exports 的屬性是個對象,那就不存在這個問題了

是以如果你要處處擷取到子產品内的最新值的話,也可以你每次更新資料的時候每次都要去更新 module.exports 上的值,比如:

// a.js
var b = require('./b');
console.log(b.foo);
setTimeout(() => {
  console.log(b.foo);
  console.log(require('./b').foo);
}, 1000);

// b.js
module.exports.foo = 1;   // 同 exports.foo = 1 
setTimeout(() => {
  module.exports.foo = 2;
}, 500);

// 執行:node a.js
// 執行結果:
// 1
// 2
// 2      

ES6 輸出值的引用

然而在 ES6 子產品中就不再是生成輸出對象的拷貝,而是動态關聯子產品中的值。

ES6 靜态編譯,CommonJS 運作時加載

關于第二點,ES6 子產品編譯時執行會導緻有以下兩個特點:

import 指令會被 JavaScript 引擎靜态分析,優先于子產品内的其他内容執行。

  • export 指令會有變量聲明提前的效果。

    import 優先執行: 

從第一條來看,在檔案中的任何位置引入 import 子產品都會被提前到檔案頂部。

// a.js
console.log('a.js')
import { foo } from './b';

// b.js
export let foo = 1;
console.log('b.js 先執行');

// 執行結果:
// b.js 先執行
// a.js      

從執行結果我們可以很直覺地看出,雖然 a 子產品中 import 引入晚于 console.log('a'),但是它被 JS 引擎通過靜态分析,提到子產品執行的最前面,優于子產品中的其他部分的執行。

由于 import 是靜态執行,是以 import 具有提升效果即 import 指令在子產品中的位置并不影響程式的輸出。

/ a.js
import { foo } from './b';
console.log('a.js');
export const bar = 1;
export const bar2 = () => {
  console.log('bar2');
}
export function bar3() {
  console.log('bar3');
}

// b.js
export let foo = 1;
import * as a from './a';
console.log(a);

// 執行結果:
// { bar: undefined, bar2: undefined, bar3: [Function: bar3] }
// a.js      

從上面的例子可以很直覺地看出,a 子產品引用了 b 子產品,b 子產品也引用了 a 子產品,export 聲明的變量也是優于子產品其它内容的執行的,但是具體對變量指派需要等到執行到相應代碼的時候。(當然函數聲明和表達式聲明不一樣,這一點跟 JS 函數性質一樣,這裡就不過多解釋)

好了,講完了 ES6 子產品和 CommonJS 子產品的不同點之後,接下來就講講相同點:

子產品不會重複執行

這個很好了解,無論是 ES6 子產品還是 CommonJS 子產品,當你重複引入某個相同的子產品時,子產品隻會執行一次。

CommonJS 子產品循環依賴

// a.js
console.log('a starting');
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');

// b.js
console.log('b starting');
exports.done = false;
const a = require('./a');
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');

// node a.js
// 執行結果:
// a starting
// b starting
// in b, a.done = false
// b done
// in a, b.done = true
// a done      

結合之前講的特性很好了解,當你從 b 中想引入 a 子產品的時候,因為 node 之前已經加載過 a 子產品了,是以它不會再去重複執行 a 子產品,而是直接去生成目前 a 子產品吐出的 module.exports 對象,因為 a 子產品引入 b 子產品先于給 done 重新指派,是以目前 a 子產品中輸出的 module.exports 中 done 的值仍為 false。而當 a 子產品中輸出 b 子產品的 done 值的時候 b 子產品已經執行完畢,是以 b 子產品中的 done 值為 true。

從上面的執行過程中,我們可以看到,在 CommonJS 規範中,當遇到 require() 語句時,會執行 require 子產品中的代碼,并緩存執行的結果,當下次再次加載時不會重複執行,而是直接取緩存的結果。正因為此,出現循環依賴時才不會出現無限循環調用的情況。雖然這種子產品加載機制可以避免出現循環依賴時報錯的情況,但稍不注意就很可能使得代碼并不是像我們想象的那樣去執行。是以在寫代碼時還是需要仔細的規劃,以保證循環子產品的依賴能正确工作。

是以有什麼辦法可以出現循環依賴的時候避免自己出現混亂呢?一種解決方式便是将每個子產品先寫 exports 文法,再寫 requre 語句,利用 CommonJS 的緩存機制,在 require() 其他子產品之前先把自身要導出的内容導出,這樣就能保證其他子產品在使用時可以取到正确的值。比如:

// a.js
exports.done = true;
let b = require('./b');
console.log(b.done)

// b.js
exports.done = true;
let a = require('./a');
console.log(a.done)      

這種寫法簡單明了,缺點是要改變每個子產品的寫法,而且大部分同學都習慣了在檔案開頭先寫 require 語句。

ES6 子產品循環依賴

跟 CommonJS 子產品一樣,ES6 不會再去執行重複加載的子產品,又由于 ES6 動态輸出綁定的特性,能保證 ES6 在任何時候都能擷取其它子產品目前的最新值。

// a.js
console.log('a starting')
import {foo} from './b';
console.log('in b, foo:', foo);
export const bar = 2;
console.log('a done');

// b.js
console.log('b starting');
import {bar} from './a';
export const foo = 'foo';
console.log('in a, bar:', bar);
setTimeout(() => {
  console.log('in a, setTimeout bar:', bar);
})
console.log('b done');

// babel-node a.js
// 執行結果:
// b starting
// in a, bar: undefined
// b done
// a starting
// in b, foo: foo
// a done
// in a, setTimeout bar: 2      

動态 import()

ES6 子產品在編譯時就會靜态分析,優先于子產品内的其他内容執行,是以導緻了我們無法寫出像下面這樣的代碼:

if(some condition) {
  import a from './a';
}else {
  import b from './b';
}

// or 
import a from (str + 'b');      

因為編譯時靜态分析,導緻了我們無法在條件語句或者拼接字元串子產品,因為這些都是需要在運作時才能确定的結果在 ES6 子產品是不被允許的,是以 動态引入 import() 應運而生。

import() 允許你在運作時動态地引入 ES6 子產品,想到這,你可能也想起了 require.ensure 這個文法,但是它們的用途卻截然不同的。

  • require.ensure 的出現是 webpack 的産物,它是因為浏覽器需要一種異步的機制可以用來異步加載子產品,進而減少初始的加載檔案的體積,是以如果在服務端的話 require.ensure 就無用武之地了,因為服務端不存在異步加載子產品的情況,子產品同步進行加載就可以滿足使用場景了。 CommonJS 子產品可以在運作時确認子產品加載。
  • 而 import() 則不同,它主要是為了解決 ES6 子產品無法在運作時确定子產品的引用關系,是以需要引入 import()

我們先來看下它的用法:

  • 動态的 import() 提供一個基于 Promise 的 API
  • 動态的import() 可以在腳本的任何地方使用
  • import() 接受字元串文字,你可以根據你的需要構造說明符

舉個簡單的使用例子:

// a.js
const str = './b';
const flag = true;
if(flag) {
  import('./b').then(({foo}) => {
    console.log(foo);
  })
}
import(str).then(({foo}) => {
  console.log(foo);
})

// b.js
export const foo = 'foo';

// babel-node a.js
// 執行結果
// foo
// foo      

當然,如果在浏覽器端的 import() 的用途就會變得更廣泛,比如 按需異步加載子產品,那麼就和 require.ensure 功能類似了。

因為是基于 Promise 的,是以如果你想要同時加載多個子產品的話,可以是 Promise.all 進行并行異步加載。

Promise.all([
  import('./a.js'),
  import('./b.js'),
  import('./c.js'),
]).then(([a, {default: b}, {c}]) => {
    console.log('a.js is loaded dynamically');
    console.log('b.js is loaded dynamically');
    console.log('c.js is loaded dynamically');
});      

還有 Promise.race 方法,它檢查哪個 Promise 被首先 resolved 或 reject。我們可以使用import()來檢查哪個CDN速度更快:

const CDNs = [
  {
    name: 'jQuery.com',
    url: 'https://code.jquery.com/jquery-3.1.1.min.js'
  },
  {
    name: 'googleapis.com',
    url: 'https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js'
  }
];

console.log(`------`);
console.log(`jQuery is: ${window.jQuery}`);

Promise.race([
  import(CDNs[0].url).then(()=>console.log(CDNs[0].name, 'loaded')),
  import(CDNs[1].url).then(()=>console.log(CDNs[1].name, 'loaded'))
]).then(()=> {
  console.log(`jQuery version: ${window.jQuery.fn.jquery}`);
});      

當然,如果你覺得這樣寫還不夠優雅,也可以結合 async/await 文法糖來使用。

async function main() {
  const myModule = await import('./myModule.js');
  const {export1, export2} = await import('./myModule.js');
  const [module1, module2, module3] =
    await Promise.all([
      import('./module1.js'),
      import('./module2.js'),
      import('./module3.js'),
    ]);
}      

動态 import() 為我們提供了以異步方式使用 ES 子產品的額外功能。 根據我們的需求動态或有條件地加載它們,這使我們能夠更快,更好地建立更多優勢應用程式。

export

一個子產品就是一個獨立的檔案。該檔案内部的所有變量,外部無法擷取。如果希望外部檔案能夠讀取該子產品的變量,就需要在這個子產品内使用export關鍵字導出變量。如:

// profile.jsexport var a = 1;export var b = 2;export var c = 3;1234      

下面的寫法是等價的,這種方式更加清晰(在底部一眼能看出導出了哪些變量):

var a = 1;var b = 2;var c = 3;
export {a, b, c}1234      

import

import指令可以導入其他子產品通過export導出的部分。

var a = 1;var b = 2;var c = 3;
export {a, b, c}
//main.js
import {a, b, c} from './abc';
console.log(a, b, c);      

如果想為導入的變量重新取一個名字,使用as關鍵字(也可以在導出中使用)。

import {a as aa, b, c};
console.log(aa, b, c)12      

如果想在一個子產品中先輸入後輸出一個子產品,import語句可以和export語句寫在一起。

import {a, b, c} form './abc';export {a, b,  c}// 使用連寫, 可讀性不好,不建議export {a, b, c} from './abc';12345      

子產品的整體加載

使用*關鍵字。

import * from as abc form './abc';      

export default

在export輸出内容時,如果同時輸出多個變量,需要使用大括号

{}

,同時導入也需要大括号。使用

export defalut

輸出時,不需要大括号,而輸入(import)

export default

輸出的變量時,不需要大括号。

// abc.jsvar a = 1, b = 2, c = 3;export {a, b};export default c;1234      
import {a, b} from './abc';
import c from './abc'; // 不需要大括号console.log(a, b, c) // 1 2 3123      

本質上,

export default

輸出的是一個叫做default的變量或方法,輸入這個default變量時不需要大括号。

// abc.js
export {a as default};

// main.js
import a from './abc'; // 這樣也是可以的
import {default as aa} from './abc'; // 這樣也是可以的
console.log(aa);123456789      

就到這裡了吧。關于循環加載(子產品互相依賴)沒寫,CommonJS和ES6處理方式不一樣。

參考文章:

  • javascript子產品化之CommonJS、AMD、CMD、UMD、ES6
  • 深入了解 ES6 子產品機制
  • 該如何了解AMD ,CMD,CommonJS規範–javascript子產品化加載學習總結
  • AMD/CMD與前端規範
  • 前端子產品化之旅(二):CommonJS、AMD和CMD
  • 研究一下javascript的子產品規範(CommonJs/AMD/CMD)
  • Javascript子產品化程式設計(一):子產品的寫法
  • Javascript子產品化程式設計(二):AMD規範
  • Javascript子產品化程式設計(三):require.js的用法
  • Module

轉載請注明來源,再唠叨JS子產品化加載之CommonJS、AMD、CMD、ES6 - javascript入門容易深入難,民工看似康莊大道卻是陷阱遍地 - 周陸軍的個人網站:https://www.zhoulujun.cn/html/webfront/ECMAScript/js/2016_0203_528.html

文有不妥之處,望告之,謝謝