为现代浏览器提供现代代码,以加快页面加载速度

在本代码实验室中,改进这个简单应用程序的性能,该应用程序允许用户评价随机的猫。了解如何通过最小化转译的代码量来优化 JavaScript 包。

App screenshot

在示例应用中,您可以选择一个词或表情符号来表达您对每只猫的喜爱程度。当您单击按钮时,应用会在当前猫图像下方显示按钮的值。

测量

在添加任何优化之前,最好先检查网站

  1. 要预览网站,请按查看应用。然后按全屏 fullscreen
  2. 按 `Control+Shift+J`(在 Mac 上按 `Command+Option+J`)打开 DevTools。
  3. 点击 Network 选项卡。
  4. 选中 Disable cache 复选框。
  5. 重新加载应用。

Original bundle size request

此应用程序使用了超过 80 KB 的空间!现在找出是否包的某些部分没有被使用

  1. Control+Shift+P(在 Mac 上按 Command+Shift+P)打开 Command 菜单。 Command Menu

  2. 输入 Show Coverage 并按 Enter 以显示 Coverage 选项卡。

  3. Coverage 选项卡中,点击 Reload 以在捕获覆盖率的同时重新加载应用程序。

    Reload app with code coverage

  4. 查看主包的已用代码量与加载代码量

    Code coverage of bundle

超过一半的包(44 KB)甚至没有被使用。这是因为其中很多代码都包含 polyfill,以确保应用程序在旧版浏览器中也能正常运行。

使用 @babel/preset-env

JavaScript 语言的语法符合称为 ECMAScript 的标准,或 ECMA-262。规范的较新版本每年发布,并包含已通过提案流程的新功能。每个主要浏览器始终处于支持这些功能的不同阶段。

应用程序中使用了以下 ES2015 功能

还使用了以下 ES2017 功能

随意深入研究 src/index.js 中的源代码,了解所有这些功能是如何使用的。

所有这些功能在最新版本的 Chrome 中都受支持,但其他不支持它们的浏览器呢?Babel(应用程序中包含)是最流行的库,用于将包含较新语法的代码编译为旧版浏览器和环境可以理解的代码。它通过两种方式实现这一点

  • 包含 Polyfill 以模拟较新的 ES2015+ 函数,以便即使浏览器不支持,也可以使用它们的 API。这是一个 polyfillArray.includes 方法的示例。
  • 插件 用于将 ES2015 代码(或更高版本)转换为旧的 ES5 语法。由于这些是与语法相关的更改(例如箭头函数),因此无法使用 polyfill 来模拟它们。

查看 package.json 以查看包含哪些 Babel 库

"dependencies": {
  "@babel/polyfill": "^7.0.0"
},
"devDependencies": {
  //...
  "babel-loader": "^8.0.2",
  "@babel/core": "^7.1.0",
  "@babel/preset-env": "^7.1.0",
  //...
}
  • @babel/core 是 Babel 核心编译器。有了这个,所有 Babel 配置都在项目根目录的 .babelrc 中定义。
  • babel-loader 在 webpack 构建过程中包含 Babel。

现在查看 webpack.config.js 以查看如何将 babel-loader 作为规则包含

module: {
  rules: [
    //...
    {
      test: /\.js$/,
      exclude: /node_modules/,
      loader: "babel-loader"
    }
  ]
},
  • @babel/polyfill 为任何较新的 ECMAScript 功能提供所有必要的 polyfill,以便它们可以在不支持这些功能的环境中工作。它已经导入到 src/index.js 的顶部。
import "./style.css";
import "@babel/polyfill";
  • @babel/preset-env 识别哪些转换和 polyfill 对于选择作为目标的任何浏览器或环境是必要的。

查看 Babel 配置文件 .babelrc,了解它是如何包含的

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions"
      }
    ]
  ]
}

这是一个 Babel 和 webpack 设置。了解如何在您的应用程序中包含 Babel,如果您碰巧使用与 webpack 不同的模块打包器。

.babelrc 中的 targets 属性标识了要定位的浏览器。@babel/preset-env 与 browserslist 集成,这意味着您可以在 browserlist 文档中找到可在此字段中使用的兼容查询的完整列表。

"last 2 versions" 值将应用程序中的代码转译为每个浏览器的 最后两个版本

调试

要完整查看所有浏览器的 Babel 目标以及包含的所有转换和 polyfill,请将 debug 字段添加到 .babelrc:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true
      }
    ]
  ]
}
  • 点击 Tools
  • 点击 Logs

重新加载应用程序,并查看编辑器底部的 Glitch 状态日志。

目标浏览器

Babel 将许多详细信息记录到控制台,包括已编译代码的所有目标环境。

Targeted browsers

请注意,此列表中包含已停止维护的浏览器,例如 Internet Explorer。这是一个问题,因为不受支持的浏览器不会添加新功能,而 Babel 会继续为它们转译特定语法。如果用户不使用此浏览器访问您的网站,这将不必要地增加您的包大小。

Babel 还记录了使用的转换插件列表

List of plugins used

这是一个很长的列表!这些是 Babel 需要使用的所有插件,用于将任何 ES2015+ 语法转换为所有目标浏览器的旧语法。

但是,Babel 不显示任何使用的特定 polyfill

No polyfills added

这是因为整个 @babel/polyfill 正在被直接导入。

单独加载 polyfill

默认情况下,当 @babel/polyfill 导入到文件中时,Babel 会包含完整的 ES2015+ 环境所需的所有 polyfill。要导入目标浏览器所需的特定 polyfill,请将 useBuiltIns: 'entry' 添加到配置中。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true
        "useBuiltIns": "entry"
      }
    ]
  ]
}

重新加载应用程序。您现在可以看到包含的所有特定 polyfill

List of polyfills imported

虽然现在只包含 "last 2 versions" 所需的 polyfill,但它仍然是一个超长的列表!这是因为仍然包含 每个 较新功能的目标浏览器所需的 polyfill。将属性的值更改为 usage,以便仅包含代码中正在使用的功能所需的 polyfill。

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "debug": true,
        "useBuiltIns": "entry"
        "useBuiltIns": "usage"
      }
    ]
  ]
}

这样,polyfill 会在需要时自动包含。这意味着您可以删除 src/index.js 中的 @babel/polyfill 导入。

import "./style.css";
import "@babel/polyfill";

现在只包含应用程序所需的必需 polyfill。

List of polyfills automatically included

应用程序包大小显著减小。

Bundle size reduced to 30.1 KB

缩小支持的浏览器列表

包含的浏览器目标数量仍然很大,并且没有多少用户使用已停止维护的浏览器,例如 Internet Explorer。将配置更新为以下内容

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "last 2 versions",
        "targets": [">0.25%", "not ie 11"],
        "debug": true,
        "useBuiltIns": "usage",
      }
    ]
  ]
}

查看获取的包的详细信息。

Bundle size of 30.0 KB

由于应用程序非常小,因此这些更改实际上并没有太大的区别。但是,建议使用浏览器市场份额百分比(例如 ">0.25%"),并排除您确信用户未使用的特定浏览器。查看 James Kyle 的 “Last 2 versions” considered harmful 文章,以了解更多信息。

使用 <script type="module">

仍有更多改进空间。虽然已删除许多未使用的 polyfill,但仍有很多 polyfill 正在运送,而某些浏览器不需要这些 polyfill。通过使用模块,可以将较新的语法直接编写并交付给浏览器,而无需使用任何不必要的 polyfill。

JavaScript 模块所有主要浏览器 中支持的相对较新的功能。可以使用 type="module" 属性创建模块,以定义从其他模块导入和导出的脚本。例如

// math.mjs
export const add = (x, y) => x + y;

<!-- index.html -->
<script type="module">
  import { add } from './math.mjs';

  add(5, 2); // 7
</script>

许多较新的 ECMAScript 功能在支持 JavaScript 模块的环境中已经受支持(而不是需要 Babel。)这意味着可以修改 Babel 配置,以便将应用程序的两个不同版本发送到浏览器

  • 一个版本适用于支持模块的较新浏览器,并且包含一个在很大程度上未转译但文件大小较小的模块
  • 一个版本包含一个更大、已转译的脚本,该脚本可以在任何旧版浏览器中工作

将 ES 模块与 Babel 结合使用

要为应用程序的两个版本分别设置 @babel/preset-env 设置,请删除 .babelrc 文件。通过为应用程序的每个版本指定两种不同的编译格式,可以将 Babel 设置添加到 webpack 配置中。

首先,将旧版脚本的配置添加到 webpack.config.js

const legacyConfig = {
  entry,
  output: {
    path: path.resolve(__dirname, "public"),
    filename: "[name].bundle.js"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        options: {
          presets: [
            ["@babel/preset-env", {
              useBuiltIns: "usage",
              targets: {
                esmodules: false
              }
            }]
          ]
        }
      },
      cssRule
    ]
  },
  plugins
}

请注意,不是使用 "@babel/preset-env"targets 值,而是使用了值为 falseesmodules。这意味着 Babel 包含所有必要的转换和 polyfill,以定位尚不支持 ES 模块的每个浏览器。

entrycssRulecorePlugins 对象添加到 webpack.config.js 文件的开头。这些都在模块和提供给浏览器的旧版脚本之间共享。

const entry = {
  main: "./src"
};

const cssRule = {
  test: /\.css$/,
  use: ExtractTextPlugin.extract({
    fallback: "style-loader",
    use: "css-loader"
  })
};

const plugins = [
  new ExtractTextPlugin({filename: "[name].css", allChunks: true}),
  new HtmlWebpackPlugin({template: "./src/index.html"})
];

现在类似地,在定义 legacyConfig 的位置下方创建一个模块脚本的 config 对象

const moduleConfig = {
  entry,
  output: {
    path: path.resolve(__dirname, "public"),
    filename: "[name].mjs"
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        loader: "babel-loader",
        options: {
          presets: [
            ["@babel/preset-env", {
              useBuiltIns: "usage",
              targets: {
                esmodules: true
              }
            }]
          ]
        }
      },
      cssRule
    ]
  },
  plugins
}

这里的主要区别在于输出文件名使用了 .mjs 文件扩展名。esmodules 值在此处设置为 true,这意味着输出到此模块中的代码是一个较小的、编译较少的脚本,在此示例中不会进行任何转换,因为所有使用的功能在支持模块的浏览器中都已受支持。

在该文件的末尾,在一个数组中导出两个配置。

module.exports = [
  legacyConfig, moduleConfig
];

现在,这将为支持模块的浏览器构建一个较小的模块,并为旧版浏览器构建一个更大的转译脚本。

支持模块的浏览器会忽略带有 nomodule 属性的脚本。相反,不支持模块的浏览器会忽略带有 type="module" 的 script 元素。这意味着您可以同时包含模块和编译后的回退。理想情况下,应用程序的两个版本应像这样在 index.html

<script type="module" src="main.mjs"></script>
<script nomodule src="main.bundle.js"></script>

支持模块的浏览器会获取并执行 main.mjs,并忽略 main.bundle.js。不支持模块的浏览器则相反。

重要的是要注意,与常规脚本不同,模块脚本默认情况下始终是延迟的。如果您希望等效的 nomodule 脚本也延迟并且仅在解析后执行,那么您需要添加 defer 属性

<script type="module" src="main.mjs"></script>
<script nomodule src="main.bundle.js" defer></script>

此处需要做的最后一件事是将 modulenomodule 属性分别添加到模块和旧版脚本。在 webpack.config.js 的顶部导入 ScriptExtHtmlWebpackPlugin

const path = require("path");

const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ScriptExtHtmlWebpackPlugin = require("script-ext-html-webpack-plugin");

现在更新配置中的 plugins 数组以包含此插件

const plugins = [
  new ExtractTextPlugin({filename: "[name].css", allChunks: true}),
  new HtmlWebpackPlugin({template: "./src/index.html"}),
  new ScriptExtHtmlWebpackPlugin({
    module: /\.mjs$/,
    custom: [
      {
        test: /\.js$/,
        attribute: 'nomodule',
        value: ''
    },
    ]
  })
];

这些插件设置为所有 .mjs script 元素添加 type="module" 属性,并为所有 .js script 模块添加 nomodule 属性。

在 HTML 文档中提供模块

最后需要做的是将旧版和现代脚本元素都输出到 HTML 文件。遗憾的是,创建最终 HTML 文件的插件 HTMLWebpackPlugin 目前不支持 同时输出 module 和 nomodule 脚本。虽然有一些解决方法和单独的插件被创建来解决这个问题,例如 BabelMultiTargetPluginHTMLWebpackMultiBuildPlugin,但为了本教程的目的,使用了手动添加模块脚本元素的更简单方法。

将以下内容添加到 src/index.js 文件的末尾

    ...
    </form>
    <script type="module" src="main.mjs"></script>
  </body>
</html>

现在在支持模块的浏览器(例如最新版本的 Chrome)中加载应用程序。

5.2 KB module fetched over network for newer browsers

仅获取模块,由于它在很大程度上是未转译的,因此包大小要小得多!另一个 script 元素完全被浏览器忽略。

如果您在旧版浏览器上加载应用程序,则只会获取较大、已转译的脚本,其中包含所有必需的 polyfill 和转换。这是在旧版本 Chrome(版本 38)上发出的所有请求的屏幕截图。

30 KB script fetched for older browsers

结论

您现在了解了如何使用 @babel/preset-env 仅提供目标浏览器所需的必要 polyfill。您还了解了 JavaScript 模块如何通过交付应用程序的两个不同转译版本来进一步提高性能。充分理解这两种技术如何显著减小您的包大小后,继续前进并进行优化吧!