一、分享背景与业务场景
针对门店菜单电子屏长时间开机、网络差、易白屏的痛点,通过 Service Worker 实现前端离线缓存与请求拦截,确保断网仍可正常展示菜单,提升顾客体验与门店运营稳定性,同时沉淀可复用的离线前端方案。
1.1 业务场景
菜单电子屏是一套面向门店的 Vue 3 前端应用,运行在店内大屏、平板等设备上,核心用于展示实时菜单、热销商品、多时段菜单等内容。
门店设备通常处于长时间开机状态,且网络环境不稳定,无法依赖稳定网络支撑应用运行,因此对离线可用和缓存策略有强需求,需保障核心功能在断网 / 弱网下正常使用。
1.2 为什么使用Service Worker
Service Worker 是浏览器后台运行的代理脚本,独立于页面线程,具备拦截所有网络请求、管理本地缓存、实现离线业务逻辑的核心能力,其价值与菜单电子屏业务高度匹配。
离线可用:断网不影响核心功能
Service Worker 在安装阶段预缓存、首次加载后懒缓存菜单基础资源(Vue 框架、静态样式、菜单模板、默认菜品图片);当设备断网时,自动拦截网络请求,直接从本地缓存返回资源,确保菜单大屏仍展示最后一次同步的菜单数据,不出现白屏 / 报错。
业务价值:
门店断网、网络波动时,顾客仍可正常查看菜单、热销品等核心信息,不影响门店基础运营,提升顾客体验、降低运营投诉。
1.3 实际效果
因此,选用Service Worker 作为离线与缓存方案的核心,并结合业务做了策略细化与联动。
二、Service Worker 详解
2.1 什么是 Service Worker
Service Worker 基于浏览器独立线程运行,不与页面主线程耦合,遵循:install → activate → active 生命周期。
- 通过浏览器原生 Cache API 管理本地缓存
- 通过拦截 fetch 请求 实现请求重定向与缓存返回
- 是前端 PWA 离线能力的核心技术
2.2 生命周期
Service Worker 从“被注册”到“真正接管页面请求”,会经历几个状态,理解它们有助于排查“为什么没生效”“为什么还是旧缓存”。
暂时无法在飞书文档外展示此内容
要点:
- Installing:脚本首次或更新后解析成功,触发
install。这里常做“预缓存”(把关键 HTML/JS/CSS 提前放进 Cache)。 - Waiting:如果当前已有旧版 SW 在控制页面,新 SW 会停在这里,直到旧 SW 控制的页面全部关闭,或主线程调用了
skipWaiting()。 - Activating:新 SW 开始接管,触发
activate。这里常做“清理旧版本用的 Cache 名字”。 - Activated:之后页面发出的、落在 SW 作用域内的 fetch 才会被
fetch事件拦截。 - Idle / Terminated:一段时间没有 fetch,浏览器可能把 SW 挂起或终止,下次有请求再拉起来。
我们项目里用了 skipWaiting: true 和 clientsClaim: true,所以新 SW 会尽快激活并立刻接管所有客户端,不用等用户关掉所有标签页。
2.3 作用域(scope)与注册
Service Worker 只对自己作用域下的请求生效。作用域由注册时传入的 path 决定,默认是 sw.js 所在目录。
例如:
sw.js在https://example.com/static/sw.js,默认 scope 为https://example.com/static/- 只有该路径及其子路径下的页面(如
/static/app/)发出的请求会被这个 SW 拦截;/other/下的页面不会。
注册方式(主线程):
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js', { scope: '/' })
.then(reg => console.log('SW 注册成功', reg.scope))
.catch(err => console.error('SW 注册失败', err))
}
2.4 拦截请求:fetch 事件
简化版手写示例:
self.addEventListener('fetch', (event) => {
const url = event.request.url
// 只处理同源或指定的接口/资源
if (!url.includes('/api/menu')) return
event.respondWith(
fetch(event.request)
.then(res => {
const clone = res.clone()
caches.open('menu-api-cache').then(cache => cache.put(event.request, clone))
return res
})
.catch(() => caches.match(event.request))
)
})
2.5 Cache API 与 Cache Storage
Service Worker 用的“缓存”不是 localStorage,而是 Cache API(浏览器里常叫 Cache Storage)。
- caches:全局对象,类似
caches.open('my-cache-name')得到一个 Cache 对象。 - 每个 Cache 里存的是 Request → Response 的键值对,键是请求对象,值是响应对象。
- 同一个域名下可以有多个 Cache(例如我们项目里的
storemenu-api-cache、storemenu-pic-cache、js-cache),互不覆盖,便于按“接口 / 图片 / 静态资源”分开管理和过期。
常用方法:
// 打开(或创建)一个命名缓存
const cache = await caches.open('storemenu-api-cache')
// 存:请求 + 响应
await cache.put(request, response)
// 取:只根据 request 查,返回 response 或 undefined
const response = await cache.match(request)
// 删
await cache.delete(request)
// 列出该 Cache 里所有 request
const keys = await cache.keys()
三、技术架构概览
3.1 技术栈与缓存策略
- 构建: Vite 3
- PWA/Service Worker:
vite-plugin-pwa+ Workbox(runtimeCaching) - 前端: Vue 3 + Pinia + Vue Router
VitePWA({
registerType: 'autoUpdate', //生成的注册脚本会自动检查SW更新,发现新版本时在后台下载并切换
srcDir: './', //指定 Service Worker 源文件所在目录。
filename: 'sw.js', //生成的 SW 文件名(最后会出现在站点根目录,例如 https://xxx/sw.js)。
includeAssets: ['favicon.ico'],//指定额外要预缓存的静态资源(通常不在 Vite 打包产物里),比如站点图标。
injectRegister: 'auto', //让插件 自动在入口文件里注入 SW 注册代码,不用你手写 navigator.serviceWorker.register(...)。
workbox: {
cacheId: 'E-menu-cache', //给当前这套 Service Worker 的缓存起一个“前缀 ID”。
cleanupOutdatedCaches: true, //自动清理 已经不再被当前 SW 配置使用的旧 cache。
skipWaiting: true, //对应生命周期里的 skipWaiting()。
clientsClaim: true, //对应生命周期里的 clients.claim()。
runtimeCaching: [
{
urlPattern: /.*/(storemenu-api).*/,//用正则或字符串匹配要处理的 URL
handler: 'NetworkFirst', //使用的策略,如 'NetworkFirst'、'CacheFirst' 等
options: {
cacheName: 'storemenu-api-cache', //对应的 cache 名字
cacheableResponse: {
statuses: [200],//只有状态码在这个列表里的响应才会被写进缓存
},
expiration: {
maxEntries: 1, // 只保留一份接口缓存
maxAgeSeconds: 7 * 24 * 60 * 60, // 缓存7天
},
},
},
],
},
}),
PWA(Progressive Web App)通常包含:
- 用 HTTPS 部署(SW 只工作在安全源下)
- Service Worker 做离线与缓存
- Web App Manifest(图标、名称、主题色、是否全屏等)
Service Worker 是 PWA 能“离线用、秒开”的核心;没有 SW,就只是普通网页。我们项目通过 vite-plugin-pwa 同时生成了 manifest 和基于 Workbox 的 sw.js,所以既满足“可安装到桌面”,又满足“菜单电子屏离线可用”。
3.2 常见缓存策略
策略本质是“先网络还是先缓存、失败时怎么回退”。下面用一句话 + 我们项目里的用法概括:
| 策略 | 逻辑(一句话) | 典型用途 | 本项目 |
|---|---|---|---|
| NetworkFirst | 先请求网络,成功则返回并写入缓存;失败则用缓存 | 希望尽量新、又要离线兜底 | 菜单 API、图片、JS/CSS |
| CacheFirst | 先查缓存,有则返回;没有再请求网络并写入缓存 | 版本化静态资源、不常变的图片 | 未用(图片我们改为 NetworkFirst 以便更新) |
| StaleWhileRevalidate | 先返缓存(若有),同时后台请求网络,下次用新响应 | 首屏要快、数据可略旧一版 | 未用 |
| NetworkOnly | 只走网络,不写缓存 | 必须实时的接口 | 未用 |
| CacheOnly | 只读缓存,没有就失败 | 预缓存好的 App Shell | 未用 |
我们清一色用 NetworkFirst,保证在线时拿到最新数据/图片,离线时再回退到 Cache,和“菜单要新、又要抗断网”的需求一致。
3.3 多时段菜单 + 图片预加载与离线切换
场景:早/午/晚等多时段菜单,每个时段有不同背景图。若只缓存“当前时段”的图,离线切换到其他时段会缺图。
做法:
- 一次拉全量:菜单 API 返回所有时段的
menuTimePeriodDisplayVO,前端缓存在内存(如cachedMenuData)。 - 预加载所有时段图片:在线拿到数据后,对每个时段的
backgroundUrl以及公共图(如discountImgUrl、hotImgUrl等)做预加载。 - 通过 fetch 写入 SW 缓存:预加载时用
new Image()触发加载,再对同一 URL 执行fetch(url, { cache: 'reload' }),让 SW 的storemenu-pic-cache写入该图片。 - 离线切换:时段切换仅改前端展示的数据和图片 URL,不再发请求;图片从
storemenu-pic-cache读取,避免no-response。
// 预加载单张图片并触发 SW 缓存(先对比缓存,避免重复下载)
const cacheImage = async (imageUrl, imageName = '') => {
const cachedUrl = await getCachedImageUrl(imageUrl) // 从 storemenu-pic-cache keys 对比
if (cachedUrl === imageUrl) return
const img = new Image()
img.src = imageUrl
img.onload = async () => {
await fetch(imageUrl, { cache: 'reload' }) // 写入 storemenu-pic-cache
}
}
// 预加载所有时段背景图 + 公共图
const preloadTimePeriodImages = async () => {
// 公共图
if (cachedMenuData.value?.discountImgUrl) await cacheImage(cachedMenuData.value.discountImgUrl, 'discountImg')
// ...
// 各时段背景图
for (const menu of cachedMenuData.value.menuTimePeriodDisplayVO || []) {
if (menu.backgroundUrl) await cacheImage(menu.backgroundUrl, `timePeriod_${menu.timePeriodId}`)
}
}
作用:
- 预加载前用
getCachedImageUrl对比当前缓存,避免重复下载。 - 多时段数据 + 多时段图片全部进缓存,离线可按时段自动切换且无缺图。
3.4 资源缓存策略
目标:既保证离线可用,又避免无用图片长期占用(如门店更换菜单后旧图仍占缓存)。
实现:
-
SW 层:
storemenu-pic-cache使用expiration.maxEntries、maxAgeSeconds、purgeOnQuotaError做自动淘汰。 -
应用层:在预加载或数据更新后,执行
cleanupUnusedImageCache():- 根据当前
cachedMenuData汇总“当前仍需要的图片 URL”(含各时段背景、公共图等)。 - 打开
storemenu-pic-cache,遍历keys(),删除不在上述集合内且属于storemenu-pic的请求。
- 根据当前
const cleanupUnusedImageCache = async () => {
const currentImageUrls = new Set()
// 收集 公共图 + 所有时段 backgroundUrl
// ...
const picCache = await caches.open('storemenu-pic-cache')
const cachedRequests = await picCache.keys()
for (const request of cachedRequests) {
if (isStoremenuPic(request.url) && !currentImageUrls.has(request.url)) {
await picCache.delete(request)
}
}
}
效果:缓存内容与当前菜单配置对齐,存储可控,离线仍能正常显示当前及多时段所需图片。
3.5调试与注意点
- Chrome DevTools → Application → Service Workers:查看当前页面的 SW 状态(installing/waiting/activated)、可 Unregister、Update、勾选 Update on reload 方便开发时每次刷新都更新 SW。
- Application → Cache Storage:看各 Cache 里的 Request/Response,可手动删某条或清空,用来验证“单份”“清理”是否生效。
- Application → Storage → Clear site data:会清掉 SW 和所有 Cache,适合做“首次访问”测试。
- 开发时若改了
vite.config.js里 workbox 配置,需要重新 build 才会生成新的sw.js;registerType: 'autoUpdate'会在下次打开页面时自动用新 SW。
四、整体请求与缓存流程
-
首次在线访问
- 页面加载 → SW 注册并接管 fetch。
- 请求菜单 API → SW NetworkFirst → 网络成功 → 写入
storemenu-api-cache(仅 1 条)。 - 前端保存
cachedMenuData,执行cleanupApiCache+ensureApiCache(apiUrl)。 - 若在线,执行
preloadTimePeriodImages(),所有时段背景图及公共图通过 fetch 进入storemenu-pic-cache;再执行cleanupUnusedImageCache()。
-
在线再次访问 / 定时刷新
- 菜单 API 再次请求 → 网络成功 → SW 更新同一条 API 缓存;应用层再次清理并 ensure 当前 URL。
- 预加载与清理同上,保证缓存与当前配置一致。
-
离线访问
- 菜单 API 请求 → SW NetworkFirst 网络失败 → 从
storemenu-api-cache返回唯一一条缓存。 - 前端用该数据填充
cachedMenuData,按当前时间筛选时段并展示;图片请求由 SW 从storemenu-pic-cache返回,多时段切换无需网络。
- 菜单 API 请求 → SW NetworkFirst 网络失败 → 从
五、小结
在菜单电子屏项目中,Service Worker 不仅提供了离线可用能力,还通过 API 单份、多时段图片预加载、应用层 + SW 双端清理 等设计,在“数据尽量新”和“离线稳定展示”之间取得平衡,并把不同类型资源拆分到独立缓存桶中,同时缓存体量可控、不再无限膨胀。上述模式可直接复用到其他需要离线优先、多版本/多时段资源的大屏或 PWA 场景中。