理解 ES6 模块
理解 ES6 模块
本文探讨 ES6 模块,展示如何通过转译器在今天使用它们。
几乎每种编程语言都有模块的概念,即在一个文件中声明的功能可以被另一个文件使用。通常,开发者会封装一个代码库,用来完成特定功能。这个库可以被应用或其他模块引用。
使用模块的好处:
- 代码可以分割成包含自包含功能的小文件。
- 相同的模块可以在任意的应用程序之间共享。
- 在理想情况下,其他开发者不需要关心模块内部,因为它们可以直接使用。
- 被引用模块是一个依赖。如果模块文件被更改或移动,马上就会发现问题。
- 模块代码(通常)有助于消除命名冲突。模块1中的函数
x()
不会与模块2中的函数x()
冲突。使用名称空间等选项,使调用变成module1.x()
和module2.x()
。
JavaScript 模块在哪里?
几年前开始网页开发的任何人都会惊讶地发现 JavaScript 中没有模块的概念。不可能在另一个 JavaScript 文件中直接引用或包含另一个 JavaScript 文件。因此,开发人员采取了一些替代方案。
<script>
标签
多个 HTML HTML 可以使用多个 <script>
标签加载任意数量的 JavaScript 文件:
<script src="lib1.js"></script>
<script src="lib2.js"></script>
<script src="core.js"></script>
<script>
console.log('inline code');
</script>
然而,这不是一个实用的解决方案:
- 每个脚本启动一个新的 HTTP 请求,这会影响页面性能。 HTTP/2 在一定程度上缓解了这个问题,但它不能帮助引用其他域上的脚本,如 CDN。
- 每个脚本在运行时会阻止进一步处理。
- 依赖管理是一个手动过程。在上面的代码中,如果
lib1.js
引用lib2.js
中的代码,则代码将失败,因为它尚未被加载。这可能破坏进一步的 JavaScript 处理。 - 函数可能会覆盖其他函数,除非使用适当的 模块模式。早期的 JavaScript 库以使用全局函数名称或覆盖本地方法而臭名昭著。
脚本串接
解决多个 <script>
标记问题的一个方法是将所有 JavaScript 文件连接到单个大文件中。这解决了一些性能和依赖关系管理问题,但可能需要手动构建和测试步骤。
模块加载器
系统,如 RequireJS 和 SystemJS,提供了一个加载和使用其他 JavaScript 库的运行时库。模块在需要时使用 Ajax 方法加载。系统可以提供帮助,但对于大型代码库或添加了标准 <script>
标记的网站来说,可能变得复杂。
模块绑定程序、预处理器和转译器
绑定程序引入了一个编译步骤,因此 JavaScript 代码在构建时生成。处理代码以包含依赖关系并产生一个单个的 ES5 跨浏览器兼容连续文件。流行的选择包括 Babel、 Browserify、 webpack 以及更通用的任务运行器,如 Grunt 和 Gulp。
JavaScript 构建过程需要一些努力,但有些好处:
- 处理是自动化的,因此人为错误的可能性更小。
- 可以进一步处理代码,包括检查代码、删除调试命令、缩小结果文件等。
- 转译器允许使用替代语法,例如 TypeScript 或 CoffeeScript。
ES6 模块
上述选项引入了多种竞争的模块定义格式。广泛采用的语法包括:
- CommonJS —— Node.js 中使用的
module.exports
和require
语法 - 异步模块定义 (AMD)
- 通用模块定义 (UMD)。
因此,ES6 (ES2015) 中提出了一个单一的本地模块标准。
ES6 模块内部的所有内容默认都是私有的,并在严格模式下运行(不需要 'use strict'
)。使用 export
公开变量、函数和类。例如:
// lib.js
export const PI = 3.1415926;
export function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
export function mult(...args) {
log('mult', args);
return args.reduce((num, tot) => tot * num);
}
// 私有函数
function log(...msg) {
console.log(...msg);
}
或者,可以使用单个 export
语句。例如:
// lib.js
const PI = 3.1415926;
function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
function mult(...args) {
log('mult', args);
return args.reduce((num, tot) => tot * num);
}
// 私有函数
function log(...msg) {
console.log(...msg);
}
export { PI, sum, mult };
然后使用 import
将模块中的项目拉入另一个脚本或模块:
// main.js
import { sum } from './lib.js';
console.log( sum(1,2,3,4) ); // 10
在这种情况下, lib.js
与 main.js
在同一个文件夹中。可以使用绝对文件引用(以 /
开头)、相对文件引用(以 ./
或 ../
开头)或完整 URL。
可以一次导入多个项目:
import { sum, mult } from './lib.js';
console.log( sum(1,2,3,4) ); // 10
console.log( mult(1,2,3,4) ); // 24
并可以将导入别名以解决命名冲突:
import { sum as addAll, mult as multiplyAll } from './lib.js';
console.log( addAll(1,2,3,4) ); // 10
console.log( multiplyAll(1,2,3,4) ); // 24
最后,通过提供名称空间,可以导入所有公共项目:
import * as lib from './lib.js';
console.log( lib.PI ); // 3.1415926
console.log( lib.add(1,2,3,4) ); // 10
console.log( lib.mult(1,2,3,4) ); // 24
在浏览器中使用 ES6 模块
在撰写本文时,基于 Chromium 的浏览器(v63+)、Safari 11+ 和 Edge 16+ 支持 ES6 模块。Firefox 支持将在版本 60 中到达(在 v58+ 中它在 about:config
标志后面)。
使用模块的脚本必须通过在 <script>
标记中设置 type="module"
属性来加载。例如:
<script type="module" src="./main.js"></script>
或内联:
<script type="module">
import { something } from './somewhere.js';
// ...
</script>
模块解析一次,无论它们在页面或其他模块中被引用多少次。
服务器考虑
模块必须使用 MIME 类型 application/javascript
服务。大多数服务器会自动执行此操作,但要注意动态生成的脚本或 .mjs
文件(请参见下面的 Node.js 部分)。
常规 <script>
标记可以获取其他域上的脚本,但模块使用跨域资源共享 (CORS) 获取。因此,不同域上的模块必须设置适当的 HTTP 标头,例如 Access-Control-Allow-Origin: *
。
最后,模块不会发送 cookie 或其他标头凭据,除非在 <script>
标记中添加 crossorigin="use-credentials"
属性,并且响应包含标头 Access-Control-Allow-Credentials: true
。
模块执行被延迟
<script defer>
属性将脚本执行延迟到文档加载和解析之后。模块,包括内联脚本,默认推迟。例如:
<!-- 第二运行 -->
<script type="module">
// do something...
</script><!-- 第三运行 -->
<script defer src="c.js"></script><!-- 第一运行 -->
<script src="a.js"></script><!-- 第四运行 -->
<script type="module" src="b.js"></script>
模块回退
不支持模块的浏览器不会运行 type="module"
脚本。可以使用附带 nomodule
属性的回退脚本,模块兼容的浏览器会忽略它。例如:
<script type="module" src="runs-if-module-supported.js"></script><script nomodule src="runs-if-module-not-supported.js"></script>
应该在浏览器中使用模块吗?
浏览器支持正在增长,但可能过早地切换到 ES6 模块。目前,最好使用模块打包程序创建一个适用于各处的脚本。
在 Node.js 中使用 ES6 模块
当 Node.js 在 2009 年发布时,无论任何运行时都不提供模块都是不可思议的。采用了 CommonJS,这意味着可以开发 Node 包管理器 npm。从那时起,使用量呈指数级增长。
CommonJS 模块可以使用类似于 ES2015 模块的方式编码。使用 module.exports
而不是 export
:
// lib.js
const PI = 3.1415926;
function sum(...args) {
log('sum', args);
return args.reduce((num, tot) => tot + num);
}
function mult(...args) {
log('mult', args);
return args.reduce((num, tot) => tot * num);
}
// 私有函数
function log(...msg) {
console.log(...msg);
}
module.exports = { PI, sum, mult };
使用require
(而不是import
)将此模块引入另一个脚本或模块:
const { sum, mult } = require('./lib.js');
console.log( sum(1,2,3,4) ); // 10
console.log( mult(1,2,3,4) ); // 24
require
也可以导入所有项目:
const lib = require('./lib.js');
console.log( lib.PI ); // 3.1415926
console.log( lib.add(1,2,3,4) ); // 10
console.log( lib.mult(1,2,3,4) ); // 24
那么在Node.js中实现ES6模块很容易,对吗? 呃,不是。
ES6模块在Node.js 9.8.0+中**需要打开标志**,并且至少要到版本10才能完全实现。虽然CommonJS和ES6模块共享类似的语法,但它们的工作方式根本不同:
- ES6模块在执行代码之前预解析以解析进一步的导入。
- CommonJS模块在执行代码时按需加载依赖项。
在上面的示例中没有任何区别,但请考虑以下ES2015模块代码:
// ES2015 modules
// ---------------------------------
// one.js
console.log('running one.js');
import { hello } from './two.js';
console.log(hello);
// ---------------------------------
// two.js
console.log('running two.js');
export const hello = 'Hello from two.js';
ES2015的输出:
running two.js
running one.js
hello from two.js
使用CommonJS编写的类似代码:
// CommonJS modules
// ---------------------------------
// one.js
console.log('running one.js');
const hello = require('./two.js');
console.log(hello);
// ---------------------------------
// two.js
console.log('running two.js');
module.exports = 'Hello from two.js';
CommonJS的输出:
running one.js
running two.js
hello from two.js
在某些应用程序中,执行顺序可能很关键,如果在同一文件中混合使用ES2015和CommonJS模块会发生什么?为了解决这个问题,Node.js只允许在扩展名为.mjs
的文件中使用ES6模块。扩展名为.js
的文件将默认使用CommonJS。这是一个简单的选项,消除了大部分复杂性,应该有助于代码编辑器和linters。
你应该在Node.js中使用ES6模块吗?
ES6模块仅适用于Node.js v10及以上版本(于2018年4月发布)。转换现有项目不太可能产生任何好处,并且会使应用程序与早期版本的Node.js不兼容。
对于新项目,ES6模块提供了CommonJS的替代方案。语法与客户端编码相同,并且可能提供更容易的路线到isomorphic JavaScript,可以在浏览器或服务器上运行。
模块搏斗
标准化的JavaScript模块系统花费了很多年时间才到达,甚至更长时间才实现,但问题已经得到解决。所有主流浏览器和从2018年中期开始的Node.js都支持ES6模块,尽管应该预期切换期滞后,直到所有人都升级为止。
今天学习ES6模块,明天从中受益,提高你的JavaScript开发。