关于 import-on-interaction
面向 Web 开发者的按交互导入指南,说明如何在用户点击或滚动等操作后再加载非关键代码,适用于第三方组件和应用功能。
主要功能
- 01通过动态 import() 延后加载模块
- 02用轻量占位界面替代重型嵌入组件
- 03在 React 中结合 lazy 和 Suspense
- 04按点击、滚动或空闲时机触发加载
适用场景
- 01点击后再加载视频播放器或聊天组件
- 02用户选择登录时才加载认证库
- 03点击表情按钮后加载表情选择器
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ patternsdev/ skills --skill import-on-interaction安装后在 Claude Code 中输入/import-on-interaction确认已加载。
详细介绍
交互时导入
目录
页面可能包含某个组件或资源的代码或数据,但这些内容并非立即必需。例如,用户在点击或滚动页面的某些部分之前不会看到的用户界面内容。这适用于你编写的许多类型的第一方代码,也适用于第三方小组件,例如视频播放器或聊天小组件,通常需要点击按钮才能显示其主要界面。
适用场景
- 使用此方法延迟加载成本较高的第三方小组件(视频播放器、聊天小组件)
- 适合将非关键代码的加载推迟到用户实际需要时
- 使用此方法改善首次输入延迟(FID)和可交互时间(TTI)
不适用场景
- 页面加载时立即需要该资源,且不需要等待用户交互的情况
- 交互后的加载延迟会明显损害用户体验的情况(可考虑使用预取或预加载)
- 动态导入的开销超过延迟加载收益的小型模块
说明
- 使用动态
import()在用户交互(点击、悬停等)时加载模块 - 为占用资源较多的第三方嵌入内容实现外观层(轻量级占位内容)
- 对于第一方代码,尽可能优先使用预取,而非交互时导入
- 考虑在悬停时预连接到所需的源,以降低延迟
- 在 React 中,使用
React.lazy和Suspense实现组件级别的交互时导入
详情
立即加载这些资源(即立刻加载)可能会在资源成本较高时阻塞主线程,从而延后用户与页面其他关键部分进行交互的时间。这可能会影响交互就绪度指标,例如首次输入延迟、总阻塞时间和可交互时间。你可以不立即加载这些资源,而是在更合适的时机加载,例如:
- 用户首次点击以与该组件交互时
- 用户滚动到该组件进入视口时
- 或将该组件的加载推迟到浏览器空闲时(通过 requestIdleCallback)。
从高层次来看,加载资源的不同方式包括:
- 立即加载 — 立刻加载资源(脚本的常规加载方式)
- 懒加载(基于路由)— 用户导航到某个路由或组件时加载
- 懒加载(交互时)— 用户点击 UI 时加载(例如,点击“显示聊天”)
- 懒加载(进入视口时)— 用户滚动到组件附近时加载
- 预取 — 在需要之前加载,但在关键资源加载之后
- 预加载 — 以更高优先级立即加载
在交互时延迟导入功能代码,是许多场景中采用的一种模式。你可能曾在 Google Docs 中见过这种做法:它将分享功能的 500KB 脚本推迟到用户交互时才加载,从而避免提前加载。
加载第三方小组件时,交互时导入也可能很适用。
使用 Facade“模拟”加载第三方 UI
你可能会导入第三方脚本,却无法控制它渲染的内容或加载代码的时机。一种实现交互后加载的简单方法是使用 Facade。Facade 是成本较高组件的简单“预览”或“占位内容”,通过图片或屏幕截图等方式模拟基本体验。这是我们在 Lighthouse 团队中一直用来描述这一做法的术语。
用户点击“预览”(Facade)后,资源的代码才会加载。这样一来,如果用户不会使用某项功能,就无需承担相应的体验成本。同样,Facade 也可以在鼠标悬停时对必要资源进行预连接。
视频播放器嵌入
Paul Irish 开发的 YouTube Lite Embed 就是一个很好的“Facade”示例。它提供了一个自定义元素,接收 YouTube 视频 ID,并显示简洁的缩略图和播放按钮。点击该元素后,会动态加载完整的 YouTube 嵌入代码,也就是说,从不点击播放的用户无需承担获取和处理这些代码的成本。
Google 的一些网站在生产环境中也采用了类似技术。在 Android.com 上,网站不会急于加载 YouTube 视频播放器嵌入代码,而是向用户显示一张缩略图和一个仿制的播放器按钮。用户点击后,会打开一个模态框,使用完整的 YouTube 视频播放器嵌入代码自动播放视频。
身份验证
应用可能需要通过客户端 JavaScript SDK 支持使用某项服务进行身份验证。这类 SDK 有时体积较大,JavaScript 执行成本也很高;如果用户不会登录,通常没必要在初始加载时就加载它们。相反,可以在用户点击“登录”按钮时动态导入身份验证库,让初始加载期间的主线程有更多空闲时间。
聊天组件
Calibre 应用通过采用类似的 Facade 方法,将其基于 Intercom 的在线聊天性能提升了 30%。他们仅使用 CSS 和 HTML 实现了一个“仿制”的快速加载在线聊天按钮,点击后才会加载 Intercom 代码包。
Postmark 指出,他们的帮助聊天组件总是在初始加载时就加载,尽管客户只是偶尔会使用它。该组件会加载 314KB 的脚本,比整个主页的脚本还多。为了改善用户体验,他们用 HTML 和 CSS 实现了一个仿制组件,点击后再加载真实组件。这项改动将可交互时间从 7.7s 缩短至 3.7s。
其他
Ne-digital 使用了一个 React 库,让用户点击“回到顶部”按钮时可以通过动画滚动回页面顶部。他们没有在初始加载时就加载 react-scroll 依赖,而是在用户与按钮交互时才加载,节省了约 7KB:
handleScrollToTop() {
import('react-scroll').then(scroll => {
scroll.animateScroll.scrollToTop({
})
})
}
如何在交互时导入?
原生 JavaScript
在 JavaScript 中,动态 import() 可以实现模块的延迟加载,并返回一个 Promise。正确使用时,它的功能相当强大。下面的示例在按钮事件监听器中使用动态 import 导入 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);
});
});
在动态 import 出现之前,或者不太适合使用它的场景中,也可以使用基于 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 提供支持,其压缩并经 gzip 处理后的大小为 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 组件结合起来处理加载状态。
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 Picker 组件的代码,而不是在应用首次加载时就急切加载:
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 中,可以通过几种不同的方式实现类似的交互时导入模式。一种方式是使用动态导入,并将其封装在函数中,即 () => import("./Emojipicker"),以动态导入 Emojipicker Vue 组件。通常,这样做会让 Vue.js 在需要渲染该组件时再延迟加载它。
接下来,我们可以将延迟加载与用户交互关联起来。在选择器的父级 div 上使用条件 v-if,并通过点击按钮切换该条件,就可以在用户点击时按需获取并渲染 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 Hotels 查看价格。完成这次交互所需的所有资源都可以预先急切加载,但如果用户还没有选择目的地,那么地图所需的 HTML/CSS/JS 就没有必要加载。
在最简单的下载场景中,假设 Google Hotels 使用的是朴素的客户端渲染(CSR)。所有代码都会预先下载并处理:先是 HTML,然后是 JS、CSS,接着获取数据,等所有内容都准备好后才进行渲染。然而,这会让用户长时间等待,屏幕上什么也不显示。大量 JavaScript 和 CSS 可能都是不必要的。
接下来,假设将这种体验改为服务端渲染(SSR)。这样用户可以更早看到视觉上完整的页面,这固然很好;但在从服务端获取数据且客户端框架完成水合之前,页面仍无法交互。
SSR 可以带来改善,但用户可能会遇到一种“恐怖谷”体验:页面看起来已经准备就绪,却无法点击任何内容。有时,这种情况被称为愤怒点击,因为用户往往会因沮丧而一遍又一遍地点击。
回到 Google Hotels 搜索示例,如果我们将界面放大来看,就会发现当用户点击“更多筛选条件”以寻找最合适的酒店时,该组件所需的代码才会被下载。
初始时仅下载极少量代码,之后则由用户交互决定何时发送哪些代码。
交互驱动的延迟加载有几个重要方面:
- 首先,我们在初始阶段下载最少量的代码,以便页面迅速完整呈现。
- 接着,当用户开始与页面交互时,我们会根据这些交互判断还要加载哪些代码。例如,加载“更多筛选条件”组件的代码。
- 这意味着,页面上许多功能的代码永远不会发送到浏览器,因为用户不需要使用这些功能。
如何避免错过早期点击?
Google 这些团队使用的框架栈可以通过追踪早期点击来实现这一点:首个 HTML 块包含一个小型事件库(JSAction),它会在框架启动完成前追踪所有点击。这些事件有两个用途:
- 根据用户交互触发组件代码的下载
- 在框架完成启动后重放用户交互
还可以考虑使用其他启发式方法来加载组件代码,例如:
- 闲置一段时间后加载
- 用户将鼠标悬停在相关 UI、按钮或行动号召上时加载
- 根据浏览器信号(例如网络速度、流量节省模式等)采用不同的加载积极程度
数据怎么办?
用于呈现页面的初始数据包含在初始页面的 SSR HTML 中,并通过流式传输提供。延迟加载的数据则根据用户交互下载,因为此时我们知道它对应哪个组件。
这样,交互时导入的整体流程就完整了,数据获取也能像 CSS 和 JS 一样运作。组件知道自己需要哪些代码和数据,因此所需的任何资源都只需一次请求即可获取。
这是通过在构建时创建组件及其依赖项的关系图实现的。Web 应用可以随时引用此关系图,并快速获取任何组件所需的资源(代码和数据)。这也意味着,我们按组件而非路由拆分代码。
如需了解上述示例的详细演示,请参阅 Elevating the Web Platform with the JavaScript Community。
权衡
将耗时的工作推迟到用户交互时执行,可以缩短页面的初始加载时间,但这种做法也有其权衡。
如果用户点击后脚本需要很长时间才能加载,该怎么办?
在 Google Hotels 的示例中,细粒度的小块可以最大限度地降低用户长时间等待代码和数据获取并执行的可能性。在其他一些情况下,大型依赖项确实可能会在网络较慢时引发这个问题。
降低这种情况发生概率的一种方法是进一步拆分加载过程,或者在页面中的关键内容加载完毕后预取这些资源。我建议测量其影响,以确定这在你的应用中是否确实是个问题。
如果用户交互前缺少功能,该怎么办?
使用门面模式的另一个权衡是,用户交互前无法提供某些功能。例如,嵌入式视频播放器将无法自动播放媒体。如果此类功能至关重要,可以考虑其他资源加载方式,例如在用户滚动到第三方 iframe 并使其进入视口时再进行延迟加载,而不是等到用户交互后才加载。
将交互式嵌入替换为静态版本
我们讨论了按交互导入模式和渐进式加载,但对于嵌入场景,是否可以完全采用静态方式?
在某些情况下,可能需要立即显示嵌入的最终渲染内容,例如首屏视口中可见的社交媒体帖子。如果嵌入还会加载 2-3MB 的 JavaScript,这也会带来额外挑战。由于嵌入内容需要立即呈现,延迟加载和门面模式可能不太适用。
如果以性能优化为目标,可以将嵌入完全替换为外观相似的静态版本,并链接到交互性更强的版本(例如原始社交媒体帖子)。在构建时,可以提取嵌入所需的数据,并将其转换为静态 HTML 版本。
静态替代方案有助于提升性能,但通常需要进行定制开发,因此评估选项时请将这一点考虑在内。
结论
在现代网页中,第一方 JavaScript 往往会影响页面的交互就绪状态,但它经常会因为非关键的第一方或第三方 JavaScript 占用主线程,而在网络加载顺序中被延后。
一般而言,应避免在文档头部加载同步第三方脚本,并尽量在第一方 JavaScript 加载完成后再加载非阻塞的第三方脚本。按交互导入等模式可以将非关键资源的加载推迟到用户更可能需要相应界面的时机。