向现代 Javascript 转型
本文为译文,内容尽可能保持和原文的一致性 译者:字节跳动 Web Infra 团队 原文作者:Houssein Djirdeh, Jason Miller 原文标题:Publish, ship, and install modern JavaScript for faster applications 原文链接:https://web.dev/publish-modern-javascript/
当今的网络正受到传统 JavaScript 的限制。使用 ES2017 语法来编写、发布和交付网页及软件所带来的收益,是传统 JavaScript 的任何一项优化都无法比拟的。
现代 JavaScript
现代 JavaScript 不是特指基于某个 ECMAScript 版本编写的代码,而是指所有现代浏览器都支持的语法。现代浏览器(像 Chrome、Edge、Firefox 以及 Safari)在浏览器市场中所占的整体份额超过了 90%,而依赖于相同底层渲染引擎的不同浏览器又占了额外的 5%。这意味着在过去的 10 年中,全球网络流量的 95% 都来自能够广泛支持 JavaScript 新语言特性的浏览器,这些特性包括:
类(Classes,ES2015) 箭头函数(Arrow Functions,ES 2015) 生成器(Generators,ES2015) 块级作用域(Block scoping,ES2015) 解构(Destructuring,ES2015) 剩余参数 / 展开语法(Rest and spread parameters,ES2015) 对象字面量简写语法(Object shorthand,ES2015) Async/await 异步语法 (ES2017)
对于较新版本的语言规范,新功能在这些现代浏览器中的支持通常都较差,不同浏览器的支持行为可能并不一致。例如,许多 ES2020 和 ES2021 的特性,仅有 70% 的浏览器能够支持 —— 虽然这个百分比可以看成是一个较大场景的覆盖率,但显然直接上手使用这些功能并不见得是明智的(考虑到兼容性)。这意味着,尽管“现代的” JavaScript 还在不断的演进,但在当下,ES2017 依然是最具广泛浏览器兼容支持的那个版本,它囊括了常用的大部分现代语法功能。换句话说,ES2017 是当今最接近现代语法标准的通行版本。
传统的 JavaScript
传统的(Legacy) JavaScript 会特意避免使用上述的新语言特性。大部分的开发人员会使用更现代的语法编写代码,之后会通过编译转换成较旧的语法以增强对不同浏览器的支持。编译为旧语法确实能支持更多的浏览器,但是其成效往往小于我们的预期。很多情况下,这种支持率的变化能从大约 95% 增加到 98%,但同时也显著的提高了成本:
传统的 JavaScript 编写的代码通常体积会大上 20%,且运行更慢。工具缺陷和配置错误经常会进一步的拉大这一差距。 在一个典型的 JavaScript 项目中,库的体积占比高达 90%。库代码由于 polyfill 和 helper 的存在,会产生更高的无谓开销,这本是可以避免的。
NPM 上的现代 JavaScript
不久前,Node.js 新增了一个 exports
字段来定义一个包的入口:
{
"exports": "./index.js"
}
使用 exports
字段的模块,意味着它的 Node.js 版本至少应是 12.8,支持 ES2019。即任何使用 exports
字段引用的模块,都可以用现代的 JavaScript 来编写。此 npm 包的使用者也必须假设带有 exports
字段的模块包含了现代代码,并在必要时进行转译。
仅发布现代代码
如果你想发布一个带有现代语法特性的包,并让包的使用者去负责转译的工作,那么你只需使用 exports
字段。
{
"name": "foo",
"exports": "./modern.js"
}
注意:这种方法并不推荐。在“理想”的场景中,每个开发者都已经配置了他们的构建系统,将所有的依赖包 (node_modules) 转译到了他们所需的语法版本中。然而,事实并非如此,如果你发布的包使用的是更新式的语法,那对于需要兼容传统浏览器的页面就无法使用此包了。
现代代码 + 传统兼容
将 exports
和 main
字段结合使用,我们可以用更新式的语法编写代码,并在发包的时候基于 ES5 + CommonJS 的方式提供对传统浏览器的向前兼容。
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs"
}
现代代码 + 传统兼容 + ESM bundler 优化
词汇对称,下文中
- legacy bundle:兼容旧浏览器的构建产物 - modern bundle:面向现代浏览器的构建产物
除了定义传统 CommonJS 入口,我们还可以使用 module
字段来指向一个 legacy bundle 入口,但它使用 JavaScript 的模块语法(import
和 export
)。
{
"name": "foo",
"exports": "./modern.js",
"main": "./legacy.cjs",
"module": "./module.js"
}
许多打包工具,例如 Webpack 和 Rollup,都依靠这个字段来利用模块特性并实现代码剪枝(tree-shaking)。除了使用了 import / export 语法外,这仍然是一个传统的 JS 包,不包含任何现代代码,所以使用此种方法来发布现代代码与备用入口仍然对打包起到了优化。
应用中的现代 JavaScript
第三方依赖占据了典型 Web 应用打包产物中的绝大部分体积。虽然 npm 依赖在历史上一直是使用传统的 ES5 语法发布的,但如今这已经不再是一个安全的假设,而且包的更新也有可能会破坏现有应用程序中的浏览器支持。
随着越来越多的 npm 包转向现代 JavaScript,确保使用正确的构建工具变得愈发重要。你所依赖的一些 npm 包很有可能已经使用了更新的语言特性。有很多选项可以在不牺牲旧浏览器支持的前提下,也能使用更新式语法编写的 npm 包。其中一个典型的想法就是让构建系统将依赖关系转译成与你自己的代码相同的语法。
Webpack
Webpack 5 现在可以通过配置指定 Webpack 在构建 Bundle 或模块时的使用的语法。这不会影响你的代码或第三方依赖,它只影响 Webpack 生成的 "胶水 "代码。要想支持指定的浏览器,你可以在项目中添加一个 browserlist 配置,或者直接在 Webpack 中进行配置。
module.exports = {
target: ['web', 'es2017'],
};
也可以配置 Webpack 来生成优化后的 bundle,当以现代 ES Modules 为编译目标时,可以省略不必要的包装函数。同样可以使用 <script type="module">
配置 Webpack 来加载代码分割 bundle。
module.exports = {
target: ['web', 'es2017'],
output: {
module: true,
},
experiments: {
outputModule: true,
},
};
有很多 Webpack 插件可以在支持传统浏览器的同时编译和发布现代 JavaScript,比如 Optimize Plugin 和 BabelEsmPlugin。
Optimize Plugin
Optimize Plugin 是一个 Webpack 插件,它可以将打包后的代码从现代 JavaScript 转换为传统 JavaScript,而不是基于源文件重新转换。这是一个独立的设置,允许你的 Webpack 配置假设所有的东西都是现代的 JavaScript,没有多输出或混杂语法的可能性。
由于 Optimize Plugin 作用于 bundle 而不是单个模块,所以它等同看待应用源码和依赖。这使得它可以安全地使用来自 npm 的现代 JavaScript 依赖项,因为这些代码都会被打包并被转译为正确的语法格式。它还可以比两步走的传统编译方案更快,同时仍然为现代和传统浏览器生成单独的 bundle。这两份 bundle 被设计成使用 模块/非模块模式 加载。
// webpack.config.js
const OptimizePlugin = require('optimize-plugin');
module.exports = {
// ...
plugins: [new OptimizePlugin()],
};
Optimize Plugin 可以比自定义 Webpack 配置更快、更高效,因为后者通常会将现代代码和传统代码分开打包。它还能为你处理 Babel 的运行问题,并使用 Terser 对 bundle 进行最小化,同时对现代和传统的输出进行单独的优化设置。最后,生成的 legacy bundles 所需的 polyfill 会被提取到一个专门的脚本中,这样它们就不会在新的浏览器中被重复的加载。
对比:“两步走”的编译方案与 bundle 转译
BabelEsmPlugin
BabelEsmPlugin 是一个 Webpack 插件,它与 @babel/reset-env 一起工作,用来生成已有 bundle 的现代版本,以便现代浏览器加载更少的编译代码。它是解决 模块/非模块 问题最流行的现成解决方案,被 Next.js 和 Preact CLI 使用。
// webpack.config.js
const BabelEsmPlugin = require('babel-esm-plugin');
module.exports = {
//...
module: {
rules: [
// your existing babel-loader configuration:
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
],
},
plugins: [new BabelEsmPlugin()],
};
BabelEsmPlugin 对 Webpack 的各种配置支持的蛮到位,因为它会运行两个独立的应用程序来执行构建。对于大型应用程序来说,两次编译可能会多花一点额外的时间,但是这种技术可以让 BabelEsmPlugin 无缝地集成到现有的 Webpack 配置中,使其成为最方便的选择之一。
配置 babel-loader 来转译 node_modules
如果你在使用 babel-loader 时没有使用前面两个插件之一,为了确保现代 JavaScript npm 模块的正常工作,还需要一个重要的步骤。即定义两个单独的 babel-loader 配置,一个可以将 node_modules中的现代语言特性自动编译为 ES2017,一个可以依旧按照项目定义的 Babel 插件和配置将你的代码进行转译。这不会为现代和传统浏览器生成两份单独的 bundle 来解决 模块/非模块 问题,但它确实可以安装和使用包含现代 JavaScript 语法的 npm 包,而不会破坏旧浏览器的支持。
webpack-plugin-modern-npm 使用了上述方法来编译在 package.json 中带有 exports
字段的 npm 包,因为这些包中可能包含现代语法:
// webpack.config.js
const ModernNpmPlugin = require('webpack-plugin-modern-npm');
module.exports = {
plugins: [
// auto-transpile modern stuff found in node_modules
new ModernNpmPlugin(),
],
};
此外,你也可以在你的 Webpack 配置中通过检查模块的 package.json 中的 exports
字段来手动实现这个技术。一个自定义的实现可能是这样的(为了简洁起见,省略了缓存配置):
// webpack.config.js
module.exports = {
module: {
rules: [
// Transpile for your own first-party code:
{
test: /\.js$/i,
loader: 'babel-loader',
exclude: /node_modules/,
},
// Transpile modern dependencies:
{
test: /\.js$/i,
include(file) {
let dir = file.match(/^.*[/\\]node_modules[/\\](@.*?[/\\])?.*?[/\\]/);
try {
return dir && !!require(dir[0] + 'package.json').exports;
} catch (e) {}
},
use: {
loader: 'babel-loader',
options: {
babelrc: false,
configFile: false,
presets: ['@babel/preset-env'],
},
},
},
],
},
};
当使用这种方法时,你需要确保你的 minifier 支持现代语法。Terser 和 uglify-es 都有一个选项 {ecma: 2017}
来指定生成的代码版本,以便在压缩和格式化过程中保留生成的 ES2017 语法。
Rollup
Rollup 内置了对生成多套捆绑包的支持,作为一次构建的一部分,并且默认生成现代代码。因此,Rollup 可以通过配置官方的插件来生成 modern bundles 和 legacy bundles。
@rollup/plugin-babel
如果你使用 Rollup,getBabelOutputPlugin() 方法 (由 Rollup 官方Babel插件提供)会将最终将代码打包成 bundle 而不是单独的源模块。Rollup 内置了对单次构建生成多套 bundle 的支持,每套 bundle 都有自己的插件配置。你可以通过不同的 Babel 配置来生成 modern 和 legacy bundles。
// rollup.config.js
import {getBabelOutputPlugin} from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
output: [
// modern bundles:
{
format: 'es',
plugins: [
getBabelOutputPlugin({
presets: [
[
'@babel/preset-env',
{
targets: {esmodules: true},
bugfixes: true,
loose: true,
},
],
],
}),
],
},
// legacy (ES5) bundles:
{
format: 'amd',
entryFileNames: '[name].legacy.js',
chunkFileNames: '[name]-[hash].legacy.js',
plugins: [
getBabelOutputPlugin({
presets: ['@babel/preset-env'],
}),
],
},
],
};
其他构建工具
Rollup 和 Webpack 是高度可配置的构建工具,这通常意味着每个使用他们项目必须手动更新其配置,在依赖中启用现代 JavaScript 语法。还有一些更高级别的构建工具,它们更倾向于使用惯例和默认值而不是配置,比如 Parcel、Snowpack、Vite 和 WMR。这些工具大多假设 npm 的依赖项可能包含现代语法,并在生产构建时将它们转译适当的语法级别。
除了 Webpack 和 Rollup 的专用插件外,还可以使用 devolution 将具有 legacy fallback 的现代 JavaScript 捆绑程序添加到任何项目中。Devolution 是一个独立的工具,它可以将构建系统的输出转换为传统 JavaScript 的变体,允许捆绑和转换为现代输出目标。
结语
如今,ES2017 是最接近现代语法的版本,npm、Babel、Webpack 和 Rollup 等工具的使用,令使用这些更新式语法配置构建系统和编写软件包成为了可能。这篇文章涵盖了相关的几种实现方法,相信你已经准备好使用最适合自己的那一种了。