从 4% 到 22%:GitHub Issues 如何用多级缓存与预测预取加速页面跳转

2026-07-22 25 预计阅读时间: 1 分钟
来源: infoq.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

GitHub 重新设计了 GitHub Issues 的客户端导航架构,将内存缓存、IndexedDB、预测预取、Service Worker 和后台同步组合起来,减少用户点击链接后等待数据的时间。GitHub 报告称,“即时导航”的比例由 4% 提升到 22%,多个导航场景的延迟也随之下降。

这次改造值得关注的地方,不只是增加了一层缓存,而是重新安排了数据获取的时间:能在点击前请求的数据提前请求,能从本地返回的数据不阻塞界面,需要更新的数据放到后台完成。

即时导航的关键是移动等待时间

传统客户端路由通常在点击之后才开始工作:

  1. 用户点击 Issue 链接。
  2. 路由器解析目标地址。
  3. 客户端请求 Issue 数据。
  4. 服务器返回结果。
  5. 页面完成渲染。

即使服务器响应只需要几百毫秒,这段等待也会直接暴露给用户。优化服务器仍然重要,但客户端架构还可以改变等待发生的位置。

预测预取把网络请求移动到点击之前。例如,当一个 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?.saveDatatrue 时跳过预取,避免消耗用户主动要求节省的流量。

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 准备清缓存和回滚路径。多级缓存确实能让导航更快,但它也引入了数据新鲜度、身份隔离、存储配额和版本迁移成本。真正有效的目标不是“命中更多缓存”,而是在不破坏正确性的前提下,让更多导航在用户点击之前就完成最昂贵的工作。


相关推荐