前言
现在的很多前端开发者,不管是出于代码可读性、代码简洁性还是其他可能存在炫技成分的原因,在编写 JS 代码时都喜欢用上最新的 ECMAScript 语法。但大多数的项目都还是会选择通过 babel 将其转换成 ES5 语法,并加上一些必要的 pollfill 去兼容小部分用户使用的不支持 ES2015 的浏览器。
但在理想情况下, 我们其实不需要在浏览器上运行一些不必要的代码,大部分用户使用的浏览器已经支持了 ES205+ 的语法了。
借助新的 JavaScript 和 DOM API,我们可以对新语法进行特征检测。而且,我们现在也确实有一种对基本 ES2015 语法支持进行特征检测的方法。
type module
随着 es6 的发布,各大浏览器也于2018年5月开始支持 <script type="module">
。至今为止,全球已有96.7%的用户使用的浏览器都支持该功能。
<script type="module">
是加载 ES 模块的方法,但 ES 模块的发布同时也伴随着ES2015+ 功能的常规语法,如 async/await、classes、箭头函数、fetch、Promises、Map、Set等。因此,该功能可用于加载ES2015+ 的JavaScript 文件并知道浏览器可以处理。
为了兼容小部分用户使用的浏览器,该功能基本上会与 nomodule 同时存在。
html
<!-- Browsers with ES module support load this file. -->
<script type="module" src="main.modern.js"></script>
<!-- Older browsers load this file (and module-supporting -->
<!-- browsers know *not* to load this file). -->
<script nomodule src="main.legacy.js"></script>
如果我们现在的项目已经支持了 es5 版本的编译,那么我们现在所需要做的就是生成 ES2015+ 版本。
编译实现
如果我们当前已经在使用 webpack、rollup、gulp 等编译器将项目编译成 es5 的 bundle 脚本,那么我们就还需要使用这些编译器,生成一个 ES2015+ 的 bundle 脚本,同时需要将 polyfill 处理成按需加载。
如果我们的工程中使用了 babel-preset-env,那么生成一个 ES2015+ 就更加简单了。我们所需要的就是在入口html 文件中加载 bundle 文件的地方修改成<script type="module">
和 <script nomodule>
的形式就可以了。
改造一下项目的 webpack,使其在编译时编译两套 bundle 脚本,假设入口文件为 './path/to/main.mjs'
javascript
function baseConfig(type) {
const isModern = type === 'es2015'
const output = isModern ? 'main.modern.js' : 'main.legacy.js'
// babel的配置有两种方式处理,可以使用esmodules 配置,
// 也可以将es2015+支持的浏览器最低版本号进行列举到browsers中
// esmodules 方式处理
const targets = isModern
? { esmodules: true }
: { browsers: ['ios >= 10', 'android >= 5.1'] }
// browsers 方式处理
const targets = isModern
? { browsers: ['Chrome >= 60',
'Safari >= 10.1',
'iOS >= 10.3',
'Firefox >= 54',
'Edge >= 15',] }
: { browsers: ['ios >= 10', 'android >= 5.1'] }
return {
entry: './path/to/main.mjs',
output: {
filename: output,
},
module: {
rules: [{
test: /.m?js$/,
use: {
loader: 'babel-loader',
options: {
presets: [
['env', {
modules: false,
useBuiltIns: true,
targets: targets
}],
],
},
},
}],
},
}
}
export default baseConfig
在处理完 webpack 的配置后,接下来我们再进行两次编译。这样做就可以基于同一个 entry 脚本生成一套 ES5 版本的 bundle 和一套 ES2015+ 版本的 bundle。这样一来,我们既能够完美兼容低版本浏览器,又能够利用 ES2015+ 新语法带来的优势了。
javascript
const fs = require('fs-extra')
const babelSupport = require('./babel-support')
const merge = require('webpack-merge')
const baseConfig = require('./config-base.js')
const proConfig = require('./config-pro.js')
const devConfig = require('./config-dev.js')
handle()
async function handle() {
// 构建前清空下构建的目标目录
await fs.remove('build')
const isPro = process.NODE_ENV === 'production'
await build(merge(baseConfig('es2015'), isPro ? proConfig() : devConfig()))
await build(merge(baseConfig('legacy'), isPro ? proConfig() : devConfig()))
}
async function build(webpackConfig) {
const compiler = webpack(webpackConfig)
return new Promise((resolve, reject) => {
compiler.run((err, status) => {
if (err) {
reject()
throw err
}
resolve()
})
})
}
通过以上的配置编译,我们会得到两个可用于生产的 JS 文件
- main.modern.js(语法为 ES2015+)
- main.legacy.js(语法为 ES5)
然后更新 HTML 文件,以便在支持 ES 模块的浏览器中有条件地加载 ES2015+ 包。可以通过判断当前浏览器的版本号来加载不同的 JS 文件,也可以通过<script type="module">
和<script nomodule>
的组合来完成此操作 :
方法1:将下面的 JS 脚本放在 html 文件的 <body>
前进行执行
javascript
// 版本分界线
var browser = {
firefox: '54',
safari: '10.1',
iOS: '10.3',
chrome: '60',
edge: '15',
}
var s = document.createElement('script');
if (p && v && (compareVersion(v, browser[p]) > 0 || compareVersion(v, browser[p]) === 0)) {
// 使用高版本
s.src = "./main.modern.js"
} else {
// 使用低版本
s.src = "./main.legacy.js.js"
}
document.head.appendChild(s);
方法2:将下面的代码放在 html 文件中
html
<script type="module" src="main.modern.js"></script>
<script nomodule src="main.legacy.js"></script>
polyfill按需加载
由于上述的方式对于双版本的分界线比较清晰,可以根据该版本的分界线将 polyfill 拆分成 es5 版本和 es2015 版本,在 babel 编译时根据编译的配置控制 entry 中是否需要添加 es5 版本的 polyfill。
语法检测
除了根据 ES 模块支持情况获取的固定的版本分界线外,还可以通过检测项目中使用的语法来确定浏览器版本的分界线。
目前市面上有两种可用的语法检测工具:es-check 和 mpx-es-check。但对于我们的场景,这两个工具都不完全符合要求。
es-check(由社区提供)只能检测源码中是否存在不符合对应 ECMAScript 版本的语法,并且只能提示第一个不符合项。
mpx-ex-check(滴滴出品)能够检测 bundle 中所有的不符合项,并将它们输出到相应的文件中。
针对我们的场景,我们需要根据检测到的语法及其对应的浏览器支持版本,获取到当前项目在不使用 babel-preset-env 编译时所支持的各大浏览器的最低版本号,然后根据该版本号配置编译的 babel 配置。目前上述两个工具都无法支持这一需求,我们可以基于 mpx-ex-check 进行增强,实现这样的功能。
babel 配置
javascript
{
...
module: {
rules: [{
test: /.m?js$/,
use: {
loader: 'babel-loader',
options: {
presets: isModern ? undifined : [
['env', {
targets: { browsers: ['ios >= 10', 'android >= 5.1'] }
}],
],
},
},
}],
},
}
通过检测以上编译脚本构建出来的 modern 版本的 bundle,就可以获取到当前 modern 版本的 bundle 最低支持的各大浏览器版本号。比如我们的框架模版库检测出来的 modern 版本最低支持的各大浏览器版本号分别是:
- Chorme > 80
- Firefox > 74
- safari > 13.1
- iOS > 13.1
然后进行 bundle 的按需加载(根据浏览器版本号)处理。此时 modern 版本将不再需要加载 polyfill。
价值
理论上因为 modern 版本减少了大量的新语法转换,构建后的 bundle 脚本体积将会降低,此时,运行时加载 js 的耗时也会因此降低。
以下 modern bundle 数据来自于以语法检测出的 bundle 最低支持浏览器版本号作为版本分界线的方案。
体积优化
体积的降低也能让我们的支持运行 modern 版本的用户群体受益。以我们框架模版库的脚本为例
拆分多版本后体积大小如下,新语法版本能节约大概 22% 的体积
版本 | dev 版本 | pro 版本 |
---|---|---|
main.modern.js | 1.5M | 539k |
main.legacy.js | 2M | 680k |
加载性能优化
bundle 脚本在 chrome 浏览器上单独运行的加载耗时
pro版本 | 加载耗时均值 |
---|---|
main.modern.js | 641ms |
main.legacy.js | 864ms |
我们框架模版库的代码量本身也足够重,不过本身我们的脚本在终端本地,所以加载上的耗时对于我们来说没有太大的影响,以上数据来自于我们框架运行在 chrome 120 模拟器上的数据,作为数据参考。
运行性能优化
接下来就是脚本的运行了。Six-Speed 平台已经整理了 ES6 相关语法在进行 Babel 编译和不进行编译时在各个平台上的运行性能数据。详情可以查看 incaseofstairs.com/six-speed 。那么针对我们上述对我们框架脚本做的处理,分别统计一下 modern bundle 和 legacy bundle 的初始化模块在各个版本浏览器上的运行 5 次的耗时数据均值
pro版本 | chrome 120 | iOS 16.0 | iOS 17.2 | 安卓 chromium 99 |
---|---|---|---|---|
main.modern.js | 1248ms | 1252ms | 1149ms | 1319ms |
main.legacy.js | 1744ms | 1545ms | 1443ms | 2589ms |
-28% | -18% | -20% | -49% |
根据以上的数据,可以看出我们的框架模版库脚本 modern 版本的初始化模块在各个浏览器上运行都有一定的性能优化,在安卓上的表现最明显。当然,这数据也比较依赖于初始化模块中使用到的新语法数据。
总结
随着 < script type="module">
标签的出现以及现代浏览器对 ES2015+ 语法的广泛支持,将ES2015+语法应用于生产环境已成为前端开发的趋势。在生产环境采用ES2015+语法的价值,不仅在于优化代码体积、加载性能、运行性能,更在于跟随时代潮流,为我们的网站带来更好的体验和效果,使我们网站的广大用户群体受益。