JavaScript模块的详细指南

81 阅读6分钟

在这篇文章中,我们将介绍标准的JavaScript模块,今天我们一般在前端应用中如何使用它们,以及未来我们可能如何使用它们。JavaScript模块有时被称为ESM,它代表ECMAScript模块:

JavaScript Modules in 2020

什么是JavaScript模块?

JavaScript模块是一种构造JavaScript代码的方式。模块中的代码与其他模块中的代码是隔离的,不在全局范围内:

<script>
  function hello() {
    console.log("hello Bob");
  }
</script>
<script>
  function hello() {
    console.log("hello Fred");
  }
</script>
<script>
  hello(); // outputs hello Fred
</script>

上面的代码展示了两个没有使用模块的函数在全局范围内发生碰撞。

JavaScript模块解决的另一个问题是不必担心HTML页面上script 元素的排序问题:

<script>
  hello(); // 💥 - Uncaught ReferenceError: hello is not defined
</script>
<script>
  function hello() {
    console.log("hello");
  }
</script>

在上面的例子中,包含hello 函数的script 元素需要放在调用helloscript 元素之前,才能发挥作用。如果有大量的JavaScript文件,这就很难管理。

今天,JavaScript模块是如何被普遍使用的

JavaScript模块语法是在ES6中引入的,在我们今天构建的应用程序中通常使用如下:

import React from 'react';
...
export const HomePage = () => ...

上面的例子导入了React 模块并导出了一个HomePage 组件。

不过,这段代码并没有在本地使用JavaScript模块。相反,Webpack将其转换为非本地模块(IIFEs)。值得注意的是,Webpack确实有一个实验性的 outputModule功能,允许它发布为本地模块格式。希望这将被包含在Webpack 5中。

在本地使用JavaScript模块

要声明一个引用JavaScript模块代码的script 元素,需要将type 属性设置为"module" :

<script type="module">
  import { hello } from "/src/a.js";
  hello();
</script>

这里是来自a.js 的JavaScript,在src 文件夹中:

// /src/a.js
import { hellob } from "/src/b.js";
import { helloc } from "/src/c.js";

export function hello() {
  hellob();
  helloc();
}

因此,a.js 中的hello 函数调用b.js 中的hellobc.js 中的helloc

下面是来自b.jsc.js 的JavaScript:

// /src/b.js
export function hellob() {
  console.log("hello b");
}
// /src/c.js
export function helloc() {
  console.log("hello c");
}

请注意,我们需要提供我们要导入的文件的完整相对路径,而且我们还需要包括文件的扩展名。我们可能更习惯于使用如下的裸导入指定器:

import { hello } from "a";

我们稍后再来讨论裸体导入指定符。

请注意,我们不需要在HTML文件中声明所有的模块。浏览器在运行时解决了它们。

值得注意的是,你不能从一个普通的script 元素中消费一个JavaScript模块。例如,如果我们尝试不使用type 属性,script 元素就不会被执行:

<script>
  // 💥 - Cannot use import statement outside a module
  import { hello } from "/src/a.js";
  hello();
</script>

编写在JavaScript模块中的代码默认以严格模式执行。所以没有必要在代码的顶部设置use strict :

<script type="module">
  let name = "Fred";
  let name = "Bob"; // 💥 - Identifier 'name' has already been declared
</script>

很好!

JavaScript模块的错误

让我们举一个前面的类似例子,我们有JavaScript模块a,b,c 。模块a 依赖于b, 和c 。模块bc 都没有依赖关系。

假设c.js 包含一个运行时错误:

export function helloc() {
  consol.log("hello c"); // 💥 - Uncaught ReferenceError: consol is not defined
}

这里是如何从HTML文件中调用代码的:

<script type="module">
  import { hello } from "/src/a.js";
  hello();
</script>

下面是a.js :

import { hellob } from "/src/b.js";
import { helloc } from "/src/c.js";

export function hello() {
  hellob();
  helloc(); // 💥
  hellob(); // never executed
}

正如我们所期望的,对hellob 的第二次调用从未到达。

如果c.js 中的问题是一个编译错误呢:

export functio helloc() {
  console.log("hello c");
}

......该模块中没有代码被执行:

<script type="module">
  // 💥 - Unexpected token 'export'
  // no code is executed
  import { hello } from "/src/a.js";
  hello();
</script>

其他模块的代码倒是可以执行。

浏览器支持

所有的现代浏览器都支持本地模块,但不幸的是,IE不支持。不过,我们有办法在支持本地模块的浏览器上使用本地模块,并为不支持本地模块的浏览器提供回退。我们使用脚本元素上的nomodule 属性来实现这一点。

// 使用JavaScript模块的代码 //不使用JavaScript模块的代码

Rollup,可以很好地输出一个模块和nomodule捆绑的配置,如下图所示:

export default [{
  ...
  output: {
    file: 'bundle-esm.js',
    format: 'es'
  }
},{
  ...
  output: {
    file: 'bundle.js',
    format: 'iife'
  }
}];

很好!

瀑布式交付

让我们看一个例子,我们从CDN引用一个模块:

<script type="module">
  import intersection from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js";
  console.log(intersection([2, 1], [2, 3]));
</script>

模块intersection 依赖于其他模块,而这些模块又依赖于其他模块。因此,在执行script 元素的代码之前,所有的依赖关系都被下载和解析了。

Module dependency downloads

预加载模块

JavaScript模块可以使用modulepreload 资源提示来预装:

<link
  rel="modulepreload"
  href="https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.js"
/>

这意味着这个模块在其他模块被下载之前就被下载和解析了:

Module dependency downloads with preload

目前只有Chrome和Edge支持modulepreload 。火狐和Safari会退回到正常下载模块。

动态导入

动态导入是指代码可以在运行时被导入,可能是基于一个条件:

<script type="module">
  if (new Date().getSeconds() < 30) {
    import("/src/a.js").then(({ helloa }) =>
      helloa()
    );
  } else {
    import("/src/b.js").then(({ hellob }) =>
      hellob()
    );
  }
</script>

这对于一些代码使用可能性很低的大型模块来说很有用。这也可以减少应用程序在浏览器中的内存占用。

使用导入地图的裸导入指定符

回到我们如何在导入语句中引用模块的问题上:

import { hello } from "/src/a.js";
import intersection from "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js

如果我们想一想,除非我们指定模块的完整路径,否则浏览器怎么会知道在哪里找到它?所以,这种语法是有意义的,即使我们不习惯它。

有一种方法可以使用裸露的导入指定符,它被称为导入地图。这是在一个特殊的importmap script 元素中定义的地图,需要在引用模块的script 元素之前定义:

<script type="importmap">
  {
    "imports": {
      "b": "/src/b.js",
      "lowdash-intersection": "https://cdn.jsdelivr.net/npm/lodash-es@4.17.15/intersection.min.js"
    }
  }
</script>

每个依赖的模块都被赋予了一个裸导入指定符的名字。

然后,我们可以在导入语句中使用裸导入指定符:

<script type="module">
  import { hellob } from "b";
  hellob();
  import intersection from "lowdash-intersection";
  console.log(intersection([2, 1], [2, 3]));
</script>

酷!

导入图目前在浏览器中还不可用。然而,这个功能在Chrome浏览器中可以通过一个实验性的标志获得:chrome://flags/#enable-experimental-web-platform-features。

依赖关系必须发布ES模块

重要的一点是,库必须发布到本地模块格式,以便消费者将库作为本地模块使用。不幸的是,这在目前并不常见。例如,React还没有发布到本地模块。

本地模块比非本地模块的好处

与IIFE模块等相比,本地模块的一个好处是,需要下载到浏览器、解析然后执行的代码更少。本地模块可以并行地下载和解析,也可以异步地进行。因此,本地模块对于大的依赖树来说可能执行得更快。另外,预加载模块可能意味着用户可以更快地与页面互动,因为这些代码是在主线程之外解析的。

除了一些性能上的提升,新的浏览器功能可能会建立在模块之上,所以使用本地模块是对代码的未来保护。

总结

本机模块适用于目前最流行的浏览器,并且可以为IE提供后备功能。Rollup捆绑包已经可以发布到这种格式,而Webpack支持似乎也即将到来。现在,我们需要的是更多的库开始以这种格式发布。