×
广告

原生 ECMAScript 模块:动态import()

96
DDU1222
2017.03.03 18:01* 字数 2827

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

这里是一些额外的资源:

日记本
Web note ad 1