交互导入

页面可能包含并非立即需要的组件或资源的代码或数据。

例如,用户不会看到部分用户界面,除非他们单击或滚动页面的某些部分。

这适用于编写的多种第一方代码,但这也适用于第三方小部件,

例如视频播放器或聊天小部件,通常需要单击按钮来显示主界面。

急切地(即立即)加载这些资源可能会阻塞主线程,如果它们代价高昂,就会推迟用户与页面更关键部分交互的时间。

这会影响交互准备指标,例如首次输入延迟、总阻塞时间和交互时间。
可以在更合适的时间加载它们,而不是立即加载这些资源,例如:

  • 当用户第一次点击与该组件交互时

  • 将组件滚动到视图中

  • 或推迟加载该组件,直到浏览器空闲(通过 requestIdleCallback

加载资源的不同方法是,在较高级别:

  • Eager - 立即加载资源(加载脚本的正常方式)

  • Lazy(Route-based) - 当用户导航到路由或组件时加载

  • Lazy(交互时) - 当用户点击 UI 时加载(例如显示聊天)

  • Lazy (In viewport) - 当用户向组件滚动时加载

  • 预取 - 在需要之前加载,但在加载关键资源之后

  • 预加载 - 急切地,具有更高的紧迫性

注意:

只有在交互之前无法预取资源时,才应执行第一部分代码的交互时导入。

然而,该模式与第三方代码非常相关,如果不重要,通常希望将其推迟到以后的时间点。

这可以通过多种方式实现(推迟到交互,直到浏览器空闲或使用其他启发式方法)。

在交互中延迟导入功能代码是本文中介绍的许多上下文中使用的模式。

之前可能使用过的 Google Docs,它通过将其加载推迟到用户交互,可以为共享功能节省 500KB 的脚本加载。

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


“Fake” 加载带有外观的第三方 UI

正在导入第三方脚本,并且对其呈现的内容或加载代码的时间的控制较少。

实现交互加载的一种选择是直截了当的:使用 facade(外观)。

外观是一个简单的 预览占位符,用于模拟基本体验的成本更高的组件,例如图像或屏幕截图。

这是在 Lighthouse 团队中一直用于这个想法的术语。

当用户单击 预览(preview)时,将加载资源的代码。

这限制了用户在不打算使用某个功能时需要为该功能支付体验费用。
同样,外观可以在悬停时预连接到必要的资源。

注意:

第三方资源经常被添加到页面中,而没有充分考虑它们如何适应网站的整体加载。

同步加载的第三方脚本会阻止浏览器解析器并延迟水合。

如果可能,应该使用 async/defer(或其他方法)加载 3P 脚本,以确保 1P 脚本不会缺乏网络带宽。

除非它们很关键,否则它们可以成为使用交互导入等模式转移到延迟加载的良好候选者。


视频播放器嵌入

“外观”的一个很好的例子是由 Paul Irish 嵌入的 YouTube Lite。

这提供了一个自定义元素,它采用 YouTube 视频 ID 并呈现最小的缩略图和播放按钮。

单击该元素会动态加载完整的 YouTube 嵌入代码,这意味着从不单击播放的用户无需支付获取和处理它的费用。

一些 Google 网站的生产中使用了类似的技术。

在 Android.com 上,不是急切地加载嵌入的 YouTube 视频播放器,而是向用户显示带有假播放器按钮的缩略图。

当他们点击它时,会加载一个模式,它使用嵌入的全脂 YouTube 视频播放器自动播放视频:


验证

应用程序可能需要通过客户端 JavaScript SDK 支持服务身份验证。

这些有时会很大,并且 JS 执行成本很高,如果用户不打算登录,人们可能宁愿不急切地预先加载它们。

相反,当用户单击“登录”按钮时动态导入身份验证库,从而在初始加载期间保持主线程更空闲。


聊天小工具

通过使用类似的外观方法,Calibre 应用程序将其基于 Intercom 的实时聊天的性能提高了 30%

他们仅使用 CSS 和 HTML 就实现了一个“假”快速加载实时聊天按钮,点击后会加载他们的 Intercom 包。

Postmark 指出,他们的“帮助”聊天小部件总是急切地加载,即使客户只是偶尔使用它。

该小部件将拉入 314KB 的脚本,超过其整个主页。

为了改善用户体验,他们使用 HTML 和 CSS 用伪造的副本替换了小部件,在点击时加载真实的东西。

此更改将交互时间从 7.7 秒减少到 3.7 秒。


其他

当用户点击“滚动到顶部”按钮时,Ne-digital 使用一个 React 库来动画滚动回到页面顶部。

他们不是急切地为此加载 react-scroll 依赖项,而是在与按钮交互时加载它,节省了大约 7KB:

handleScrollToTop() {
    import('react-scroll').then(scroll => {
      scroll.animateScroll.scrollToTop({
      })
    })
}


如何导入交互?

Vanilla JavaScript

在 JavaScript 中,

动态 import() 启用延迟加载模块并返回承诺,并且在正确应用时可以非常强大。

下面是在按钮事件侦听器中使用动态导入来导入 lodash.sortby 模块然后使用它的示例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10

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

btn.addEventListener('click', e => {
  e.preventDefault();
  import('lodash.sortby')
    .then(module => module.default)
    .then(sortInput()) // 使用导入的依赖
    .catch(err => { console.log(err) });
});

在动态导入或用例之前,它也不适合,

使用基于 Promise 的脚本加载器将脚本动态注入页面也是一种选择请参阅此处了解演示登录外观的完整实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

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!`);
  });
});
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11

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)。

在初始页面加载时急切加载所有这些组件是很常见的。

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

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

使用代码拆分来分解这项工作的加载相对简单。

React.lazy 方法可以很容易地使用动态导入在组件级别对 React 应用程序进行代码拆分。

React.lazy 函数提供了一种内置方法,可以将应用程序中的组件分离为单独的 JavaScript 块,而且工作量很少。

当将它与 Suspense 组件结合时,你可以处理加载状态。


 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

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> 中单击 Emoji 图标时为 Emoji Picker 组件导入代码,而不是在应用程序最初加载时立即执行:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

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>
  );
};


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

在交互中加载代码也恰好是谷歌如何在航班和照片等大型应用程序中处理渐进式加载的关键部分。

为了说明这一点,看一下一个例子。

假设用户计划去印度孟买旅行,他们访问 Google Hotels 查看价格。
此交互所需的所有资源都可以预先加载,但如果用户未选择任何目的地,则地图所需的 HTML/CSS/JS 将是不必要的。

在最简单的下载场景中,假设 Google Hotels 正在使用简单的客户端渲染 (CSR)。

所有代码都将被预先下载和处理:HTML,然后是 JS、CSS,然后获取数据,只有在拥有所有内容后才进行渲染。

然而,这让用户等待很长时间,屏幕上没有显示任何内容。 大部分 JavaScript 和 CSS 可能是不必要的。

接下来,想象一下将这种体验转移到服务器端渲染 (SSR) 的情况。

允许用户更快地获得一个视觉上完整的页面,这很好,但是直到从服务器获取数据并且客户端框架完成水合之前它不会是交互式的。

SSR 可以是一种改进,但用户可能会有一种不可思议的山谷体验,页面看起来已经准备好了,但他们无法点击任何东西。

有时这被称为愤怒点击,因为用户往往会沮丧地反复点击。

回到 Google Hotels 搜索示例,如果稍微放大 UI,可以看到,当用户单击“更多过滤器”以准确找到合适的酒店时,会下载该组件所需的代码。

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

仔细看看这个加载场景。

交互驱动的延迟加载有许多重要方面:

  • 首先,最初下载最少的代码,因此页面在视觉上很快就完成了。

  • 接下来,当用户开始与页面交互时,使用这些交互来确定要加载哪些其他代码。 例如加载“更多过滤器”组件的代码。

  • 这意味着页面上许多功能的代码永远不会发送到浏览器,因为用户不需要使用它们。

如何避免丢失早期点击?

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

这些事件用于两件事:

  • 基于用户交互触发组件代码下载

  • 当框架完成引导时重放用户交互

可以使用的其他潜在启发式方法包括加载组件代码:

  • 空闲时间后的一段时间

  • 用户将鼠标悬停在相关的 UI/按钮/号召性用语上

  • 基于基于浏览器信号(例如网络速度、数据保护模式等)的热切程度

数据呢?

用于呈现页面的初始数据包含在初始页面的 SSR HTML 中并进行流式传输。
延迟加载的数据是根据用户交互下载的,因为我们知道它与哪个组件一起使用。

这完成了交互导入图片,数据获取工作类似于 CSS 和 JS 的功能。
由于组件知道它需要什么代码和数据,因此它的所有资源永远不会超过一个请求。

这在构建期间创建组件及其依赖关系图时起作用。
Web 应用程序能够在任何时候引用此图并快速获取任何组件所需的资源(代码和数据)。
这也意味着我们基于组件而不是路由进行代码拆分。

有关上述示例的演练,请参阅通过 JavaScript 社区提升 Web 平台


权衡

将昂贵的工作转移到更接近用户交互的位置可以优化页面最初加载的速度,但是该技术并非没有折衷。

如果用户点击后加载脚本需要很长时间会发生什么?

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

减少这种情况发生的一种方法是在页面中的关键内容加载完成后更好地中断加载或预取这些资源。
鼓励衡量这种影响,以确定它在您的应用程序中的真实应用程序。

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

外观的另一个权衡是在用户交互之前缺乏功能。
例如,嵌入式视频播放器将无法自动播放媒体。
如果此类功能很重要,您可能会考虑加载资源的替代方法,例如在用户上延迟加载这些第三方 iframe,将它们滚动到视图中,而不是将加载推迟到交互。


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

我们已经讨论了交互导入模式和渐进式加载,但是对于嵌入用例完全静态如何?

在某些情况下,可能会立即需要来自嵌入的最终渲染内容,例如在初始视口中可见的社交媒体帖子。

当嵌入带来 2-3MB 的 JavaScript 时,这也会带来它自己的挑战。
由于需要立即嵌入内容,因此延迟加载和外观可能不太适用。

如果优化性能,则可以使用看起来相似的静态变体完全替换嵌入,链接到更具交互性的版本(例如原始社交媒体帖子)。
在构建时,嵌入的数据可以被拉入并转换为静态 HTML 版本。

这是@wongmjane 在他们的博客上为一种社交媒体嵌入所采用的方法,既提高了页面加载性能,又消除了由于嵌入代码增强了后备文本而导致布局偏移而导致的累积布局偏移。

虽然静态替换对性能有好处,但它们通常需要做一些自定义的事情,因此在评估您的选项时请记住这一点。


结论

第一部分 JavaScript 通常会影响 Web 上现代页面的交互准备情况,但它通常会在来自保持主线程忙碌的第一方或第三方源的非关键 JS 后面的网络上延迟。

一般情况下,避免在文档头部同步第三方脚本,目的是在第一方 JS 加载完成后加载非阻塞的第三方脚本。
交互时导入等模式为我们提供了一种方法,可以将非关键资源的加载推迟到用户更有可能需要他们支持的 UI 时。


知识点

  • preview

  • React.lazy

  • Suspense