跳到主要内容

SPA 导航性能

问题

如何优化 SPA 的路由切换?怎样处理路由代码、数据请求、缓存、软导航指标、转场动画和发布后的静态资源版本错配?

面试速答版

SPA 导航优化不能只说“路由懒加载”,需要把一次导航拆成完整链路:

  1. 触发与反馈:点击后立即更新选中态、进度条或局部占位,不能等数据回来才反馈。
  2. 代码准备:按高概率用户路径预取路由 chunk,低概率路径继续懒加载,避免把所有路由都提前下载。
  3. 数据准备:路由代码和首屏数据并行获取;共享数据进入有 TTL、失效和容量约束的缓存,消除组件级串行请求瀑布。
  4. 渲染提交:缩小状态更新范围,控制主线程长任务、Hydration 和布局成本。
  5. 导航能力:站内高概率整页导航可评估 Speculation Rules;同文档转场可渐进增强 View Transitions;前进后退要保住 BFCache 和滚动位置。
  6. 监控与发布:记录路由触发、数据、提交和可见时间;支持时接入 Soft Navigations API,不支持时保留业务指标。静态资源采用内容哈希、原子发布并保留旧 chunk。

一次 SPA 导航发生了什么

常见的慢导航不是单点问题,而是多个阶段串行:先下载路由 chunk,组件挂载后才请求接口,接口返回后才发现子组件 chunk,最后一次性渲染大量 DOM。即使每段只慢几百毫秒,串起来也会形成明显等待。

建议至少记录以下业务阶段:

阶段含义常见瓶颈
route intent用户表达导航意图点击处理被前置长任务阻塞
route matched路由匹配完成同步守卫、权限和配置计算
code ready路由代码可执行chunk 发现晚、缓存未命中、版本错配
data ready首屏数据就绪请求瀑布、接口慢、缓存策略错误
committed新页面 DOM 提交框架渲染、Hydration、同步计算
content visible主要内容完成下一帧呈现样式、布局、图片、字体和主线程竞争

路由预取:基于意图,而不是全部下载

什么时候预取

预取最适合“命中概率高、资源成本可控、不会产生副作用”的下一步:

  • 鼠标或触控开始表达意图,例如 pointerenterpointerdown
  • 链接通过 IntersectionObserver 进入附近视口,但要限制数量。
  • 业务漏斗中高度确定的下一步,例如商品列表到详情。
  • 浏览器空闲且网络、流量节省模式和设备资源允许。
type NetworkInformationLike = {
saveData?: boolean;
effectiveType?: string;
};

function canPrefetch(): boolean {
const connection = (navigator as Navigator & {
connection?: NetworkInformationLike;
}).connection;

if (connection?.saveData) return false;
if (connection?.effectiveType === 'slow-2g') return false;
return document.visibilityState === 'visible';
}

const prefetchedRoutes = new Set<string>();

async function prefetchProductRoute(productId: string): Promise<void> {
const key = `/products/${productId}`;
if (!canPrefetch() || prefetchedRoutes.has(key)) return;

prefetchedRoutes.add(key);

await Promise.allSettled([
import('./routes/ProductDetail'),
queryClient.prefetchQuery({
queryKey: ['product', productId],
queryFn: () => fetchProduct(productId),
staleTime: 30_000, // 示例值,应按数据更新频率配置
}),
]);
}

预取要同时考虑代码和数据。只预取 chunk,点击后仍然等待接口;只预取数据,路由代码仍可能产生网络瀑布。

预取的边界

  • 不在页面启动时遍历并预取所有链接,这会抢占 LCP、字体和关键 CSS。
  • 不让预取执行写操作、曝光或一次性 token 消耗。
  • 记录命中率、浪费字节、缓存过期和用户流量成本。
  • 对移动网络、Save-Data、后台页面和低内存设备降低积极程度。
  • 预取失败不应影响正常点击,正式导航仍要有独立重试与错误 UI。

数据缓存与请求瀑布

先画依赖图

只有真正依赖前一步结果的请求才串行。权限、详情和推荐如果只依赖路由参数,可以并行启动;编辑配置如果依赖权限结果,再放到第二层。

组件各自在 useEffect 中请求数据,容易出现“父组件请求完成 -> 子组件挂载 -> 子组件再请求”的瀑布。更好的做法是让路由 loader、查询层或服务端边界提前知道首屏数据依赖。

interface ProductRouteData {
product: Product;
recommendations: Product[];
permissions: PermissionSet;
}

async function loadProductRoute(id: string): Promise<ProductRouteData> {
const [product, recommendations, permissions] = await Promise.all([
queryClient.ensureQueryData({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
}),
queryClient.ensureQueryData({
queryKey: ['recommendations', id],
queryFn: () => fetchRecommendations(id),
}),
fetchPermissions(id),
]);

return { product, recommendations, permissions };
}

缓存不是“永不过期的 Map”

缓存策略至少需要回答:

  • key 是否包含用户、租户、语言、筛选条件和数据版本?
  • 数据多久算 stale,多久从内存或持久化缓存移除?
  • 写操作成功后精确失效哪些查询?
  • 是否允许 stale-while-revalidate,刷新时怎样避免界面闪回 Loading?
  • 失败、权限变化、退出登录和切换租户时如何清理?
  • 长会话中缓存是否有容量上限?

对强一致数据,不要为了“秒开”展示可能造成错误操作的旧结果。可以保留页面结构和上一份内容,但要明确刷新状态、版本冲突和提交前校验。


立即反馈、并发渲染与取消

用户点击后应尽快看到导航已被接受。可以更新链接选中态、顶部进度条或目标区域占位,然后再处理非关键工作。

let activeNavigation: AbortController | null = null;

async function navigateToProduct(id: string): Promise<void> {
activeNavigation?.abort();
const controller = new AbortController();
activeNavigation = controller;

showNavigationPending(id);

try {
const data = await fetch(`/api/products/${id}`, {
signal: controller.signal,
}).then((response) => response.json() as Promise<Product>);

if (controller.signal.aborted) return;
commitProductRoute(data);
} catch (error) {
if ((error as DOMException).name !== 'AbortError') {
showNavigationError(error);
}
}
}

取消旧请求不仅节省资源,也避免较早请求晚返回后覆盖新路由。服务端若已开始昂贵计算,还要有超时、请求 ID 或业务取消协议,不能假设浏览器 abort 一定停止全部后端工作。


BFCache 与前进后退

浏览器前进后退缓存会冻结完整页面,恢复时保留 DOM、JavaScript 堆和滚动位置。它不等于 HTTP 缓存,也不是 SPA 内部数据缓存。

window.addEventListener('pageshow', (event) => {
if (event.persisted) {
resumePollingOnce();
revalidateSensitiveData();
}
});

window.addEventListener('pagehide', (event) => {
if (event.persisted) {
pausePolling();
} else {
disposePageResources();
}
});

实践要点:

  • 不用 unload 作为唯一清理机制,它可能影响 BFCache 资格。
  • 恢复时不要重复注册监听、埋点、计时器和 WebSocket。
  • 敏感或强时效数据恢复后重新校验,但不要立即清空整个页面造成闪烁。
  • 使用 Chrome DevTools 的 BFCache 检查定位阻塞原因。
  • SPA 内部返回还要单独保存路由状态、业务锚点和滚动容器位置。

Speculation Rules

Speculation Rules 面向文档级导航,允许浏览器根据规则 prefetch 或 prerender 候选页面。它适合服务端可直接访问的 URL,并不是 SPA import() 的替代品。

<script type="speculationrules">
{
"prefetch": [
{
"where": { "href_matches": "/products/*" },
"eagerness": "moderate"
}
],
"prerender": [
{
"urls": ["/checkout/review"],
"eagerness": "conservative"
}
]
}
</script>

使用前必须确认:

  • GET 导航没有删除、支付、登出等不可逆副作用。
  • 预渲染阶段不会误记曝光、触发客服会话或消耗一次性凭证。
  • 页面能识别 prerendering/activation 生命周期。
  • 高概率命中才使用 prerender,低概率路径最多 prefetch。
  • 有能力检测和普通导航降级,不能把支持情况当成全浏览器一致。

View Transitions

View Transitions 可以把 DOM 更新前后的视觉状态交给浏览器编排过渡。它能减少手写快照动画代码,但不会自动减少数据等待、渲染成本或布局工作。

interface DocumentWithViewTransition extends Document {
startViewTransition?: (update: () => void | Promise<void>) => unknown;
}

async function commitWithTransition(update: () => void): Promise<void> {
const doc = document as DocumentWithViewTransition;

if (
!doc.startViewTransition ||
matchMedia('(prefers-reduced-motion: reduce)').matches
) {
update();
return;
}

doc.startViewTransition(update);
}

转场期间仍要保证:目标内容可聚焦、浏览器历史正确、滚动恢复符合预期、快速连续导航可取消,以及不支持 API 时功能完整。


Soft Navigations API:2026 年状态与接入边界

浏览器状态(截至 2026-07-18)

Chrome 官方在 2026 年 7 月更新说明:Soft Navigations API 从 Chrome 151 开始启动默认开放,不再要求站点开启 flag。此前 Chrome 147~149 进行最后一轮 Origin Trial。

这仍是 Chromium 新能力,旧版 Chrome、其他浏览器和部分 WebView 不会产生这些条目;API 形状也经历过多次变化。必须做特性检测、标注浏览器版本,并保留跨浏览器业务指标。不要把 Soft Navigation 数据与传统硬导航数据直接混成同一历史序列。

function supportsSoftNavigationEntries(): boolean {
return PerformanceObserver.supportedEntryTypes.includes('soft-navigation');
}

if (supportsSoftNavigationEntries()) {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
reportSoftNavigation({
url: entry.name,
startTime: entry.startTime,
entryType: entry.entryType,
browserVersion: getNormalizedBrowserVersion(),
});
}
});

observer.observe({ type: 'soft-navigation', buffered: true });
}

接入时要注意:

  1. 一个新的 soft-navigation 到来时,先结算上一导航的指标,再初始化新导航状态。
  2. 条目时间仍相对于最初硬导航的时间原点;计算软导航耗时时要减去对应 startTime
  3. 软导航 LCP 使用新的 interaction-contentful-paint 体系,映射时不能只看 URL;需要遵循当前 Chrome 文档对 interactionIdnavigationIdgetLargestInteractionContentfulPaint() 的说明。
  4. 长生命周期应用应持续使用 PerformanceObserver 消费条目,不能只依赖有数量上限的缓冲查询。
  5. 字段中记录 navigationType、浏览器版本和采集算法版本,便于和传统硬导航、业务自定义指标分开聚合。

完整 Core Web Vitals 切片算法不要在业务中凭印象手写,应优先使用已明确支持当前 Soft Navigation 规范的 RUM SDK,并通过标准样例验证。

跨浏览器降级指标

interface RouteMeasure {
routeId: string;
intentAt: number;
committedAt?: number;
visibleAt?: number;
}

function markRouteVisible(measure: RouteMeasure): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
measure.visibleAt = performance.now();
reportRouteMeasure({
...measure,
duration: measure.visibleAt - measure.intentAt,
metric: 'route-content-visible',
});
});
});
}

双 rAF 只能作为“DOM 提交后经过呈现机会”的业务近似,不等于标准 FCP/LCP。可结合页面主要区域的稳定 data-performance-id、接口阶段和交互反馈形成可解释指标。


静态资源版本错配

典型故障

用户打开了旧 HTML,发布后点击一个从未访问过的路由,此时浏览器请求旧 chunk;如果 CDN 已删除旧产物,就会出现 ChunkLoadError 和局部白屏。Service Worker 缓存旧 HTML、CDN 缓存配置和非原子发布会扩大问题。

发布策略

  1. JS、CSS 和图片使用内容哈希,资源 URL 不可变并长期缓存。
  2. HTML、路由清单和运行时配置使用短缓存或协商缓存。
  3. 先完整上传新资源,再切换 HTML;回滚时旧资源仍可用。
  4. 旧 chunk 至少保留覆盖 HTML、Service Worker 和长会话生命周期的时间。
  5. Service Worker 更新采用版本协调,避免新旧客户端共享不兼容缓存。
  6. 监控 chunk URL、页面版本、资源版本和 Service Worker 版本。
async function loadRouteWithRecovery<T>(
loader: () => Promise<T>,
): Promise<T> {
try {
return await loader();
} catch (error) {
if (!isChunkLoadError(error)) throw error;

reportVersionMismatch({
pageVersion: window.__APP_VERSION__,
route: location.pathname,
message: String(error),
});

throw new Error('应用已更新,请刷新页面后重试', { cause: error });
}
}

客户端不要遇到错误就无限自动刷新。应显示明确提示,只允许一次受控恢复,并保留用户未提交内容;根本解决方案仍是原子发布和旧资源保留。


监控与预算

建议按路由类型记录:

  • 路由代码缓存命中率、chunk 加载失败率和版本错配率。
  • 预取覆盖率、点击命中率、浪费字节和预取失败率。
  • route intent 到反馈、数据 ready、commit、content visible 的 P50/P75/P95。
  • 导航期间最长任务、最慢交互和渲染提交耗时。
  • BFCache 恢复率、未命中原因和恢复后的数据校验耗时。
  • Soft Navigation 支持率、浏览器版本和采集算法版本。

预算要按路由复杂度和设备制定。例如“内容详情在中端移动设备、数据已缓存条件下,route-content-visible P75 不超过 500ms”可以作为一个起始示例,但不能套给编辑器等重交互页面。


常见面试问题

Q1: SPA 路由切换慢应该怎么排查?

答案

先从点击开始录制 Performance 和 Network,把导航拆成路由匹配、代码 ready、数据 ready、框架 commit 和主要内容 visible。看代码与数据是否串行、主线程是否有前置长任务,以及提交后是否又发生昂贵布局。

再分别处理:高概率路由预取代码和数据,独立请求并行,旧导航取消,缓存设明确 stale/失效规则,渲染阶段缩小更新范围。最后用同一路由旅程和线上 P75 验证。

Q2: 路由懒加载和路由预取矛盾吗?

答案

不矛盾。懒加载定义异步边界,避免所有路由进入首屏;预取是在浏览器确认用户高概率需要某个异步边界后,提前填充代码或数据缓存。

关键是命中率和优先级:首屏不要批量预取,Save-Data 或慢网降低积极程度,预取失败不能影响正式导航。

Q3: 为什么组件级请求容易产生瀑布?

答案

子组件只有在父组件数据返回并完成渲染后才挂载,随后才发起自己的请求,天然形成串行。动态 import 还可能把子组件代码下载插在中间。

把首屏依赖提升到路由 loader、查询层或服务端边界,提前建立依赖图;只让真正依赖上一步结果的请求串行。

Q4: BFCache 和 SPA 数据缓存有什么区别?

答案

BFCache 是浏览器保存完整文档快照,用于跨文档前进后退;SPA 数据缓存是应用保存接口结果,服务于同一文档内的路由与重渲染。

BFCache 恢复会带回 JavaScript 堆和 DOM,因此要避免重复初始化;数据缓存仍要按时效和权限重新验证,两者不能互相替代。

Q5: Soft Navigations API 现在能直接全量使用吗?

答案

截至 2026 年 7 月,Chrome 从 151 开始默认开放,但它仍是 Chromium 新能力,旧 Chrome、其他浏览器和部分 WebView 不支持。必须特性检测并记录浏览器与算法版本。

生产中同时保留跨浏览器的 route-content-visible 等业务指标;Soft Navigation 的 LCP、INP、CLS 切片遵循当前官方算法或成熟 RUM SDK,不能简单重置传统 LCP 变量。

Q6: View Transitions 能解决 SPA 性能问题吗?

答案

它主要改善状态切换的视觉连续性,不能缩短接口、chunk 或主线程耗时。更新函数本身很重时,转场仍会卡顿。

把它作为渐进增强,支持 reduced motion 和无 API 降级;内容、焦点、历史和错误恢复必须不依赖动画。

Q7: Speculation Rules 和 SPA 预取如何选择?

答案

SPA 预取通常准备当前文档内的路由代码和查询缓存;Speculation Rules 更适合提前获取或预渲染一个文档 URL。混合渲染应用可能同时使用。

选择时看导航是否会加载新文档、命中概率、副作用和资源成本。prerender 成本高于 prefetch,只用于非常确定的安全路径。

Q8: 如何避免发布后路由 chunk 404?

答案

内容哈希资源不可覆盖,先上传全部新资源再发布 HTML,并保留旧 chunk 覆盖旧 HTML、Service Worker 和长会话周期。HTML 与清单不能长期 immutable。

客户端提供一次性刷新提示和未提交内容保护,但不能用无限刷新掩盖非原子发布。

相关链接