GitHub 重新设计了 GitHub Issues 的客户端导航架构,将内存缓存、IndexedDB、预测预取、Service Worker 和后台同步组合起来,减少用户点击链接后等待数据的时间。GitHub 报告称,“即时导航”的比例由 4% 提升到 22%,多个导航场景的延迟也随之下降。
这次改造值得关注的地方,不只是增加了一层缓存,而是重新安排了数据获取的时间:能在点击前请求的数据提前请求,能从本地返回的数据不阻塞界面,需要更新的数据放到后台完成。
即时导航的关键是移动等待时间
传统客户端路由通常在点击之后才开始工作:
- 用户点击 Issue 链接。
- 路由器解析目标地址。
- 客户端请求 Issue 数据。
- 服务器返回结果。
- 页面完成渲染。
即使服务器响应只需要几百毫秒,这段等待也会直接暴露给用户。优化服务器仍然重要,但客户端架构还可以改变等待发生的位置。
预测预取把网络请求移动到点击之前。例如,当一个 Issue 链接进入视口、获得键盘焦点,或者用户将指针停留在链接上时,客户端就可以低优先级加载目标数据。用户真正点击时,渲染层可能已经拿到结果。
这种优化不能简单理解为“预取所有链接”。Issue 列表可能包含大量目标,无限制预取会消耗带宽、服务器容量和移动设备电量。实践中需要控制触发条件、并发数、缓存有效期,并允许取消已经失去价值的请求。
内存、IndexedDB 与 Service Worker 各自解决什么问题
这套架构使用多种存储机制,是因为它们承担的职责不同:
| 层级 | 适合处理的问题 | 主要限制 |
|---|---|---|
| 内存缓存 | 当前标签页内的最快读取 | 刷新页面后消失,容量有限 |
| IndexedDB | 跨刷新保存结构化响应 | 读取是异步的,需要版本和淘汰策略 |
| Service Worker | 拦截请求、支持离线或后台更新 | 生命周期复杂,调试和发布需要谨慎 |
| 网络请求 | 获取权威的新数据 | 受延迟、失败和连接质量影响 |
一次导航可以按以下顺序处理:
- 内存命中时立即渲染。
- 内存未命中时读取 IndexedDB,并在可接受的新鲜度范围内先展示。
- 本地数据缺失或过期时访问网络。
- 已展示缓存数据后,在后台重新验证并更新本地副本。
这接近 stale-while-revalidate 思路:缓存负责快速呈现,后台请求负责最终新鲜度。不过,Issues 不是静态资源。状态、负责人、标签和评论都可能变化,因此界面必须区分“快速读取”和“权威写入”。提交编辑、关闭 Issue 等操作不能仅依赖旧缓存判断成功。
可以这样实践:一个简化的三级读取器
下面不是 GitHub 的实现,而是一个可直接改造的最小示例。它使用内存 Map 和 IndexedDB 保存 API 响应,并在缓存命中后启动后台更新。
将 API_BASE 改成自己的服务地址。接口需要返回 JSON,并为每个资源提供稳定的 URL。代码可保存为 issue-store.js,直接在现代浏览器的前端项目中导入。
const API_BASE = '/api/issues';
const DB_NAME = 'issue-navigation-cache';
const STORE_NAME = 'responses';
const MAX_AGE_MS = 30_000;
const memory = new Map();
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore(STORE_NAME, { keyPath: 'key' });
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async function readPersistent(key) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const request = tx.objectStore(STORE_NAME).get(key);
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => reject(request.error);
});
}
async function writePersistent(entry) {
const db = await openDatabase();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(entry);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
async function fetchAndCache(issueId, signal) {
const key = String(issueId);
const response = await fetch(`${API_BASE}/${encodeURIComponent(key)}`, {
headers: { Accept: 'application/json' },
signal
});
if (!response.ok) {
throw new Error(`Issue request failed: ${response.status}`);
}
const entry = {
key,
data: await response.json(),
storedAt: Date.now()
};
memory.set(key, entry);
await writePersistent(entry);
return entry.data;
}
export async function getIssue(issueId, { signal } = {}) {
const key = String(issueId);
const inMemory = memory.get(key);
if (inMemory && Date.now() - inMemory.storedAt < MAX_AGE_MS) {
return inMemory.data;
}
const persisted = await readPersistent(key);
if (persisted) {
memory.set(key, persisted);
// 缓存先返回;后台更新失败不影响当前导航。
fetchAndCache(key, signal).catch(() => {});
return persisted.data;
}
return fetchAndCache(key, signal);
}
export function prefetchIssue(issueId) {
const controller = new AbortController();
getIssue(issueId, { signal: controller.signal }).catch(() => {});
return () => controller.abort();
}
列表页面可以在链接获得焦点或指针停留时触发预取:
import { getIssue, prefetchIssue } from './issue-store.js';
const pendingPrefetches = new WeakMap();
document.querySelectorAll('[data-issue-id]').forEach((link) => {
const start = () => {
if (!pendingPrefetches.has(link)) {
pendingPrefetches.set(link, prefetchIssue(link.dataset.issueId));
}
};
const cancel = () => {
pendingPrefetches.get(link)?.();
pendingPrefetches.delete(link);
};
link.addEventListener('pointerenter', start, { once: true });
link.addEventListener('focus', start, { once: true });
link.addEventListener('pointerleave', cancel);
link.addEventListener('click', async (event) => {
event.preventDefault();
const issue = await getIssue(link.dataset.issueId);
history.pushState({}, '', link.href);
renderIssue(issue); // 替换成项目中的渲染函数。
});
});
生产实现还需要限制预取并发数,并根据网络状态关闭预测请求。例如,在 navigator.connection?.saveData 为 true 时跳过预取,避免消耗用户主动要求节省的流量。
Service Worker 应放在架构的什么位置
Service Worker 可以统一处理页面重载、多个标签页和弱网场景,但不宜让它成为不可观察的第二套业务状态。可以让它负责 GET 请求缓存和后台更新,而把权限判断、写操作结果和界面状态留在应用层。
下面是一个简化示例,展示如何对 Issue GET 请求采用“缓存先返回、网络后台更新”。这是可供实践的通用方案,并非对 GitHub 内部代码的复现。
const CACHE_NAME = 'issue-api-v1';
self.addEventListener('fetch', (event) => {
const request = event.request;
const url = new URL(request.url);
if (request.method !== 'GET' || !url.pathname.startsWith('/api/issues/')) {
return;
}
event.respondWith((async () => {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
const update = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
});
if (cached) {
event.waitUntil(update.catch(() => {}));
return cached;
}
return update;
})());
});
注册 Service Worker 时必须通过 HTTPS 提供页面,本地开发中的 localhost 除外:
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js');
}
对于包含私有数据的接口,还要评估响应是否允许持久化、用户退出后如何清理、不同账号是否可能共享缓存键。仅按 URL 缓存认证响应,可能造成严重的数据隔离问题。
不要只测平均延迟
“即时导航比例从 4% 到 22%”比单纯的平均值更贴近用户感知:它回答了有多少次跳转快到近乎不需要等待。团队采用类似方案时,可以同时记录以下指标:
- 点击到首个可用内容渲染的时间。
- 内存、IndexedDB 和网络各自的命中比例。
- 预取数据最终被使用的比例。
- 预取产生的额外请求量和字节数。
- 缓存数据被后台更新后,界面发生修正的频率。
- Service Worker 更新失败、旧版本滞留和缓存读取错误。
上线时应逐步扩大流量,并为 Service Worker 准备清缓存和回滚路径。多级缓存确实能让导航更快,但它也引入了数据新鲜度、身份隔离、存储配额和版本迁移成本。真正有效的目标不是“命中更多缓存”,而是在不破坏正确性的前提下,让更多导航在用户点击之前就完成最昂贵的工作。