静态站缓存与搜索
Service Worker 分桶缓存与 Pagefind 索引
静态站的缓存与搜索基建
这篇记录本站的两件基建:Service Worker 的缓存策略,和 Pagefind 的站内搜索。
缓存策略回答”资源怎么存、什么时候算新鲜”,站内搜索回答”内容怎么查、怎么做到零运行时服务”。
两者配合得很紧密。搜索索引是一批内容哈希命名的静态分片,恰好是缓存策略里最省心的那类客户。而缓存桶的设计一开始就为它留了位置。
上半场讲 Service Worker 的完整缓存策略,下半场讲 Pagefind 的索引方案与一个隐蔽的坑。
上半场:Service Worker 缓存策略
静态博客上了 CDN 之后,首次访问已经够快,Service Worker(SW)的价值主要在二次访问。
把”回源校验”都省掉,做到切页零等待。顺带解决一个静态站特有的账单问题:某些 API 按调用计费,缓存放对了地方就是真金白银。
先分桶
Cache Storage 是个大平层,混着放会让清理和策略都变纠缠。
本站按”资源的失效特性”分了四桶:
| 缓存桶 | 内容 | 失效特性 |
|---|---|---|
| static | 内容哈希资源(/_astro/)、Pagefind 索引、图片、字体 | 文件名带哈希,内容变则名字变,永不失效 |
| pages | HTML 页面 | 会过时,需要后台刷新 |
| cdn | jsDelivr / unpkg 的公共库 | 基本不变,偶尔要拿新版 |
| api 缓存(也在 pages 桶) | 评论系统 Waline 的响应 | 按 TTL 过期 |
分桶的直接收益在 activate 阶段:版本升级时只要换掉桶名后缀(frosti-v2 → v3),不在新名单里的桶整体删除,清理逻辑是一行过滤。
如果全混在一个桶里,就得逐条判断”这条缓存属于哪类、该不该清”。
一个语义要澄清:static 桶的”永不失效”指的是主动策略上不清(哈希命名保证命中即正确),不是”浏览器保证永存”。
存储压力下 Cache Storage 的条目可能被浏览器按自己的逐出策略清掉,被清了下次走网络回填就行,正确性不受影响。
它和站内预取(见切页与首屏那篇的前半)是分工而不是竞争:预取把 HTML 填进 HTTP 缓存,SW 没接管的导航直接吃这层。SW 接管后,SWR 的后台 revalidate 请求走浏览器网络栈,同样能命中预取留下的缓存。
两套机制各自记各自的账,天然互补。
策略矩阵
cache-first:哈希资源、图片、字体、搜索索引。
命中缓存直接返回,未命中才走网络并写入。
/_astro/ 下的文件名自带内容哈希,URL 和内容一一对应,缓存命中即正确,永远不需要回源校验。
图片字体同理(前提是部署时替换文件要换名字,否则就会踩”更新了图但用户看到旧的”的坑。这是 cache-first 的唯一代价)。
stale-while-revalidate(SWR):HTML 页面和 CDN 资源。
HTML 是最微妙的一类。不能 cache-first(版本更新后用户会永远看旧站),也不能 network-first(每次切页都等网络,二次访问的优势全无)。
SWR 是折中:先回缓存秒开,同时后台发请求更新缓存,下次进来就是新的。
代价是用户这一次看到的可能是旧版。对博客内容来说完全可接受,配合下面的版本机制收敛。
cache-first + TTL:按调用量计费的 API。
评论系统的响应如果每次导航都打一次接口,Vercel 函数的调用量会很难看。
策略是缓存命中且未过 TTL(5 分钟)时连网络请求都不发,过期才真正 revalidate。
实现上有个小技巧:Cache API 会保存响应的 Date 头,拿它和当前时间比较就是免费的新鲜度判断。
另外在 activate 阶段还有一个兜底清扫:把缓存里超过 30 分钟的 API 响应直接删掉,防止桶无限膨胀。
版本更替与接管时机
三个生命周期细节决定升级体验:
skipWaiting():新 SW 就绪后不等旧 SW 控制的页面全部关闭,立即接管。配合 HTML 的 SWR,用户最多看到一版旧页面,下一次导航就是新的;clients.claim():接管后连”当前这个还没被任何 SW 控制过的页面”也一并管上,首次安装即可享受缓存;- activate 清理:删除不在白名单里的旧桶 + 过期 API 响应,老版本的遗留不会无限堆积。
这里有一个诚实声明要写进设计文档:HTML 走 SWR 意味着”用户可能短暂看到旧版本”。
对内容型站点这是正确取舍;如果是发版频率高、新旧页交互有依赖的应用,就要改成 network-first 或加”发现新版本弹提示刷新”的机制。
另一个”没做”的边界也记一笔:离线兜底页。
SWR 之下,访问过的页面几乎总有缓存,但”首次访问即离线”的用户仍会看到浏览器错误页。
彻底 PWA 化的做法是预缓存一个轻量离线页、fetch 失败时兜底返回。对博客的收益很小(谁会离线第一次打开一个博客?),暂时不做。
这里写下来,是为了明确记录这一项是有意不做的。
千万别缓存的
本站有个访客统计:每次会话向统计端点 POST 一次。
SW 的 fetch 拦截里第一行就是:
if (request.method !== 'GET') return;POST/PUT/DELETE 携带副作用,缓存它们轻则统计丢失(响应被缓存,后续”请求”全是缓存回放,服务端根本没收到),重则出现重复提交。
同理要绕开的还有:带 Authorization 的接口、实时性强的 WebSocket(SW 本来也拦不住)、以及任何”请求即事件”的埋点。
缓存策略表的第一行永远是排除项。
两个工程细节
缓存键的规范化。
Cache Storage 按 URL 逐字存取,同一篇文章的 /blog/foo/ 和 /blog/foo/?utm_source=twitter 是两条独立缓存。
跟踪参数、分页参数、滚动恢复参数都会制造”内容相同、键不同”的冗余条目,HTML 桶因此缓慢膨胀。
做法是在 SW 查缓存前先把 URL 规范化:剥掉已知无害的 query 参数(utm*、fbclid 这类),只用规范化后的键存取。命中率上升,桶也干净。
注意别剥掉功能性参数(搜索词、页码),白名单思路比黑名单稳。
版本提示的 UI。
skipWaiting() 让用户最多看到一版旧页面,“下一次导航就是新的”对大多数场景够用。但对发版前后交互有依赖的站点,值得补一个显式提示:registration.addEventListener('updatefound', ...) 监听到新 SW 安装完成后,页面上弹一个”有新版本,点击刷新”的小条。
点击时调用 skipWaiting() 并刷新,用户自己选择何时切换。比自动静默替换更可控,也比”永远等下一次导航”更及时。
博客可以不做,应用类站点建议做。
作用域与拦截时序
两个基础机制值得单独交代,因为它们决定”SW 管得到谁、管在谁前面”。
作用域(scope):SW 的管辖范围由 sw.js 文件的路径决定。放在站点根目录(本站 /sw.js),默认作用域就是 /,全站页面都在它的控制下。放进 /sub/sw.js,它只能控制 /sub/** 下的页面。
“改了 sw.js 但某些页面行为没变”,先查作用域。大概率那些页面根本不归它管。
服务器的 Service-Worker-Allowed 响应头可以放宽默认作用域,但多数站点用根路径就够了。
拦截时序:SW 接管后,页面发出的每个请求都先经过 fetch handler,而不是先查 HTTP 缓存。
SW 是请求的第一站,“回缓存还是走网络”完全由策略代码决定。handler 里调用的 fetch() 才会去利用 HTTP 缓存。
这个顺序解释了两件事:为什么 SW 能实现 HTTP 缓存做不到的策略(SWR 的”先回缓存再后台更新”在纯 HTTP 层无法表达)。也为什么 SW 出 bug 的破坏力大:它拦在最前面,handler 抛异常整个请求就失败。
所以 fetch handler 里永远要有兜底 catch,宁可”退回直接 fetch”也不让请求死在半路。
注册时机
注册放在 window load 事件之后:
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js'));理由:SW 及其后续缓存动作不应和首屏关键资源抢带宽。
首屏加载完成后再注册,安装、预缓存都发生在空闲时段。
这是浏览器最佳实践的标准做法,没什么创造性,但值得写死在模板里防止被”优化”掉。
调试 SW 的日常工具
SW 的坑几乎都发生在”浏览器按策略办事而你以为是 bug”,DevTools 里三件套对应三种排查:
- Update on reload(Application 面板):每次刷新强制更新 SW 并跳过等待。开发期开着它,改完 sw.js 刷新即生效,不用手动
skipWaiting()验证; - Bypass for network:临时让所有请求绕过 SW 直连网络。“这页数据不对”到底是缓存的锅还是代码的锅,开关一打就见分晓;
- Cache Storage 逐桶展开:本站分了四桶,面板里逐桶看条目的 URL 和响应头,TTL 判断用的
Date头、哈希命名的产物是否更新,直接目视验证。
Firefox 的等价物在 about:debugging,另有一处命令行级工具值得知道:navigator.storage.estimate() 能查当前源站的总配额与已用空间。
Cache Storage 计入存储配额,长期运行后用它确认”static 桶增长是否符合预期”(内容涨了缓存涨,属正常;内容没涨缓存疯涨,说明有桶在漏)。
跨域与不透明响应
cdn 桶(jsDelivr / unpkg)涉及一个隐蔽的响应类型问题:<script> 标签发出的请求默认是 no-cors 模式,得到的响应是不透明响应(opaque)。
Cache API 照样能存,但内容、状态码全都读不到(status 是 0)。
后果在 TTL 策略里显形:想读 Date 头判断新鲜度,读到的是空。
规避方法是在引入 CDN 资源时加 crossorigin 属性,让请求走 CORS 模式、拿到”正常”响应。
jsDelivr、unpkg 这类公共服务本来就发 CORS 头,加上属性没有副作用。
缓存不透明响应不算错误(有些场景只能这么存),但要清楚代价:存得进、读不出,一切依赖响应内容的策略都会静默失效。
下半场:Pagefind 索引
上半场的分桶表里反复出现”Pagefind 索引”,现在完整交代它是什么、为什么建、以及一个差点让排除开关形同虚设的坑。
为什么是构建期索引
静态站要做全文搜索,选项通常是个不愉快的三岔口:接外部服务(引入依赖与费用)、自建倒排(复杂度爆炸)、或者构建期建索引。
把搜索这件事彻底做完在 CI 里,运行时只剩静态文件。
本站选了第三条路的 Pagefind。
两阶段模型:构建期,pagefind 扫描 dist/ 里的 HTML,为每个页面切块、分词、建倒排,输出一个自包含的索引目录(本站放在 /pagefind/),并做激进的分片。按页面块切成大量小片段,配合内容哈希命名。运行时,搜索组件加载一个几 KB 的引导脚本,用户输入时按需拉取只包含命中片段的小分片。
代价模型变成了”搜索一次 = 几 KB 转移”,而不是把整个站的内容表下载下来。
中文分词依赖构建期对页面的切块粒度,实测对中文内容的召回是够用的。
客户端全量索引(lunr、flexsearch 这类)把全站内容打进一个 JSON,搜索零延迟,但首包体积随内容线性增长。文章到几百篇时,用户为了搜一次要先下载几 MB 的索引,静态站的”轻”就毁了。
外部搜索服务(Algolia 之类)能力强,但引入 key、配额、数据同步三件套,个人站的搜索量摊不平这笔复杂度。
构建期分片索引是两条路的中间态:索引总体积同样随内容增长,但用户只下载命中的那几个分片。体积问题被”按需”消化掉了。
对纯静态站,这个方案的好处是零运行时:没有 API key、没有配额、没有延迟峰值,索引跟着构建走,部署就是全部。
搜索引擎不索引这个目录(robots 里已 Disallow /pagefind/),它纯粹为站内服务。
限定索引范围
默认 Pagefind 会索引整页。包括导航、侧栏、页脚这些每页重复的壳。
这会让搜索结果被”侧栏的分类列表”污染:搜”算法”,命中 500 个页面的侧栏链接。
解决办法是给 <body> 打 data-pagefind-body,告诉 Pagefind 只以此为根收集内容。壳被天然排除,索引里只剩文章本体。
body/ignore 之外,同一套 data 属性还有几个提高信噪比的手段(属性名细节随版本演进,以官方文档为准):元数据。把标题、发布日期、作者在元素上声明出来,搜索结果就能带出”这篇文章发布于何时”而不是只有正文片段。过滤器。把分类、标签声明成可过滤字段,前端就能做”只搜’算法’分类”这类 narrowing。权重。标题区的命中比正文命中更有价值,给标题容器加权后,“标题包含搜索词”的文章会排到”正文顺带提到”的前面。
这三个能力共同把搜索从”全文 grep”拉到”结构化检索”的门口,而成本只是模板里多几个属性。
这个标记是双刃剑,坑就埋在这里。
坑:html 层的排除标记被短路了
站内有一类文章需要排除出搜索(frontmatter 里 excludeSearch: true,比如纯记录用的流水账)。
实现思路很自然:布局层判断,被排除的页面在 <html> 上打 data-pagefind-ignore。
按 Pagefind 文档,这个标记会让爬取跳过整页。
结果却是不生效:排除的文章依然能搜到。
定位过程值得完整走一遍,因为它是”两个机制各自正确、组合起来失效”的典型:
<html>上的data-pagefind-ignore:按文档,作用于整页。单独用是对的;<body>上的data-pagefind-body:按文档,“以该元素为索引根”。单独用也是对的;- 组合的问题:一旦存在
data-pagefind-body,Pagefind 的爬取根就变成了 body,索引逻辑不再向上走到 html 层。html 上的 ignore 标记根本没机会被读到。
文档里这句话藏在不显眼的位置,而两个标记分属”排除语义”和”限定语义”,直觉上应该叠加,实际是后者短路前者。
修复:排除语义必须在 body 层面实现。被排除的页面同步不打 data-pagefind-body(连标签属性都不输出),整页级忽略才真正生效。
本站的布局层把三个条件(frontmatter 的 excludeSearch/exclude、布局的 noindex)汇合到一个布尔量上,控制 body 属性的有无。
索引范围和排除范围从此由同一份逻辑决定,不再可能单边失效。
双语站的语言绑定
本站 zh/en 双语,两种语言的页面都进了同一个索引目录,语言边界由 Pagefind 在首次创建实例时读取的 <html lang> 决定,之后这个实例就钉死在那门语言上。
这里藏着一个 SPA 式的暗坑:搜索页如果做成组件、切语言后复用旧实例,它会拿着上一门语言的绑定继续搜,结果整页错乱。
本站的解法是把实例名按语言拆开(zh/en 各一套),语言变了就换实例,宁可重建也不复用。
搜索页本身的构成也值得一记:UI 用 @pagefind/component-ui 的原生组件(输入框、摘要、结果列表),核心引擎在运行时动态 import。按 URL 加载 /pagefind/ 里的包,而不是打包进站点的 bundle。
索引和引擎都只在用户真正打开搜索页的那一刻才下载,普通浏览文章的流量开销为零。
交互上本站做了两档结果模式:严格匹配无结果时,自动降级到宽松模式再试一轮。把查询拆散、放宽逐词匹配的要求,宁可给出”沾边”的结果也不给一堵白墙。
搜索这种交互,“零结果”是最伤的输出:用户分不清是站里没有,还是自己打错了词。
宽松档就是替用户说一句”你是不是想找这些”。
结果链接统一开新标签,用户搜到一半不会把搜索页自己弄丢。
结果呈现还有一层值得经营的细节:摘要与高亮。
Pagefind 的每条结果自带一段命中上下文(excerpt),命中的词以高亮标签标出。
摘要质量取决于索引时的分块结构:块切得越接近”一个自然段落”,摘要越完整。把正文切成碎片式的小标签,摘要就会变成残句。
前端拿到摘要后配合样式重排即可,不需要自己实现高亮逻辑。
排除粒度上也有个中间档可用:data-pagefind-ignore 不只能上 html/body 整页级,还能贴在页面里的某个元素上(比如侧栏的”最新文章”小挂件)。
整页要进索引、但其中某块是噪音,就做局部排除,不必在”整页进/整页出”之间二选一。
把搜索状态放进 URL
搜索页有个容易被忽略的工程决策:查询词住在哪。
最省事的写法是存在组件 state 里,输入即搜、不留痕迹。
本站选择了更老派的方案:搜索框是一个真正的表单,GET 提交到 /search/?q=关键词,查询词成为 URL 的一部分。
换来三件免费的事:可分享(把搜索结果的 URL 发给别人,对方打开就是同一页结果)、可后退(改关键词搜索是历史记录里的一步,回退键可用)、刷新不丢(F5 之后查询词还在,结果原样重搜)。
对”找资料”这种场景,这三件事的价值远超实现成本。
URL 是浏览器原生的状态管理,不要重复造。
输入即时搜索(不按回车就出结果)要配一个细节:防抖。
用户打”javascript”是九次按键,不防抖就是九次全量搜索请求。Pagefind 的单次搜索很快,但没理由付九倍的成本。
300ms 左右的防抖把”连打”合并成”停下就搜”,体感即时、开销一次。
URL 同步与防抖的组合拳:输入防抖搜索,按回车(或结果稳定后)才更新 ?q=,两边的节奏各归各。
索引体积的账
索引的”总大小”和”单次成本”要分开记账。
总大小随站内正文的词项多样性线性增长,构建产物里能看到分片数量随之变多。但运行时用户单次搜索只拉”引导脚本 + 命中的那几片”,单次成本基本恒定。
站从 200 篇写到 600 篇,用户每次搜索的下载量几乎不变。这是分片设计的核心红利。
定期体检很简单:构建后看一眼 /pagefind/ 目录的总大小与分片数(健康指标是”小分片、多文件”),再实际搜几个代表词,看 Network 里单次搜索拉了几个分片、多大体积。
两个数字放一起,索引的健康度一眼可见。
索引的新鲜度与缓存
这里接回上半场。
索引跟着构建走:每次部署生成一套新分片,文件名带内容哈希,内容变了哈希就变,浏览器缓存的旧分片天然失效。
不会出现”文章更新了、搜索结果还是旧的”。
整套索引里唯一 URL 固定的是那个几 KB 的引导脚本,它本身不含数据,跨部署复用无害。真要谨慎,把它放进 SW 的网络优先策略即可。
分片本身则躺在上半场 static 桶的 cache-first 策略里:命中即正确、永不回源校验。
这正是”内容哈希命名的资产”能拿到最优缓存待遇的原因,搜索索引是这个待遇最好的客户。
经验与检查清单
这个坑的教训可以推广成三条:
- “限定根”和”排除标记”是互斥语义,不是叠加语义。任何工具的文档里,读到”以 X 为根”时都要追问一句:以 X 为根之后,X 的祖先上的规则还生效吗?
- 同一个人必须管同一个布尔量。索引范围、搜索排除、
noindex三件事如果是三处独立判断,迟早单边漂移。收敛到一个变量,一处修改全局一致。本站后来把”不进 sitemap 的页面”也挂到了同一套判断上; - 排除类功能必须有验证用例。失效的排除是静默失败(搜到就是生效了,谁会专门去搜一篇不该出现的文章?),上线时手动搜一次被排除的文章标题,一分钟的事。
小结
| 资源类型 | 策略 | 桶 |
|---|---|---|
| 哈希命名的构建产物、图片、字体、搜索索引 | cache-first | static |
| HTML 导航 | stale-while-revalidate | pages |
| 公共 CDN 库 | stale-while-revalidate | cdn |
| 评论 API | cache-first + TTL(5 分钟) | pages |
| POST / 埋点 / 授权接口 | 不拦截 | — |
两条线合起来看:缓存这条线按失效特性分类、按容忍度选策略、按副作用划红线;搜索这条线把查询成本做成构建期一次性支出,运行时只按需支付命中的分片。
两者咬合点在”内容哈希命名”。它同时是 cache-first 的正确性保证和索引分片的失效机制。
修复后的全链路:文章 frontmatter 控制搜索的 in/out,布局层单点判断,Pagefind 索引只含正文本体,构建期生成、SW 缓存、边缘分发,搜索词响应中位几十毫秒。
全链路没有任何运行时服务,对个人站,维护成本约等于零。