把SSR和PWA放在同一张设计图里,你会很快发现:问题的核心不是哪一种更好,而是你想把“可离线、可秒开、可更新、可个性化”这几件互相牵制的事,摆在什么优先级上。全页service worker缓存与按内容片段渲染,本质上是把“页面组装”放在不同位置完成:一个更偏“客户端代理返回整页”,另一个更偏“服务端或边缘把页面拆成可独立变化的块,再组合”。
下面我从浏览器渲染链路讲起,把这两条路线的收益、代价、坑点与折中方案摊开讲清楚,并给出一套可落地的决策框架。
service worker在浏览器渲染管线里到底卡在哪一层
理解缓存策略之前,先把浏览器里几层“会缓存、会复用、会短路”的路径画在脑子里。
- 页面发起一次导航请求时,浏览器网络栈会走自己的
HTTP cache逻辑,决定是命中本地缓存还是走网络,并受Cache-Control、ETag、Vary等机制影响。 (MDN Web Docs) - 若站点注册了
service worker,它会像一个“设备端的中间层代理”拦截作用域内的请求,决定从Cache Storage返回、走网络返回,或用本地算法合成响应。 (MDN Web Docs) - 更微妙的一点是:实际浏览器实现里,缓存层不只一层。
service worker缓存与浏览器HTTP cache并不是互斥关系,甚至某些浏览器还会在service worker缓存之前再叠一层内存缓存,导致你以为自己在测service worker,实际命中的是另一层。 (web.dev)
这段链路会直接影响你对“全页缓存”的期待:你缓存的不是“页面体验”,而是“某一次导航请求的响应对象”,它带着当时的HTML、可能还带着当时的头信息语义,以及当时你选择的组装方式。
两条路线各自到底在做什么
路线 A:全页service worker缓存(导航请求直接返回缓存的HTML)
典型做法是拦截所有导航请求,把它们统一映射到缓存里的“应用外壳”或某个离线页。Workbox把这种模式抽象为application shell model:无论用户访问站内哪个深链接,都先返回缓存的外壳,再由前端去拉数据填充内容。 (Chrome for Developers)
这在“离线可用、重复访问秒开”上很强,因为导航请求根本不需要等待网络。
路线 B:按内容片段服务端渲染(服务端或边缘输出可拆分的HTML块)
这里的关键不是“有没有SSR”,而是“把动态变化的部分分块”,让每块有自己的缓存、失效与更新策略。行业里常见的两种范式:
- 传统
ESI(Edge Side Includes):用一套标记语言在边缘或代理层把多个片段组装成一页,目的是让动态片段可单独刷新、可独立缓存。 (W3C) - 现代框架的“部分预渲染/流式渲染”:同一路由内把静态与动态内容混合输出,静态先到,动态以流式方式补齐。
Next.js的Partial Prerendering就是明确把这件事产品化:同一路由同时包含静态与动态内容,以提升首屏同时又保留个性化能力。 (Next.js)
这条路线更像在说:页面不是一个整体缓存对象,而是一组可组合的内容单元。
全页service worker缓存的优点与“隐性成本”
为什么它看起来很美
离线能力天然强
导航请求直接从缓存返回,离线时不需要额外降级逻辑。application shell model甚至可以让用户打开从未访问过的深链接,也至少能拿到外壳与可用功能入口。 (Chrome for Developers)重复访问非常快
外壳与静态资源被precache后,渲染路径变短,首屏更稳定。Workbox也强调了这种模式在重复访问时的确定性收益。 (Chrome for Developers)对浏览器渲染友好
浏览器拿到完整HTML后可以立刻做解析、构建DOM与CSSOM,关键渲染路径不必等待一堆API请求才能出结构。
但它的坑通常出在“内容语义”而不是“技术实现”
个性化内容与隐私风险
一旦你把“带用户态”的HTML缓进了Cache Storage,就要非常小心账号切换、多人共用设备、同源内不同身份访问的场景。即使只是HTTP cache,Cache-Control用错也会带来安全与隐私问题;service worker自己管缓存时,这个责任基本落在你身上。 (web.dev)更新与失效的复杂度被低估
全页缓存最难的是“何时该让它过期”。静态资源可以用哈希文件名做强缓存,HTML却经常受发布节奏、灰度实验、地区策略影响。你会很快写出大量“例外规则”,最后把全页缓存写成了一个小型网关。真实网络并不总是慢,
service worker启动可能反而拖后腿
某些场景下,service worker冷启动会增加导航延迟。为了解决这个问题,平台提供了navigation preload:让浏览器在启动service worker的同时并行发起网络请求,等service worker接手后再决定用预加载结果还是自定义响应。 (web.dev)
如果你的全页缓存策略本来是network-first,却没启用navigation preload,体验可能还不如“直接走网络 +HTTP cache”。头信息语义与缓存分歧
navigation preload还带来一个很实际的问题:服务器可能需要根据Service-Worker-Navigation-Preload头返回不同内容,此时必须正确使用Vary,否则缓存层会混淆不同响应。 (MDN Web Docs)
片段化服务端渲染的优点与代价
它解决的其实是“变化率不同”这件事
把页面拆成片段,通常是因为这些片段的变化规律完全不同:
Header、Footer、路由框架几乎不变,适合长期缓存- 文章正文变化慢,适合
stale-while-revalidate - 库存、价格、推荐列表变化快,适合更短
TTL或强制走网络 - 用户昵称、未读数高度个性化,甚至适合
no-store或仅内存态展示
这类策略在MDN的PWA缓存指南里能看到很明确的策略描述,例如stale while revalidate(缓存优先但后台刷新),在“速度重要、但也要一定新鲜度”的资源上非常好用。 (MDN Web Docs)
片段化的两种落地方式
边缘或代理拼装(
ESI)ESI的设计目标就是动态页面的片段化组装与缓存命中提升,它把组装点从源站推到边缘或代理。 (W3C)
这种方式对传统多页站点很友好,但对现代前端来说,你需要评估你的CDN、反向代理、网关是否支持,以及运维链路是否能管住它。框架级的部分预渲染与流式输出
现代框架把片段化“内建”进渲染模型里:同一路由内,静态先出,动态后补。Next.js的PPR就是把静态与动态混合在同一路由中,目标是兼顾首屏与个性化。 (Next.js)
代价也很现实
- 系统复杂度上升:你把一个“页面缓存问题”变成了“片段依赖图 + 组合协议 + 失效策略”的系统工程
- 可观测性更难:命中率、回源、错误回退要按片段统计,否则你不知道性能瓶颈在哪里
- 一致性要有预案:价格片段更新了、库存片段没更新,用户看到的组合可能短暂不一致,这需要产品层能接受或有 UI 兜底
真正好用的折中:外壳缓存 + 内容按需渲染 + 必要时用流把两者拼起来
很多团队走到最后,会发现“全页缓存 vs 片段渲染”并不是二选一,而是三层组合:
外壳与静态资源:强缓存,离线也能打开
用Workbox之类的工具做precache与版本管理,保证JS、CSS、图标、路由框架稳定命中。 (web.dev)数据接口:按变化率选择策略
- 推荐列表:
stale-while-revalidate - 详情页接口:可
network-first,离线时回退到最近一次缓存 - 用户态接口:更谨慎,必要时
no-store,或只缓存“与身份弱相关”的部分
这里的策略分类与术语在MDN的PWA缓存指南里描述得很清楚。 (MDN Web Docs)
HTML导航:用Streams API把外壳与内容拼在一次响应里
这一步很容易被忽略,但它非常符合你提问里SSR与PWA的张力:既想像MPA那样首屏快,又想像SPA那样外壳可缓存。web.dev的PWA architecture明确提到:借助Streams API,可以把缓存的外壳与动态内容在service worker里组合成流式响应,让你同时拿到外壳缓存的灵活性与MPA的速度特征。 (web.dev)
Chrome 团队也有专门文章讨论用ReadableStream做这种“边产出边渲染”的体验路径。 (Chrome for Developers)
这一套折中方案在工程上往往更“顺人性”:你不必把全站导航都绑定到同一个缓存HTML,也不必把所有页面都拆成极细的片段去拼装,而是把“长期稳定”和“高频变化”分层处理。
决策框架:什么时候选全页缓存,什么时候选片段渲染
下面这套判断逻辑,建议按你的业务逐条过一遍。它不是教条,更像一张“风险雷达”。
更偏向全页service worker缓存的场景
- 页面结构稳定,内容可延迟加载:例如活动官网、文档站的壳非常固定,正文可按需拉取
- 离线诉求强:例如展会现场网络不稳,用户至少要能打开日程、场馆地图、收藏列表
- 深链接必须可用:
application shell model对未知路径也能先把壳兜住 (Chrome for Developers) - 个性化程度低:匿名访问为主,或同一用户长期单设备使用
这类场景下,全页缓存像“保底”,体验很稳。
更偏向按片段渲染的场景
- 页面强个性化:首页推荐、未读数、登录态差异巨大
- 内容变化率分层明显:价格与库存分钟级变化,评论与正文小时级变化,外壳月级变化
- 合规与隐私要求高:不希望把用户态
HTML落到可持久化缓存里 (web.dev) - 你已有边缘能力:例如
CDN支持片段缓存或ESI,能把源站压力与延迟压下去 (W3C)
很多团队忽略但极关键的一条:service worker的启动成本与并行能力
如果你需要service worker参与导航路径,建议认真评估navigation preload:它的目的就是把“等service worker启动”这段时间变成“网络请求并行进行”,尤其对移动端冷启动很实用。 (web.dev)
两个真实世界的类比案例,把抽象选择落到手感上
案例 1:会议类PWA(更适合外壳缓存 + 离线优先)
像大会日程、地点导航、演讲收藏这类产品,用户最痛的是“进不去、打开慢、网络差”。这时你可以把外壳、核心路由、基础数据结构预缓存,导航请求即使离线也能打开 UI,再用数据缓存做逐步补全。Google I/O 团队在web.dev的案例里就谈过如何用service worker让应用更快并支持离线优先的体验。 (web.dev)
这类产品对“内容秒级新鲜”要求通常不高,但对“可用性”要求极高,所以全页或外壳级缓存的收益非常直接。
案例 2:电商首页或资讯流(更适合片段化与混合渲染)
电商首页通常同时包含:
- 基础导航与布局(稳定)
- 个性化推荐(高变化 + 强用户态)
- 价格与库存(高变化 + 强一致性)
- 营销活动条幅(中变化)
若你把整页HTML缓下来,最常见的事故是:用户回到首页看到“旧推荐 + 旧价格”,甚至出现账号切换后的信息混杂。更合理的做法是:布局壳缓存,推荐与价格走网络优先或短TTL的片段策略,离线时给明确的“离线态 UI”,别强行展示“看起来像最新但实际过期”的内容。MDN对stale-while-revalidate的定位也很适合用来处理“可以稍旧但要快”的内容;而涉及交易一致性的内容,就不该用这种策略硬顶。 (MDN Web Docs)
一个更务实的结论:把HTML当成“体验载体”,把缓存当成“风险管理”
回到你的问题:构建PWA时,究竟用全页service worker缓存更好,还是服务端渲染单独内容块更好?
更可靠的答案是:
- 把“外壳可用性”交给
service worker:用application shell model或类似思路保证离线与重复访问稳定,必要时配合Streams API把壳与内容流式拼起来。 (Chrome for Developers) - 把“内容新鲜度与个性化”交给片段化渲染:对不同变化率、不同隐私级别的内容,选
stale-while-revalidate、network-first或更严格的缓存策略,并用正确的Cache-Control与Vary约束边界条件。 (MDN Web Docs) - 把“并行与冷启动”交给
navigation preload:当导航路径必须经过service worker时,用并行下载降低启动抖动。 (web.dev)
当你按这个分层去做,选择会变得很自然:全页缓存更像“保底与兜底”,片段渲染更像“精细化控制与风险隔离”。绝大多数严肃业务,最终都会落在“混合方案”,只是混合的边界画在哪里,取决于你的内容变化率、用户态强度、合规压力与团队运维能力。
实操建议:用调试工具把策略验证到位
做完策略,别只靠体感。建议用浏览器开发者工具把链路走通:
Application面板查看Service Workers状态、更新与scopeCache Storage里核对哪些请求被缓存、命中是否符合预期Network面板结合离线模式与弱网模式,看导航请求是否走了service worker、是否触发了navigation preload- 对关键响应检查
Cache-Control、Vary等头,避免出现“不同条件下返回不同内容却没分缓存键”的问题 (MDN Web Docs)
把这些验证动作做扎实,你的缓存策略才不是“写在代码里的一种愿望”,而是真正在用户设备上可控、可解释、可回滚的工程能力。