关于使用(与发布)ES2015+ 包的思考
本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →
对于那些需要支持旧版浏览器的开发者,我们通常会在应用代码上运行 Babel 这类编译器。但这并非我们交付给浏览器的全部代码;还有位于 node_modules 中的依赖代码。
能否让编译依赖不仅成为可能,更成为常态?
依赖编译能力是整个生态系统的赋能特性需求。基于 Babel v7 为实现选择性依赖编译所做的改进,我们希望看到这一实践在未来成为标准。
基本前提
-
我们面向支持原生 ES2015+ 的现代浏览器(无需支持 IE),或能发送多种类型资源包(例如通过
<script type="module">和<script nomodule>) -
依赖包实际发布的是 ES2015+ 代码而非当前的 ES5/ES3 基准
-
未来基准不应固定为 ES2015,而是动态演进的靶点
为什么需要它
为何需要编译依赖(而不仅是自身代码)?
-
获得在代码运行环境权衡上的自主权(区别于库的兼容策略)
-
减少向用户交付的代码量,因为 JavaScript 存在性能成本
瞬态 JavaScript 运行时
支持依赖编译的核心理由,与 Babel 最终引入 @babel/preset-env 的动因一致——我们预见到开发者终将突破仅编译到 ES5 的局限。
Babel 前身是 6to5,因其仅转换 ES2015(当时称 ES6)至 ES5。彼时浏览器对 ES2015 的支持几乎为零,JavaScript 编译器作为创新方案极具价值:开发者能编写现代代码并兼容所有用户环境。
但浏览器运行时本身呢?常青浏览器终将跟进标准(如 ES2015 的普及),创建 preset-env 帮助 Babel 和社区与浏览器及 TC39 保持同步。若仅编译至 ES5,用户将永远无法在浏览器中运行原生现代代码。
关键差异在于认识到兼容性支持始终存在_动态窗口_:
-
应用代码(我们支持的环境)
-
浏览器(Chrome、Firefox、Edge、Safari)
-
Babel(抽象层)
-
TC39/ECMAScript 提案(及 Babel 实现)
因此需求不仅是将 6to5 更名为 Babel(因其可编译 7to5),更要改变 Babel 默认仅针对 ES5 的隐含设定。借助 @babel/preset-env,开发者能编写最新 JavaScript 并定位任意浏览器/环境!
Babel 与 preset-env 帮助我们适应这个持续变化的动态窗口。然而即便使用它们,目前也仅作用于应用代码,而非代码依赖。
谁掌控着我们的依赖项?
由于我们对自己的代码拥有控制权,因此能够利用 preset-env:既可以用 ES2015+ 编写代码,又能够面向支持 ES2015+ 的浏览器。
但我们的依赖项情况未必如此;为了获得与编译自身代码相同的好处,我们可能需要做出一些调整。
这就像直接在 node_modules 上运行 Babel 那么简单吗?
当前编译依赖项的复杂性
编译器的复杂性
尽管这不应阻碍我们实现目标,但需意识到编译依赖项确实会扩大问题范围和复杂性,尤其对 Babel 本身而言。
-
编译器与其他程序无异,同样存在 bug。
-
并非所有依赖项都需要编译,编译更多文件必然导致构建速度变慢。
-
preset-env本身可能存在 bug,因为我们使用compat-table而非官方测试套件 Test262 的数据。 -
浏览器在运行原生 ES2015+ 代码时(相较于 ES5)也可能存在问题。
-
如何定义"支持"仍是问题:参见 babel/babel-preset-env#54 的边缘案例。仅因语法通过解析或有部分支持就算兼容吗?
Babel v6 中的具体问题
将 script 作为 module 运行会导致 SyntaxError、新运行时错误或意外行为,根源在于经典脚本与模块的语义差异。
Babel v6 将所有文件视为 module 并默认启用严格模式。
有人认为这反而是优势,因为 Babel 用户默认就选择了严格模式 🙂。
在 node_modules 上使用常规配置运行 Babel,可能对 jQuery 插件这类 script 代码造成问题。
典型案例如 this 被转换为 undefined。
// Input
(function($) {
// …
}(this.jQuery));
// Output
"use strict";
(function ($) {
// …
})(undefined.jQuery);
v7 版本已修改此行为,仅当源文件是 module 时才自动注入 "use strict"。
编译依赖本就不在 Babel 初始范畴:我们确实收到过误操作导致构建变慢的反馈。工具链中的默认设置和文档都有意禁用了 node_modules 编译。
使用非标准语法
交付未编译的提案语法存在诸多问题(本文灵感源自 Dan 的担忧)。
提案阶段推进机制
TC39 提案推进流程并非单向:提案可能从阶段 3 回退到阶段 2(如数值分隔符 1_000),被完全废弃(如 Object.observe() 等已被遗忘的提案 😁),或像函数绑定(a::b)和装饰器那样长期停滞。
- 各阶段概述:阶段 0 没有具体标准,表示提案仅是一个想法;阶段 1 表示认可问题值得解决;阶段 2 需用规范文本描述解决方案;阶段 3 表示具体方案已构思完善;阶段 4 表示提案已准备好纳入规范,具备测试用例、多浏览器实现和实际应用经验。
使用提案语法
— Rach Smith 🌈 (@rachsmithtweets) August 1, 2017
我们始终建议开发者谨慎使用 Stage 3 以下的提案语法,更不应将其发布到生产环境。
但单纯告诫人们"不要使用 Stage X 语法"违背了 Babel 的核心使命。提案能持续改进并推进的关键动力,正是委员会通过实际使用(无论是否在生产环境)获得的反馈——而 Babel 正是实现这一过程的核心工具。
这里需要把握平衡:我们既不希望开发者因畏惧而放弃新语法(这很难说服 😂),也不愿人们产生"只要进入 Babel 就代表语法已官方定型"的误解。理想情况是开发者主动研究提案目标,根据自身场景权衡使用利弊。
在 v7 中移除阶段预设
尽管使用 Stage 0 预设是最常见的做法,我们仍决定在 v7 移除阶段预设。我们曾认为这能提升便利性——反正用户会创建非官方预设,或许还能缓解"JavaScript 疲劳"。但现实表明它引发了更多问题:开发者持续复制/粘贴配置,却从未理解预设的底层原理。
毕竟看到 "stage-0" 并不能传递任何有效信息。通过强制显式声明提案插件,我们希望促使用户主动了解自己引入的非标准语法。更深层的目标是:这不仅提升对 Babel 的理解,更能帮助开发者认知 JavaScript 作为语言的演进过程,而非仅停留在使用层面。
发布非标准语法
作为库作者,发布非标准语法会让用户面临潜在的不兼容风险、重构负担和项目崩溃。由于 TC39 提案(即使是 Stage 3)仍可能变更,库代码必然需要随之修改。"新"提案不代表方案已固化,而是表明我们正在共同探索解决方案的可能性。
如果发布编译后的版本,至少能保证功能正常运作;库维护者可通过修改输出代码保持原有行为。而发布未编译版本意味着:任何使用者都必须配置构建步骤,且必须与我们的 Babel 配置完全一致。这等同于要求用户配合我们的 TS/JSX/Flow 环境——显然不应成为消费依赖项的前提条件。
混淆 JavaScript 模块与 ES2015+ 特性
当我们使用 import foo from "foo" 或 require("foo") 时,若 foo 模块不存在 index.js,解析器会读取其 package.json 中的 main 字段。
Rollup/webpack 等工具还会读取另一个名为 module(原为 jsnext:main)的字段,该字段用于解析指向 ES 模块(ESM)的文件。
- 以
redux为例
// redux package.json
{
...
"main": "lib/redux.js", // ES5 + Common JS
"module": "es/redux.js", // ES5 + JS Modules
}
引入此字段的初衷是让用户能直接消费 ES 模块(ESM)。
但该字段的核心目的仅限 ESM,并不包含其他特性。Rollup 文档明确指出:module 字段不适用于未来 JavaScript 语法特性。
尽管有此警示,包作者仍普遍将 ES 模块的使用与其源码采用的 JavaScript 语言特性级别混为一谈。
因此,我们可能需要另一种方式来声明语言级别。
不可扩展的解决方案?
常见的建议是让库在另一个字段(如 es2015)下发布 ES2015 代码,例如 "es2015": "es2015/package.mjs"。
// @angular/core package.json
{
"main": "./bundles/core.umd.js",
"module": "./fesm5/core.js",
"es2015": "./fesm2015/core.js",
"esm5": "./esm5/core.js",
"esm2015": "./esm2015/core.js",
"fesm5": "./fesm5/core.js",
"fesm2015": "./fesm2015/core.js",
}
这适用于 ES2015,但问题是 ES2016 该怎么办?难道要为每年创建新文件夹并在 package.json 中添加新字段吗?这显然不可持续,还会导致 node_modules 体积不断膨胀。
Babel 本身就遇到过这个问题:我们原本计划持续发布年度预设(
preset-es2015、preset-es2016...),直到意识到preset-env能从根本上解决这个问题。
基于特定环境/语法发布同样不可行,因为组合数量只会无限增长(例如 "ie-11-arrow-functions")。
直接分发源代码呢?如果使用非标准语法(如前所述),这同样会带来类似问题。
设置 esnext 字段可能也不完全解决问题。JavaScript 的"最新版本"会随着代码编写时间点而变化。
依赖项可能不发布 ES2015+
只有当库作者能轻松应用这项实践时,它才可能成为标准。如果新库和主流库都无法使用最新语法,这项变革的重要性将难以体现。
由于复杂性和工具链配置,项目发布 ES2015+/ESM 可能存在困难。这可能是最需要解决的核心问题,仅靠补充文档远远不够。
对 Babel 而言,我们可能需要为 @babel/cli 添加功能支持,或许让 babel 包默认实现此功能?或者更好地集成 @developit 的 microbundle 等工具。
我们如何处理 polyfill(这将是后续文章的主题)?如何让库作者(或用户)无需关心 polyfill?
综上所述,Babel 如何帮助我们应对这些挑战?
Babel v7 的解决方案
如前所述,在 Babel v6 中编译依赖项相当痛苦。Babel v7 将解决部分痛点。
首先是配置查找问题。Babel 当前按文件处理,编译时会尝试查找最近的配置(.babelrc)。如果在当前目录未找到,会持续向上级目录搜索。
project
└── .babelrc // closest config for a.js
└── a.js
└── node_modules
└── package
└── .babelrc // closest config for b.js
└── b.js
我们做了几项改进:
-
在包边界停止查找(遇到
package.json即停止),确保 Babel 不会加载应用外的配置文件(最典型的问题是意外加载用户主目录的配置) -
在单体仓库中,可为每个包配置
.babelrc并继承中央配置 -
Babel 自身采用单体仓库结构,因此我们改用新的
babel.config.js,该配置会应用于所有文件(无需逐级查找)
使用 "overrides" 实现选择性编译
我们新增了 "overrides" 选项,可为特定文件路径创建独立配置
这样每个配置对象都可以指定
test/include/exclude字段,就像在 Webpack 中的做法一样。每个字段可接受单个条目或由条目组成的数组,这些条目可以是string、RegExp或function。
这让我们可以为整个应用使用单一配置:比如我们希望以不同于客户端代码的方式编译服务端 JavaScript 代码(同时编译 node_modules 中的某些包)。
// babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {
targets: { node: 'current' },
}],
],
overrides: [{
test: ["./client-code", "./node_modules/package-a"],
presets: [
['@babel/preset-env', {
targets: { "chrome": "60" } },
}],
],
}],
}
建议讨论方向
我们应该转变固定的 JavaScript 发布观念,采用与最新标准同步的动态发布方式。
我们应继续在 main 字段下发布 ES5/CJS 版本以保持与现有工具链的向后兼容性,同时在新标准化字段(如 main-es)下发布编译为最新语法(不含实验性提案)的版本。(我认为 module 字段不适合此用途,因为它专指 JS 模块)。
或许我们应该在
package.json中确定另一个字段,比如"es"?这让我想起为 babel-preset-latest 发起的投票。
编译依赖项不应仅是单个项目/公司的优化手段:这需要整个社区共同推动。尽管这个过程是自然演进,但仍需某种标准化机制:我们可以制定库作者发布 ES2015+ 的准入标准,并通过 CI/工具链/npm 本身进行验证。
需要更新文档以说明编译 node_modules 的优势,指导库作者如何实现,并说明打包器/编译器如何消费这类依赖。
借助 Babel 7,用户能更安全地使用 preset-env,并通过 overrides 等新配置选项选择性地编译 node_modules。
立即行动!
JavaScript 编译不应局限于 ES2015/ES5 的区分——无论应用代码还是依赖项皆然!希望本文能推动重启讨论,将 ES2015+ 发布的依赖项提升为生态系统的一等公民。
本文探讨了 Babel 在此过程中的助力方式,但改变生态需要众人合力:加强教育引导、增加选择性发布包、优化工具链支持。
感谢众多审阅本文的贡献者,包括 @chrisdarroch、@existentialism、@mathias、@betaorbust、@_developit、@jdalton、@bonsaistudio。