原生 ECMAScript 模块:动态import()

原文链接: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的传递使得在模块中函数调用不同。下面是控制台的截图:

屏幕快照 2017-03-01 下午10.06.40.png

让我们来分析一下:第一个不同寻常的地方在于-我们引入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 href="https://plnkr.co/edit/pHoD7S9kXicUvvpsLoEz?p=preview">传统脚本</a>和一个<a href="https://plnkr.co/edit/mHB6R5khaRcUHVbAgWWe?p=preview">模块</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');
});

控制台输出展示了这两种方式都没有在全局中执行:

屏幕快照 2017-03-02 下午4.00.57.png

这意味着,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]));
});

这是控制台输出:

屏幕快照 2017-03-02 下午4.56.29.png

小结:

  • 在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)

屏幕快照 2017-03-02 下午6.06.10.png

当然,这看起来有点奇怪,但这只不过是向你显示,你可以使用基于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文件夹或类似文件夹)中执行该方法时,可能会导致报错。
让我们想想下面的文件夹结构和代码:

屏幕快照 2017-03-02 下午8.12.31.png

// 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

正如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上实现了在浏览器中运行,这意味着不久的将来这个规范会成为一个标准。

这里是一些额外的资源:

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,340评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,762评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,329评论 0 329
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,678评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,583评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,995评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,493评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,145评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,293评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,250评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,267评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,973评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,556评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,648评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,873评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,257评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,809评论 2 339

推荐阅读更多精彩内容

  • 官方中文版原文链接 感谢社区中各位的大力支持,译者再次奉上一点点福利:阿里云产品券,享受所有官网优惠,并抽取幸运大...
    HetfieldJoe阅读 3,644评论 2 27
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,559评论 18 139
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,257评论 25 707
  • Git的三个配置文件 过滤条件 例子 注意事项 1. Git的三个配置文件 线上设置.gitignore 用来保存...
    wuli伊阳光阅读 177评论 1 0
  • 回顾往事,犹如噩梦新来,如果你也和我曾经一样,花三分钟看完我的故事同样会改变你的一生,再次分享我背后的心酸历程,不...
    0aa922e7d0e1阅读 256评论 0 0