原文:www.patterns.dev/vanilla/imp...
标题:Import On Interaction
作者:patterns.dev
简介:当用户与需要它的 UI 交互时,懒加载非关键资源
你的页面可能包含一些当前不立即需要的组件或资源的代码或数据。例如,用户界面中的某些部分,除非用户点击或在页面上滚动,否则看不到。这适用于你编写的各种代码,同时也适用于第三方小工具,比如视频播放器或聊天小工具,通常需要点击按钮才能展示主界面。
如果这些资源很耗费资源,并且急切地加载(即立刻加载),可能会阻塞主线程,延迟用户与页面上更关键部分的交互时间。这可能会影响到交互准备性指标,如首次输入延迟(First Input Delay)、总阻塞时间(Total Blocking Time)和可交互时间(Time to Interactive)。与其立即加载这些资源,不如在更合适的时机进行加载,比如:
- 当用户首次点击与该组件进行交互时
- 滚动该组件进入视图时
- 或者延迟加载该组件,直到浏览器处于空闲状态(通过requestIdleCallback)。
以高层次来看,加载资源的不同方式包括:
- 急切加载 - 立即加载资源(加载脚本的常规方式)
- 懒加载(基于路由) - 当用户导航到路由或组件时加载
- 懒加载(通过交互) - 当用户点击 UI 时加载(例如显示聊天)
- 懒加载(在视口内) - 当用户向组件滚动时加载
- Prefetch - 在需要之前加载,但在关键资源加载后
- Preload - 急切加载,具有更高的紧急程度
对于项目本身代码代码,只有在无法在交互之前预取资源时才应该进行交互加载。然而,这种模式在第三方代码中非常相关,通常希望将其推迟到以后的时间点,如果它对非关键部分很重要。这可以通过许多方式实现(推迟到交互时,直到浏览器处于空闲状态或使用其他启发式方法)。
在交互时懒加载功能代码是许多上下文中使用的一种模式,我们将在本文中介绍。你可能以前在 Google 文档中使用过这种模式,他们通过推迟加载共享功能的脚本来节省 500KB 的加载。
另一个适合使用按需加载的地方是加载第三方小部件。
"假"加载带有外观的第三方 UI
你可能正在导入第三方脚本,并且对它呈现的内容或加载代码的时间的控制较少。实现交互加载的一个选项是直接的:使用外观。外观是一个简单的"预览"或"占位符",用于模拟成本更高的组件,你可以在其中模拟基本体验,例如使用图像或屏幕截图。这是我们在 Lighthouse 团队中一直使用的术语。
当用户单击"预览"(外观)时,将加载资源的代码。这限制了用户在不打算使用某项功能时需要支付的体验成本。同样,立面可以在悬停时预先连接到必要的资源。
第三方资源通常被添加到页面中,而没有充分考虑它们如何适应网站的整体加载。同步加载的第三方脚本会阻止浏览器解析器,并可能延迟冻结。如果可能,应使用 async/defer(或其他方法)加载 3P 脚本,以确保 1P 脚本不会缺少网络带宽。除非它们很关键,否则它们可以很好地使用交互时导入等模式转移到延迟延迟加载。
视频播放器嵌入
"占位符"的一个很好的例子是 Paul Irish 的 YouTube Lite Embed。这提供了一个自定义元素,该元素采用 YouTube 视频 ID,并显示最小的缩略图和播放按钮。点击该元素会动态加载完整的 YouTube 嵌入代码,这意味着从不点击播放的用户无需支付获取和处理该元素的费用。
在一些 Google 网站上的生产中使用了类似的技术。在 Android.com 上,不是急切地加载嵌入的 YouTube 视频播放器,而是向用户显示带有虚假播放器按钮的缩略图。当他们单击它时,会加载一个模态,该模态使用嵌入的全脂 YouTube 视频播放器自动播放视频:
认证场景
应用可能需要支持通过客户端 JavaScript SDK 对服务进行身份验证。这些有时可能很大,JS 执行成本很高,如果用户不打算登录,人们可能宁愿不急于预先加载它们。相反,当用户单击"登录"按钮时,动态导入身份验证库,从而在初始加载期间保持主线程更加自由。
聊天小部件
Calibre 应用程序通过使用类似的门面方法,将基于对讲机的实时聊天的性能提高了 30%。他们仅使用 CSS 和 HTML 实现了一个"假"快速加载实时聊天按钮,单击该按钮时将加载他们的对讲机包。
Postmark 指出,他们的帮助聊天小部件总是被急切地加载,即使它只是偶尔被客户使用。该小部件将提取 314KB 的脚本,比他们的整个主页还要多。为了改善用户体验,他们使用 HTML 和 CSS 将小部件替换为假副本,点击即可加载真实的东西。此更改将交互时间从 7.7 秒缩短到 3.7 秒。
其他
Ne-digital 使用 React 库在用户单击"滚动到顶部"按钮时以动画方式滚动回页面顶部。他们没有急切地为此加载 react-scroll 依赖项,而是在与按钮交互时加载它,节省了 ~7KB:
js
handleScrollToTop() {
import('react-scroll').then(scroll => {
scroll.animateScroll.scrollToTop({
})
})
}
你应该如何做到交互时导入?
在 JavaScript 中,dynamic import() 支持延迟加载模块并返回一个 promise,如果应用得当,它会非常强大。下面是一个示例,其中在按钮事件侦听器中使用动态导入来导入 lodash.sortby 模块,然后使用它。
js
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 的脚本加载器将脚本动态注入页面也是一种选择(演示登录外观的完整实现):
js
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
让我们想象一下,我们有一个 Chat 应用程序,它有一个 ,<MessageList>``<MessageInput>
和一个<EmojiPicker>
组件(由 emoji-mart 提供支持,它是 98KB 缩小和 gzip 压缩的)。在初始页面加载时急切地加载所有这些组件是很常见的。
jsx
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 组件耦合时,您可以处理加载状态。
jsx
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>
);
};
我们可以将这个想法扩展到仅在 Emoji 图标被单击时导入 Emoji Picker 组件的代码<MessageInput>
jsx
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>
);
};
Vue
在 Vue.js 中,类似的交互导入模式可以通过几种不同的方式完成。一种方法是使用包装在函数中的动态导入来动态导入 Emojipicker
Vue 组件,即 () () => import("./Emojipicker")
)。通常这样做会让 Vue.js 在需要渲染组件时延迟加载组件。
然后,我们可以在用户交互后面限制延迟加载。在选择器的父 div
上使用条件 v-if
,通过单击按钮进行切换,然后我们可以在用户单击时有条件地获取和呈现 Emojipicker
组件。
vue
<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)都应该可以实现交互导入模式。
作为渐进式加载的一部分的 first-party code的交互时导入
在交互时加载代码也恰好是 Google 如何处理大型应用程序(如 Flights 和 Photos)中渐进式加载的关键部分。为了说明这一点,让我们看一下 Shubhie Panicker 之前提出的一个例子。
想象一下,一位用户正在计划去印度孟买旅行,他们访问 Google 酒店查看价格。这种交互所需的所有资源都可以预先加载,但如果用户没有选择任何目的地,则地图所需的 HTML/CSS/JS 将是不必要的。
在最简单的下载场景中,假设 Google Hotels 正在使用朴素的客户端渲染 (CSR)。所有代码都将预先下载和处理:HTML,然后是JS,CSS,然后获取数据,只有在我们拥有所有内容后才能呈现。但是,这会使用户等待很长时间,而屏幕上不会显示任何内容。很大一部分 JavaScript 和 CSS 可能是不必要的。
接下来,假设此体验已移至服务器端渲染 (SSR)。我们将允许用户更快地获得视觉上完整的页面,这很好,但是在从服务器获取数据并且客户端框架完成冻结之前,它不会是可交互式的。
SSR 可能是一种改进,但用户可能会有一种不可思议的山谷体验,页面看起来已经准备好了,但他们无法点击任何内容。有时这被称为愤怒点击,因为用户往往会在沮丧中一遍又一遍地点击。
回到 Google Hotels 搜索示例,如果我们稍微放大 UI,我们可以看到,当用户单击"更多过滤器"以找到正确的酒店时,该组件所需的代码就会被下载。
最初只下载非常少的代码,除此之外,用户交互决定了何时发送哪些代码。
让我们仔细看看这个加载场景。
交互驱动的后期加载有许多重要方面:
- 首先,我们最初下载最少的代码,以便页面在视觉上快速完成。
- 接下来,当用户开始与页面交互时,我们使用这些交互来确定要加载的其他代码。例如,加载"更多过滤器"组件的代码。
- 这意味着页面上许多功能的代码永远不会发送到浏览器,因为用户不需要使用它们。
我们如何避免失去早期点击?
在这些 Google 团队使用的框架堆栈中,我们可以尽早跟踪点击次数,因为 HTML 的第一个块包含一个小型事件库 (JSAction),用于在框架引导之前跟踪所有点击次数。这些事件用于两件事:
- 根据用户交互触发组件代码的下载
- 在框架完成引导时重播用户交互
可以使用的其他潜在启发式方法包括加载组件代码:
- 空闲时间后的时间段
- 在用户将鼠标悬停在相关 UI/按钮/号召性用语上时
- 基于基于浏览器信号(例如网络速度、数据保护模式等)的热切程度的滑动比例。
结论
first-party JavaScript 通常会影响 Web 上现代页面的交互准备情况,但它通常会在网络上延迟,这些 JS 来自页面本身或第三方来源,使主线程保持繁忙。
一般情况下,避免在文档头中使用同步的第三方脚本,并在first-party JS 完成加载后加载非阻塞的第三方脚本。像交互时导入这样的模式为我们提供了一种方法,可以将非关键资源的加载推迟到用户更有可能需要他们支持的 UI 的程度。
特别感谢 Shubhie Panicker、Connor Clark、Patrick Hulce、Anton Karlovskiy 和 Adam Raine 的投入。