歷史上,JavaScript 一直沒有模塊(module)體系,無法將一個大程序拆分成互相依賴的小文件,再用簡單的方法拼裝起來。其他語言都有這項功能,比如 Ruby 的require
、Python 的import
,甚至就連 CSS 都有@import
,但是 JavaScript 任何這方面的支持都沒有,這對開發(fā)大型的、復(fù)雜的項目形成了巨大障礙。
在 ES6 之前,社區(qū)制定了一些模塊加載方案,最主要的有 CommonJS 和 AMD 兩種。前者用于服務(wù)器,后者用于瀏覽器。ES6 在語言標(biāo)準(zhǔn)的層面上,實現(xiàn)了模塊功能,而且實現(xiàn)得相當(dāng)簡單,完全可以取代 CommonJS 和 AMD 規(guī)范,成為瀏覽器和服務(wù)器通用的模塊解決方案。
ES6 模塊的設(shè)計思想,是盡量的靜態(tài)化,使得編譯時就能確定模塊的依賴關(guān)系,以及輸入和輸出的變量。CommonJS 和 AMD 模塊,都只能在運行時確定這些東西。比如,CommonJS 模塊就是對象,輸入時必須查找對象屬性。
// CommonJS模塊let { stat, exists, readFile } = require('fs');// 等同于let _fs = require('fs');let stat = _fs.stat;let exists = _fs.exists;let readfile = _fs.readfile;
上面代碼的實質(zhì)是整體加載fs
模塊(即加載fs
的所有方法),生成一個對象(_fs
),然后再從這個對象上面讀取3個方法。這種加載稱為“運行時加載”,因為只有運行時才能得到這個對象,導(dǎo)致完全沒辦法在編譯時做“靜態(tài)優(yōu)化”。
ES6 模塊不是對象,而是通過export
命令顯式指定輸出的代碼,再通過import
命令輸入。
// ES6模塊import { stat, exists, readFile } from 'fs';
上面代碼的實質(zhì)是從fs
模塊加載3個方法,其他方法不加載。這種加載稱為“編譯時加載”或者靜態(tài)加載,即 ES6 可以在編譯時就完成模塊加載,效率要比 CommonJS 模塊的加載方式高。當(dāng)然,這也導(dǎo)致了沒法引用 ES6 模塊本身,因為它不是對象。
由于 ES6 模塊是編譯時加載,使得靜態(tài)分析成為可能。有了它,就能進(jìn)一步拓寬 JavaScript 的語法,比如引入宏(macro)和類型檢驗(type system)這些只能靠靜態(tài)分析實現(xiàn)的功能。
除了靜態(tài)加載帶來的各種好處,ES6 模塊還有以下好處。
UMD
模塊格式了,將來服務(wù)器和瀏覽器都會支持 ES6 模塊格式。目前,通過各種工具庫,其實已經(jīng)做到了這一點。navigator
對象的屬性。Math
對象),未來這些功能可以通過模塊提供。本章介紹 ES6 模塊的語法,下一章介紹如何在瀏覽器和 Node 之中,加載 ES6 模塊。
ES6 的模塊自動采用嚴(yán)格模式,不管你有沒有在模塊頭部加上"use strict";
。
嚴(yán)格模式主要有以下限制。
with
語句delete prop
,會報錯,只能刪除屬性delete global[prop]
eval
不會在它的外層作用域引入變量eval
和arguments
不能被重新賦值arguments
不會自動反映函數(shù)參數(shù)的變化arguments.callee
arguments.caller
this
指向全局對象fn.caller
和fn.arguments
獲取函數(shù)調(diào)用的堆棧protected
、static
和interface
)上面這些限制,模塊都必須遵守。由于嚴(yán)格模式是 ES5 引入的,不屬于 ES6,所以請參閱相關(guān) ES5 書籍,本書不再詳細(xì)介紹了。
其中,尤其需要注意this
的限制。ES6 模塊之中,頂層的this
指向undefined
,即不應(yīng)該在頂層代碼使用this
。
模塊功能主要由兩個命令構(gòu)成:export
和import
。export
命令用于規(guī)定模塊的對外接口,import
命令用于輸入其他模塊提供的功能。
一個模塊就是一個獨立的文件。該文件內(nèi)部的所有變量,外部無法獲取。如果你希望外部能夠讀取模塊內(nèi)部的某個變量,就必須使用export
關(guān)鍵字輸出該變量。下面是一個 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
語句前)是等價的,但是應(yīng)該優(yōu)先考慮使用這種寫法。因為這樣就可以在腳本尾部,一眼看清楚輸出了哪些變量。
export
命令除了輸出變量,還可以輸出函數(shù)或類(class)。
export function multiply(x, y) { return x * y;};
上面代碼對外輸出一個函數(shù)multiply
。
通常情況下,export
輸出的變量就是本來的名字,但是可以使用as
關(guān)鍵字重命名。
function v1() { ... }function v2() { ... }export { v1 as streamV1, v2 as streamV2, v2 as streamLatestVersion};
上面代碼使用as
關(guān)鍵字,重命名了函數(shù)v1
和v2
的對外接口。重命名后,v2
可以用不同的名字輸出兩次。
需要特別注意的是,export
命令規(guī)定的是對外的接口,必須與模塊內(nèi)部的變量建立一一對應(yīng)關(guān)系。
// 報錯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};
上面三種寫法都是正確的,規(guī)定了對外的接口m
。其他腳本可以通過這個接口,取到值1
。它們的實質(zhì)是,在接口名與模塊內(nèi)部變量之間,建立了一一對應(yīng)的關(guān)系。
同樣的,function
和class
的輸出,也必須遵守這樣的寫法。
// 報錯function f() {}export f;// 正確export function f() {};// 正確function f() {}export {f};
另外,export
語句輸出的接口,與其對應(yīng)的值是動態(tài)綁定關(guān)系,即通過該接口,可以取到模塊內(nèi)部實時的值。
export var foo = 'bar';setTimeout(() => foo = 'baz', 500);
上面代碼輸出變量foo
,值為bar
,500毫秒之后變成baz
。
這一點與 CommonJS 規(guī)范完全不同。CommonJS 模塊輸出的是值的緩存,不存在動態(tài)更新,詳見下文《ES6模塊加載的實質(zhì)》一節(jié)。
最后,export
命令可以出現(xiàn)在模塊的任何位置,只要處于模塊頂層就可以。如果處于塊級作用域內(nèi),就會報錯,下一節(jié)的import
命令也是如此。這是因為處于條件代碼塊之中,就沒法做靜態(tài)優(yōu)化了,違背了ES6模塊的設(shè)計初衷。
function foo() { export default 'bar' // SyntaxError}foo()
上面代碼中,export
語句放在函數(shù)之中,結(jié)果報錯。
使用export
命令定義了模塊的對外接口以后,其他 JS 文件就可以通過import
命令加載這個模塊。
// main.jsimport {firstName, lastName, year} from './profile';function setName(element) { element.textContent = firstName + ' ' + lastName;}
上面代碼的import
命令,用于加載profile.js
文件,并從中輸入變量。import
命令接受一對大括號,里面指定要從其他模塊導(dǎo)入的變量名。大括號里面的變量名,必須與被導(dǎo)入模塊(profile.js
)對外接口的名稱相同。
如果想為輸入的變量重新取一個名字,import
命令要使用as
關(guān)鍵字,將輸入的變量重命名。
import { lastName as surname } from './profile';
import
后面的from
指定模塊文件的位置,可以是相對路徑,也可以是絕對路徑,.js
路徑可以省略。如果只是模塊名,不帶有路徑,那么必須有配置文件,告訴 JavaScript 引擎該模塊的位置。
import {myMethod} from 'util';
上面代碼中,util
是模塊文件名,由于不帶有路徑,必須通過配置,告訴引擎怎么取到這個模塊。
注意,import
命令具有提升效果,會提升到整個模塊的頭部,首先執(zhí)行。
foo();import { foo } from 'my_module';
上面的代碼不會報錯,因為import
的執(zhí)行早于foo
的調(diào)用。這種行為的本質(zhì)是,import
命令是編譯階段執(zhí)行的,在代碼運行之前。
由于import
是靜態(tài)執(zhí)行,所以不能使用表達(dá)式和變量,這些只有在運行時才能得到結(jié)果的語法結(jié)構(gòu)。
// 報錯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';}
上面三種寫法都會報錯,因為它們用到了表達(dá)式、變量和if
結(jié)構(gòu)。在靜態(tài)分析階段,這些語法都是沒法得到值的。
最后,import
語句會執(zhí)行所加載的模塊,因此可以有下面的寫法。
import 'lodash';
上面代碼僅僅執(zhí)行lodash
模塊,但是不輸入任何值。
如果多次重復(fù)執(zhí)行同一句import
語句,那么只會執(zhí)行一次,而不會執(zhí)行多次。
import 'lodash';import 'lodash';
上面代碼加載了兩次lodash
,但是只會執(zhí)行一次。
import { foo } from 'my_module';import { bar } from 'my_module';// 等同于import { foo, bar } from 'my_module';
上面代碼中,雖然foo
和bar
在兩個語句中加載,但是它們對應(yīng)的是同一個my_module
實例。也就是說,import
語句是 Singleton 模式。
除了指定加載某個輸出值,還可以使用整體加載,即用星號(*
)指定一個對象,所有輸出值都加載在這個對象上面。
下面是一個circle.js
文件,它輸出兩個方法area
和circumference
。
// circle.jsexport function area(radius) { return Math.PI * radius * radius;}export function circumference(radius) { return 2 * Math.PI * radius;}
現(xiàn)在,加載這個模塊。
// 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
),應(yīng)該是可以靜態(tài)分析的,所以不允許運行時改變。下面的寫法都是不允許的。
import * as circle from './circle';// 下面兩行都是不允許的circle.foo = 'hello';circle.area = function () {};
從前面的例子可以看出,使用import
命令的時候,用戶需要知道所要加載的變量名或函數(shù)名,否則無法加載。但是,用戶肯定希望快速上手,未必愿意閱讀文檔,去了解模塊有哪些屬性和方法。
為了給用戶提供方便,讓他們不用閱讀文檔就能加載模塊,就要用到export default
命令,為模塊指定默認(rèn)輸出。
// export-default.jsexport default function () { console.log('foo');}
上面代碼是一個模塊文件export-default.js
,它的默認(rèn)輸出是一個函數(shù)。
其他模塊加載該模塊時,import
命令可以為該匿名函數(shù)指定任意名字。
// import-default.jsimport customName from './export-default';customName(); // 'foo'
上面代碼的import
命令,可以用任意名稱指向export-default.js
輸出的方法,這時就不需要知道原模塊輸出的函數(shù)名。需要注意的是,這時import
命令后面,不使用大括號。
export default
命令用在非匿名函數(shù)前,也是可以的。
// export-default.jsexport default function foo() { console.log('foo');}// 或者寫成function foo() { console.log('foo');}export default foo;
上面代碼中,foo
函數(shù)的函數(shù)名foo
,在模塊外部是無效的。加載的時候,視同匿名函數(shù)加載。
下面比較一下默認(rèn)輸出和正常輸出。
// 第一組export default function crc32() { // 輸出 // ...}import crc32 from 'crc32'; // 輸入// 第二組export function crc32() { // 輸出 // ...};import {crc32} from 'crc32'; // 輸入
上面代碼的兩組寫法,第一組是使用export default
時,對應(yīng)的import
語句不需要使用大括號;第二組是不使用export default
時,對應(yīng)的import
語句需要使用大括號。
export default
命令用于指定模塊的默認(rèn)輸出。顯然,一個模塊只能有一個默認(rèn)輸出,因此export default
命令只能使用一次。所以,import
命令后面才不用加大括號,因為只可能對應(yīng)一個方法。
本質(zhì)上,export default
就是輸出一個叫做default
的變量或方法,然后系統(tǒng)允許你為它取任意名字。所以,下面的寫法是有效的。
// modules.jsfunction add(x, y) { return x * y;}export {add as default};// 等同于// export default add;// app.jsimport { default as xxx } from 'modules';// 等同于// import xxx 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
本質(zhì)是將該命令后面的值,賦給default
變量以后再默認(rèn),所以直接將一個值寫在export default
之后。
// 正確export default 42;// 報錯export 42;
上面代碼中,后一句報錯是因為沒有指定對外的接口,而前一句指定外對接口為default
。
有了export default
命令,輸入模塊時就非常直觀了,以輸入 lodash 模塊為例。
import _ from 'lodash';
如果想在一條import
語句中,同時輸入默認(rèn)方法和其他變量,可以寫成下面這樣。
import _, { each } from 'lodash';
對應(yīng)上面代碼的export
語句如下。
export default function (obj) { // ···}export function each(obj, iterator, context) { // ···}export { each as forEach };
上面代碼的最后一行的意思是,暴露出forEach
接口,默認(rèn)指向each
接口,即forEach
和each
指向同一個方法。
export default
也可以用來輸出類。
// MyClass.jsexport default class { ... }// main.jsimport MyClass from 'MyClass';let o = new MyClass();
如果在一個模塊之中,先輸入后輸出同一個模塊,import
語句可以與export
語句寫在一起。
export { foo, bar } from 'my_module';// 等同于import { foo, bar } from 'my_module';export { foo, bar };
上面代碼中,export
和import
語句可以結(jié)合在一起,寫成一行。
模塊的接口改名和整體輸出,也可以采用這種寫法。
// 接口改名export { foo as myFoo } from 'my_module';// 整體輸出export * from 'my_module';
默認(rèn)接口的寫法如下。
export { default } from 'foo';
具名接口改為默認(rèn)接口的寫法如下。
export { es6 as default } from './someModule';// 等同于import { es6 } from './someModule';export default es6;
同樣地,默認(rèn)接口也可以改名為具名接口。
export { default as es6 } from './someModule';
下面三種import
語句,沒有對應(yīng)的復(fù)合寫法。
import * as someIdentifier from "someModule";import someIdentifier from "someModule";import someIdentifier, { namedIdentifier } from "someModule";
為了做到形式的對稱,現(xiàn)在有提案,提出補上這三種復(fù)合寫法。
export * as someIdentifier from "someModule";export someIdentifier from "someModule";export someIdentifier, { namedIdentifier } from "someModule";
模塊之間也可以繼承。
假設(shè)有一個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
變量和默認(rèn)方法。
這時,也可以將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
模塊的默認(rèn)方法加載為exp
方法。
本書介紹const
命令的時候說過,const
聲明的常量只在當(dāng)前代碼塊有效。如果想設(shè)置跨模塊的常量(即跨多個文件),或者說一個值要被多個模塊共享,可以采用下面的寫法。
// 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 './constants';
前面介紹過,import
命令會被 JavaScript 引擎靜態(tài)分析,先于模塊內(nèi)的其他模塊執(zhí)行(叫做”連接“更合適)。所以,下面的代碼會報錯。
// 報錯if (x === 2) { import MyModual from './myModual';}
上面代碼中,引擎處理import
語句是在編譯時,這時不會去分析或執(zhí)行if
語句,所以import
語句放在if
代碼塊之中毫無意義,因此會報句法錯誤,而不是執(zhí)行時錯誤。也就是說,import
和export
命令只能在模塊的頂層,不能在代碼塊之中(比如,在if
代碼塊之中,或在函數(shù)之中)。
這樣的設(shè)計,固然有利于編譯器提高效率,但也導(dǎo)致無法在運行時加載模塊。從語法上,條件加載就不可能實現(xiàn)。如果import
命令要取代 Node 的require
方法,這就形成了一個障礙。因為require
是運行時加載模塊,import
命令無法取代require
的動態(tài)加載功能。
const path = './' + fileName;const myModual = require(path);
上面的語句就是動態(tài)加載,require
到底加載哪一個模塊,只有運行時才知道。import
語句做不到這一點。
因此,有一個提案,建議引入import()
函數(shù),完成動態(tài)加載。
import(specifier)
上面代碼中,import
函數(shù)的參數(shù)specifier
,指定所要加載的模塊的位置。import
命令能夠接受什么參數(shù),import()
函數(shù)就能接受什么參數(shù),兩者區(qū)別主要是后者為動態(tài)加載。
import()
返回一個 Promise 對象。下面是一個例子。
const main = document.querySelector('main');import(`./section-modules/${someVariable}.js`) .then(module => { module.loadPageInto(main); }) .catch(err => { main.textContent = err.message; });
import()
函數(shù)可以用在任何地方,不僅僅是模塊,非模塊的腳本也可以使用。它是運行時執(zhí)行,也就是說,什么時候運行到這一句,也會加載指定的模塊。另外,import()
函數(shù)與所加載的模塊沒有靜態(tài)連接關(guān)系,這點也是與import
語句不相同。
import()
類似于 Node 的require
方法,區(qū)別主要是前者是異步加載,后者是同步加載。
下面是import()
的一些適用場合。
(1)按需加載。
import()
可以在需要的時候,再加載某個模塊。
button.addEventListener('click', event => { import('./dialogBox.js') .then(dialogBox => { dialogBox.open(); }) .catch(error => { /* Error handling */ })});
上面代碼中,import()
方法放在click
事件的監(jiān)聽函數(shù)之中,只有用戶點擊了按鈕,才會加載這個模塊。
(2)條件加載
import()
可以放在if
代碼塊,根據(jù)不同的情況,加載不同的模塊。
if (condition) { import('moduleA').then(...);} else { import('moduleB').then(...);}
上面代碼中,如果滿足條件,就加載模塊 A,否則加載模塊 B。
(3)動態(tài)的模塊路徑
import()
允許模塊路徑動態(tài)生成。
import(f()).then(...);
上面代碼中,根據(jù)函數(shù)f
的返回結(jié)果,加載不同的模塊。
import()
加載模塊成功以后,這個模塊會作為一個對象,當(dāng)作then
方法的參數(shù)。因此,可以使用對象解構(gòu)賦值的語法,獲取輸出接口。
import('./myModule.js').then(({export1, export2}) => { // ...·});
上面代碼中,export1
和export2
都是myModule.js
的輸出接口,可以解構(gòu)獲得。
如果模塊有default
輸出接口,可以用參數(shù)直接獲得。
import('./myModule.js').then(myModule => { console.log(myModule.default);});
上面的代碼也可以使用具名輸入的形式。
import('./myModule.js').then(({default: theDefault}) => { console.log(theDefault);});
如果想同時加載多個模塊,可以采用下面的寫法。
Promise.all([ import('./module1.js'), import('./module2.js'), import('./module3.js'),]).then(([module1, module2, module3]) => { ···});
import()
也可以用在 async 函數(shù)之中。
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'), ]);}main();
聯(lián)系客服