有兴趣阅读我们下一本书吗?了解更多关于 使用 React 构建大规模 JavaScript Web 应用

性能模式

交互式导入

简而言之:在用户与需要它的 UI 交互时延迟加载非关键资源

您的页面可能包含并非立即必要的组件或资源的代码或数据。例如,用户界面的一部分,用户只有在点击或滚动页面部分时才能看到。这可能适用于您编写的许多第一方代码,但也适用于第三方小部件,例如视频播放器或聊天小部件,在这些小部件中,您通常需要点击一个按钮才能显示主界面。

如果这些资源成本很高,那么热切地加载它们(即立即加载)可能会 阻塞主线程,推迟用户何时才能与页面的更关键部分交互。这会影响交互准备指标,例如 首次输入延迟总阻塞时间交互时间。与其立即加载这些资源,您可以选择更合适的时间加载它们,例如

  • 当用户第一次点击与该组件交互时
  • 滚动组件进入视图
  • 或者延迟加载该组件,直到浏览器空闲(通过 requestIdleCallback)。

加载资源的不同方法从宏观上来说是

  • 热切 - 立即加载资源(加载脚本的正常方式)
  • 延迟 (基于路由) - 当用户导航到某个路由或组件时加载
  • 延迟(交互式) - 当用户点击 UI 时加载(例如显示聊天)
  • 延迟(视窗内) - 当用户滚动到组件附近时加载
  • 预取 - 在需要之前加载,但在关键资源加载之后
  • 预加载 - 热切地加载,并且优先级更高

仅当您无法在交互之前预取资源时,才应该对第一方代码使用交互式导入。然而,这种模式对于第三方代码非常相关,因为您通常希望在稍后的时间点推迟第三方代码,除非它是关键的。这可以通过多种方式实现(推迟到交互、到浏览器空闲或使用其他启发式方法)。

延迟交互式导入功能代码是本文将介绍的许多上下文中使用的一种模式。您可能之前在 Google 文档中使用过它,他们在加载共享功能的 500KB 脚本时,延迟加载到用户交互发生后。

Clicking a Share button in Google Docs triggers a download of code needed for this highly interactive feature. It weighs in at 500KB of script, which is better loaded on-demand rather than eagerly with the rest of Google Docs on page load

交互式导入另一个适合的地方是加载第三方小部件。


使用外观“伪造”加载第三方 UI

您可能正在导入第三方脚本,并且对它渲染的内容或它何时加载代码没有太多控制。实现交互式加载的一个选项很简单:使用 外观。外观是更昂贵组件的简单“预览”或“占位符”,在其中您可以模拟基本体验,例如使用图像或屏幕截图。这是 Lighthouse 团队一直在使用这个想法的术语。

当用户点击“预览”(外观)时,资源代码将被加载。这限制了用户在不使用功能的情况下需要为功能支付体验成本。同样,外观可以 预连接 悬停时的必要资源。

第三方资源通常会添加到页面中,而没有充分考虑它们如何融入站点的整体加载。同步加载的第三方脚本会阻塞浏览器解析器,并可能延迟水合。如果可能,3P 脚本应该使用 async/defer(或其他方法)加载,以确保 1P 脚本不会被剥夺网络带宽。除非它们是关键的,否则它们是使用交互式导入等模式转换为延迟加载的理想选择。


视频播放器嵌入

一个很好的“外观”示例是 Paul Irish 创建的 YouTube Lite Embed。它提供一个自定义元素,该元素接受一个 YouTube 视频 ID 并显示一个最小的缩略图和播放按钮。点击该元素会动态加载完整的 YouTube 嵌入代码,这意味着从未点击播放的用户不会为获取和处理它付出成本。

The lite-youtube component only loads up 3KB of script on page load and the full-fat YouTube payload on interaction

在几个 Google 网站的生产环境中使用了一种类似的技术。在 Android.com 上,他们没有热切地加载 YouTube 视频播放器嵌入,而是向用户显示了一个带有伪造播放按钮的缩略图。当他们点击它时,一个模态窗口会加载,使用完整的 YouTube 视频播放器嵌入自动播放视频

Android.com loads code for their video embeds on demand when a user clicks on a thumbnail for one of these videos

身份验证

应用程序可能需要通过客户端 JavaScript SDK 支持与服务的身份验证。这些 SDK 有时可能很大,并且带有沉重的 JS 执行成本,如果用户不打算登录,您可能不希望在前端热切地加载它们。相反,当用户点击“登录”按钮时,动态导入身份验证库,使主线程在初始加载期间更加自由。

A HTML and CSS only version of a Google sign-in button which loads the full client-side SDK and actual button on interaction

聊天小部件

Calibre 应用程序 通过使用类似的外观方法将基于 Intercom 的实时聊天的性能提高了 30%。他们使用仅 CSS 和 HTML 实现了一个“伪造”快速加载的实时聊天按钮,当点击它时,会加载他们的 Intercom 捆绑包。

An simulated version of the Intercom chat widget button which loads the full chat widget on page interaction

Postmark 指出,他们的帮助聊天小部件总是热切地加载,即使它只是偶尔被客户使用。该小部件会拉取 314KB 的脚本,比他们的整个主页还要多。为了改善用户体验,他们用一个使用 HTML 和 CSS 的伪造副本替换了该小部件,在点击时加载真实内容。此更改将交互时间从 7.7 秒减少到 3.7 秒。

A HTML+CSS approximate of the customer help widget

其他

Ne-digital 使用了一个 React 库来实现动画滚动回到页面顶部,当用户点击“滚动到顶部”按钮时。他们没有热切地加载这个 react-scroll 依赖项,而是在与按钮交互时加载它,从而节省了约 7KB

handleScrollToTop() {
    import('react-scroll').then(scroll => {
      scroll.animateScroll.scrollToTop({
      })
    })
}
Loading resources on interaction with DevTools showing the resource being fetched

如何进行交互式导入?

Vanilla JavaScript

在 JavaScript 中,动态 import() 使得延迟加载模块成为可能,并返回一个 promise,在正确应用时非常强大。以下是一个在按钮事件监听器中使用动态导入来导入 lodash.sortby 模块,然后使用它的示例。

const btn = document.querySelector("button");

btn.addEventListener("click", (e) => {
  e.preventDefault();
  import("lodash.sortby")
    .then((module) => module.default)
    .then(sortInput()) // use the imported dependency
    .catch((err) => {
      console.log(err);
    });
});

在动态导入之前或对于它不适合的用例,动态地将脚本注入到页面中使用基于 Promise 的脚本加载器也是一种选择(参见 此处以获取完整实现,它演示了一个登录外观)

const loginBtn = document.querySelector("#login");

loginBtn.addEventListener("click", () => {
  const loader = new scriptLoader();
  loader
    .load(["//apis.google.com/js/client:platform.js?onload=showLoginScreen"])
    .then(({ length }) => {
      console.log(`${length} scripts loaded!`);
    });
});

React

假设我们有一个聊天应用程序,它有一个 <MessageList><MessageInput> 和一个 <EmojiPicker> 组件(由 emoji-mart 提供支持,该组件大小为 98KB,经过压缩和 gzip 处理)。通常会在初始页面加载时热切地加载所有这些组件。

import MessageList from './MessageList';
import MessageInput from './MessageInput';
import EmojiPicker from './EmojiPicker';

const Channel = () => {
  ...
  return (
    <div>
      <MessageList />
      <MessageInput />
      {emojiPickerOpen && <EmojiPicker />}
    </div>
  );
};
Showing different components that are loaded separately

使用 代码拆分 可以相对轻松地将此工作的加载拆分开来。React.lazy 方法使在组件级别使用动态导入来代码拆分 React 应用程序变得容易。React.lazy 函数提供了一种内置方法,可以在应用程序中将组件分离为单独的 JavaScript 块,而无需做太多工作。然后,您可以结合 Suspense 组件来处理加载状态。

import React, { lazy, Suspense } from 'react';
import MessageList from './MessageList';
import MessageInput from './MessageInput';

const EmojiPicker = lazy(
  () => import('./EmojiPicker')
);

const Channel = () => {
  ...
  return (
    <div>
      <MessageList />
      <MessageInput />
      {emojiPickerOpen && (
        <Suspense fallback={<div>Loading...</div>}>
          <EmojiPicker />
        </Suspense>
      )}
    </div>
  );
};

我们可以将此想法扩展到仅在 <MessageInput> 中点击表情符号图标时才导入表情符号选择器组件的代码,而不是在应用程序最初加载时热切地加载。

import React, { useState, createElement } from "react";
import MessageList from "./MessageList";
import MessageInput from "./MessageInput";
import ErrorBoundary from "./ErrorBoundary";

const Channel = () => {
  const [emojiPickerEl, setEmojiPickerEl] = useState(null);

  const openEmojiPicker = () => {
    import(/* webpackChunkName: "emoji-picker" */ "./EmojiPicker")
      .then((module) => module.default)
      .then((emojiPicker) => {
        setEmojiPickerEl(createElement(emojiPicker));
      });
  };

  const closeEmojiPickerHandler = () => {
    setEmojiPickerEl(null);
  };

  return (
    <ErrorBoundary>
      <div>
        <MessageList />
        <MessageInput onClick={openEmojiPicker} />
        {emojiPickerEl}
      </div>
    </ErrorBoundary>
  );
};
Lazy-load the Emoji component on interaction

Vue

在 Vue.js 中,可以通过几种不同的方法实现类似的交互式导入模式。一种方法是使用包装在函数中的动态导入动态导入 Emojipicker Vue 组件,即 () => import("./Emojipicker")。通常这样做会让 Vue.js 在需要渲染时延迟加载该组件。

然后,我们可以将延迟加载置于用户交互之后。在选择器父 div 上使用一个条件 v-if,该 div 通过点击按钮进行切换,我们可以通过用户点击来有条件地获取和渲染 Emojipicker 组件。

<template>
  <div>
    <button @click="show = true">Load Emoji Picker</button>
    <div v-if="show">
      <emojipicker></emojipicker>
    </div>
  </div>
</template>

<script>
  export default {
    data: () => ({ show: false }),
    components: {
      Emojipicker: () => import("./Emojipicker"),
    },
  };
</script>

交互式导入模式应该适用于大多数支持动态组件加载的框架和库,包括 Angular


作为渐进式加载一部分的第一方代码的交互式导入

交互式加载代码也是 Google 在 Flights 和 Photos 等大型应用程序中处理渐进式加载的关键部分。为了说明这一点,让我们看看 Shubhie Panicker 之前提供的示例。

假设用户正在计划前往印度孟买旅行,他们访问了 Google 酒店以查看价格。与这种交互相关的全部资源可以预先热切地加载,但是如果用户还没有选择任何目的地,则地图所需的 HTML/CSS/JS 将是多余的。

Google Hotels on mobile web

在最简单的下载场景中,假设 Google 酒店使用的是简单的 客户端渲染 (CSR)。所有代码都会预先下载和处理:HTML,然后是 JS,CSS,然后获取数据,直到我们拥有所有内容才进行渲染。但是,这会让用户等待很长时间,并且屏幕上没有任何显示。一大块 JavaScript 和 CSS 可能是多余的。

Basic client-side rendering

接下来,想象一下这种体验迁移到服务器端渲染(SSR)。我们会让用户更快地获得一个视觉上完整的页面,这很好,但是它在从服务器获取数据并完成客户端框架的 hydration 之前不会具有交互性。

Basic server-side rendering

SSR 可以是一项改进,但用户可能会遇到一种怪异的体验,即页面看起来已经准备好了,但他们却无法点击任何东西。有时这被称为 rage clicks,因为用户往往会反复点击,以表达他们的沮丧情绪。

回到 Google 酒店搜索示例,如果我们稍微放大一下 UI,就会发现当用户点击“更多过滤器”来找到合适的酒店时,该组件所需的代码就会被下载下来。

最初只下载了非常少的代码,除此之外,用户的交互行为决定了何时发送哪些代码。

让我们仔细看看这种加载场景。

Interacting with filters pulling in 30KB of JS and data on interaction

交互驱动的延迟加载有一些重要的方面。

  • 首先,我们最初下载最少的代码,这样页面就能很快地显示完整的视觉效果。
  • 接下来,当用户开始与页面交互时,我们会使用这些交互来确定要加载哪些其他代码。例如,加载“更多过滤器”组件的代码。
  • 这意味着页面上许多功能的代码永远不会被发送到浏览器,因为用户不需要使用它们。
Interaction driven late loading

我们如何避免错过早期点击?

在这些 Google 团队使用的框架堆栈中,我们可以尽早跟踪点击,因为第一块 HTML 包含一个小型事件库 (JSAction),它在框架启动之前跟踪所有点击。这些事件用于两件事:

  • 根据用户交互触发组件代码的下载
  • 在框架完成启动后重播用户交互

其他潜在的启发式方法包括加载组件代码

  • 空闲一段时间后
  • 当用户将鼠标悬停在相关的 UI/按钮/呼叫操作上时
  • 基于浏览器信号(例如网络速度、数据节省模式等)的积极程度滑动比例。
Tiny event library included with the initial HTML

数据呢?

用于渲染页面的初始数据包含在初始页面的 SSR HTML 中并被流式传输。延迟加载的数据是根据用户交互下载的,因为我们知道它属于哪个组件。

这完成了基于交互的导入画面,数据获取与 CSS 和 JS 的工作方式类似。由于组件知道自己需要哪些代码和数据,因此其所有资源都只需一个请求就能获取。

How does data-fetching work? By component

这是因为我们在构建时创建了一个组件及其依赖关系的图形。Web 应用程序可以在任何时候引用此图形,并快速获取任何组件所需的资源(代码和数据)。这也意味着我们根据组件而不是路由进行代码拆分。

有关上述示例的演练,请参见 使用 JavaScript 社区提升 Web 平台


权衡

将代价高昂的工作转移到更接近用户交互的位置可以优化页面最初的加载速度,但是这种技术并非没有权衡。

如果在用户点击后加载脚本需要很长时间怎么办?

在 Google 酒店示例中,小的粒度块最大限度地减少了用户等待代码和数据获取和执行的时间。在其他一些情况下,大型依赖项确实会在速度较慢的网络上引入这种担忧。

减少这种情况发生的一种方法是在页面中的关键内容加载完成后,更好地分解这些资源的加载或预取。我建议测量这种方法的影响,以确定它在您的应用程序中有多大程度上的实际应用。

在用户交互之前缺乏功能怎么办?

使用外观的另一个权衡是,在用户交互之前缺乏功能。例如,嵌入式视频播放器将无法自动播放媒体。如果这种功能是关键,您可能需要考虑其他方法来加载资源,例如在用户滚动到嵌入式视频时延迟加载这些第三方 iframe,而不是等到交互后再延迟加载。


用静态变体替换交互式嵌入

我们已经讨论了基于交互的导入模式和渐进式加载,但是对于嵌入式用例来说,完全使用静态方式怎么样?

在某些情况下,可能需要立即使用嵌入式内容的最终渲染内容,例如在初始视窗中可见的社交媒体帖子。当嵌入式内容带来了 2-3MB 的 JavaScript 时,这也会带来自己的挑战。由于需要立即使用嵌入式内容,因此延迟加载和外观可能不太适用。

如果要优化性能,可以完全用看起来类似的静态变体替换嵌入式内容,并链接到更具交互性的版本(例如原始社交媒体帖子)。在构建时,可以获取嵌入式内容的数据并将其转换为静态 HTML 版本。

An original heavy-weight JavaScript embed compared to a statically rendered alternative

这里@wongmjane 在他们的博客中为一种类型的社交媒体嵌入式内容所使用的方法,它 利用了这种方法,既提高了页面加载性能,又消除了由于嵌入式代码增强回退文本而导致的 累积布局偏移

虽然静态替换对性能有利,但它们通常需要进行一些自定义操作,因此在评估选项时请牢记这一点。


结论

第一方 JavaScript 经常影响现代网页的交互准备情况,但它通常会延迟加载,因为它位于网络上的非关键 JS 后面,这些 JS 可能来自第一方或第三方源,它们会占用主线程。

一般来说,避免在文档头部使用同步第三方脚本,并尽力在第一方 JS 加载完成后加载非阻塞第三方脚本。像基于交互的导入这样的模式为我们提供了一种方法,可以将非关键资源的加载延迟到用户更有可能需要它们所支持的 UI 的时间点。

特别感谢 Shubhie Panicker、Connor Clark、Patrick Hulce、Anton Karlovskiy 和 Adam Raine 提供的意见。