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

通过启用新型 JavaScript 依赖项和输出来提高性能。

超过 90% 的浏览器能够运行现代 JavaScript,但盛行的旧版 JavaScript 仍然是当今 Web 性能问题的一个主要原因。

现代 JavaScript

新型 JavaScript 的特点并非是用特定 ECMAScript 规范版本编写的代码,而是其语法均受所有现代浏览器支持。Chrome、Edge、Firefox 和 Safari 等现代网络浏览器占据了 90% 以上的浏览器市场,而依赖相同底层渲染引擎的不同浏览器则分别占据了另外 5%。这意味着,全球 95% 的网络流量都来自于过去 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)依靠此字段来利用模块功能并启用 Tree shaking。 这仍然是一个旧版软件包,其中不包含任何除 import/export 语法以外的新式代码,因此您可以使用此方法来搭载新式代码,同时提供仍针对捆绑优化进行了优化的旧版回退。

应用中的现代 JavaScript

第三方依赖项构成了 Web 应用中的绝大部分典型的生产 JavaScript 代码。虽然 npm 依赖项历来都是作为旧版 ES5 语法发布的,但这不再是安全的假设,并且依赖项更新可能会破坏应用中的浏览器支持。

随着越来越多的 npm 软件包迁移到现代 JavaScript,请务必设置构建工具来处理这些软件包。您依赖的某些 npm 软件包很可能已在使用现代语言功能。npm 提供了许多选项供您使用新式代码,而无需在旧版浏览器中破坏您的应用,但一般建议是让构建系统将依赖项转译为与源代码相同的语法目标。

Webpack

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

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

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

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

有很多 Webpack 插件(如优化工具插件和 BabelEsmPlugin)可让您编译和交付新型 JavaScript,同时仍支持旧版浏览器。

优化工具插件

Optimize Plugin 是一个 webpack 插件,可将最终的捆绑代码从现代 JavaScript 转换为旧版 JavaScript,而不是每个单独的源文件。它是一种独立的设置,可让您的 webpack 配置假定所有内容都是现代 JavaScript,而无需针对多个输出或语法进行特殊分支。

由于优化工具插件基于软件包(而不是单个模块)运行,因此它会以同等方式处理应用的代码和依赖项。这可让您放心地使用 npm 中的新式 JavaScript 依赖项,因为它们的代码将被捆绑并转译为正确的语法。此方法可能比涉及两个编译步骤的传统解决方案更快,同时仍可为现代和旧版浏览器生成单独的软件包。这两组软件包设计为使用 module/nomodule 模式加载。

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

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

与自定义 webpack 配置(通常单独捆绑新式和旧版代码)相比,Optimize Plugin 可能更快、更高效。它还会为您运行 Babel,并使用 Terser 缩减软件包的大小,并为现代输出和旧版输出采用单独的最佳设置。最后,生成的旧版软件包所需的 polyfill 会提取到专用脚本中,因此不会在较新的浏览器中重复或不必要地加载这些 polyfill。

比较:与编译生成的软件包相比,转译源模块两次。

BabelEsmPlugin

BabelEsmPlugin 是一个 webpack 插件,可与 @babel/preset-env 结合使用,生成现有软件包的新版本,从而向新型浏览器提供更少转译的代码。它是适用于 module/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(),
  ],
};

或者,您也可以在模块的 package.json 中检查 "exports" 字段(在解析模块时),在 webpack 配置中手动实现该技术。为简洁起见,省略了缓存,自定义实现可能如下所示:

// 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 语法。

汇总

Rollup 内置了对在单个 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 的专用插件之外,您还可以使用 devolution 将具有旧版回退机制的新款 JavaScript 软件包添加到任何项目中。Devolution 是一款独立工具,可转换构建系统的输出以生成旧版 JavaScript 变体,允许捆绑和转换假定现代输出目标。