打开 GitHub Issues 列表,点进一条 Issue,再切回列表——每次导航都要等白屏、等网络、等渲染。用户感知到的不是"毫秒级延迟",而是"又卡了"。GitHub Issues 团队最近把这套体验彻底翻新,核心武器只有三样:客户端缓存、智能预取、Service Worker。本文拆解他们的思路,并给出可直接落地的代码示例。
问题根因:每次导航都是一次完整远征
传统 SPA 的路由切换看似"本地跳转",实际流程却是:
- 路由变化 → 触发数据请求
- 等待网络响应(RTT + 服务端处理)
- 拿到数据 → 渲染组件
用户点击的那一刻,浏览器什么都没准备好。如果网络慢或服务端重,体验就崩了。GitHub Issues 的列表页、详情页、过滤视图之间频繁切换,这个问题尤其刺眼。
关键洞察:用户下一步要去哪里,很多时候是可以预测的。 预测到了,就可以提前把数据搬到家门口。
三层加速策略
第一层:客户端缓存——把远征变本地读取
GitHub Issues 团队没有选择每次都重新请求,而是在客户端维护了一份结构化缓存。核心原则:
- 请求过的数据不丢弃,按资源 ID 索引存入内存/IndexedDB
- 路由切换时先查缓存,命中则立即渲染,同时后台发请求做 stale-while-revalidate
- 缓存粒度不是"整页 HTML",而是单条 Issue、用户信息、标签列表等细粒度资源
这样做的好处:列表页已经拉过 50 条 Issue,点进第 3 条时数据已经在手里,渲染零等待;回到列表时同样瞬间恢复。
第二层:智能预取——猜你要去哪,提前搬货
缓存只能处理"已经见过的页面"。对于"还没去过但大概率会去"的页面,GitHub 用了预取(prefetch):
- 用户鼠标 hover 到某条 Issue 链接时,立即发起数据请求
- 列表页渲染完成后,静默预取当前视口内前 N 条 Issue 的详情数据
- 过滤条件切换前,预取对应过滤结果的第一页
预取的节奏需要克制:不能一口气预取几百条,既浪费带宽又可能挤占当前请求。GitHub 的做法是按视口和交互信号限流,只预取高概率目标。
第三层:Service Worker——离线兜底与缓存策略的终极防线
Service Worker 是整个方案的底层基础设施:
- 拦截 fetch 请求,按策略返回缓存或网络响应
- 为预取的数据提供持久化存储(IndexedDB),页面刷新后缓存仍在
- 网络断开时,已缓存页面仍可正常访问和渲染
三层叠加后的效果:点击 → 缓存命中 → 即刻渲染 → 后台静默更新。用户感知到的就是"瞬间切换"。
实战:用 Service Worker + 预取搭建即时导航
下面给出一个可运行的最小示例,演示如何在任意 SPA 中实现类似 GitHub Issues 的三层加速。以 React + Vite 项目为例,核心改动三处。
1. 注册 Service Worker 并声明缓存策略
sw.js——采用 stale-while-revalidate 策略,API 请求优先返缓存,后台同步更新:
// sw.js — Service Worker 缓存策略
const CACHE_NAME = 'issues-cache-v1';
const API_PREFIX = '/api/';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// 预缓存应用 shell(HTML + 关键静态资源)
return cache.addAll([
'/',
'/index.html',
'/app.js',
'/app.css',
]);
})
);
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// API 请求:stale-while-revalidate
if (url.pathname.startsWith(API_PREFIX)) {
event.respondWith(
caches.open(CACHE_NAME).then(async (cache) => {
const cached = await cache.match(event.request);
const networkPromise = fetch(event.request)
.then((response) => {
// 只缓存成功响应
if (response.ok) {
cache.put(event.request, response.clone());
}
return response;
})
.catch(() => cached); // 网络失败时 fallback 到缓存
// 有缓存就立即返回,同时后台更新
return cached || networkPromise;
})
);
return;
}
// 非 API 请求:常规 cache-first
event.respondWith(
caches.match(event.request).then((cached) => {
return cached || fetch(event.request);
})
);
});
在应用入口注册:
// main.js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js');
}
2. 客户端内存缓存层——细粒度资源索引
// cache.js — 客户端细粒度缓存
const resourceCache = new Map();
export function getCachedResource(key) {
return resourceCache.get(key) || null;
}
export function setCachedResource(key, data) {
resourceCache.set(key, {
data,
timestamp: Date.now(),
});
}
// stale-while-revalidate:先返缓存,后台刷新
export async function fetchWithCache(key, url) {
const cached = getCachedResource(key);
const networkPromise = fetch(url)
.then((res) => res.json())
.then((data) => {
setCachedResource(key, data);
return data;
});
if (cached) {
// 缓存命中:立即返回,不阻塞
return cached.data;
}
// 无缓存:等网络
return networkPromise;
}
3. hover 预取——鼠标悬停即触发
// prefetch.js — 智能预取
import { setCachedResource } from './cache.js';
const prefetched = new Set();
export function prefetchOnHover(linkElement, fetchUrl) {
linkElement.addEventListener('mouseenter', () => {
const key = fetchUrl;
if (prefetched.has(key)) return; // 已预取,跳过
prefetched.add(key);
fetch(fetchUrl)
.then((res) => res.json())
.then((data) => {
setCachedResource(key, data);
})
.catch(() => {
prefetched.delete(key); // 失败则移除标记,允许重试
});
}, { passive: true });
}
在列表组件中接入:
// IssuesList.jsx
import { fetchWithCache, getCachedResource } from './cache';
import { prefetchOnHover } from './prefetch';
import { useEffect, useRef } from 'react';
function IssueItem({ issue }) {
const linkRef = useRef(null);
useEffect(() => {
if (linkRef.current) {
prefetchOnHover(linkRef.current, `/api/issues/${issue.id}`);
}
}, [issue.id]);
return (
<a ref={linkRef} href={`/issues/${issue.id}`}>
{issue.title}
</a>
);
}
export function IssuesList() {
const [issues, setIssues] = useState(null);
useEffect(() => {
// 列表数据也走缓存
fetchWithCache('issues-list', '/api/issues').then(setIssues);
}, []);
if (!issues) return <div>Loading...</div>;
return issues.map((issue) => <IssueItem key={issue.id} issue={issue} />);
}
4. Issue 详情页——缓存优先渲染
// IssueDetail.jsx
import { getCachedResource, fetchWithCache } from './cache';
export function IssueDetail({ issueId }) {
const [issue, setIssue] = useState(() => {
// 同步查缓存:如果 hover 预取已命中,零等待渲染
return getCachedResource(`/api/issues/${issueId}`);
});
useEffect(() => {
// 无论缓存是否命中,都后台刷新保证数据新鲜
fetchWithCache(`/api/issues/${issueId}`, `/api/issues/${issueId}`)
.then((fresh) => setIssue(fresh));
}, [issueId]);
if (!issue) return <div>Loading...</div>;
return <div>{issue.title} — {issue.body}</div>;
}
运行方式:将以上文件放入 Vite 项目,后端提供 /api/issues 和 /api/issues/:id 接口,vite build 后部署即可验证 Service Worker 缓存与预取效果。开发环境可用 npx serve dist 启动静态服务器测试 SW 行为。
落地清单与取舍
| 决策点 | 建议 | 风险 |
|---|---|---|
| 缓存粒度 | 按资源 ID 索引,而非整页 | 缓存管理复杂度上升,需要处理关联数据一致性 |
| 预取触发时机 | hover + 视口内前 N 条 | hover 在移动端不存在,需补充 touchstart 或滚动预取 |
| 预取带宽限制 | 并发上限 2–3 个,队列排队 | 预取过多会挤占主请求,需 requestIdleCallback 或 AbortController 兜底 |
| SW 缓存策略 | API 用 stale-while-revalidate,静态资源用 cache-first | stale 期间用户看到旧数据,对强一致性场景不适用 |
| 缓存失效 | 写操作(创建/更新 Issue)后主动清除对应 key | 忘记清缓存 = 用户看到脏数据,必须有显式失效流程 |
| IndexedDB 持久化 | 大量数据存 IndexedDB,内存只放热数据 | IndexedDB 读写异步,首屏需提前预热 |
不要照搬全部。如果你的应用数据一致性要求高(比如金融交易),stale-while-revalidate 就不合适;如果页面间跳转路径不可预测,预取的命中率会很低,投入产出不成比例。先量一下当前导航的 P75 延迟,再决定在哪一层加码。
GitHub Issues 的做法本质上是把"等网络"变成"查本地",把"不可预测的延迟"变成"可控的缓存命中"。三层叠加不是炫技,而是每一层解决上一层覆盖不到的盲区。你的应用未必需要全部三层,但思路值得拆开来看、按需取用。