标签

Service Worker

Service Worker 是一种运行在浏览器背后的脚本,充当网站和浏览器之间的代理服务器。它能够在浏览器背景中运行,即使用户没有访问网页也是如此。Service Worker 的引入使得开发者能够创建更加丰富和可靠的用户体验,特别是离线体验和网络性能优化方面。

Service Worker
服务端5月29日 22:48
Service Worker 的生命周期有哪些阶段?四个阶段:注册(register)→ 安装(install)→ 等待(waiting)→ 激活(activate)→ 运行中。register 触发下载 SW 文件,install 事件中预缓存资源,install 成功后进入 waiting 等待旧 SW 退出,activate 时清理旧缓存。跳过等待:skipWaiting() 让新 SW 立即激活,clients.claim() 立即接管页面。默认不跳过——已打开的页面继续用旧 SW 直到关闭。 ## 追问 ### 为什么要有 waiting 阶段? 安全考虑。已打开的页面可能依赖旧 SW 的缓存结构,强制切换可能导致缓存未命中白屏。waiting 确保所有旧页面关闭后才切换。 ### skipWaiting 什么时候该用? 紧急修复(如缓存 bug 导致白屏)时用。常规更新不建议——如果新 SW 的缓存 key 和旧的不同,skipWaiting 后页面可能加载失败。 ### SW 什么时候会被销毁? 空闲约 30 秒后浏览器自动终止 SW 线程节省内存。下次 fetch/push/sync 事件再唤醒。所以 SW 里不要用全局变量存状态——唤醒后变量重置。持久状态存 IndexedDB。 ### 页面关闭后 SW 还活着吗? 可能活着(有 push/sync 事件时),也可能被终止。不能假设 SW 常驻。 ### 注册失败怎么排查? 检查:SW 文件路径是否正确、是否在 HTTPS 下(localhost 例外)、SW 文件是否有语法错误、scope 是否覆盖目标页面。DevTools Application > Service Workers 面板会显示错误。
服务端5月29日 22:40
Service Worker 调试有哪些常用方法和工具?Chrome DevTools 是主战场:Application > Service Workers 面板可查看注册状态、更新、卸载;Sources 面板可打断点;Network 面板勾选 Bypass for network 绕过 SW。Console 里 self.addEventListener(fetch, e => console.log(e.request.url)) 打日志最直接。开发时勾选 Update on reload 避免手动等待激活。 ## 追问 ### SW 更新后页面还是旧逻辑? SW 更新走 install→wait→activate。wait 阶段等旧 SW 退出才激活。开发时勾选 Update on reload;生产用 skipWaiting()+clients.claim() 立即接管,但可能导致不兼容。推荐:显示提示让用户主动刷新。 ### 如何模拟离线场景? DevTools Network 选 Offline,或 Application > Service Workers 勾选 Offline。也可用 navigator.onLine 检测。注意:离线需要 SW 有缓存策略否则白屏。 ### SW 里 console.log 看不到? SW 运行在独立线程,日志在 Sources > Service Worker 专用控制台。或在 Application > Service Workers 点 inspect 打开专用 DevTools。 ### 缓存没命中怎么排查? 打印 caches.match(request) 结果:命中返回 Response,未命中 undefined。常见原因:URL 查询参数不一致、request method 不匹配(默认只匹配 GET)、vary 头导致缓存分裂。 ### 生产环境如何监控 SW 异常? self.addEventListener(error/unhandledrejection, ...) 捕获异常上报。关注注册失败率和缓存命中率。Workbox 的 workbox-google-analytics 可追踪离线 PV。
服务端5月29日 01:38
Workbox 是什么?它如何简化 Service Worker 的缓存策略?Workbox 是 Google 推出的 Service Worker 工具库,将常见的缓存策略、路由匹配、预缓存等能力封装为开箱即用的模块,大幅降低 SW 开发复杂度。核心提供三种缓存策略:CacheFirst(缓存优先,适合静态资源)、NetworkFirst(网络优先,适合API数据)、StaleWhileRevalidate(先用缓存再后台更新,适合非关键资源)。配合 workbox-precaching 可在 install 阶段批量预缓存,配合 ExpirationPlugin 可控制缓存条目数和过期时间。 ## 追问 **CacheFirst 和 StaleWhileRevalidate 分别适合什么场景?** CacheFirst 适合字体、样式等不变资源,命中缓存直接返回,速度最快;StaleWhileRevalidate 适合图片或第三方脚本,先返回缓存保证响应速度,同时后台更新缓存,下次请求拿到新版本。 **workbox-precaching 和运行时缓存有什么区别?** precache 在 SW install 时一次性缓存资源清单(self.__WB_MANIFEST),确保离线可用;运行时缓存在 fetch 事件触发时按策略写入,首次请求仍需网络。 **Workbox 如何与构建工具集成?** Webpack 用 workbox-webpack-plugin(GenerateSW 自动生成或 InjectManifest 注入清单);Vite 用 vite-plugin-pwa,在构建时自动生成 SW 并注入运行时缓存配置。 **Workbox 的 BackgroundSyncPlugin 解决了什么问题?** 当 POST 请求因离线失败时,自动将请求放入队列,待网络恢复后重试,确保数据不丢失。适用于表单提交、数据上报等场景。 ## 写段代码 ```javascript import { registerRoute } from 'workbox-routing'; import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies'; registerRoute( ({ request }) => request.destination === 'style', new CacheFirst({ cacheName: 'styles' }) ); registerRoute( ({ request }) => request.destination === 'image', new StaleWhileRevalidate({ cacheName: 'images' }) ); ```
服务端5月29日 01:37
Service Worker 是什么?它如何实现离线缓存和后台同步?Service Worker 是运行在浏览器后台的独立 JS 线程,充当应用与网络之间的代理服务器。它能拦截所有网络请求、管理缓存资源,从而实现离线访问、推送通知和后台同步等能力,是 PWA 的核心技术。其生命周期为 register → install → activate → fetch,浏览器会在空闲时自动终止 SW 线程以节省资源。SW 无法直接操作 DOM,只能通过 postMessage 与主线程通信,且必须在 HTTPS 环境下运行。 ## 追问 **SW 的 install 和 activate 阶段分别适合做什么?** install 阶段适合预缓存关键资源(App Shell),调用 event.waitUntil() 确保资源缓存完成后才完成安装;activate 阶段适合清理旧版本缓存,避免残留数据冲突。 **SW 如何拦截和处理网络请求?** 通过监听 fetch 事件,可以决定从缓存返回、从网络请求或组合策略(如 Stale-While-Revalidate)。event.respondWith() 用于返回自定义响应。 **为什么 SW 必须在 HTTPS 下运行?** 因为 SW 能拦截和篡改所有网络请求,若在 HTTP 下运行,中间人攻击者可注入恶意 SW 劫持全部流量。localhost 是唯一例外。 **SW 与 Web Worker 有什么区别?** Web Worker 由页面创建且随页面销毁,用于 CPU 密集计算;SW 生命周期独立于页面,由浏览器管理,专门处理网络代理和后台任务。 ## 写段代码 ```javascript self.addEventListener('install', (e) => { e.waitUntil( caches.open('v1').then(c => c.addAll(['/index.html', '/app.js'])) ); }); self.addEventListener('fetch', (e) => { e.respondWith( caches.match(e.request).then(r => r || fetch(e.request)) ); }); ```
服务端5月28日 02:21
Service Worker 的更新机制是怎样的?## Service Worker 更新机制核心回答 浏览器通过**字节级对比**检测 Service Worker 文件变化,发现差异后启动更新流程:新 Worker 安装 → 进入 waiting 状态 → 旧 Worker 控制的页面全部关闭后激活。整个过程是**非破坏性的**——旧版本继续服务已有页面,新版本等待接管。 三个关键点决定更新行为: 1. **什么时候检查更新?** 用户导航到 Service Worker 作用域内的页面时,或 functional event(push/sync)触发时(距上次检查超过 24 小时),以及手动调用 `registration.update()` 时。 2. **为什么新版本不能立即生效?** 默认策略保证页面生命周期内 Service Worker 不变,避免同一个页面被新旧两个 Worker 同时控制导致状态不一致。 3. **如何让新版本提前生效?** 在新 Worker 中调用 `self.skipWaiting()` + 在主线程监听 `controllerchange` 事件后刷新页面。 ### 追问:skipWaiting 会有什么问题? `skipWaiting()` 让新 Worker 立即激活,但已有页面可能正由旧 Worker 处理请求。新 Worker 的 `fetch` 事件处理逻辑可能与旧版本不同,导致同一页面中部分请求走新逻辑、部分走旧逻辑,出现不一致。实际项目中应该配合用户提示:检测到新版本 → 弹出提示 → 用户确认后调用 `skipWaiting` → 监听 `controllerchange` 刷新页面。 ## 更新触发的具体时机 浏览器的更新检查并非"每次访问都检查",而是有明确的触发条件: - **导航事件**:用户访问 Service Worker 作用域内的页面时,浏览器会请求 SW 文件并对比字节。如果服务器返回的文件与本地缓存的版本有字节差异,就触发更新。 - **Functional event**:push、sync 等事件触发时也会检查更新,但有 24 小时的最小间隔,避免过于频繁的网络请求。 - **手动触发**:调用 `registration.update()` 强制检查,不受 24 小时限制。 - **register() 调用**:只有 SW 文件的 URL 发生变化时才会触发更新检查。如果 URL 不变,`register()` 不会额外发起请求。 一个容易被忽略的细节:浏览器对 SW 文件的请求默认会加上 `Cache-Control: no-cache` 的语义,即使服务器返回了缓存头,浏览器也会尝试条件请求(If-Modified-Since / If-None-Match)。所以**服务器必须正确配置 SW 文件的响应头**: ``` Cache-Control: no-cache, no-store, must-revalidate ``` 如果服务器把 SW 文件缓存了(比如 CDN 配置不当),浏览器拿到的永远是旧文件,更新永远不会触发。 ### 追问:如何确认浏览器是否检测到了更新? 在 DevTools → Application → Service Workers 面板中可以看到当前状态。代码中监听 `updatefound` 事件可以捕获到新 Worker 的出现: ```javascript navigator.serviceWorker.register('/sw.js').then(reg => { reg.addEventListener('updatefound', () => { const newWorker = reg.installing; console.log('检测到新版本:', newWorker); }); }); ``` ## 更新生命周期详解 ### 安装阶段 新 Worker 被发现后,浏览器执行其 `install` 事件。这一步通常用来预缓存新版本的资源: ```javascript const CACHE_NAME = 'app-v2'; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(['/index.html', '/app.js', '/styles.css']) ) ); }); ``` 如果 `install` 事件回调执行失败(比如某个资源加载失败),新 Worker 会被直接丢弃,旧 Worker 继续工作。这就是更新过程的**容错机制**——安装失败的 Worker 不会影响线上服务。 ### 等待阶段 安装成功后,新 Worker 进入 `waiting` 状态。它必须等到旧 Worker 控制的所有页面(clients)都关闭后才能激活。注意:**刷新页面不算关闭**。用户需要关闭所有标签页再重新打开,或者通过代码干预。 这是很多开发者困惑的地方:为什么更新了 SW 文件但页面行为没变?答案就是新 Worker 还在 waiting。 检查 waiting 状态的代码: ```javascript navigator.serviceWorker.ready.then(reg => { if (reg.waiting) { console.log('新版本等待中:', reg.waiting); } }); ``` ### 激活阶段 旧 Worker 不再控制任何页面后,新 Worker 进入 `activate` 状态。这一步的核心工作是**清理旧缓存**: ```javascript self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(names => Promise.all( names .filter(name => name !== CACHE_NAME) .map(name => caches.delete(name)) ) ) ); self.clients.claim(); }); ``` `clients.claim()` 的作用是让新激活的 Worker 立即接管所有未受控制的页面。但已经由旧 Worker 控制的页面不会自动切换——这就是为什么即使调用了 `claim()`,已有页面仍需要刷新才能使用新逻辑。 ## 生产环境的更新策略 ### 策略一:用户确认更新 这是最稳妥的方式。检测到新版本后提示用户,用户确认后触发更新并刷新: ```javascript let refreshing = false; let newWorker = null; navigator.serviceWorker.register('/sw.js').then(reg => { reg.addEventListener('updatefound', () => { newWorker = reg.installing; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { showUpdatePrompt(); } }); }); }); function onUpdateConfirm() { newWorker.postMessage({ type: 'SKIP_WAITING' }); } navigator.serviceWorker.addEventListener('controllerchange', () => { if (!refreshing) { refreshing = true; window.location.reload(); } }); // sw.js 中 self.addEventListener('message', event => { if (event.data.type === 'SKIP_WAITING') { self.skipWaiting(); } }); ``` 注意 `refreshing` 标志——`controllerchange` 事件可能触发多次,需要防止重复刷新。 ### 策略二:静默更新 对于非关键更新(比如缓存策略微调),可以不做提示,让新 Worker 自然等待旧页面关闭后激活。用户体验无感知,但更新有延迟。 ### 策略三:定期轮询 浏览器自身的检查依赖用户导航,如果用户长时间停留在 SPA 页面,可能很久都不会触发检查。可以加一层定时轮询: ```javascript setInterval(() => { navigator.serviceWorker.ready.then(reg => reg.update()); }, 60 * 60 * 1000); ``` 但不要把间隔设得太短,否则浪费用户流量和服务器资源。 ## 缓存版本管理 更新过程中缓存管理容易出错。核心原则:**每个版本独立缓存,激活时清理旧版本**。 ```javascript const VERSION = 'v2.1.0'; const CACHE_NAME = `app-${VERSION}`; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS)) ); }); self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(names => Promise.all( names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)) ) ) ); }); ``` 常见错误是在 `install` 阶段就删除旧缓存——如果新 Worker 安装后还没激活就被丢弃了,旧缓存已经被清掉,会导致离线功能失效。清理操作必须放在 `activate` 阶段。 ## 更新策略对比 | 策略 | skipWaiting | 用户提示 | 适用场景 | |------|-------------|----------|----------| | 用户确认 | 用户确认后调用 | 有 | 生产环境推荐 | | 静默等待 | 不调用 | 无 | 非关键更新 | | 强制更新 | install 时调用 | 无 | 紧急修复、开发环境 | | 定期轮询 | 配合用户确认 | 有 | SPA 长驻页面 | ## 调试技巧 开发时更新行为和线上不同,Chrome DevTools 提供了几个调试选项: - **Update on reload**:每次刷新页面都强制检查更新并激活,跳过 waiting - **Bypass for network**:请求绕过 Service Worker,直接走网络 - **SkipWaiting 按钮**:在 Application 面板手动激活等待中的新 Worker 代码中查看当前状态: ```javascript navigator.serviceWorker.getRegistration().then(reg => { console.log('active:', reg.active?.state); console.log('waiting:', reg.waiting?.state); console.log('installing:', reg.installing?.state); }); ``` 注销 Service Worker(开发调试用,不要在生产环境调用): ```javascript navigator.serviceWorker.getRegistration().then(reg => { reg?.unregister(); }); ``` 理解 Service Worker 更新机制的关键在于把握一个原则:**更新是非破坏性的,旧版本在页面关闭前始终有效**。所有策略和技巧都是围绕如何在这个约束下平衡更新速度与用户体验。
服务端5月28日 02:21
如何在 Service Worker 中实现推送通知功能?## 核心回答 Service Worker 推送通知的实现依赖三个 API 协作:**Notification API**(请求权限+显示通知)、**Push API**(订阅推送服务)、**Service Worker**(后台监听 push 事件)。完整流程:请求通知权限 → 订阅推送服务(生成 endpoint)→ 将订阅发送给服务器 → 服务器调用推送服务 → Service Worker 的 push 事件触发 → 调用 showNotification 显示通知。 关键前提:页面必须在 **HTTPS** 环境下运行,且 Service Worker 已注册并激活。 ## 推送通知工作原理 ``` ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ 应用服务器 │────▶│ 浏览器推送服务 │────▶│ 用户浏览器 │ │ (你的后端) │ │ (FCM/Mozilla等) │ │ (Service Worker) │ └──────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ 订阅时:浏览器向推送服务注册 │ │ 返回 subscription(endpoint+keys) │ │ │ └──── 服务器用 subscription.endpoint 发消息 ────────┘ ``` 应用服务器并不直接与浏览器通信,而是通过浏览器厂商提供的推送服务(Chrome 用 FCM,Firefox 用 Mozilla Push Service)中转。订阅时浏览器会生成一个唯一 endpoint URL,服务器向这个 URL 发送加密消息即可。 ## 实现步骤 ### 1. 请求通知权限 ```javascript // main.js async function requestNotificationPermission() { // 三种状态:default / granted / denied if (Notification.permission === 'granted') { return true; } if (Notification.permission === 'denied') { console.log('用户已拒绝通知权限,无法再次请求'); return false; } const permission = await Notification.requestPermission(); return permission === 'granted'; } ``` 注意:`denied` 状态下再次调用 `requestPermission()` 不会弹出授权弹窗,只能引导用户去浏览器设置中手动开启。 ### 2. 订阅推送服务 ```javascript // main.js async function subscribeUserToPush() { const registration = await navigator.serviceWorker.ready; // 先检查是否已有订阅 let subscription = await registration.pushManager.getSubscription(); if (!subscription) { // 获取 VAPID 公钥(服务器生成,用于标识你的应用) const vapidPublicKey = await fetch('/api/vapid-public-key').then(r => r.text()); const convertedKey = urlBase64ToUint8Array(vapidPublicKey); subscription = await registration.pushManager.subscribe({ userVisibleOnly: true, // 必须为 true:推送必须显示通知 applicationServerKey: convertedKey // VAPID 公钥 }); } // 将订阅信息发给服务器保存 await fetch('/api/save-subscription', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(subscription) }); return subscription; } function urlBase64ToUint8Array(base64String) { const padding = '='.repeat((4 - base64String.length % 4) % 4); const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); const rawData = window.atob(base64); return Uint8Array.from([...rawData].map(c => c.charCodeAt(0))); } ``` `userVisibleOnly: true` 是硬性要求——浏览器不允许静默推送,每条推送都必须展示通知。VAPID(Voluntary Application Server Identification)让推送服务知道是哪个应用在发送消息,无需每条消息单独验证。 ### 3. Service Worker 处理推送事件 ```javascript // sw.js self.addEventListener('push', event => { let data = {}; if (event.data) { data = event.data.json(); } const options = { body: data.body || '您有一条新消息', icon: '/icons/icon-192x192.png', badge: '/icons/badge-72x72.png', tag: data.tag || 'default', // 相同 tag 的通知会替换 requireInteraction: false, // true 则通知不会自动消失 actions: [ { action: 'open', title: '查看' }, { action: 'dismiss', title: '忽略' } ], data: { url: data.url || '/', timestamp: Date.now() } }; event.waitUntil( self.registration.showNotification(data.title || '新通知', options) ); }); ``` `event.waitUntil()` 确保 Service Worker 在通知显示完成前不会被终止。`tag` 属性用于通知分组——相同 tag 的新通知会替换旧的,避免通知栏堆叠。 ### 4. 处理通知点击 ```javascript // sw.js self.addEventListener('notificationclick', event => { event.notification.close(); if (event.action === 'dismiss') return; const urlToOpen = event.notification.data?.url || '/'; event.waitUntil( clients.matchAll({ type: 'window', includeUncontrolled: true }) .then(clientList => { // 优先聚焦已有窗口 for (const client of clientList) { if (client.url.includes(new URL(urlToOpen).pathname) && 'focus' in client) { return client.focus(); } } // 没有则打开新窗口 return clients.openWindow(urlToOpen); }) ); }); ``` 点击通知后应优先聚焦已有标签页而非重复打开,这是常见的用户体验考量点。 ## 服务器端发送推送 ```javascript // server.js const webpush = require('web-push'); // 生成 VAPID 密钥:npx web-push generate-vapid-keys const vapidKeys = { publicKey: 'YOUR_PUBLIC_KEY', privateKey: 'YOUR_PRIVATE_KEY' }; webpush.setVapidDetails( 'mailto:your-email@example.com', vapidKeys.publicKey, vapidKeys.privateKey ); async function sendPush(subscription, payload) { try { await webpush.sendNotification(subscription, JSON.stringify(payload)); } catch (err) { if (err.statusCode === 410) { // 订阅已过期,从数据库删除 await removeSubscription(subscription.endpoint); } throw err; } } ``` 410 状态码表示订阅已失效(用户取消授权或卸载浏览器),必须清理,否则后续推送全部失败。 ## 常见面试追问 **Q: 推送消息有大小限制吗?** 推送消息载荷上限约 4KB(不同推送服务略有差异)。大数据应先推送通知,用户点击后从服务器拉取完整内容。 **Q: 用户关闭浏览器后还能收到推送吗?** 可以。推送服务是浏览器厂商运行的云端服务,消息先到达推送服务,等浏览器上线后投递。但浏览器完全退出(非后台驻留)时,桌面端可能收不到,移动端依赖系统级推送通道。 **Q: VAPID 和旧版 GCM 密钥有什么区别?** VAPID 是标准化的应用身份验证方案,不依赖特定厂商(如 Google),Firefox 和 Chrome 都支持。GCM 密钥是 Chrome 早期的私有方案,已废弃。 **Q: 如何处理推送失败?** 订阅可能随时失效(用户撤销权限、浏览器更新、订阅过期),服务端必须捕获 410/404 响应并清理无效订阅,否则会持续推送失败拖慢系统。 **Q: periodicSync 和 push 的区别?** `periodicsync` 是浏览器定时触发的后台同步,用于定期拉取数据(如每日新闻),目前仅 Chrome 支持且有严格的频率限制。`push` 是服务器主动推送,实时性更强,兼容性更好。 ## 浏览器兼容性 | 功能 | Chrome | Firefox | Safari | Edge | |------|--------|---------|--------|------| | Push API | 支持 | 支持 | 16.4+ | 支持 | | Notification | 支持 | 支持 | 支持 | 支持 | | actions | 支持 | 支持 | 不支持 | 支持 | | badge | 支持 | 不支持 | 不支持 | 支持 | | periodicSync | 支持 | 不支持 | 不支持 | 不支持 | Safari 对 Push API 的支持从 16.4 开始,但 actions 和 badge 等增强特性暂不支持。生产环境中建议做特性检测后再使用对应能力。
服务端5月28日 02:19
Service Worker 与 Web Worker 有什么区别?## Service Worker 与 Web Worker 的核心区别 两者都是浏览器提供的后台线程机制,但设计目标完全不同:Web Worker 解决的是主线程阻塞问题,Service Worker 解决的是网络请求控制问题。 ## 一图看懂关键差异 | 维度 | Service Worker | Web Worker | |------|----------------|------------| | 定位 | 网络代理,拦截和控制请求 | 后台线程,执行耗时计算 | | 生命周期 | 独立于页面,可被浏览器自动重启 | 随页面存活,页面关闭即销毁 | | DOM 访问 | 不可访问 | 不可访问 | | 网络拦截 | 可以拦截所有作用域内的请求 | 无法拦截 | | 通信方式 | postMessage + clients API | postMessage | | 安全要求 | 仅限 HTTPS(localhost 例外) | HTTP/HTTPS 均可 | | 作用域 | 由注册路径决定,默认限 scope 内 | 仅与创建它的页面通信 | | 持久化 | 浏览器关闭后仍可被唤醒 | 不可 | ## Service Worker 的核心能力 ### 网络代理与缓存策略 Service Worker 最本质的能力是充当网络代理,通过监听 fetch 事件拦截请求,配合 Cache API 实现多种缓存策略: ```javascript // Cache First:优先从缓存取,缓存没有再走网络 self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cached => { return cached || fetch(event.request); }) ); }); // Network First:优先走网络,失败再回退缓存 self.addEventListener('fetch', event => { event.respondWith( fetch(event.request).catch(() => caches.match(event.request)) ); }); ``` 常见缓存策略的选择依据:静态资源用 Cache First,频繁更新的接口用 Network First,非关键请求用 Stale While Revalidate。 ### 独立生命周期 Service Worker 有完整的 install → waiting → activate 流程。安装后即使所有页面关闭,浏览器仍可在需要时重新唤醒它,这是它能处理推送通知和后台同步的前提。 ```javascript // 推送通知 self.addEventListener('push', event => { event.waitUntil( self.registration.showNotification('新消息', { body: event.data.text() }) ); }); // 后台同步 self.addEventListener('sync', event => { if (event.tag === 'sync-data') { event.waitUntil(syncData()); } }); ``` ### 作用域控制 Service Worker 的作用域由注册时的路径决定。在 /sw.js 注册会控制整个站点,在 /app/sw.js 注册只控制 /app/ 路径下的请求。可以通过 scope 参数显式指定,但无法超出脚本所在目录的范围。 ## Web Worker 的核心能力 ### 耗时计算的离线处理 Web Worker 的设计目的很简单:把耗时计算移出主线程,防止 UI 卡顿。 ```javascript // main.js const worker = new Worker('worker.js'); worker.postMessage({ data: largeArray }); worker.onmessage = event => { console.log('计算结果:', event.data); }; // worker.js self.onmessage = event => { const result = heavyComputation(event.data); self.postMessage(result); }; ``` ### 生命周期与页面绑定 Web Worker 的生命周期严格绑定创建它的页面。页面关闭,Worker 销毁,不存在"浏览器自动重启"的机制。这决定了它适合一次性或页面级的计算任务,不适合后台持续运行。 ### 三种 Worker 类型 ```javascript // Dedicated Worker — 一对一,最常用 const worker = new Worker('worker.js'); // Shared Worker — 多页面共享同一个 Worker 实例 const sharedWorker = new SharedWorker('shared-worker.js'); sharedWorker.port.start(); sharedWorker.port.postMessage('hello'); // Service Worker — 网络代理型 Worker navigator.serviceWorker.register('/sw.js'); ``` Shared Worker 适合多标签页需要共享状态的场景(如 WebSocket 连接复用),但它无法拦截网络请求,这与 Service Worker 有本质区别。 ## 面试常见追问 **追问 1:Service Worker 为什么只能在 HTTPS 下使用?** 因为 Service Worker 能拦截和篡改网络请求,如果在 HTTP 下运行,中间人攻击者可以注入恶意 Worker 篡改所有响应。localhost 是唯一例外,方便本地开发。 **追问 2:Service Worker 更新后如何生效?** 浏览器会字节级对比新旧 SW 文件,发现不同会启动新的 Worker 进入 install 状态。但旧 Worker 不会立即被替换,需等所有受控页面关闭后新 Worker 才进入 activate 状态。可通过 self.skipWaiting() 和 clients.claim() 加速生效,但生产环境需谨慎使用,避免新旧缓存不一致。 **追问 3:Web Worker 能操作 DOM 吗?** 不能。Worker 运行在独立线程,没有 DOM API。需要操作 DOM 时,把计算结果通过 postMessage 发回主线程,由主线程执行 DOM 操作。 **追问 4:Service Worker 和 Web Worker 能同时使用吗?** 可以且推荐。Service Worker 负责离线缓存和网络策略,Web Worker 负责复杂计算。例如一个图片编辑 PWA:Service Worker 缓存图片资源,Web Worker 执行图片滤镜计算,主线程只负责渲染和交互。
服务端5月28日 02:18
Service Worker 有哪些安全风险和防护手段?## 为什么 Service Worker 天生需要安全约束 Service Worker 本质是一个浏览器级的网络代理——它能拦截页面发出的所有请求、读写 Cache Storage、接收推送消息。这意味着一旦攻击者控制了 Service Worker,就能在用户毫无感知的情况下窃取数据、注入恶意内容。浏览器因此对 Service Worker 施加了严格的安全限制,而理解这些限制背后的原因,是回答本题的关键。 ## HTTPS 是硬性前提 Service Worker 只能在 HTTPS 环境下注册(localhost 例外)。这不是建议,是强制要求。 原因很直接:HTTP 明文传输,中间人可以篡改响应内容,将恶意脚本注入 `sw.js`,从而在用户浏览器中植入一个持久的代理。由于 Service Worker 注册后即使关闭页面也继续运行,这种攻击的持久性和隐蔽性远超普通 XSS。 ```javascript // 注册前检查安全上下文 if (window.isSecureContext) { navigator.serviceWorker.register('/sw.js'); } else { console.error('Service Worker 需要 HTTPS 环境'); } ``` 值得注意的细节:`localhost` 被视为安全上下文仅限开发阶段,生产环境绝不能依赖此例外。 ## 作用域限制与越权防护 Service Worker 默认只能控制其脚本所在目录及其子路径下的页面。注册时可通过 `scope` 参数缩小范围,但不能扩大: ```javascript // scope 只能缩小,不能超出脚本所在目录 navigator.serviceWorker.register('/sw.js', { scope: '/app/' }); // /app/page.html → 可控制 // /other/page.html → 无法控制 ``` 浏览器通过 `Scope` 限制阻止一个 Service Worker 越权接管其他路径的请求。如果需要更大作用域,必须将脚本文件放在更高层级的目录,而非通过参数绕过。 在 `fetch` 事件中处理请求时,必须校验路径,防止路径遍历攻击: ```javascript self.addEventListener('fetch', event => { const { pathname } = new URL(event.request.url); if (pathname.includes('..') || pathname.includes('//')) { event.respondWith(new Response('Invalid path', { status: 400 })); return; } const allowed = ['/api/', '/assets/', '/static/']; if (!allowed.some(p => pathname.startsWith(p))) { event.respondWith(new Response('Forbidden', { status: 403 })); return; } event.respondWith(caches.match(event.request)); }); ``` ## 缓存中的敏感数据泄露 这是面试中容易被追问的高频点。Service Worker 拥有 Cache Storage 的读写权限,如果盲目缓存所有响应,用户认证信息、个人数据都会被持久化到磁盘,其他同源脚本可以读取这些缓存。 核心原则:**敏感数据永远不进缓存**。 ```javascript const PRIVATE_PATHS = ['/api/auth/', '/api/user/', '/api/payment/']; self.addEventListener('fetch', event => { const { pathname } = new URL(event.request.url); const isPrivate = PRIVATE_PATHS.some(p => pathname.startsWith(p)); if (isPrivate) { // 敏感请求只走网络,不缓存 event.respondWith(fetch(event.request)); return; } // 公共资源走缓存策略 event.respondWith( caches.match(event.request).then(r => r || fetch(event.request)) ); }); ``` 清理旧缓存时也要注意命名空间,避免误删其他应用的缓存: ```javascript self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(names => Promise.all( names.filter(n => n.startsWith('my-app-') && n !== CACHE_NAME) .map(n => caches.delete(n)) ) ) ); }); ``` ## CSP 与 Service Worker 的交互 页面设置的 CSP 对 Service Worker 脚本本身不直接生效(SW 脚本由浏览器单独加载),但 CSP 会限制页面中注册 Service Worker 的方式——内联脚本创建的 Blob URL 注册会被 CSP 阻止: ```javascript // 这种方式会被 CSP 拦截 const blob = new Blob([swCode], { type: 'application/javascript' }); navigator.serviceWorker.register(URL.createObjectURL(blob)); // ❌ // 只能用标准的外部脚本注册 navigator.serviceWorker.register('/sw.js'); // ✅ ``` 另一个关键点:Service Worker 内部通过 `importScripts()` 加载的脚本不受页面 CSP 约束,但受 Service Worker 自身响应头中 `Content-Security-Policy` 的约束。服务器应在 SW 脚本响应头中设置 CSP,限制 `importScripts` 可加载的来源。 ## XSS 向 Service Worker 的渗透路径 虽然 Service Worker 没有 DOM 访问权限,但它能监听 `message` 事件,攻击者可利用页面中的 XSS 漏洞向 SW 发送恶意指令: ```javascript // SW 端:不验证消息来源的写法是危险的 self.addEventListener('message', event => { eval(event.data); // 严重漏洞 }); // 安全写法:验证 origin + 白名单校验 action self.addEventListener('message', event => { if (event.origin !== 'https://your-domain.com') return; const { action } = event.data; const ALLOWED = ['skipWaiting', 'claimClients']; if (!ALLOWED.includes(action)) return; action === 'skipWaiting' && self.skipWaiting(); action === 'claimClients' && self.clients.claim(); }); ``` 这条渗透路径意味着:**页面 XSS 的危害会因为 Service Worker 的存在而放大**——攻击者不仅能操作当前页面,还能指挥后台代理篡改后续所有请求。 ## 更新机制中的安全风险 Service Worker 的更新依赖浏览器对 `sw.js` 的字节级比对。如果攻击者能控制服务器响应(如 CDN 被入侵),就可以推送恶意更新。 防护手段: - 在 SW 脚本响应头设置 `Cache-Control: no-cache`,确保浏览器每次都检查更新 - 使用 SRI(Subresource Integrity)验证脚本完整性:`<script src="/sw.js" integrity="sha384-xxxxx">`(注意:`register()` 方式不支持 SRI,需配合 Service Worker 的 importScripts 做完整性校验) - 实现紧急 kill-switch:部署一个功能为 `self.unregister()` 的新版 SW,用于紧急卸载 ```javascript // kill-switch: 紧急卸载 self.addEventListener('activate', () => { self.registration.unregister(); }); ``` ## 中间人攻击的纵深防御 虽然 HTTPS 是第一道防线,但 Service Worker 还可以补充检查响应头,作为纵深防御: ```javascript self.addEventListener('fetch', event => { event.respondWith( fetch(event.request).then(response => { // 检查关键安全头是否存在 const hsts = response.headers.get('Strict-Transport-Security'); if (!hsts) { console.warn('缺少 HSTS 头,可能存在降级攻击风险'); } return response; }) ); }); ``` 不过要注意,这种检查本身也在 SW 中运行——如果 SW 已被篡改,检查也就失效了。因此 HTTPS + 证书体系才是根本,SW 检查只是辅助告警。 ## 追问方向 - **Service Worker 被恶意注册后如何彻底清除?** → `registration.unregister()` + `Clear-Site-Data` 响应头可强制清除所有缓存和注册 - **第三方 iframe 中的 Service Worker 会影响宿主页面吗?** → Chrome 已将第三方 iframe 的 SW 按顶级站点分区(partitioned),与宿主页面的 SW 隔离 - **如何防止旧版 Service Worker 拒绝更新?** → `skipWaiting()` 强制激活新版,但需确保旧缓存清理逻辑在 `activate` 事件中执行 - **Service Worker 和 Web Worker 的安全模型有何差异?** → Web Worker 受同源策略约束但无法拦截网络请求,Service Worker 的代理能力是其额外安全风险的根源
服务端5月28日 01:49
Service Worker 中的 Cache Storage API 如何使用?Cache Storage API 是 Service Worker 中管理请求/响应对缓存的接口,支持离线访问和性能优化,是前端 PWA 和面试的高频考点。 ## 核心答案 **Cache Storage API 做什么?** 在 Service Worker 中以代码驱动的方式缓存网络请求和响应,实现对缓存内容的完全控制,替代传统的 HTTP 缓存启发式策略。 **关键方法速记:** - `caches.open(name)` — 打开/创建命名缓存 - `cache.add(url)` / `cache.addAll(urls)` — fetch 并缓存 - `cache.put(req, res)` — 手动存储请求/响应对 - `cache.match(req)` / `caches.match(req)` — 检索缓存(后者跨所有缓存) - `cache.delete(req)` / `caches.delete(name)` — 删除缓存项或整个缓存 - `caches.keys()` — 列出所有缓存名称 **与 HTTP 缓存的关系:** Cache Storage 是应用层缓存,优先级高于 HTTP 缓存;浏览器填充 Cache Storage 时仍会检查 HTTP 缓存。建议对带版本哈希的资源设置 `Cache-Control: max-age=31536000`,其余资源配合 Service Worker 手动管理。 ## 基本使用 ```javascript // install 阶段预缓存静态资源 const CACHE_NAME = 'app-v1'; const ASSETS = ['/', '/index.html', '/styles.css', '/app.js']; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME) .then(cache => cache.addAll(ASSETS)) .then(() => self.skipWaiting()) ); }); // activate 阶段清理旧缓存 self.addEventListener('activate', event => { event.waitUntil( caches.keys().then(names => Promise.all( names.filter(n => n !== CACHE_NAME).map(n => caches.delete(n)) ) ) ); }); ``` ## 三种缓存策略 面试中最常问的缓存策略,根据场景选择: ### Cache First(缓存优先) 适用场景:静态资源、字体、图片等不常变化的内容。 ```javascript self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cached => { return cached || fetch(event.request).then(response => { const clone = response.clone(); caches.open('dynamic-v1').then(cache => cache.put(event.request, clone)); return response; }); }) ); }); ``` ### Network First(网络优先) 适用场景:API 请求、频繁更新的内容。 ```javascript self.addEventListener('fetch', event => { event.respondWith( fetch(event.request).then(response => { const clone = response.clone(); caches.open('api-v1').then(cache => cache.put(event.request, clone)); return response; }).catch(() => caches.match(event.request)) ); }); ``` ### Stale-While-Revalidate(先用缓存,后台更新) 适用场景:非关键数据,可接受短暂过期。 ```javascript self.addEventListener('fetch', event => { event.respondWith( caches.open('swr-v1').then(cache => cache.match(event.request).then(cached => { const fetchPromise = fetch(event.request).then(response => { cache.put(event.request, response.clone()); return response; }); return cached || fetchPromise; }) ) ); }); ``` ## 关键注意事项 ### Response 只能消费一次 ```javascript // 错误:Response body 被消耗后无法再次使用 const res = await fetch('/api/data'); await cache.put(request, res); return res; // 已消耗,返回空 // 正确:clone() 创建副本 const res = await fetch('/api/data'); await cache.put(request, res.clone()); return res; ``` ### 缓存匹配规则 ```javascript // 默认严格匹配 URL(含查询参数) await cache.match('/api/data'); // 不匹配 /api/data?id=1 // 使用选项放宽匹配 await cache.match(request, { ignoreSearch: true, // 忽略查询参数 ignoreMethod: true, // 忽略 HTTP 方法 ignoreVary: true // 忽略 Vary 头 }); ``` ### 存储配额 ```javascript if ('storage' in navigator && 'estimate' in navigator.storage) { const { usage, quota } = await navigator.storage.estimate(); console.log(`已用 ${(usage / 1024 / 1024).toFixed(1)}MB,配额 ${(quota / 1024 / 1024).toFixed(0)}MB`); } ``` 超出配额后浏览器会按 LRU 策略清理,建议控制缓存数量上限: ```javascript async function trimCache(name, maxItems) { const cache = await caches.open(name); const keys = await cache.keys(); if (keys.length > maxItems) { await Promise.all(keys.slice(0, keys.length - maxItems).map(k => cache.delete(k))); } } ``` ## 常见面试追问 **Q: Cache Storage 和 localStorage 有什么区别?** localStorage 是同步的、容量小(约5MB)、只能存字符串;Cache Storage 是异步的、容量大(通常数百MB)、专门存储 Request/Response 对象,适合缓存网络资源。 **Q: 页面主线程能直接用 Cache Storage 吗?** 能。`caches` 对象在 window 和 Service Worker 中都可用,但缓存策略的拦截逻辑必须在 Service Worker 的 fetch 事件中实现。 **Q: 如何让用户获取到更新后的缓存?** 修改缓存版本号(如 `app-v1` → `app-v2`),新 Service Worker install 后会在 activate 事件中清理旧缓存。注意页面需要关闭再打开才会激活新 Service Worker,也可用 `skipWaiting()` + `clients.claim()` 立即生效。
服务端5月28日 01:42
Service Worker 的浏览器兼容性如何?如何处理兼容性问题?## 核心回答 Service Worker 在 2026 年已获得所有主流浏览器的全面支持,兼容性得分达到 92/100。处理兼容性问题的核心思路是三点:**特性检测优先于浏览器检测**、**渐进增强而非降级开发**、**为不支持的功能提供回退方案**。 ```javascript // 最简兼容性检测模板 if ("serviceWorker" in navigator) { navigator.serviceWorker.register("/sw.js").catch(() => fallback()); } else { fallback(); } ``` ## 浏览器支持现状(2026) 截至 2026 年,Service Worker 的浏览器兼容格局已经非常清晰: | 浏览器 | 最低支持版本 | 备注 | |--------|-------------|------| | Chrome | 40+ | 全功能支持 | | Firefox | 44+ | 隐私模式下不可用 | | Safari | 11.1+ | iOS 16.4+ 支持 Web Push | | Edge | 17+ | 基于 Chromium 后完全对齐 Chrome | | IE | - | 已停止支持,无需考虑 | 需要注意的现实情况:**IE 已正式退役,Edge 已切换到 Chromium 内核**,曾经需要大量兼容代码的场景已经大幅减少。真正需要关注的兼容性问题,集中在 Safari 的功能差异和 Firefox 隐私模式的限制上。 ## Safari 与 iOS 的特殊限制 Safari 一直是 Service Worker 兼容性处理的重点对象,主要限制如下: **通知权限必须由用户交互触发**——Safari 不允许页面加载时自动请求通知权限,必须绑定到点击等用户行为上: ```javascript document.addEventListener("click", () => { if (Notification.permission === "default") { Notification.requestPermission(); } }, { once: true }); ``` **Service Worker 更新机制不同**——Safari 不会像 Chrome 那样在 24 小时内自动检查更新,需要手动触发: ```javascript // Safari 下定期检查 SW 更新 if (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) { setInterval(() => { navigator.serviceWorker.ready.then(reg => reg.update()); }, 3600000); // 每小时检查一次 } ``` **iOS 存储配额更严格**——iOS 上 Service Worker 的缓存空间有限,通常在 50MB 左右,超出后系统可能直接清理缓存。建议实现缓存淘汰策略,优先保留核心资源。 ## Firefox 隐私模式的坑 Firefox 在隐私浏览模式下完全禁用 Service Worker,且没有 API 可以检测当前是否处于隐私模式。应对方式是做好注册失败的错误处理,不要让应用崩溃: ```javascript navigator.serviceWorker.register("/sw.js").catch(err => { // 可能是隐私模式或不安全上下文 console.warn("SW 注册失败,将使用在线模式", err); }); ``` ## 功能分级检测与渐进增强 兼容性处理的正确姿势是按功能分级检测,而不是一刀切: ```javascript function detectSWCapabilities() { const caps = { sw: "serviceWorker" in navigator, cache: "caches" in window, push: "PushManager" in window, sync: "sync" in ServiceWorkerRegistration.prototype, periodicSync: "periodicSync" in ServiceWorkerRegistration.prototype }; return caps; } ``` 根据检测结果提供不同级别的体验: - **完整模式**:SW + Cache + Push + Background Sync 全部可用 - **标准模式**:SW + Cache + Push 可用,Background Sync 不可用时用 setInterval 模拟 - **基础模式**:SW 不可用,用 localStorage 做简单缓存,纯在线体验 ```javascript // Background Sync 不可用时的回退方案 if ("sync" in ServiceWorkerRegistration.prototype) { // 使用原生 Background Sync navigator.serviceWorker.ready.then(reg => { return reg.sync.register("sync-data"); }); } else { // 回退:在线时定期同步 window.addEventListener("online", () => { syncPendingData(); }); setInterval(() => { if (navigator.onLine) syncPendingData(); }, 60000); } ``` ## Cache API 不存在时的 Polyfill 当浏览器不支持 Cache API 时,可以用 IndexedDB 做简单替代(不要用 localStorage,它的 5MB 限制和同步阻塞特性不适合缓存场景): ```javascript // 简化的 IndexedDB 缓存方案 async function openCache() { if ("caches" in window) return await caches.open("app-v1"); // 回退到 IndexedDB const db = await idb.open("sw-cache", 1, upgradeDB => { upgradeDB.createObjectStore("cache"); }); return { async match(req) { const tx = db.transaction("cache"); return tx.objectStore("cache").get(new Request(req).url); }, async put(req, res) { const tx = db.transaction("cache", "readwrite"); await tx.objectStore("cache").put(await res.clone(), new Request(req).url); } }; } ``` ## 面试追问:2026 年还需要担心 Service Worker 兼容性吗? 需要,但关注点已经变了。浏览器碎片化作为 PWA 阻碍的时代基本结束,核心 Service Worker API 在所有主流浏览器都已可用。当前真正需要关注的兼容性问题集中在三个方面:一是 Safari 对部分 API(如 Periodic Background Sync)仍不支持;二是 Firefox 隐私模式完全禁用 SW;三是移动端存储配额差异大,尤其是 iOS 设备。实际项目中的最佳做法是:始终用特性检测代替浏览器检测,为每个 SW 功能提供独立回退,而不是全局开关。
服务端5月28日 01:42
Service Worker 中常用的缓存策略有哪些?## Service Worker 中常用的缓存策略有哪些? Service Worker 的核心能力之一就是拦截网络请求并控制缓存策略。不同的业务场景需要不同的策略组合,选错策略可能导致用户看到过期数据,或者离线时完全不可用。以下是前端面试中必须掌握的六种缓存策略。 ## Cache First(缓存优先) 优先从缓存读取响应,缓存未命中时才发起网络请求,并将响应存入缓存。 **适用场景**:静态资源(CSS、JS、字体、图片),这些资源变化频率低,缓存命中率高。 ```javascript const CACHE_NAME = 'static-v1'; self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request) .then(cachedResponse => { if (cachedResponse) { return cachedResponse; } return fetch(event.request).then(networkResponse => { // 将网络响应克隆后存入缓存 const responseToCache = networkResponse.clone(); caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }); }) .catch(() => { // 网络请求也失败时的兜底处理 return new Response('Offline', { status: 503 }); }) ); }); ``` **优点**:响应速度极快,离线可用 **缺点**:缓存未更新前用户始终看到旧版本,需配合版本控制机制主动更新 ## Network First(网络优先) 优先发起网络请求,成功后将响应写入缓存;网络失败时回退到缓存。 **适用场景**:实时性要求高的数据,如 API 请求、用户配置、动态页面。 ```javascript const CACHE_NAME = 'dynamic-v1'; self.addEventListener('fetch', event => { event.respondWith( fetch(event.request) .then(networkResponse => { const responseToCache = networkResponse.clone(); caches.open(CACHE_NAME).then(cache => { cache.put(event.request, responseToCache); }); return networkResponse; }) .catch(() => { return caches.match(event.request); }) ); }); ``` **优点**:优先保证数据新鲜度 **缺点**:网络不稳定时首屏加载慢,无缓存且离线时完全不可用 ## Stale While Revalidate(先缓存后更新) 立即返回缓存的响应(如果存在),同时在后台发起网络请求更新缓存。下次请求时用户就能看到最新内容。 **适用场景**:对实时性有一定要求但更看重响应速度的场景,如文章列表、配置信息。 ```javascript const CACHE_NAME = 'swr-v1'; self.addEventListener('fetch', event => { event.respondWith( caches.open(CACHE_NAME).then(cache => { return cache.match(event.request).then(cachedResponse => { const fetchPromise = fetch(event.request).then(networkResponse => { cache.put(event.request, networkResponse.clone()); return networkResponse; }).catch(() => cachedResponse); return cachedResponse || fetchPromise; }); }) ); }); ``` **优点**:兼顾速度与新鲜度,用户永远不会等待网络 **缺点**:当前请求返回的可能是过期数据,不适用于对数据一致性要求严格的场景 ## Cache Only(仅缓存) 只从缓存获取响应,不发起任何网络请求。资源必须在 Service Worker 安装阶段预缓存。 **适用场景**:App Shell、离线页面、预缓存的静态资源。 ```javascript // 安装阶段预缓存 self.addEventListener('install', event => { event.waitUntil( caches.open('precache-v1').then(cache => { return cache.addAll([ '/offline.html', '/styles/main.css', '/scripts/app.js' ]); }) ); }); // 仅从缓存读取 self.addEventListener('fetch', event => { event.respondWith(caches.match(event.request)); }); ``` **优点**:响应最快,完全离线可用 **缺点**:缓存内容不会自动更新,必须通过更新 Service Worker 重新预缓存 ## Network Only(仅网络) 只发起网络请求,不读取也不写入缓存。 **适用场景**:实时性要求极高且不可缓存的操作,如支付请求、敏感数据查询、非 GET 请求。 ```javascript self.addEventListener('fetch', event => { // 对于非 GET 请求或不允许缓存的接口,直接走网络 if (event.request.method !== 'GET') { event.respondWith(fetch(event.request)); } }); ``` **优点**:保证数据始终是最新的 **缺点**:离线时完全不可用,网络差时体验不佳 ## 自定义策略组合 实际项目中不会只用一种策略,而是根据请求类型和 URL 特征路由到不同的缓存策略: ```javascript self.addEventListener('fetch', event => { const { request } = event; const url = new URL(request.url); // 非 GET 请求走网络 if (request.method !== 'GET') { event.respondWith(fetch(request)); return; } // 静态资源 - Cache First if (isStaticAsset(request)) { event.respondWith(cacheFirst(request)); return; } // API 请求 - Network First if (url.pathname.startsWith('/api/')) { event.respondWith(networkFirst(request)); return; } // HTML 页面 - Stale While Revalidate if (request.mode === 'navigate') { event.respondWith(staleWhileRevalidate(request)); return; } // 默认 - Network First event.respondWith(networkFirst(request)); }); function isStaticAsset(request) { return ['style', 'script', 'image', 'font'].includes(request.destination); } ``` ## 缓存策略对比 | 策略 | 响应速度 | 数据新鲜度 | 离线可用 | 典型场景 | |------|----------|-----------|---------|---------| | Cache First | 快 | 低 | 是 | 静态资源 | | Network First | 慢 | 高 | 是 | API 请求 | | Stale While Revalidate | 快 | 中 | 是 | 配置/列表 | | Cache Only | 最快 | 最低 | 是 | App Shell | | Network Only | 最慢 | 最高 | 否 | 支付/写操作 | ## 缓存更新与版本控制 缓存策略能管好读取,但缓存什么时候失效、如何主动更新,才是面试中容易踩坑的地方。核心思路是通过缓存名称中的版本号来管理: ```javascript const CACHE_NAME = 'static-v2'; self.addEventListener('activate', event => { // 激活时清理旧版本缓存 event.waitUntil( caches.keys().then(cacheNames => { return Promise.all( cacheNames .filter(name => name !== CACHE_NAME) .map(name => caches.delete(name)) ); }) ); }); ``` 当 Service Worker 文件发生变更时,浏览器会触发更新流程:安装新的 Worker -> 等待旧 Worker 不再控制页面 -> 激活新 Worker 并清理旧缓存。这就是为什么修改缓存版本号能强制刷新缓存。 ## 使用 Workbox 简化缓存管理 Google 的 Workbox 库封装了上述所有策略,生产环境中建议直接使用: ```javascript import { registerRoute } from 'workbox-routing'; import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'; // 静态资源 registerRoute( ({ request }) => ['style', 'script', 'image'].includes(request.destination), new CacheFirst({ cacheName: 'static-v1' }) ); // API 请求 registerRoute( ({ url }) => url.pathname.startsWith('/api/'), new NetworkFirst({ cacheName: 'api-v1' }) ); // HTML 页面 registerRoute( ({ request }) => request.mode === 'navigate', new StaleWhileRevalidate({ cacheName: 'pages-v1' }) ); ``` Workbox 还内置了缓存过期清理(`ExpirationPlugin`)、缓存大小限制、后台同步等能力,比手写策略更健壮。 ## 面试追问方向 - **Service Worker 的生命周期**:install -> waiting -> activate 三个阶段各做什么?waiting 状态如何跳过? - **缓存击穿问题**:多个请求同时未命中缓存时,如何避免重复网络请求? - **跨域请求的缓存**:opaque response 的限制和注意事项 - **与 HTTP 缓存的区别**:Service Worker 缓存优先级高于 HTTP 缓存,两者的协作关系
服务端5月28日 01:41
Service Worker Background Sync 是什么?怎么用?## Service Worker Background Sync 是什么? Background Sync 是 Service Worker 提供的一种延迟任务机制——当用户离线时发起的操作(如表单提交、消息发送),会在网络恢复后自动重试,用户不需要停留在页面上,也不需要手动操作。 它解决的核心痛点很简单:**离线操作不应该丢失**。传统做法是监听 `online` 事件再重试,但用户可能已经关掉了页面。Background Sync 让这个重试逻辑跑在 Service Worker 里,即使用户离开了网站也能执行。 ## 怎么用?三步走 ### 第一步:主页面注册 sync 事件 ```javascript async function submitForm(data) { // 先把数据存到 IndexedDB await saveToIndexedDB('outbox', data); const registration = await navigator.serviceWorker.ready; try { await registration.sync.register('sync-forms'); } catch { // 不支持 Background Sync,走降级逻辑 if (navigator.onLine) await sendToServer(data); } } ``` 关键点:`sync.register(tag)` 的 tag 是同步事件的唯一标识。同一个 tag 重复注册不会创建多个事件,而是合并。这意味着你不需要担心重复提交的问题——浏览器会帮你去重。 ### 第二步:Service Worker 中监听 sync 事件 ```javascript // sw.js self.addEventListener('sync', event => { if (event.tag === 'sync-forms') { event.waitUntil(doSync()); } }); async function doSync() { const items = await getAllFromIndexedDB('outbox'); for (const item of items) { const res = await fetch('/api/submit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item), }); if (res.ok) { await removeFromIndexedDB('outbox', item.id); } else { throw new Error('同步失败'); // 抛出错误才会触发重试 } } } ``` 这里有个容易被忽略的细节:**必须抛出错误才能触发重试**。如果你 `catch` 了错误然后默默吞掉,浏览器会认为同步成功,不会重试。`event.waitUntil()` 接收的 Promise 如果 reject,浏览器会按照指数退避策略重试(最多3次)。 ### 第三步:IndexedDB 持久化 Background Sync 不负责存储数据,你必须在注册 sync 之前把待发送的数据写入 IndexedDB。Service Worker 无法访问 localStorage,IndexedDB 是唯一选择。 ```javascript function openDB() { return new Promise((resolve, reject) => { const req = indexedDB.open('sync-db', 1); req.onupgradeneeded = e => { const db = e.target.result; if (!db.objectStoreNames.contains('outbox')) { db.createObjectStore('outbox', { keyPath: 'id' }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); } async function saveToIndexedDB(store, data) { const db = await openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(store, 'readwrite'); tx.objectStore(store).put(data); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); } ``` ## 重试机制是怎么回事? Background Sync 的重试策略是浏览器内置的,开发者无法自定义重试间隔。大致规则: - 首次失败后,会在短时间内重试 - 后续重试间隔逐渐增大(指数退避) - 最多重试 3 次 - 3 次都失败后,这个 sync 事件会被丢弃 这也意味着你的同步逻辑必须是**幂等**的——同一份数据提交多次不应该产生重复记录。服务端需要做去重处理(比如用 `id` 判重),或者客户端在发送时带唯一标识。 ## Periodic Background Sync 又是什么? Background Sync 是"用户触发、离线延迟执行"。Periodic Background Sync 是"浏览器自动触发、定期执行",比如每天同步一次最新数据。 ```javascript // 注册定期同步 const registration = await navigator.serviceWorker.ready; await registration.periodicSync.register('daily-content', { minInterval: 24 * 60 * 60 * 1000, // 最少间隔 24 小时 }); // Service Worker 处理 self.addEventListener('periodicsync', event => { if (event.tag === 'daily-content') { event.waitUntil(fetchLatestContent()); } }); ``` Periodic Sync 的限制更严格:必须是在 `minInterval` 指定的时间之后才可能触发,而且浏览器会根据用户使用频率决定是否真的触发。如果一个网站用户一周都没访问过,Periodic Sync 大概率不会执行。 ## Background Sync vs Background Fetch 这两个 API 经常被混淆,区别很明确: | 维度 | Background Sync | Background Fetch | |------|----------------|-----------------| | 数据量 | 小(表单、消息) | 大(文件上传下载) | | 用户感知 | 静默执行 | 显示进度条 | | 权限要求 | 无 | 需要用户授权 | | 进度回调 | 无 | 有 | | 典型场景 | 离线表单提交 | 离线下载大文件 | 简单判断:数据量小、不需要显示进度 → Sync;数据量大、需要显示进度 → Fetch。 ## 浏览器兼容性——这是最大的坑 截至 2026 年: | 浏览器 | Background Sync | Periodic Sync | |--------|----------------|---------------| | Chrome | 支持 | 支持 | | Edge | 支持 | 支持 | | Firefox | 不支持 | 不支持 | | Safari | 不支持 | 不支持 | Firefox 和 Safari 至今不支持 Background Sync。这意味着如果你的用户群在 iOS 或 Firefox 上,必须提供降级方案。 降级思路:检测 `registration.sync` 是否存在,不存在就走 `online` 事件监听 + 页面内重试的老路。 ```javascript async function syncWithFallback(tag) { const registration = await navigator.serviceWorker.ready; if ('sync' in registration) { await registration.sync.register(tag); } else { // 降级:监听网络恢复 window.addEventListener('online', () => doSync()); // 如果已经在线,直接执行 if (navigator.onLine) await doSync(); } } ``` ## 生产环境的注意事项 1. **数据必须先落盘**:注册 sync 之前,把数据写入 IndexedDB。Service Worker 随时可能被终止,内存里的数据靠不住。 2. **做好去重**:sync 可能重试,同一份数据可能发送多次。服务端根据唯一 ID 去重,或者用 `PUT` 替代 `POST`。 3. **给用户反馈**:同步成功后用 `self.registration.showNotification()` 发通知,让用户知道离线操作已经完成。 4. **tag 命名要规范**:不要用 `'sync'` 这种通用名字,用 `'sync-user-form'`、`'sync-chat-message'` 这种带业务语义的 tag,方便在 Service Worker 里分派不同逻辑。 5. **不要存太多数据**:IndexedDB 里的待同步队列不要无限增长。加个上限,超过就提示用户而不是静默堆积。 6. **测试方法**:Chrome DevTools → Application → Service Workers → 勾选 "Offline" 模拟离线,注册 sync 后取消勾选观察是否自动触发。 ## 追问:Sync 事件会不会无限重试消耗电量? 不会。浏览器内置了保护机制:最多重试 3 次,间隔指数增长。3 次失败后事件被丢弃。另外,sync 事件只在浏览器认为"条件合适"时才触发(电量充足、网络稳定),不会在恶劣条件下反复尝试。 ## 追问:一个页面能注册多少个 sync tag? 规范没有硬性上限,但浏览器有实现限制。Chrome 大约允许 100 个未完成的 sync 注册。实际开发中,应该按业务分类使用少数几个 tag(如一个表单 tag、一个消息 tag),而不是每个操作一个 tag。同 tag 的新注册会覆盖旧的,所以同一类操作用同一个 tag 就够了。 ## 追问:页面关闭后 sync 还能执行吗? 能。这就是 Background Sync 的核心价值——sync 事件在 Service Worker 中触发,Service Worker 独立于页面运行。即使用户关掉了标签页,只要浏览器进程还在,sync 就能在网络恢复时执行。这也是为什么数据必须存 IndexedDB 而不能依赖页面内存。