发布、发布和安装新型 JavaScript,加快应用速度

通过启用现代 JavaScript 依赖项和输出来提升性能。

超过 90% 的浏览器都能够运行新型 JavaScript,但旧版 JavaScript 的广泛使用仍然是当今网络上性能问题的主要根源。

现代 JavaScript

现代 JavaScript 的特点不是使用特定 ECMAScript 规范版本编写的代码,而是使用所有现代浏览器都支持的语法。Chrome、Edge、Firefox 和 Safari 等新型网络浏览器占据了超过 90% 的浏览器市场,而依赖于相同底层渲染引擎的其他浏览器则占据了另外 5%。这意味着,全球 95% 的 Web 流量来自支持过去 10 年内最常用的 JavaScript 语言功能的浏览器,包括:

  • 类 (ES2015)
  • 箭头函数 (ES2015)
  • 生成器 (ES2015)
  • 块作用域 (ES2015)
  • 解构 (ES2015)
  • 休息和分散参数 (ES2015)
  • 对象缩写 (ES2015)
  • Async/await (ES2017)

较新版本语言规范中的功能在现代浏览器中的支持通常不太一致。例如,许多 ES2020 和 ES2021 功能仅在 70% 的浏览器市场中受支持,这仍然是大多数浏览器,但不足以保证直接依赖这些功能是安全的。这意味着,虽然“现代”JavaScript 是一个移动目标,但 ES2017 具有最广泛的浏览器兼容性,并且包含大多数常用的现代语法功能。换句话说,ES2017 是最接近现代语法的版本

旧版 JavaScript

旧版 JavaScript 是指专门避免使用上述所有语言功能的代码。大多数开发者使用现代语法编写源代码,但会将所有内容编译为旧版语法,以便获得更广泛的浏览器支持。编译为旧版语法确实会增加浏览器支持,但效果通常不如我们想象中那么大。在许多情况下,支持比例从 95% 左右增加到 98%,但会产生大量费用:

  • 与等效的现代代码相比,旧版 JavaScript 通常大约 20%,速度也更慢。工具缺陷和错误配置通常会进一步拉大这一差距。

  • 安装的库占典型生产 JavaScript 代码的 90% 以上。由于 polyfill 和帮助程序重复,库代码会产生更高的旧版 JavaScript 开销,而发布现代代码可以避免这种开销。

npm 上的现代 JavaScript

最近,Node.js 标准化了 "exports" 字段,以定义软件包的入口点

{
  "exports": "./index.js"
}

"exports" 字段引用的模块意味着 Node 版本至少为 12.8,该版本支持 ES2019。这意味着,使用 "exports" 字段引用的任何模块都可以使用新型 JavaScript 编写。软件包使用方必须假定包含 "exports" 字段的模块包含现代代码,并根据需要进行转译。

仅限现代版

如果您想发布包含现代代码的软件包,并让使用者在将软件包用作依赖项时自行对其进行转译,请仅使用 "exports" 字段。

{
  "name": "foo",
  "exports": "./modern.js"
}

现代版(支持旧版回退)

"exports" 字段与 "main" 结合使用,以便使用新式代码发布软件包,但还要提供适用于旧版浏览器的 ES5 + CommonJS 回退机制。

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs"
}

现代化,采用旧版回退和 ESM 捆绑器优化

除了定义回退 CommonJS 入口点之外,您还可以使用 "module" 字段指向一个类似的旧版回退软件包,但该软件包使用的是 JavaScript 模块语法(importexport)。

{
  "name": "foo",
  "exports": "./modern.js",
  "main": "./legacy.cjs",
  "module": "./module.js"
}

许多捆绑器(例如 webpack 和 Rollup)都依赖于此字段来利用模块功能并启用树摇动。这仍然是一个旧版软件包,除了 import/export 语法之外,不包含任何现代代码,因此请使用此方法将现代代码与仍针对捆绑进行优化的旧版回退一起提供。

应用中的现代 JavaScript

第三方依赖项占 Web 应用中典型生产 JavaScript 代码的绝大多数。虽然 npm 依赖项过去一直以旧版 ES5 语法发布,但这已不再是一个安全的假设,可能会导致依赖项更新破坏应用中的浏览器支持。

随着越来越多的 npm 软件包改用现代 JavaScript,请务必确保构建工具已设置为处理这些软件包。您依赖的某些 npm 软件包很可能已经在使用现代语言功能。您可以通过多种方式使用 npm 中的现代代码,而不会破坏旧版浏览器中的应用,但一般方法是让构建系统将依赖项转译为与源代码相同的语法目标。

webpack

从 webpack 5 开始,您现在可以配置 webpack 在为软件包和模块生成代码时将使用哪种语法。这不会转译您的代码或依赖项,只会影响 webpack 生成的“粘合”代码。如需指定浏览器支持目标,请向项目添加 browserslist 配置,或直接在 webpack 配置中执行此操作:

module.exports = {
  target: ['web', 'es2017'],
};

您还可以配置 webpack 以生成经过优化的软件包,在以现代 ES 模块环境为目标平台时,这些软件包会省略不必要的封装容器函数。这还会配置 webpack 以使用 <script type="module"> 加载代码分块软件包。

module.exports = {
  target: ['web', 'es2017'],
  output: {
    module: true,
  },
  experiments: {
    outputModule: true,
  },
};

有许多 webpack 插件可用,可让您在编译和交付现代 JavaScript 的同时,仍支持旧版浏览器,例如 Optimize Plugin 和 BabelEsmPlugin。

优化工具插件

优化插件是一个 webpack 插件,用于将最终的捆绑代码从现代 JavaScript 转换为旧版 JavaScript,而不是每个单独的源文件。这是一种自包含设置,可让您的 webpack 配置假定所有内容都是现代 JavaScript,并且没有针对多个输出或语法的特殊分支。

由于优化插件针对的是软件包(而非单个模块),因此它会平等地处理应用的代码和依赖项。这样一来,您就可以放心地使用 npm 中的现代 JavaScript 依赖项,因为其代码会被捆绑并转译为正确的语法。它也比涉及两个编译步骤的传统解决方案更快,同时仍能为现代浏览器和旧版浏览器生成单独的软件包。这两组 bundle 旨在使用 module/nomodule 模式加载。

// webpack.config.js
const OptimizePlugin = require('optimize-plugin');

module.exports = {
  // ...
  plugins: [new OptimizePlugin()],
};

Optimize Plugin 的速度和效率可能比自定义 webpack 配置更快,后者通常会将现代代码和旧版代码分开打包。它还会为您运行 Babel,并使用 Terser 缩减捆绑包,并为现代版和旧版输出分别提供最佳设置。最后,生成的旧版软件包所需的 polyfill 会提取到专用脚本中,以免在较新的浏览器中重复或不必要地加载。

比较:对源模块进行两次转译与转译生成的软件包。

BabelEsmPlugin

BabelEsmPlugin 是一个 Webpack 插件,可与 @babel/preset-env 搭配使用,以生成现有软件包的现代版本,从而向现代浏览器提交更少的转译代码。它是模块/nomodule 最常用的现成解决方案,由 Next.jsPreact 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 配置,因为它会运行应用的两个基本独立 build。对于大型应用,编译两次可能需要额外一些时间,但这种方法可让 BabelEsmPlugin 无缝集成到现有 webpack 配置中,是可用选项中最方便的一种。

配置 babel-loader 以转译 node_modules

如果您使用 babel-loader 而不使用前两个插件中的任何一个,则必须执行一个重要步骤,才能使用新式 JavaScript npm 模块。定义两个单独的 babel-loader 配置后,即可将 node_modules 中的现代语言功能自动编译为 ES2017,同时使用项目配置中定义的 Babel 插件和预设将您自己的第一方代码转译。这不会为 module/nomodule 设置生成现代版和旧版软件包,但可以安装和使用包含现代版 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'],
          },
        },
      },
    ],
  },
};

使用此方法时,您需要确保您的缩减器支持新式语法。Terseruglify-es 都可以选择指定 {ecma: 2017} 以保留内容,并且在某些情况下还会在压缩和格式化过程中生成 ES2017 语法。

分组

汇总内置了在单个 build 中生成多组软件包的支持,并默认生成现代代码。因此,您可以将 Rollup 配置为使用您可能已经在使用的官方插件生成新版和旧版软件包。

@rollup/plugin-babel

如果您使用 Rollup,则 getBabelOutputPlugin() 方法(由 Rollup 的官方 Babel 插件提供)会转换生成的软件包中的代码,而不是单个源模块中的代码。Rollup 内置支持在单个 build 中生成多组软件包,每组软件包都有自己的插件。您可以使用它通过不同的 Babel 输出插件配置传递每个软件包,从而针对现代和旧版生成不同的软件包:

// 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 语法。还有一些更高层级的构建工具(例如 ParcelSnowpackViteWMR)更注重惯例和默认值。其中大多数工具都假定 npm 依赖项可能包含现代语法,并会在构建正式版时将其转译为适当的语法级别。

除了适用于 webpack 和 Rollup 的专用插件之外,具有旧版回退的现代 JavaScript 软件包还可以添加到任何使用 devolution 的项目。Devolution 是一种独立工具,可以转换构建系统的输出,以生成旧版 JavaScript 变体,从而支持捆绑和转换来假定使用现代输出目标。