1.概述
曆史上,JavaScript 一直沒有模組(module)體系,無法將一個大程式拆分成互相依賴的小檔案,再用簡單的方法拼裝起來。其他語言都有這項功能,比如 Ruby 的require、Python 的import,甚至就連 CSS 都有@import,但是 JavaScript 任何這方面的支援都沒有,這對開發大型的、複雜的項目形成了巨大障礙。
在 ES6 之前,社區制定了一些模組載入方案,最主要的有 CommonJS 和 AMD 兩種。前者用於伺服器,後者用於瀏覽器。ES6 在語言標準的層面上,實現了模組功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規範,成為瀏覽器和伺服器通用的模組解決方案。
ES6 模組的設計思想,是盡量的靜態化,使得編譯時間就能確定模組的依賴關係,以及輸入和輸出的變數。CommonJS 和 AMD 模組,都只能在運行時確定這些東西。比如,CommonJS 模組就是對象,輸入時必須尋找對象屬性。
ES6 可以在編譯時間就完成模組載入,效率要比 CommonJS 模組的載入方式高。當然,這也導致了沒法引用 ES6 模組本身,因為它不是對象。
由於 ES6 模組是編譯時間載入,使得靜態分析成為可能。有了它,就能進一步拓寬 JavaScript 的文法,比如引入宏(macro)和類型檢驗(type system)這些只能靠靜態分析實現的功能。
除了靜態載入帶來的各種好處,ES6 模組還有以下好處。
不再需要UMD模組格式了,將來伺服器和瀏覽器都會支援 ES6 模組格式。目前,通過各種工具庫,其實已經做到了這一點。
將來瀏覽器的新 API 就能用模組格式提供,不再必須做成全域變數或者navigator對象的屬性。
不再需要對象作為命名空間(比如Math對象),未來這些功能可以通過模組提供。 2.export命令
模組功能主要由兩個命令構成:export和import。export命令用於規定模組的對外介面,import命令用於輸入其他模組提供的功能。
一個模組就是一個獨立的檔案。該檔案內部的所有變數,外部無法擷取。如果你希望外部能夠讀模數塊內部的某個變數,就必須使用export關鍵字輸出該變數。下面是一個 JS 檔案,裡面使用export命令輸出變數。
// profile.jsexport var firstName = 'Michael';export var lastName = 'Jackson';export var year = 1958;
上面代碼是profile.js檔案,儲存了使用者資訊。ES6 將其視為一個模組,裡面用export命令對外部輸出了三個變數。
export的寫法,除了像上面這樣,還有另外一種。
// profile.jsvar firstName = 'Michael';var lastName = 'Jackson';var year = 1958;export {firstName, lastName, year};
上面代碼在export命令後面,使用大括弧指定所要輸出的一組變數。它與前一種寫法(直接放置在var語句前)是等價的,但是應該優先考慮使用這種寫法。因為這樣就可以在指令碼尾部,一眼看清楚輸出了哪些變數。
export命令除了輸出變數,還可以輸出函數或類(class)。
export function multiply(x, y) { return x * y;};
上面代碼對外輸出一個函數multiply。
通常情況下,export輸出的變數就是本來的名字,但是可以使用as關鍵字重新命名。
function v1() { ... }function v2() { ... }export { v1 as streamV1, v2 as streamV2, v2 as streamLatestVersion};
上面代碼使用as關鍵字,重新命名了函數v1和v2的對外介面。重新命名後,v2可以用不同的名字輸出兩次。
需要特別注意的是,export命令規定的是對外的介面,必須與模組內部的變數建立一一對應關係。
// 報錯export 1;// 報錯var m = 1;export m;
上面兩種寫法都會報錯,因為沒有提供對外的介面。第一種寫法直接輸出 1,第二種寫法通過變數m,還是直接輸出 1。1隻是一個值,不是介面。正確的寫法是下面這樣。
// 寫法一export var m = 1;// 寫法二var m = 1;export {m};// 寫法三var n = 1;export {n as m};
上面三種寫法都是正確的,規定了對外的介面m。其他指令碼可以通過這個介面,取到值1。它們的實質是,在介面名與模組內部變數之間,建立了一一對應的關係。
同樣的,function和class的輸出,也必須遵守這樣的寫法。
// 報錯function f() {}export f;// 正確export function f() {};// 正確function f() {}export {f};
另外,export語句輸出的介面,與其對應的值是動態綁定關係,即通過該介面,可以取到模組內部即時的值。
export var foo = 'bar';setTimeout(() => foo = 'baz', 500);
上面代碼輸出變數foo,值為bar,500 毫秒之後變成baz。
這一點與 CommonJS 規範完全不同。CommonJS 模組輸出的是值的緩衝,不存在動態更新,詳見下文《Module 的載入實現》一節。
最後,export命令可以出現在模組的任何位置,只要處於模組頂層就可以。如果處於塊級範圍內,就會報錯,下一節的import命令也是如此。這是因為處於條件代碼塊之中,就沒法做靜態最佳化了,違背了 ES6 模組的設計初衷。
function foo() { export default 'bar' // SyntaxError}foo()
上面代碼中,export語句放在函數之中,結果報錯。 3.import 命令
使用export命令定義了模組的對外介面以後,其他 JS 檔案就可以通過import命令載入這個模組。
// main.jsimport {firstName, lastName, year} from './profile';function setName(element) { element.textContent = firstName + ' ' + lastName;}
上面代碼的import命令,用於載入profile.js檔案,並從中輸入變數。import命令接受一對大括弧,裡面指定要從其他模組匯入的變數名。大括弧裡面的變數名,必須與被匯入模組(profile.js)對外介面的名稱相同。
如果想為輸入的變數重新取一個名字,import命令要使用as關鍵字,將輸入的變數重新命名。
import { lastName as surname } from './profile';
import後面的from指定模組檔案的位置,可以是相對路徑,也可以是絕對路徑,.js尾碼可以省略。如果只是模組名,不帶有路徑,那麼必須有設定檔,告訴 JavaScript 引擎該模組的位置。
import {myMethod} from 'util';
上面代碼中,util是模組檔案名稱,由於不帶有路徑,必須通過配置,告訴引擎怎麼取到這個模組。
注意,import命令具有提升效果,會提升到整個模組的頭部,首先執行。
foo();import { foo } from 'my_module';
上面的代碼不會報錯,因為import的執行早於foo的調用。這種行為的本質是,import命令是編譯階段執行的,在代碼運行之前。
由於import是靜態執行,所以不能使用運算式和變數,這些只有在運行時才能得到結果的文法結構。
// 報錯import { 'f' + 'oo' } from 'my_module';// 報錯let module = 'my_module';import { foo } from module;// 報錯if (x === 1) { import { foo } from 'module1';} else { import { foo } from 'module2';}
上面三種寫法都會報錯,因為它們用到了運算式、變數和if結構。在靜態分析階段,這些文法都是沒法得到值的。
最後,import語句會執行所載入的模組,因此可以有下面的寫法。
import 'lodash';
上面代碼僅僅執行lodash模組,但是不輸入任何值。
如果多次重複執行同一句import語句,那麼只會執行一次,而不會執行多次。
import 'lodash';import 'lodash';
上面代碼載入了兩次lodash,但是只會執行一次。
import { foo } from 'my_module';import { bar } from 'my_module';// 等同於import { foo, bar } from 'my_module';
上面代碼中,雖然foo和bar在兩個語句中載入,但是它們對應的是同一個my_module執行個體。也就是說,import語句是 Singleton 模式。
目前階段,通過 Babel 轉碼,CommonJS 模組的require命令和 ES6 模組的import命令,可以寫在同一個模組裡面,但是最好不要這樣做。因為import在靜態解析階段執行,所以它是一個模組之中最早執行的。下面的代碼可能不會得到預期結果。
require('core-js/modules/es6.symbol');require('core-js/modules/es6.promise');import React from 'React';
4.模組的整體載入
除了指定載入某個輸出值,還可以使用整體載入,即用星號(*)指定一個對象,所有輸出值都載入在這個對象上面。
下面是一個circle.js檔案,它輸出兩個方法area和circumference。
// circle.jsexport function area(radius) { return Math.PI * radius * radius;}export function circumference(radius) { return 2 * Math.PI * radius;}
現在,載入這個模組。
// main.jsimport { area, circumference } from './circle';console.log('圓面積:' + area(4));console.log('圓周長:' + circumference(14));
上面寫法是逐一指定要載入的方法,整體載入的寫法如下。
import * as circle from './circle';console.log('圓面積:' + circle.area(4));console.log('圓周長:' + circle.circumference(14));
注意,模組整體載入所在的那個對象(上例是circle),應該是可以靜態分析的,所以不允許運行時改變。下面的寫法都是不允許的。
import * as circle from './circle';// 下面兩行都是不允許的circle.foo = 'hello';circle.area = function () {};
5.export default 命令
從前面的例子可以看出,使用import命令的時候,使用者需要知道所要載入的變數名或函數名,否則無法載入。但是,使用者肯定希望快速上手,未必願意閱讀文檔,去瞭解模組有哪些屬性和方法。
為了給使用者提供方便,讓他們不用閱讀文檔就能載入模組,就要用到export default命令,為模組指定預設輸出。
// export-default.jsexport default function () { console.log('foo');}
上面代碼是一個模組檔案export-default.js,它的預設輸出是一個函數。
其他模組載入該模組時,import命令可以為該匿名函數指定任意名字。
// import-default.jsimport customName from './export-default';customName(); // 'foo'
上面代碼的import命令,可以用任意名稱指向export-default.js輸出的方法,這時就不需要知道原模組輸出的函數名。需要注意的是,這時import命令後面,不使用大括弧。
export default命令用在非匿名函數前,也是可以的。
// export-default.jsexport default function foo() { console.log('foo');}// 或者寫成function foo() { console.log('foo');}export default foo;
上面代碼中,foo函數的函數名foo,在模組外部是無效的。載入的時候,視同匿名函數載入。
下面比較一下預設輸出和正常輸出。
// 第一組export default function crc32() { // 輸出 // ...}import crc32 from 'crc32'; // 輸入// 第二組export function crc32() { // 輸出 // ...};import {crc32} from 'crc32'; // 輸入
上面代碼的兩組寫法,第一組是使用export default時,對應的import語句不需要使用大括弧;第二組是不使用export default時,對應的import語句需要使用大括弧。
export default命令用於指定模組的預設輸出。顯然,一個模組只能有一個預設輸出,因此export default命令只能使用一次。所以,import命令後面才不用加大括弧,因為只可能唯一對應export default命令。
本質上,export default就是輸出一個叫做default的變數或方法,然後系統允許你為它取任意名字。所以,下面的寫法是有效。
// modules.jsfunction add(x, y) { return x * y;}export {add as default};// 等同於// export default add;// app.jsimport { default as foo } from 'modules';// 等同於// import foo from 'modules';
正是因為export default命令其實只是輸出一個叫做default的變數,所以它後面不能跟變數聲明語句。
// 正確export var a = 1;// 正確var a = 1;export default a;// 錯誤export default var a = 1;
上面代碼中,export default a的含義是將變數a的值賦給變數default。所以,最後一種寫法會報錯。
同樣地,因為export default命令的本質是將後面的值,賦給default變數,所以可以直接將一個值寫在export default之後。
// 正確export default 42;// 報錯export 42;
6.export 與 import 的複合寫法
如果在一個模組之中,先輸入後輸出同一個模組,import語句可以與export語句寫在一起。
export { foo, bar } from 'my_module';// 等同於import { foo, bar } from 'my_module';export { foo, bar };
上面代碼中,export和import語句可以結合在一起,寫成一行。
模組的介面改名和整體輸出,也可以採用這種寫法。
// 介面改名export { foo as myFoo } from 'my_module';// 整體輸出export * from 'my_module';
預設介面的寫法如下。
export { default } from 'foo';
具名介面改為預設介面的寫法如下。
export { es6 as default } from './someModule';// 等同於import { es6 } from './someModule';export default es6;
同樣地,預設介面也可以改名為具名介面。
export { default as es6 } from './someModule';
下面三種import語句,沒有對應的複合寫法。
import * as someIdentifier from "someModule";import someIdentifier from "someModule";import someIdentifier, { namedIdentifier } from "someModule";
為了做到形式的對稱,現在有提案,提出補上這三種複合寫法。
export * as someIdentifier from "someModule";export someIdentifier from "someModule";export someIdentifier, { namedIdentifier } from "someModule";
7.模組的繼承
模組之間也可以繼承。
假設有一個circleplus模組,繼承了circle模組。
// circleplus.jsexport * from 'circle';export var e = 2.71828182846;export default function(x) { return Math.exp(x);}
上面代碼中的export ,表示再輸出circle模組的所有屬性和方法。注意,export 命令會忽略circle模組的default方法。然後,上面代碼又輸出了自訂的e變數和預設方法。
這時,也可以將circle的屬性或方法,改名後再輸出。
// circleplus.jsexport { area as circleArea } from 'circle';
上面代碼錶示,只輸出circle模組的area方法,且將其改名為circleArea。
載入上面模組的寫法如下。
// main.jsimport * as math from 'circleplus';import exp from 'circleplus';console.log(exp(math.e));
上面代碼中的import exp表示,將circleplus模組的預設方法載入為exp方法。 8.跨模組常量
本書介紹const命令的時候說過,const聲明的常量只在當前代碼塊有效。如果想設定跨模組的常量(即跨多個檔案),或者說一個值要被多個模組共用,可以採用下面的寫法。
// constants.js 模組export const A = 1;export const B = 3;export const C = 4;// test1.js 模組import * as constants from './constants';console.log(constants.A); // 1console.log(constants.B); // 3// test2.js 模組import {A, B} from './constants';console.log(A); // 1console.log(B); // 3
如果要使用的常量非常多,可以建一個專門的constants目錄,將各種常量寫在不同的檔案裡面,儲存在該目錄下。
// constants/db.jsexport const db = { url: 'http://my.couchdbserver.local:5984', admin_username: 'admin', admin_password: 'admin password'};// constants/user.jsexport const users = ['root', 'admin', 'staff', 'ceo', 'chief', 'moderator'];
然後,將這些檔案輸出的常量,合并在index.js裡面。
// constants/index.jsexport {db} from './db';export {users} from './users';
使用的時候,直接載入index.js就可以了。
// script.jsimport {db, users} from './index';