原文鏈接:Native ECMAScript modules: dynamic import()
貢獻者: 晨雪
在上一篇文章《原生ECMAScript 模塊: Webpack模塊的新特性和的差異》中,我們了解ES模塊之間的不同,與此同時,也明白了他們在打包/編譯(如Webpack / Babel)上的應用。至此,我們明白了許多,并知道如何使用import / export 聲明,以及我們在JS中使用它們時可能存在的問題。
但是,在很多年以前JavaScript已經異步了,在現在網絡實踐中,使用非阻塞的基于Promise的語法是一個不錯的選擇。默認情況下,ECMAScript模塊通過default來實現靜態化:你必須在模塊的頂層使用import / exports
。這對于優化JS引擎很有幫助,然而它卻限制了開發者運用最好的方法來實現異步模塊加載。
可以基于Promise的API添加一些缺失功能實現動態import()
的操作。
一些動態import()
的操作,可以添加缺失功能,最好的實現是基于Promise的API。
目的和原理
每個進步都是從一個小小的想法開始的。Domenic Denicola和模塊加載社區引入并推進動態導入的想法。
現在,我們有一個在TC39第三階段的規范草案的一個初始模型。
這意味著,在第四階段開始之前,仍有幾個實現方案需要完成,并且需要額外的搜集和處理來自用戶及這些方案本身的一些反饋。
你也可以成為其中之一,動態import()
已經在Safari技術預覽上部署實現。你可以下載,開始使用并測試(這里是一個簡單的演示)。
你的反饋對我們來說很重要,你可以通過問卷調查或評論WHATWG提案來與我們聯系。
語法
語法很簡明:
import("./specifier.js"); // returns a Promise
這是一個從靜態到動態導入轉換的例子(你可以試試demo):
// STATIC
import './a.js';
import b from './b.js';
b();
import {c} from './c.js';
c();
// DYNAMIC
import('./a.js').then(()=>{
console.log('a.js is loaded dynamically');
});
import('./b.js').then((module)=>{
const b = module.default;
b('isDynamic');
});
import('./c.js').then(({c})=>{
c('isDynamic');
});
isDynamic
的傳遞使得在模塊中函數調用不同。下面是控制臺的截圖:
讓我們來分析一下:第一個不同尋常的地方在于-我們引入a.js兩次,然而只得到了一次反饋。你可能還記得,這是ES模塊的一個特性,當他們為單例時,他們只被調用一次。
其次,動態導入在靜態導入之前執行。這是因為我在我的HTML中引入了傳統的腳本中調用了動態import()
(你也可以在傳統的腳本中使用動態導入,不僅僅是在模塊中!):
<script type="module" src="static.js"></script>
<script src="dynamic.js"></script>
我們知道type="module"
腳本會默認延遲,等到DOM解析后才會按順序被引入進來。這就是為什么dynamic
腳本先被執行。熟練運用import()會讓你找到一把打開所有原生ES模塊的一把鑰匙,你可以隨時隨地的加載并使用他們。
第三個不同在于:靜態導入保證你的腳本按順序執行, 但是動態導入的腳本不按它們在代碼中顯示的順序執行。你必須知道,每一個動態導入都是獨立存在的,他們之間互無關聯,也不會等待其他執行完成執行。
讓我們總結一下:
- 動態
import()
提供基于Promise的API -
import()
遵循ES模塊規則:單例、匹配符、CORS 等 -
import()
可以在傳統的腳本中使用也可以在模塊中使用 - 在代碼中使用的
import()
的順序與它們被解析的順序沒有任何關聯
腳本生效和環境
上文已經說過,你可以從傳統或者模塊腳本調用import()
。但是它是如何作為一個模塊或者在全局環境中執行的呢?
你可能會認為,動態導入是作為一個模塊執行,它提供與全局完全不同的環境。
我們可以做個測試:
// imported.js
console.log(`imported.js "this" reference is: ${this}`);
如果在全局中執行腳本,“this”引用指向一個全局對象。所以讓我們從一個<a >傳統腳本</a>和一個<a >模塊</a>執行我們的示例:
<!--module.js-->
<script type="module" src="module.js"></script>
<!--classic.js-->
<script src="classic.js"></script>
// module/classic.js
import('./imported.js').then(()=>{
console.log('imported.js is just imported from the module/classic.js');
});
控制臺輸出展示了這兩種方式都沒有在全局中執行:
這意味著,import()
作為模塊執行腳本實際上與在then()
函數中我們可以使用模塊導出(如module.default等)的語法一致。
附加功能
這個附加功能使我們可以不只是在最頂部使用動態導入操作符。例如:
function loadUserPage(){
import('user-page.js').then(doStuff);
}
loadUserPage();
這使得你可以使用延遲加載和導入實現需求上的新功能(例如關于用戶操作):
// load a script and use it on user actions
FBshareBtn.on('click', ()=>{
import('/fb-sharing').then((FBshare)=>{
FBshare.do();
});
});
我們已經知道import()
腳本只會加載一次,這只是其中一個優點。
更重要的是,動態導入的非靜態性質讓你跨過模塊限制,根據自己的需求來構建代碼,例如(demo):
const locale = 'en';
import(`./utils_${locale}.js`).then(
(utils)=>{
console.log('utils', utils);
utils.default();
}
);
正如你已經注意到的,默認導入在module.default
屬性下可用。
當然, 你也可以根據條件加載:
if(user.loggedIn){
import('user-widget.js');
}
小結:
- 你可以在延遲加載、條件加載和用戶操作的情景下使用動態導入
- 動態
import()
可以在腳本的任何地方使用 -
import()
能夠傳遞字符串,你可以根據你的需求構造匹配符
調試
關于調試 - 最突出的優點,你可以在瀏覽器DevTools控制臺中訪問ES模塊,因為import()可以任何地方使用。
你可以輕松的加載、測試或者調試模塊。讓我們做一個簡單的例子,加載一個官方ECMAScript版本的lodash(lodash-es)并檢查其版本和一些其他功能:
import("https://cdn.rawgit.com/lodash/lodash/4.17.4-es/lodash.default.js")
.then(({default:_})=>{// load and use lodash
console.log(`lodash version ${_.VERSION} is loaded`)
console.log('_.uniq([2, 1, 2]) :', _.uniq([2, 1, 2]));
});
這是控制臺輸出:
小結:
- 在DevTools控制臺中使用動態導入(有助于開發和調試)
Promise API的優點
動態導入使用了JS Promise API。 那么它給我們帶來了什么優勢呢?
首先,我們可以并行地加載多個動態腳本。讓我們重做我們的最開始的示例來觸發和捕獲多個腳本的加載:
Promise.all([
import('./a.js'),
import('./b.js'),
import('./c.js'),
])
.then(([a, {default: b}, {c}]) => {
console.log('a.js is loaded dynamically');
b('isDynamic');
c('isDynamic');
});
我在腳本中使用JavaScript解構來避免const _b = b.default。還有Promise.race方法,它檢查哪個Promise更先或者更快被處理。
在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}`);
});
這是幾次重新加載之后的控制臺輸出,顯示了哪個CDN加載文件更快(在這種情況下通知import()
、加載和執行這兩個文件并注冊jQuery)
當然,這看起來有點奇怪,但這只不過是向你顯示,你可以使用基于Promises的API的所有功能。
最后,讓我們看一些語法糖。ECMAScript async/ await功能也是基于Promise的,這意味著你能很容易用動態導入重構。
因此,讓我們嘗試使用與靜態導入類似但具有動態import()
(演示)的所有功能的語法:
// utils_en.js
const test = (isDynamic) => {
let prefix;
if (isDynamic) {
prefix = 'Static import';
} else {
prefix = 'Dynamic import()';
}
const phrase = `${prefix}: ECMAScript dynamic module loader
"import()" works in this browser`;
console.log(phrase);
alert(phrase);
};
export {test};
// STATIC
import {test} from './utils_en.js'; // no dynamic locale
test();
// DYNAMIC
(async () => {
const locale = 'en';
const {test} = await import(`./utils_${locale}.js`);
test('isDynamic');
})();
小結:
- 使用
Promise.all
并行加載模塊 - 所有promise API的功能都可以用于
import()
匹配符的用法 - 可以使用async/await動態導入
Promise API注意事項
有一個額外的警告,從Promises性質來說我們千萬不要忘記錯誤處理。如果在執行期間使用帶有匹配符的靜態導入或模塊拼寫中存在任何錯誤,則會自動拋出錯誤。在使用Promises的情況下,你應該為then()
方法增加一個函數,或者在catch()
結構中捕獲錯誤,否則你的程序永遠無法跑通。
以下是導入一個不存在的腳本的demo:
import (`./non-existing.js`)
.then(console.log)
.catch((err) => {
console.log(err.message); // "Importing a module script failed."
// apply some logic, e.g. show a feedback for the user
});
最近一段時間,如果你沒有對Promise做錯誤處理,瀏覽器/Node.js不會給你反饋任何信息。所以社區推薦了在控制臺沒有報錯或者在Node.js應用被異常終止的情況下全局處理錯誤的功能。
以下是如何在全局添加處理Promises的監聽:
window.addEventListener("unhandledrejection", (event)=> {
console.warn(`WARNING: Unhandled promise rejection. Reason: ${event.reason}`);
console.warn(event);
});
// process.on('unhandledRejection'... in case of Node.js
其他注意事項
我們討論下在import()
匹配符的相對路徑。正如你所期望的,它是相對于被調用文件的路徑。當你要從不同的文件夾導入模塊并且在第三方模塊位置(例如utils文件夾或類似文件夾)中執行該方法時,可能會導致報錯。
讓我們想想下面的文件夾結構和代碼:
// utils.js - is used to load a dependency
export const loadDependency = (src) => {
return import(src)
.then((module) => {
console.log('dependency is loaded');
return module;
})
};
// inner.js - the main file we will use to test the passed import() path
import {loadDependency} from '../utils.js';
loadDependency('../dependency.js');
// Failed to load resource, as import() is called in ../dependency.js
loadDependency('./dependency.js');// Successfully loaded
正如demo中所示,import()
匹配符總是相對于調用它的文件,所以謹記這個事實以避免意外的錯誤。
小結:
-
import()
匹配符總是相對于被調用的文件
支持和polyfills
至此為止,幾乎沒有瀏覽器支持import()
。Node.js正在考慮添加這個功能,可能會更像require.import()
。
為了檢測它支持某個瀏覽器或Node.js,請運行以下代碼或嘗試這個demo:
let dynamicImportSupported = false;
try{
Function('import("")');
dynamicImportSupported = true;
}catch(err){};
console.log(dynamicImportSupported);
關于polyfills,模塊加載社區準備了一個importModule函數解決方案,它提供了類似于import()
的功能:
function importModule(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
const tempGlobal = "__tempModuleLoadingVariable" +
Math.random().toString(32).substring(2);
script.type = "module";
script.textContent = `import * as m from "${url}"; window.${tempGlobal} = m;`;
script.onload = () => {
resolve(window[tempGlobal]);
delete window[tempGlobal];
script.remove();
};
script.onerror = () => {
reject(new Error("Failed to load module script with URL " + url));
delete window[tempGlobal];
script.remove();
};
document.documentElement.appendChild(script);
});
}
但是這個解決方案有很多漏洞,僅供參考。
Babel為這種語法提供了dynamic-import-webpack插件,你可以安裝它,并用它解析import()
匹配符。
Webpack 2支持使用動態import()
代碼拆分,在你以前使用require.ensure的地方。
importScripts(urls)替換
在Worker / ServiceWorker腳本中,importScripts(urls)
接口用于將一個或多個腳本同步導入到工作程序的作用域中。它的語法很簡單:
importScripts('foo.js', 'bar.js' /*, ...*/);
你可以將import()
視為importScripts()
的高級,異步和非阻塞版本。
當Worker類型是“module”時,則嘗試使用importScripts會拋出一個TypeError異常,這一點是需要注意的。
隨著動態導入無處不在,當它支持所有瀏覽器時,將importScripts()用法使用動態import()重構時是個不錯的選擇。在執行模塊時還要仔細檢查范圍,避免出錯。
最后
動態import()
給我們提供了用異步方式使用ES模塊的額外功能。根據我們的需要動態或有條件地加載它們,這使我們能夠更快,更好地創建更多優秀的應用程序。
webpack2使用了這個API,目前在Stage 3上實現了在瀏覽器中運行,這意味著不久的將來這個規范會成為一個標準。