让页面切换快如闪电:GitHub Issues 的缓存、预取与 Service Worker 实战

2026-05-15 24 预计阅读时间:1 分钟
来源:github.blog AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:10 分钟

打开 GitHub Issues 列表,点进一条 Issue,再切回列表——每次导航都要等白屏、等网络、等渲染。用户感知到的不是"毫秒级延迟",而是"又卡了"。GitHub Issues 团队最近把这套体验彻底翻新,核心武器只有三样:客户端缓存、智能预取、Service Worker。本文拆解他们的思路,并给出可直接落地的代码示例。


问题根因:每次导航都是一次完整远征

传统 SPA 的路由切换看似"本地跳转",实际流程却是:

  1. 路由变化 → 触发数据请求
  2. 等待网络响应(RTT + 服务端处理)
  3. 拿到数据 → 渲染组件

用户点击的那一刻,浏览器什么都没准备好。如果网络慢或服务端重,体验就崩了。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 的做法本质上是把"等网络"变成"查本地",把"不可预测的延迟"变成"可控的缓存命中"。三层叠加不是炫技,而是每一层解决上一层覆盖不到的盲区。你的应用未必需要全部三层,但思路值得拆开来看、按需取用。


相关推荐