es6 模組與commonJS的區別
在剛接觸模組化開發的階段,我總是容易將export、import、require等語法給弄混,今天索性記個筆記,將ES6 模組
知識點理清楚
未接觸ES6 模組時,模組開發方案常見的有CommonJS、AMD、CMD三種。CommonJS用於伺服器,而另外兩種是用於瀏覽器。
接觸ES6 模組後,模組體系變得更加完善,功能實現更簡單,伺服器和瀏覽器都通用,完全可以取代常見的三種規範。今天就記一下es6模組和CommonJS的區別。
一、載入方式
ES6 模組的設計思想,是儘量的靜態化,使得編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。CommonJS 和 AMD、CMD 模組,都只能在執行時確定這些東西。
// CommonJS模組
let { stat, exists, readFile } = require('fs'); // 等同於
let _fs = require('fs');
let stat = _fs.stat, exists = _fs.exists, readfile = _fs.readfile;
上面的程式碼實質是整體載入了模組fs的所有方法,生成了一個_fs物件,然後從這個物件讀取三個方法。如果模組裡沒有很多方法的話,這樣開發貌似是不錯的,
可是一旦方法多了起來,那效能將大大降低,載入了多餘的方法,浪費我的寬頻。這載入簡稱點就是“執行後載入”。
而ES6 模組不是物件,而是通過export命令顯式指定輸出的程式碼,再通過import命令輸入。
// ES6模組
import { stat, exists, readFile } from 'fs';
上面程式碼的實質是從fs模組載入3個方法,其他方法不載入。這種載入稱為“編譯時載入”或者靜態載入,即 ES6 可以在編譯時就完成模組載入,效率要比 CommonJS 模組的載入方式高。當然,這也導致了沒法引用 ES6 模組本身,因為它不是物件。
由於 ES6 模組是編譯時載入,使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的語法,比如引入巨集(macro)和型別檢驗(type system)這些只能靠靜態分析實現的功能。
除了靜態載入帶來的各種好處,ES6 模組還有以下好處。
- 不再需要UMD模組格式了,將來伺服器和瀏覽器都會支援 ES6 模組格式。目前,通過各種工具庫,其實已經做到了這一點。
- 將來瀏覽器的新 API 就能用模組格式提供,不再必要做成全域性變數或者navigator物件的屬性。
- 不再需要物件作為名稱空間(比如Math物件),未來這些功能可以通過模組提供。
二、值的引用
除了載入方式的不一樣,在模組值的運用也有不一樣的特點。CommonJS模組輸出的是一個值的拷貝,而ES6模組輸出的是值的引用。
CommonJS模組輸出的是被輸出值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值。請看下面這個模組檔案lib.js的例子。
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
上面程式碼輸出內部變數counter和改寫這個變數的內部方法incCounter。然後,在main.js裡面載入這個模組。
// main.js
var mod = require('./lib'); console.log(mod.counter); // 3
mod.incCounter();
console.log(mod.counter); // 3
上面程式碼說明,lib.js模組載入以後,它的內部變化就影響不到輸出的mod.counter了。這是因為mod.counter是一個原始型別的值,會被快取。除非寫成一個函式,才能得到內部變動後的值。
// lib.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
get counter() {
return counter
},
incCounter: incCounter,
};
上面程式碼中,輸出的counter屬性實際上是一個取值器函式。現在再執行main.js,就可以正確讀取內部變數counter的變動了。
ES6模組的執行機制與CommonJS不一樣,它遇到模組載入命令import時,不會去執行模組,而是隻生成一個動態的只讀引用。等到真的需要用到時,再到模組裡面去取值,換句話說,ES6的輸入有點像Unix系統的“符號連線”,原始值變了,import輸入的值也會跟著變。因此,ES6模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組。
還是舉上面的例子。
// lib.js
export let counter = 3;
export function incCounter() {
counter++;
} // main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4
上面程式碼說明,ES6模組輸入的變數counter是活的,完全反應其所在模組lib.js內部的變化。
再舉一個出現在export一節中的例子。
// m1.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500); // m2.js
import {foo} from './m1.js';
console.log(foo);
setTimeout(() => console.log(foo), 500);
上面程式碼中,m1.js的變數foo,在剛載入時等於bar,過了500毫秒,又變為等於baz。
讓我們看看,m2.js能否正確讀取這個變化。
$ babel-node m2.js
bar
baz
上面程式碼表明,ES6模組不會快取執行結果,而是動態地去被載入的模組取值,並且變數總是繫結其所在的模組。
由於ES6輸入的模組變數,只是一個“符號連線”,所以這個變數是隻讀的,對它進行重新賦值會報錯。
// lib.js
export let obj = {}; // main.js
import { obj } from './lib'; obj.prop = 123; // OK
obj = {}; // TypeError
上面程式碼中,main.js從lib.js輸入變數obj,可以對obj新增屬性,但是重新賦值就會報錯。因為變數obj指向的地址是隻讀的,不能重新賦值,這就好比main.js創造了一個名為obj的const變數。
最後,export通過介面,輸出的是同一個值。不同的指令碼載入這個介面,得到的都是同樣的例項。
// mod.js
function C() {
this.sum = 0;
this.add = function () {
this.sum += 1;
};
this.show = function () {
console.log(this.sum);
};
} export let c = new C();
上面的指令碼mod.js,輸出的是一個C的例項。不同的指令碼載入這個模組,得到的都是同一個例項。
// x.js
import {c} from './mod';
c.add(); // y.js
import {c} from './mod';
c.show(); // main.js
import './x';
import './y';
現在執行main.js,輸出的是1。
$ babel-node main.js
1
這就證明瞭x.js和y.js載入的都是C的同一個例項。
三、迴圈載入
“迴圈載入”(circular dependency)指的是,a
指令碼的執行依賴b
指令碼,而b
指令碼的執行又依賴a
指令碼。
// a.js
var b = require('b'); // b.js
var a = require('a');
通常,“迴圈載入”表示存在強耦合,如果處理不好,還可能導致遞迴載入,使得程式無法執行,因此應該避免出現。
但是實際上,這是很難避免的,尤其是依賴關係複雜的大專案,很容易出現a
依賴b
,b
依賴c
,c
又依賴a
這樣的情況。這意味著,模組載入機制必須考慮“迴圈載入”的情況。
對於JavaScript語言來說,目前最常見的兩種模組格式CommonJS和ES6,處理“迴圈載入”的方法是不一樣的,返回的結果也不一樣。
CommonJS模組的迴圈載入
CommonJS模組的重要特性是載入時執行,即指令碼程式碼在require
的時候,就會全部執行。一旦出現某個模組被"迴圈載入",就只輸出已經執行的部分,還未執行的部分不會輸出。
讓我們來看,Node官方檔案裡面的例子。指令碼檔案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 執行完畢');
上面程式碼之中,a.js
指令碼先輸出一個done
變數,然後載入另一個指令碼檔案b.js
。注意,此時a.js
程式碼就停在這裡,等待b.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 執行完畢');
上面程式碼之中,b.js
執行到第二行,就會去載入a.js
,這時,就發生了“迴圈載入”。系統會去a.js
模組對應物件的exports
屬性取值,可是因為a.js
還沒有執行完,從exports
屬性只能取回已經執行的部分,而不是最後的值。
a.js
已經執行的部分,只有一行。
exports.done = false;
因此,對於b.js
來說,它從a.js
只輸入一個變數done
,值為false
。
然後,b.js
接著往下執行,等到全部執行完畢,再把執行權交還給a.js
。於是,a.js
接著往下執行,直到執行完畢。我們寫一個指令碼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);
執行main.js
,執行結果如下。
$ node main.js 在 b.js 之中,a.done = false
b.js 執行完畢
在 a.js 之中,b.done = true
a.js 執行完畢
在 main.js 之中, a.done=true, b.done=true
上面的程式碼證明瞭兩件事。一是,在b.js
之中,a.js
沒有執行完畢,只執行了第一行。二是,main.js
執行到第二行時,不會再次執行b.js
,而是輸出快取的b.js
的執行結果,即它的第四行。
exports.done = true;
總之,CommonJS輸入的是被輸出值的拷貝,不是引用。
另外,由於CommonJS模組遇到迴圈載入時,返回的是當前已經執行的部分的值,而不是程式碼全部執行後的值,兩者可能會有差異。所以,輸入變數的時候,必須非常小心。
var a = require('a'); // 安全的寫法
var foo = require('a').foo; // 危險的寫法 exports.good = function (arg) {
return a.foo('good', arg); // 使用的是 a.foo 的最新值
}; exports.bad = function (arg) {
return foo('bad', arg); // 使用的是一個部分載入時的值
};
上面程式碼中,如果發生迴圈載入,require('a').foo
的值很可能後面會被改寫,改用require('a')
會更保險一點。
ES6模組的迴圈載入
ES6處理“迴圈載入”與CommonJS有本質的不同。ES6模組是動態引用,如果使用import
從一個模組載入變數(即import foo from 'foo'
),那些變數不會被快取,而是成為一個指向被載入模組的引用,需要開發者自己保證,真正取值的時候能夠取到值。
請看下面這個例子。
// a.js如下
import {bar} from './b.js';
console.log('a.js');
console.log(bar);
export let foo = 'foo'; // b.js
import {foo} from './a.js';
console.log('b.js');
console.log(foo);
export let bar = 'bar';
上面程式碼中,a.js
載入b.js
,b.js
又載入a.js
,構成迴圈載入。執行a.js
,結果如下。
$ babel-node a.js
b.js
undefined
a.js
bar
上面程式碼中,由於a.js
的第一行是載入b.js
,所以先執行的是b.js
。而b.js
的第一行又是載入a.js
,這時由於a.js
已經開始執行了,所以不會重複執行,而是繼續往下執行b.js
,所以第一行輸出的是b.js
。
接著,b.js
要列印變數foo
,這時a.js
還沒執行完,取不到foo
的值,導致打印出來是undefined
。b.js
執行完,開始執行a.js
,這時就一切正常了。
再看一個稍微複雜的例子(摘自 Dr. Axel Rauschmayer 的《Exploring ES6》)。
// a.js
import {bar} from './b.js';
export function foo() {
console.log('foo');
bar();
console.log('執行完畢');
}
foo(); // b.js
import {foo} from './a.js';
export function bar() {
console.log('bar');
if (Math.random() > 0.5) {
foo();
}
}
按照CommonJS規範,上面的程式碼是沒法執行的。a
先載入b
,然後b
又載入a
,這時a
還沒有任何執行結果,所以輸出結果為null
,即對於b.js
來說,變數foo
的值等於null
,後面的foo()
就會報錯。
但是,ES6可以執行上面的程式碼。
$ babel-node a.js
foo
bar
執行完畢 // 執行結果也有可能是
foo
bar
foo
bar
執行完畢
執行完畢
上面程式碼中,a.js
之所以能夠執行,原因就在於ES6載入的變數,都是動態引用其所在的模組。只要引用存在,程式碼就能執行。
下面,我們詳細分析這段程式碼的執行過程。
// a.js // 這一行建立一個引用,
// 從`b.js`引用`bar`
import {bar} from './b.js'; export function foo() {
// 執行時第一行輸出 foo
console.log('foo');
// 到 b.js 執行 bar
bar();
console.log('執行完畢');
}
foo(); // b.js // 建立`a.js`的`foo`引用
import {foo} from './a.js'; export function bar() {
// 執行時,第二行輸出 bar
console.log('bar');
// 遞迴執行 foo,一旦隨機數
// 小於等於0.5,就停止執行
if (Math.random() > 0.5) {
foo();
}
}
我們再來看ES6模組載入器SystemJS給出的一個例子。
// even.js
import { odd } from './odd'
export var counter = 0;
export function even(n) {
counter++;
return n == 0 || odd(n - 1);
} // odd.js
import { even } from './even';
export function odd(n) {
return n != 0 && even(n - 1);
}
上面程式碼中,even.js
裡面的函式even
有一個引數n
,只要不等於0,就會減去1,傳入載入的odd()
。odd.js
也會做類似操作。
執行上面這段程式碼,結果如下。
$ babel-node
> import * as m from './even.js';
> m.even(10);
true
> m.counter
6
> m.even(20)
true
> m.counter
17
上面程式碼中,引數n
從10變為0的過程中,even()
一共會執行6次,所以變數counter
等於6。第二次呼叫even()
時,引數n
從20變為0,even()
一共會執行11次,加上前面的6次,所以變數counter
等於17。
這個例子要是改寫成CommonJS,就根本無法執行,會報錯。
// even.js
var odd = require('./odd');
var counter = 0;
exports.counter = counter;
exports.even = function(n) {
counter++;
return n == 0 || odd(n - 1);
} // odd.js
var even = require('./even').even;
module.exports = function(n) {
return n != 0 && even(n - 1);
}
上面程式碼中,even.js
載入odd.js
,而odd.js
又去載入even.js
,形成“迴圈載入”。這時,執行引擎就會輸出even.js
已經執行的部分(不存在任何結果),所以在odd.js
之中,變數even
等於null
,等到後面呼叫even(n-1)
就會報錯。
$ node
> var m = require('./even');
> m.even(10)
TypeError: even is not a function