一文搞懂 Dynamic Import 和 Top-level await 提案

·  阅读 1765

1. 前言

随着 ES6 的发布,JavaScript 语法也越来越趋于成熟,新的提案也在不断地提出。

ECMA 提案一共有四个阶段,处于 Stage3 的都需要我们持续关注,以后很可能就会被纳入新标准中。

今天主要来深入讲解一下动态 import 和 Top-level await。

动态import

1. Dynamic Import

如果你写过 Node,会发现和原生的 import/export 有个不一样的地方就是 Node 支持就近加载。

Node 允许你可以在用到的时候再去加载这个模块,而不用全部放到顶部加载。

而 ES Module 的语法是静态的,会自动提升到代码的顶层。

以下面这个 Node 模块为例子,最后依次打印出来的是 mainnoop

// noop.js
console.log('noop');
module.exports = function() {}
// main.js
console.log('main')
const noop = require('./noop')
复制代码

如果换成 import/export,不管你将 import 放到哪里,打印结果都是相反的。比如下面依次打印的是 noopmain

// noop.js
console.log('noop');
export default function() {}
// main.js
console.log('main')
import noop from './noop'
复制代码

在我们前端开发中,为了优化用户体验,往往需要对页面资源按需加载。

如果只想在用户进入某个页面的时候再去加载这个页面的资源,那么就可以配合路由去动态加载资源。

1.1 React Suspense

在很久很久之前,我们都是用 webpack 提供的 require.ensure() 来实现 React 路由切割。

const rootRoute = {
  path: '/',
  indexRoute: {
    getComponent(nextState, cb) {
      require.ensure([], (require) => {
        cb(null, require('pages/Home'))
      }, 'Home')
    },
  },
  getComponent(nextState, cb) {
    require.ensure([], (require) => {
      cb(null, require('pages/Login'))
    }, 'Login')
  }
}

ReactDOM.render(
  (
    <Router
      history={browserHistory}
      routes={rootRoute}
      />
  ), document.getElementById('app')
);
复制代码

在 React16 中,已经提供了 Suspense/lazy 支持了按需加载。我们可以通过 Dynamic Import 来加载页面,配合 Suspense 实现路由分割。

import react, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

const Home = lazy(() => import('./pages/home'))
const Login = lazy(() => import('./pages/login'))
function Routes() { 
    return (
        <Router>
            <Suspense fallback={<div>loading</div>}>
                <Switch>
                    <Route exact path="/" component={Home} />
                     <Route path="/login" component={Login} />
                </Switch>
            </Suspense>
        </Router>
    )
}
复制代码

1.2 动态 import 提案

由于各种历史原因,一个动态 import 的提案就被提了出来,这个提案目前已经走到了 Stage4 阶段。

通过动态 import 允许我们按需加载 JavaScript 模块,而不会在最开始的时候就将全部模块加载。

const router = new Router({
    routes: [{
        path: '/home',
        name: 'Home',
        component: () =>
            import('./pages/Home.vue')
    }]
})
复制代码

动态 import 返回了一个 Promise 对象,这也意味着可以在 then 中等模块加载成功后去做一些操作。

<nav>
  <a href="books.html" data-entry-module="books">Books</a>
  <a href="movies.html" data-entry-module="movies">Movies</a>
  <a href="video-games.html" data-entry-module="video-games">Video Games</a>
</nav>

<main>Content will load here!</main>

<script>
  const main = document.querySelector("main");
  for (const link of document.querySelectorAll("nav > a")) {
    link.addEventListener("click", e => {
      e.preventDefault();

      import(`./section-modules/${link.dataset.entryModule}.js`)
        .then(module => {
          module.loadPageInto(main);
        })
        .catch(err => {
          main.textContent = err.message;
        });
    });
  }
</script>
复制代码

1.3 手动实现一个动态 import 函数

其实我们自己也完全可以通过 Promise 来封装这样一个 api,核心在于动态生成 script 标签,在 script 中导入需要懒加载的模块,将其挂载到 window 上面。

function importModule(url) {
    return new Promise((resolve, reject) => {
        const script = document.createElement("script");
        script.type = "module";
        script.textContent = `import * as m from "${url}"; window.tempModule = m;`;
    })
}
复制代码

当 script 的 onload 事件触发之时,就把 tempModule 给 resolve 出去,同时删除 window 上面的 tempModule

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);
  });
}
复制代码

这个 importModule 也是官方推荐的在不支持动态 import 的浏览器环境中的一种实现。

2. Top-level await

前面讲了动态 import,但是如果想在动态引入某个模块之后再导出当前模块的数据,那么该怎么办呢?

如果在模块中我依赖了某个需要异步获取的数据之后再导出数据怎么办?

2.1 ES Module 的缺陷

如果你认真研究过 ES Module 和 CommonJS,会发现两者在导出值的时候还有一个区别。

可以简单地理解为,CommonJS 导出的是快照,而 ES Module 导出的是引用。

举个栗子:

我们在模块 A 里面定义一个变量 count,将其导出,同时在这个模块中设置 1000ms 之后修改 count 值。

// moduleA.js
export let count = 0;
setTimeout(() => {
    count = 10;
}, 1000)

// moduleB.js
import { count } from 'moduleA'

console.log(count);
setTimeout(() => {
    console.log(count);
}, 2000)
复制代码

你会觉得这两次输出会有什么不一样吗?这个 count 怎么看都是一个基本类型,难道 2000ms 之后输出还会变化不成?

没错,在 2000ms 后再去打印 count 的确是会变化,你会发现 count 变成了 10,这也意味着 ES Module 导出的时候并不会用快照,而是从引用中来获取值。

而在 CommonJS 中则完全相反,CommonJS 中两次都输出了 0,这意味着 CommonJS 导出的是快照。

2.2 IIAFEs 的局限性

已知在 JS 中使用 await 都要在外面套一个 async 函数,如果想要导出一个异步获取之后的值,传统的做法如下:

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
async function main() {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
}
main();
export { output };
复制代码

或者使用 IIAFE,由于这种模式和 IFEE 比较像,所以被叫做 Immediately Invoked Async Function Expression,简称 IIAFE。

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
(async () => {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
})();
export { output };
复制代码

但是这两种做法有一个问题,如果导入这个模块后立即使用 output,那么拿到的是个 undefined,因为异步加载的数据还没有获取到。一直到异步加载的数据拿到了之后,才能导入正确的值。

想要拿到异步加载之后的数据,最粗暴的方式就是在一段时间之后再去获取这个 output,例如:

import { output } from './awaiting'
setTimeout(() => {
    console.log(output)
}, 2000)
复制代码

2.3 升级版的 IIAFEs

当然上面的这种做法也很不靠谱,毕竟谁也不知道异步加载要经过多少秒才返回,所以就诞生了另外一种写法,直接导出整个 async 函数 和 output 变量。

// awaiting.mjs
import { process } from "./some-module.mjs";
let output;
export default (async () => {
  const dynamic = await import(computedModuleSpecifier);
  const data = await fetch(url);
  output = process(dynamic.default, data);
})();
export { output };
复制代码

导入 async 函数之后,在 then 方法里面再去使用我们导入的 output 变量,这样就确保了数据一定是动态加载之后的。

// usage.mjs
import promise, { output } from "./awaiting.mjs";
export function outputPlusValue(value) { return output + value }

promise.then(() => {
  console.log(outputPlusValue(100));
  setTimeout(() => console.log(outputPlusValue(100), 1000);
});
复制代码

2.4 Top-level await

Top-level await 允许你将整个 JS 模块视为一个巨大的 async 函数,这样就可以直接在顶层使用 await,而不必用 async 函数包一层。 那么来重写上面的例子吧。

// awaiting.mjs
import { process } from "./some-module.mjs";
const dynamic = import(computedModuleSpecifier);
const data = fetch(url);
export const output = process((await dynamic).default, await data);
复制代码

可以看到,直接在外层 使用 await 关键字来获取 dynamic 这个 Promise 的返回值,这种写法解决了原来因为 async 函数导致的各种问题。

Top-level await 现在处于 Stage3 阶段。

分类:
前端
标签: