译者:supot
我最近交流过的前端开发人员都喜欢使用 async/awiat
、classes
、箭头函数这些新特性去编写他们的JavaScript代码。尽管所有的现代浏览器都可以运行ES2015+的代码并且原生支持上面提到的新特性,但是绝大多数开发人员还是会把他们ES2015+的代码编译成ES5的格式,并且提供一份polyfill文件,使得很少一部分使用旧浏览器的用户能够正常访问页面。
这很糟糕。在理想的情况中,我们不会发送没有必须的代码。
对于JavaScript和DOM新的API,我们可以在运行时去检测这些API的支持度,然后按需的去引入相应的polyfill。但是使用一些新的语法,这会非常棘手。浏览器在遇到未知的语法时,会导致解析错误,后面的代码将无法执行。
虽然我们目前没有针对特性检测新语法的解决方案,但是现在我们有一种方案去检测ES2015的语法支持。
解决方案就是<script type="module">
。
大部分开发人员认为<script type="module">
是加载ES模块的方式(这当然是对的),但是<script type="module">
还有一个更加直接和实用的使用场景--加载ES2015+的JavaScript文件并且知道浏览器能够正确处理这些具有新特性的JavaScript文件。
换句话说,每一个支持<script type="module">
的浏览器也将支持绝大数你熟悉并喜欢的ES2015+特性。例如:
- 每个支持
<script type="module">
的浏览器都支持async/await
- 每个支持
<script type="module">
的浏览器都支持Classes
- 每个支持
<script type="module">
的浏览器都支持箭头函数
- 每个支持
<script type="module">
的浏览器都支持fetch
、Promises
、Map
、Set
,以及更多的ES2015+特性
接下来要做的唯一一件事就是为不支持<script type="module">
的浏览器提供一个回退方案。幸运的是,如果你现在已经给你的代码提供了ES5的版本,那你已经完成了这项工作。你现在需要做的就是为你的代码提供一个ES2015+的版本。
接下来的部分将介绍如何实现这个功能,并且讨论发布ES2015+代码将如何改变我们编写模块的方式。
实现
如果你已经在使用webpack或者rollup来打包生成你的代码,那么你应该继续这么做。
接下来,除了当前生成的build文件,你还需要生成第二份build文件,他们唯一的区别就是第二份文件不会编译为ES5的格式,并且不再包含用不到的polyfill(比如Map和Set的polyfill文件)。
如果你正在使用babel-preset-env
(你应该这样),那么第二步也非常简单。你所需要做的就是把目标浏览器列表改成仅支持<script type="module">
的浏览器,Babel将不会转义那些目标浏览器已经支持的语法,配合babel-preset-env的一些配置项,也可以去掉一些已经支持的polyfill文件。
换句话说,它将输出ES2015+的代码而不是ES5的代码。
举个栗子,如果你正在使用webpack,并且入口文件是./path/to/main.mjs
,那么当前你的ES5版本的配置项可能像这样(注意,我将输出文件命名为main.es5.js,因为它是ES5版本的代码):
module.exports = {
entry: './path/to/main.mjs',
output: {
filename: 'main.es5.js',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [{
test: /\.m?js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['env', {
modules: false,
useBuiltIns: true,
targets: {
browsers: [
'> 1%',
'last 2 versions',
'Firefox ESR',
],
},
}],
],
},
},
}],
},
};
复制代码
要生成一份ES2015+的代码,你需要完成第二份配置单,将目标环境设置为支持<script type="module">
的浏览器。它看起来可能是下面的配置项(注意,这里使用.mjs作为扩展名,因为它是一个ES6的模块):
module.exports = {
entry: './path/to/main.mjs',
output: {
filename: 'main.mjs',
path: path.resolve(__dirname, 'public'),
},
module: {
rules: [{
test: /\.m?js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['env', {
modules: false,
useBuiltIns: true,
targets: {
browsers: [
'Chrome >= 60',
'Safari >= 10.1',
'iOS >= 10.3',
'Firefox >= 54',
'Edge >= 15',
],
},
}],
],
},
},
}],
},
};
复制代码
构建运行后,这两个配置将生成两个用于生产环境的build文件
main.mjs
(ES2015+语法)main.es5.js
(ES5语法)
下一步是更新你的HTML模板文件,使得在支持ES6 模块语法的浏览器中能加载ES2015+的文件。你可以使用<script type="module">
和<script nomodule>
的组合:
<!-- 支持ES module 的浏览器将加载这个文件. -->
<script type="module" src="main.mjs"></script>
<!-- 不支持ES module的浏览器将加载这个文件(支持ES module的浏览器会忽略这个文件)-->
<script nomodule src="main.es5.js"></script>
复制代码
Note: 我已经更新了文章中的示例,将所有的模块的拓展名都改成了.mjs。因为这种做法比较新,所以,如果我没有指出使用它的时候会遇到的问题,这将会是我的失职:
你的web服务器需要使用content-type: text/javascript来提供对.mjs文件的支持。如果你的现代浏览器无法加载.mjs文件,可能就是这个原因造成的。
如果你使用webpack和babel来构建你的项目,你需要对配置项做出一些修改,将正则中的/.js$/改成 /.m?js$/
webpack比较老的版本不会为.mjs文件生成sourcemap文件,但是已经在4.19.1的版本中修复了,请使用4.19.1以上的版本
重要的考虑因素
在绝大多数的情况下,这种方案"只是起作用",在实现落地这个方案之前,我们需要了解一些如何去加载模块(.mjs文件)的细节信息:
- 模块文件的加载和
<script defer>
一样,这意味着这些模块只有在文档解析完之后才会执行。普通的js脚本在加载的时候会阻塞html的解析,但是加了defer以后,脚本的下载会和html解析并行执行。如果你的代码需要在此之前运行,最好将该代码拆分并单独加载。 - 模块总是以严格模式运行的,所以如果你需要在非严格模式下运行代码,需要把代码拆出来单独加载。
- module处理顶级的var和函数声明不同于常规的js脚本。在常规的js脚本中,定义变量
var foo = 'bar';
,可以通过window.foo
来访问这个变量,但是在一个模块中无法这么使用。请确保你的代码中没有依赖这种行为。
警告! 在Safari 10中不支持nomodule属性,但是你可以在所有<script nomodule>之前通过内联注入这段代码来解决这个问题。(Safari 11中已经修复这个问题)
一个可以运行的例子
我在github创建了一个仓库webpack-esnext-boilerplate,开发者可以通过这个例子来了解这个方案的具体实现。
在这个项目中,我有意包含了几个webpack的高级功能,因为我想证明这个方案是可用于生产环境的。这些高级功能包含了如下的的一些最佳实践:
因为我永远不会推荐我自己没有使用的东西,所以我已经用这个方案对这个博客网站已经了重构。如果你想了解更多信息,可以查看源码。
如果你使用webpack之外的打包构建工具,这个改造的过程和上面介绍的不会有很大的出入。在这个示例中,我之所以选择使用webpack,是因为webpack是现在最流行的构建工具,而且它足够复杂。我想如果这个方案能够和webpack一起使用,那么它可以适用于其他任何的构建工具。
额外的投入真的值得吗?
在我看来,它绝对值得!它带来了巨大的提升。例如,下面是这个博客网站生成的两个版本文件大小的比较:
Version | Size (minified) | Size (minified + gzipped) |
---|---|---|
ES2015+ (main.mjs) | 80K | 21K |
ES5 (main.es5.js) | 175K | 43K |
ES5的文件大小是ES2015+版本的两倍多(甚至是gzip压缩后)。
更大的文件,需要更长的时间去下载,并且需要更长的时间去解析和执行。两个版本的文件在我的博客网站中的实际效果,ES5的解析和执行时间也是ES2015+版本的两倍:
Version | Parse/eval time (individual runs) | Parse/eval time (avg) |
---|---|---|
ES2015+ (main.mjs) | 184ms, 164ms, 166ms | 172ms |
ES5 (main.es5.js) | 389ms, 351ms, 360ms | 367ms |
虽然这些文件的大小不是很大,解析/执行的时间也不是特别长,但是这只是一个博客网站,我不需要加载大量的脚本。对于大部分的网站来说,情况并非如此。你用的脚本越多,你使用ES2015+所获得的收益就越大。
如果你任然持怀疑态度,并且认为文件大小和执行时间的差异主要是因为ES5需要更多的polyfill文件而造成的,那么你并没有完全错误。但是无论好坏,引入大量的polyfill文件已经是今天很多网站非常普遍的做法了。
HTTPArchive收集到的数据显示,Alexa排名最高的网站中,有85181个网站中包含babel-polyfill、core-js或者regenerator-runtime,而在六个月之前,这个数字是34588!
现实正在转变,包含polyfill正在迅速成为新的常态。不幸的是,这意味着数十亿的用户通过网络去下载数万亿个字节的代码,而这些浏览器本来是可以直接运行没有转义的ES2015+的代码的。
是时候发布ES2015的模块了
目前这个方案的主要问题是很多npm包的作者并不发布ES2015+的代码,而是发布了转义后的ES5的代码。
既然已经可以部署ES2015+的代码,那么是时候去改变它了。
我完全明白这对眼前的未来提出了很多挑战。现在绝大多数的构建工具都会发布文档,并且建议所有的模块都是ES5的。这意味着,如果一个包的作者想npm发布一个ES2015+的代码,他们可能会破坏用户的构建任务,并且通常会导致一些混淆。
问题是绝大多数的开发者在使用babel时,会通过配置忽略node_modules里面的代码,不对node_modules里面的代码进行转义。但是如果使用ES2015+的代码进行发布,这就会产生问题。幸运的是,这个问题很容易修复。你只需要删除你配置项中的node_modules:
rules: [
{
test: /\.m?js$/,
exclude: /node_modules/, // Remove this line
use: {
loader: 'babel-loader',
options: {
presets: ['env']
}
}
}
]
复制代码
修改以后带来的问题是,babel将会转义所以node_modules里面的文件,构建速度会变慢。幸运的是,这个问题可以通过构建工具的本地缓存解决。
无论在ES2015+作为新的包发布标准的路上遇到何种困难,我认为所有的努力都是值得的。如果我们作为包的作者,只将ES5的版本发布到npm上,那么我们会强制包的使用者去使用体积更大、执行效率更低的代码。
通过发布ES2015的代码,我们为开发者提供了一个选项,并且最终是所有人都会从中受益。
总结
虽然<script type="module">
是为了在浏览器中加载使用模块,但是它能做的不仅仅只有这些。
<script type="module">
可以在浏览器中加载JavaScript文件,这为开发者提供了一个急需的方法,可以在支持模块的浏览器中使用一些新的特性。
通过和nomodule属性的配合,为我们在生产环境中使用ES2015+代码提供了一个方案,我们终于可以停止向那些不需要代码转义的浏览器发送转换后的代码了。
编写ES2015+的代码对于开发者来说是一个胜利,部署ES2015+的代码对于用户来说是一个胜利。