将现代 JavaScript 引入库

2025-05-24

将现代 JavaScript 引入库

简而言之:为了将现代 JavaScript 引入我们的库,我们应该采用新的"browser2017" 条件导出键。该"browser2017"键指向针对现代浏览器的现代代码,而无需使用那些会使我们的包膨胀的 polyfill。此更改需要打包器的支持和包作者的采纳。

背景

尽管现代浏览器占据了超过 90% 的网络流量,但许多网站仍在将 JavaScript 转译为 ES5,以支持仍然使用 IE 11 等老旧浏览器的不到 10% 的用户。为此,大多数网站会转译其代码并提供 polyfill,这些 polyfill 会重新实现现代浏览器中已包含的功能。这会产生更大的包,这意味着每个人都需要更长的加载和解析时间。

模块/无模块模式

2017 年,模块/无模块模式<script type="module">开始被推荐作为解决此问题的解决方案。利用较新的浏览器支持而较旧的浏览器不支持这一事实,我们可以执行以下操作:

<script type="module" src="bundle.modern.js"></script>
<script nomodule src="bundle.legacy.js"></script>

这项技术为较新的浏览器提供 ES2017index.modern.js包,为较旧的浏览器提供 polyfill 的 ES5index.legacy.js包。虽然这涉及到一些复杂性,但它为大多数用户提供了一种机制,使他们能够利用 ES2017 语法,而无需依赖用户代理检测或动态托管。

问题

尽管 module/nomodule 模式引入了一种服务于现代 bundles 的机制,但仍然存在一个明显的问题:几乎所有第三方依赖项(以及我们大部分的 JavaScript 代码)都停留在 ES5 的框架下。我们将编译工作留给了包作者,但却没有为他们建立发布现代版本代码的机制。在我们制定出一套标准之前,应用程序无法真正享受现代 JavaScript 的优势。条件导出可以提供这项标准。


提案:"browser2017"有条件出口

2020 年 1 月,Node v13.7.0宣布正式支持条件导出。条件导出允许包通过 package.json 字段指定每个环境的入口点"exports"。例如,一个库可能会执行以下操作:

// my-library's package.json
{
    "name": "my-library",
    "main": "./index-node.js",
    "module": "./index.production.mjs",
    "browser": "./index.production.js",
    "exports": {
        "node": "./index-node.js", // Node.js build
        "development": "./index.development.mjs", // browser development build
        "default": "./index.production.js" // browser ES5 production build
    }
}

从这里开始,根据匹配的条件,捆绑器或运行时(如 Node.js)可以选择在解析模块时使用最合适的入口点。

随着条件导出的引入,我们终于有机会让软件包提供其代码的现代版本。为此,我们建议标准化一个新的条件导出键"browser2017"

// my-library's package.json
{
    "name": "my-library",
    "main": "./index-node.js",
    "module": "./index.production.mjs",
    "browser": "./index.production.js",
    "exports": {
        "node": "./index-node.js", // Node.js build
        "development": "./index.development.mjs", // browser development build
        "browser2017": "./index.browser2017.mjs", // browser modern production build
        "default": "./index.production.js" // browser ES5 production build
    }
}

"browser2017"键指定了一个 ES 模块入口点,该入口点使用支持 的浏览器中可用的 JavaScript 功能<script type="module">。这相当于 Chrome 61+、Edge 16+、Firefox 60+ 和 Safari 10.1+。

这些目标与 module/nomodule 模式完美匹配,从而消除了以下 polyfill:

  • 所有 ES2015 功能(类、箭头函数、映射、集合),不包括尾调用优化
  • 所有 ES2016 功能(array.includes()、指数运算符)
  • 大多数 ES2017 功能(async/await、Object.entries())

    注意:该"browser2017"键值仅近似于 ECMAScript 2017。这是因为浏览器对 ECMAScript 规范的实现是独立且随意的。例如,除了 Safari 之外,没有其他浏览器实现了 ES2015 的尾调用优化,而 Firefox 和 Safari 对 ES2017 的共享内存和原子操作特性的实现都存在缺陷或部分缺失。

这个键的命名"browser2017"可能看起来有点令人困惑,因为它的语义并不完全映射到 ECMAScript 2017,而是作为支持 的浏览器的别名<script type="module">。然而,这个名称清楚地告诉开发者,它代表了某个语法级别,而该语法级别与 ES2017 最为接近。

支持的功能 铬合金 边缘 火狐 Safari
<script type="module"> 61岁以上 16岁以上 60岁以上 10.1+
所有 ES2017 功能(不包括原子+共享内存) 58岁以上 16岁以上 53岁以上 10.1+

包可以使用 @babel/preset-env 的targets.esmodules 选项或 TypeScript 编译器的ES2017 目标来生成此入口点。

按照转译目标划分的库大小

发布现代 JavaScript 的好处之一是,新语法通常比已填充的 ES5 语法小得多。下表显示了一些常用库的大小差异:

图书馆 ES5 “浏览器2017”
鲍泽 25.2 千字节 23.3 KB (-7.5%)
驻波比 24.0 千字节 14.4 KB (-40.0%)
反应带 225.0 千字节 197.5 KB (-12.1%)
React-popper 11.3KB 9.75KB (-13.7%)

*使用未缩小和未压缩的输出收集的数据

此外,一些库作者被迫​​使用旧语法编写代码,因为转译后的现代代码有时会比旧代码慢得多或体积更大。建立"browser2017"入口点可以让这些作者使用现代语法编写代码,并针对现代浏览器进行优化。

软件包作者的采用

对于许多已经使用现代语法编写源代码的包作者来说,支持此功能可能就像在构建过程中添加另一个目标一样简单。例如,如果使用 Rollup:

示例 rollup.config.js
export default [
    // existing config
    {
        input: 'src/main.js',
        output: { file: pkg.main, format: 'es' },
        plugins: [ babel({exclude: 'node_modules/**'}) ]
    },

    // additional "browser2017" config
    {
        input: 'src/main.js',
        output: { file: pkg.exports.browser, format: 'es' },
        plugins: [
            babel({
                exclude: 'node_modules/**',
                presets: [['@babel/preset-env', {
                    targets: { "esmodules": true }
                }]],
            })
        ]
    }
];

捆绑商的支持

在应用程序使用条件导出之前,它"browser2017"需要现有工具的支持。然而,目前大多数工具尚未实现对条件导出的支持。具体细节如下:

捆绑器/工具 导出地图 条件映射
Node.js 已发货 已发货
Webpack 实施的 实施的
汇总 未实施 未实施
Browserify 未实施 未实施
包裹 未实施 未实施
稳定管理 未实施 未实施
积雪 实施的 未实施
维特 未实施 未实施
es-dev-服务器 未实施 未实施

缺点

条件导出可以发布 ES2017 语法,但 ES2018+ 的功能怎么办?我们仍然需要为诸如object rest/spreadfor await...of 之"browser2017"类的功能进行转译付出代价。此外,该密钥并非面向未来。到 ES2025 到来时,它可能会被视为遗留问题。"browser2017""browser2017"

替代解决方案:按年份划分的多个入口点

一个解决方案是每年增加额外的入境点:

// my-library's package.json
{
    "name": "my-library",
    "main": "./index-node.js",
    "module": "./index.production.mjs",
    "browser": "./index.production.js",
    "exports": {
        "node": "./index-node.js",
        "development": "./index.development.mjs",
        "browser": {
            "2020": "./index.2020.mjs",
            "2019": "./index.2019.mjs",
            "2018": "./index.2018.mjs",
            "2017": "./index.2017.mjs"
        },
        "default": "./index.production.js"
    }
}

虽然 module/nomodule 模式无法利用"browser2018"+ 键,但其他技术可以。例如,网站可以通过以下任一方式提供 ES2019 代码:

缺点

ES2018+ 差异加载技术的缺点

然而,上述每种机制都有其缺点,因此并未获得广泛采用。用户代理嗅探复杂且容易出错,动态加载不支持预加载(来源)。2019年提出了一种静态解决方案,但遇到了标准化挑战。最早,导入映射"browser2021"可能会为我们提供密钥或某种形式的差异化加载技术。

规模不断缩小的改进

还值得强调的是,ES2017 之后的 ECMAScript 版本包含的功能较少,采用率较低,因此额外的入口点可能不会对捆绑包大小产生显著影响。

ECMAScript 年份的功能
es2015 es2016 es2017 es2018 es2019 es2020 es2021+
const,让 ** 操作员 异步/等待 对象扩展/静止 Array.flat、Array.flatMap 字符串.matchAll 字符串.replaceAll
模板字面量 数组.includes 字符串填充 Promise.finally 对象.fromEntries BigInt Promise.any
解构 对象.{值,条目,…} RegExp 功能 可选的 catch 绑定 Promise.allSettled 逻辑赋值
箭头函数 原子 等待...的 globalThis …待定
课程 共享内存 可选链式调用
承诺 空值合并
...更多
按照转译目标划分的库大小

"browser2017"目标相比,转换为"browser2019"目标往往只会导致尺寸的很小减小。

图书馆 ES5 “浏览器2017” “浏览器2019”
鲍泽 25.2 千字节 23.3 KB (-7.5%) 23.3 KB (-0%)
驻波比 24.0 千字节 14.4 KB (-40.0%) 13.8 KB (-4.2%)
反应带 225.0 千字节 197.5 KB (-12.1%) 197.5 KB (-0%)
React-popper 11.3KB 9.75KB (-13.7%) 8.98 KB (-7.9%)

*使用未缩小和未压缩的输出收集的数据

转译目标的最大 Polyfill 大小

实际上,polyfill 的大小取决于实际使用的功能。但是,我们可以估算每个转译目标的最大 polyfill 大小(假设所有不支持的功能都已 polyfill)。这些数据有助于比较,但需要注意的是,es2017 和 es2019 的值包含了大量由于技术限制而导致的过度 polyfill,这些限制是可以解决的。

转译目标 浏览器 最大 Polyfill 大小
ES5 IE11+ 97.6 千字节
"browser2017" CH 61,边缘 16,FF 60,SF 10.1 59.5 千字节
"browser2019" CH 73,Edge 79,FF 64,SF 12.1 39.5 千字节

* 使用最小化和未压缩的输出收集数据。仅包含由 babel+core-js 填充的 ECMAScript 功能

复杂

至少就目前而言,年度入口点可能会进一步加剧软件包创作流程的复杂性。这需要社区就哪些浏览器版本被视为特定年份的一部分达成年度共识,并且要求软件包创作者正确遵循这些定义。鉴于 JavaScript 生态系统的去中心化特性,重要的是要考虑到更简单的解决方案更容易被采用。

将来,只有在大量新功能发布,或新的差异化加载机制可用后,添加新的入口点才有意义。届时,我们可以扩展粒度较小的"browser2017""browser2021""browser2027"入口点,并将年份作为一组目标浏览器的别名。像@babel/preset-env这样的工具可能会采用这些别名,并抽象出它们的精确定义。

替代解决方案:"esnext"入口点

注意:这与 Webpack提出的“浏览器”入口点几乎相同

我们可以看到:

  • 只有应用程序开发人员才能知道他们的目标浏览器
  • 维护多个软件包变体是软件包作者的痛点
  • 应用程序开发人员已经将转译功能集成到他们自己的代码的构建过程中。

鉴于上述情况,如果我们将转译的负担从包作者转移到应用程序开发者身上会怎么样?通用"esnext"导出映射键可以指向包含截至包发布日期的任何稳定 ECMAScript 特性的代码。有了这些信息,应用程序开发者就可以转译所有包,使其适用于目标浏览器。

// my-library's package.json
{
    "name": "my-library",
    "main": "./index-node.js"
    "module": "./index.production.mjs",
    "browser": "./index.production.js",
    "exports": {
        "node": "./index-node.js",
        "development": "./index.development.mjs",
        "esnext": "./index.esnext.mjs",
        "default": "./index.production.js"
    }
}

包作者和应用程序开发人员都不再需要担心包发布的语法级别。理想情况下,该解决方案将使 JavaScript 库始终提供最现代的输出 - 即使“现代”的定义发生变化。

缺点

迁移到 Transpiling node_modules

JavaScript 生态系统长期以来根深蒂固地认为我们不应该进行转译node_modules,我们的工具也反映了这一点。由于库在发布之前已经转译,大多数应用程序都已将 Babel 配置为排除转译node_modules。迁移到"esnext"入口点将要求应用程序开发人员放弃预先转译的依赖项,转而采用速度较慢的完全转译构建。通过缓存和将转译限制在生产构建中,可以在一定程度上减轻构建影响。一些工具已经采用了这种方法,包括 Parcel 和 Create React App。此更改还需要更改工具,以便选择性地仅转译那些公开“esnext”入口点的包。

无声的破坏

移动的"esnext"目标可能会导致应用程序中出现静默中断。例如,ES2021 可能会将Observable引入标准库。如果 npm 库开始在其"esnext"入口点使用 Observable,旧版本的 Babel将不会 polyfill Observable,但不会输出任何错误或警告。对于不更新其转译工具的应用程序开发人员来说,此错误将不会被捕获,直到进入测试甚至生产阶段。在我们的 package.json 中添加更多元数据可能是解决此问题的一种方法。即使有了这些信息,仍然很难或不可能可靠地确定已安装软件包的发布日期:npm 在安装时将发布日期注入本地 package.json 文件中,但其他工具(如 Yarn)则不会。

解决方案比较

解决方案 优点 缺点
浏览器2017
  • 最简单的解决方案
  • 与一组浏览器相关的精确定义
  • 应用程序不需要转译依赖项
  • 需要对工具/配置进行微小更改
  • 软件包作者控制他们的软件包如何被编译
  • 缺少 ES2018+ 语法
  • 未来我们可能需要引入“browser2025”切入点
  • 不支持所有 ES2017 语法;可能会被误解
browser2017 browser2018 browser2019 ...
  • 使应用程序能够针对任何语法级别
  • 应用程序不需要转译依赖项
  • 软件包作者控制他们的软件包如何被编译
  • 需要对工具/配置进行微小更改
  • 目前没有用于服务 ES2018+ 语法的静态差异加载机制
  • ES2018+ 入口点目前不会显著减少大小
  • 使软件包创作过程复杂化
esnext
  • 让应用程序充分决定其目标浏览器
  • 面向未来;库将始终使用最新的语法
  • 简化包创作过程
  • 目前没有用于服务 ES2018+ 语法的静态差异加载机制
  • 生产构建缓慢;可以通过缓存来缓解
  • 必须构建工具来选择性地转译 node_modules
  • 可能导致软件包用户无声中断
  • 软件包作者无法控制他们的软件包如何被转译

期待

预编译的"browser2017"条件导出功能可以充分发挥现代 JavaScript 的大部分潜在优势。不过,未来我们可能需要后续的“browser2021”和“browser2027”字段。

相比之下,"esnext"它面向未来,但需要一个能够解决静默破坏和版本控制共识的解决方案才能实现。它还需要对现有的工具和配置进行许多更改。

我们的应用程序将从支持现代 JavaScript 中受益。无论选择哪种机制,我们都需要考虑它如何影响生态系统的各个部分:打包器、库作者和应用程序开发者。

我很想听听你的想法😃!欢迎在下方留言或建议👇


其他资源

文章来源:https://dev.to/garylchew/bringing-modern-javascript-to-libraries-432c
PREV
Docker 单元测试:如何测试 Dockerfile(2020 指南)
NEXT
如何寻找实践 Web 开发的项目创意(不是 Todo App)