面向哲思的编程与架构
笔记哲思阅读动态搜索RSS 订阅
切换到深色模式
搜索
RSS 订阅
切换到深色模式
© 2026 Vic Chen. All rights reserved.CC BY-NC-ND 4.0
← 笔记
打磨 iOS Web Clip 体验(三):一次 Service Worker 卡死排查

打磨 iOS Web Clip 体验(三):一次 Service Worker 卡死排查

2026年6月30日2,9749分钟

给站点加一个只对白名单设备可见的管理入口,看似是一次简单的权限收敛,却在 iOS Web Clip 上牵出三层叠加的假象:一个永不 resolve 的 Promise、一个卡死在 installing 的 Service Worker、一个被远程调试污染的权限判断。记录完整的排查链路和每一步的证据。


目录
  • TL;DR
  • 1. 需求与方案:复用 Web Push 的订阅标识
  • 2. 第一层假象:serviceWorker.ready 永远 pending
  • 3. 第二层假象:Service Worker 卡死在 installing
  • 4. 第三层假象:远程调试本身污染了权限判断
  • 5. 三层假象叠在一起意味着什么
  • 6. 结果
目录
  • TL;DR
  • 1. 需求与方案:复用 Web Push 的订阅标识
  • 2. 第一层假象:serviceWorker.ready 永远 pending
  • 3. 第二层假象:Service Worker 卡死在 installing
  • 4. 第三层假象:远程调试本身污染了权限判断
  • 5. 三层假象叠在一起意味着什么
  • 6. 结果
目录
  1. TL;DR
  2. 1. 需求与方案:复用 Web Push 的订阅标识
  3. 2. 第一层假象:serviceWorker.ready 永远 pending
  4. 3. 第二层假象:Service Worker 卡死在 installing
  5. 4. 第三层假象:远程调试本身污染了权限判断
  6. 5. 三层假象叠在一起意味着什么
  7. 6. 结果
PWA前端
相关文章
  • 01
    打磨 iOS Web Clip 体验(二):接入 Web Push 通知2026/06
  • 02
    打磨 iOS Web Clip 体验(一):原生质感的下拉刷新2026/05
  • 03
    Prefetch 的完整图景2026/07
← 上一篇打磨 iOS Web Clip 体验(二):接入 Web Push 通知
下一篇 →我以为我很了解自己

评论

© 2026 Vic Chen · 面向哲思的编程与架构CC BY-NC-ND 4.0

TL;DR

前两篇解决的是「站内交互的连续性」(下拉刷新)和「站外触达的可能性」(Web Push 通知)。这篇要解决的是如何把管理页面的入口「安全地」暴露在站点上,但只对特定设备可见。

问题不难,方案也很快定了下来。真正花时间的是把方案落地后,在 iOS Web Clip 上完全无法验证成功,排查过程比功能本身长得多,牵出了三个叠在一起的假象,最终定位到一处从项目初期就存在、但从未被真正测试到的疏漏。


1. 需求与方案:复用 Web Push 的订阅标识

由于 iOS Web Clip 没有地址栏,如果一个页面没有入口链接,就只能永远打不开。管理页面本身已经有独立的密码机制,缺的只是「怎么导航到那里」。

最终选择直接复用 Web Push 的订阅 endpoint 作为设备标识(Chen 注:试想过用一个专门生成的设备 token(存在 localStorage 里)来做这件事,比复用推送订阅更稳定,推送订阅的 endpoint 会在重装、清缓存时失效,需要手动更新白名单。但我更想复用「已经在做的事」,接受这个偶尔要维护的代价。),而不是另起一套 token 机制:

图1:设备白名单决定首页入口是否渲染——密码门本身不受影响

数据模型很简单,一个 Redis hash,key 是 endpoint,value 是这台设备可见的页面 key 数组:

src/lib/push.ts
const DEVICE_PAGES_KEY = "push:device-pages";
export const DEVICE_PAGE_KEYS = ["push", "todo", "comments", "annex"] as const;
export type DevicePageKey = typeof DEVICE_PAGE_KEYS[number];
 
export async function getDevicePages(endpoint: string): Promise<DevicePageKey[]> {
  const raw = await redis.hget<DevicePageKey[] | string>(DEVICE_PAGES_KEY, endpoint);
  if (!raw) return [];
  const parsed = typeof raw === "string" ? JSON.parse(raw) as unknown : raw;
  return Array.isArray(parsed) ? parsed.filter(isDevicePageKey) : [];
}

配合一个不需要鉴权的公开接口,只返回「这个 endpoint 能看哪些页面 key」,不泄露任何其他设备信息:

src/app/api/push/pages-for-device/route.ts
export async function POST(req: NextRequest) {
  const body = await req.json().catch(() => null);
  const endpoint = body?.endpoint;
  if (typeof endpoint !== "string" || !endpoint) {
    return Response.json({ pages: [] });
  }
  const

首页这边,一个 client 组件在 useEffect 里拿当前设备的订阅 endpoint,查一次白名单,命中就渲染入口,不命中什么都不渲染。没有 loading 占位,非白名单用户看到的首页在 DOM 结构上跟命中前完全一样,避免暴露「这里本该有什么」的痕迹:

src/components/ui/AdminEntryLinks.tsx(简化)
export function AdminEntryLinks() {
  const [pages, setPages] = useState<DevicePageKey[]>([]);
 
  useEffect(() => {
    if (!isMobile()) return;
    if (!("serviceWorker" in navigator) || !("PushManager" in window)) return;














白名单本身的维护也不新开页面,直接复用现有的订阅设备列表里。这一层功能写完、类型检查通过、部署上线,看起来是一次很平常的小改动。


2. 第一层假象:serviceWorker.ready 永远 pending

部署后第一件事是确认白名单生效,但在 iOS Web Clip 里打开则什么都没出现。

先用最直接的方式确认:远程连上 Mac Safari 的 Web Inspector(Chen 注:iOS 本身不带开发者工具,调试 Web Clip/PWA 只能借道 Mac,在 iOS 和 Mac 上的 Safari 都打开「Web 检查器」,之后 Mac Safari 就能看到这台设备,选中对应页面或 Web Clip 实例,就能打开一个连着真机、实时同步的 Web Inspector。Console、Network、Elements 面板跑在真实的 iOS WebKit 环境里,不是模拟器,这次能在真机上直接跑 `register()`、`getRegistrations()` 观察 Service Worker 状态,靠的就是这条链路。),在 Console 里手动跑一遍订阅状态判断的逻辑。

Console
navigator.serviceWorker.ready

返回的是一个 Promise {status: "pending"},不是慢,是永远不会 resolve。这一下解释了当时观察到的所有现象:

  • usePushSubscription 的初始 useEffect 里,serviceWorker.ready.then(...) 永远不会触发,2 秒兜底 timeout 先到,把状态强行设成 "unsubscribed",铃铛按钮显示成「订阅更新通知」,即便这台设备实际上一直在收推送。
  • 点击按钮后走的是 subscribe() 分支,函数体内 await navigator.serviceWorker.ready 卡住,永远不会往下执行到 pushManager.subscribe(),也就永远不会发出网络请求,这和跟观察到的「Console 无输出、Network 无请求」完全吻合。

serviceWorker.ready 的语义是「等到有一个 active 的 Service Worker 时才 resolve」。第一个合理的怀疑方向是:这个 Promise 之所以卡住,可能是当时还没有任何 Service Worker 注册过。用 getRegistrations() 确认了这一点:数组是空的。

修复思路是不要依赖一个「可能永久悬挂」的 Promise 来判断状态,换成立即返回结果的 getRegistration():

src/components/ui/PushSubscribeButton.tsx
// 之前
navigator.serviceWorker.ready
  .then((reg) => reg.pushManager.getSubscription())
  .then((sub) => setState(sub ? "subscribed" : "unsubscribed"));
 
// 之后:getRegistration 立即返回已存在的 registration 或 undefined,不会悬挂
navigator.serviceWorker.getRegistration()
  .then((reg) => reg ? reg.pushManager.getSubscription

subscribe() 和 unsubscribe() 内部同样用到 serviceWorker.ready 的地方也一并替换。部署、重新打开 Web Clip,但问题依旧存在。


3. 第二层假象:Service Worker 卡死在 installing

换掉 API 之后毫无改善,说明问题比「选错了哪个 Promise」更底层。回到远程调试,直接调用 register(),绕开所有业务逻辑,看最原始的注册行为:

Console
navigator.serviceWorker.register("/sw.js")
  .then(reg => console.log("registered:", reg))
输出
registered: ServiceWorkerRegistration
  installing: ServiceWorker
  waiting: null
  active: null
  scope: "https://whchen.dev/"

注册本身成功了,sw.js 文件没问题,scope 也对。但 active: null,installing 里挂着一个 ServiceWorker 对象,但它从未真正激活过,卡死在最初的安装阶段。这就是 serviceWorker.ready 永远 pending 的根本原因:ready 的定义是「等到 active」,而这个 SW 永远不会变成 active,ready 自然永远不会 resolve。之前换 API 只是绕开了症状,没碰到病灶。

Service Worker 的标准生命周期是 installing → installed → activating → activated,中间那一步默认要等到「所有由旧版本 SW 控制的页面都关闭」才会往下走。查看 public/sw.js,里面只有 push 和 notificationclick 两个事件监听器,完全没有处理 install / activate 事件:

public/sw.js(改动前)
self.addEventListener("push", (event) => { /* ... */ });
self.addEventListener("notificationclick", (event) => { /* ... */ });

这不是代码写错了,是缺了显式的生命周期控制(Chen 注:没有显式调用 `skipWaiting()` 时,浏览器默认让新 SW 停留在 waiting 状态,等所有现有 client(打开的标签页/窗口)都关闭并重新打开后才激活,这是为了避免同一个页面在生命周期中被不同版本的 SW 混合控制。iOS WebKit 在 standalone 模式下,这个「等待所有 client 关闭」的条件判断存在已知的实现差异,实际观察到的行为是即使只有一个 client(这个 Web Clip 本身),SW 依然可能长期卡在 installing,不会自然推进到 activated。)。补上 skipWaiting() 和 clients.claim(),让新 SW 装好立刻激活、激活后立刻接管所有页面,不依赖「等旧 tab 关闭」这个在 Web Clip 场景下很难自然满足的条件:

public/sw.js
// Service Worker for Web Push Notifications
 
// 立即激活,不等待旧 client 关闭——iOS standalone 下否则可能永久卡在 installing
self.addEventListener("install", () => {
  self.skipWaiting();
});
 
self.addEventListener("activate", (event) => {
  event.waitUntil(self.clients.claim());
});
 
self.addEventListener("push", (event) 

部署,完全退出 Web Clip 重新打开,再跑一次 getRegistrations():

输出
[{ scope: "https://whchen.dev/", active: true, waiting: false, installing: false }]

active: true。这一层问题解决了。


4. 第三层假象:远程调试本身污染了权限判断

SW 激活之后,点击订阅按钮,还是没有反应。Console 依旧空白,Network 依旧没有请求。

这时候怀疑的方向变了:SW 已经不是瓶颈,问题可能出在事件本身有没有真的到达处理函数。用远程调试的元素选择工具点按钮,Elements 面板准确定位到了 <button aria-label="订阅更新通知">,按钮本身没错位、没被盖住,aria-label 显示的是「未订阅」态,跟设备实际收得到推送的事实矛盾,但暂且按下不表,先确认订阅状态:

Console
navigator.serviceWorker.getRegistration()
  .then(r => r.pushManager.getSubscription())
  .then(s => console.log('sub:', s))
// sub: null

真的是 null。于是继续往前查权限:

Console
Notification.requestPermission().then(p => console.log('permission:', p))
// permission: "denied"

「denied」。看起来问题揭晓了,通知权限被拒绝,subscribe() 函数第一行 await Notification.requestPermission() 直接返回 "denied",函数走 return,不会有任何后续请求。

但这个结论站不住:设备一直能收到推送,iOS 系统设置里通知开关也确认是打开的。「系统层面允许,API 却报告 denied」,两者不可能同时为真,除非查错了对象。

问题出在查证方式本身:Notification.requestPermission() 是一个方法调用,而不是单纯读取状态。iOS WebKit 对这个方法有一条特殊限制,必须由真实的用户手势同步触发,否则可能返回一个不代表真实系统状态的保护性结果。而这次调用是在 Mac 远程调试的 Console 里手动敲的,不带任何用户手势上下文。换成只读属性重新确认:

Console
console.log('actual permission:', Notification.permission)
// actual permission: "denied"

依然是 denied。这次排除了「方法调用被手势限制污染」的可能,矛盾还在——系统设置允许,属性却是拒绝。回头再看一遍整个调试对象:整个过程连的确实是「添加到主屏幕」的 Web Clip 实例,不是平时用 Safari App 打开的普通标签页。

到这里,唯一能同时解释「系统允许」和「API 报告拒绝」的可能,是这两者本来就不是同一份状态:iOS 上通知权限的系统开关,和某个 Web App 实例首次请求权限时被缓存下来的 Notification.permission 值,在特定情况下会不同步,如果这个实例最初请求权限时曾经被拒绝过(哪怕是很早以前的一次失败请求),之后即使去系统设置里手动打开,网页 JS 侧读到的值也可能不会自动刷新。

这不是能在代码层面修的问题,需要清掉这个实例的历史状态,重新走一次完整流程:删除 Web Clip → 清 Safari 里这个站点的网站数据 → 重新添加到主屏幕 → 重新订阅。这次订阅弹出的是一次全新的系统权限请求,Notification.permission 变成 granted,订阅按钮恢复正常响应。


5. 三层假象叠在一起意味着什么

回头看整个排查链路,三个问题互相掩盖:

图2:三层假象的因果链——每一层的「表面证据」都指向了错误的方向
  • 换 API(ready → getRegistration())解决的是「怎么正确地问问题」,没有触碰到「SW 为什么从未激活」这个真正的病灶。
  • 补 skipWaiting() 解决的是 SW 生命周期本身的缺陷。这是三层里唯一改了生产代码的一环,也是唯一称得上「bug」的部分。前两次尝试都停留在症状层面,直到确认 active: null 才找到了需要动代码的地方。
  • 第三层完全不是代码问题,是远程调试引入的假象叠加了设备本身遗留的权限状态污染。两个环境问题叠在一起,产生了一个看起来极其像「代码逻辑错误」的现象(denied 权限 + 无网络请求),实际上跟这次改动毫无关系。

调试工具本身的行为特性,也可能成为排查过程里的一个变量(Chen 注:这次排查里最容易走偏的一步,是在 Console 里手动调用 requestPermission() 来验证权限——这个动作本身会因为缺少用户手势而返回不可信的结果,等于是用一把不准的尺子去量一个已经不准的东西,两次误差叠加后指向了完全错误的结论。后续验证权限状态改用只读属性 Notification.permission,避免再触发这个副作用。),而不只是一个透明的观察窗口。


6. 结果

修复落地在两个文件:public/sw.js 加了 install/activate 的生命周期控制,PushSubscribeButton.tsx 和 AdminEntryLinks.tsx 里所有 serviceWorker.ready 换成 getRegistration()。设备侧的动作是删除重装一次 Web Clip、重新走一遍订阅流程。

功能本身(白名单模型、条件渲染入口)从设计到实现只用了很短时间,验证阶段反而牵出了一处从系列第一篇上线起就存在、但从未被真正触发过的生命周期缺陷:因为这个站点此前的 Service Worker 更新场景很少(sw.js 几乎没改过),「新 SW 装好后卡在 installing」这条路径一直没被走到过,直到这次改动第一次触发了完整的重新注册流程,才第一次暴露出来。

三篇系列文章合起来,覆盖了 iOS Web Clip 场景下三类不同性质的问题:交互连续性(下拉刷新)、站外触达(Web Push 的实现)、以及触达能力本身的可靠性(Service Worker 生命周期)。后者往往是最容易被忽略的一层,功能「能用」和功能「在所有场景下都能正确进入可用状态」之间,中间隔着一整套生命周期假设,而这类假设不触发一次真实的冷启动,很难被测试到。

pages
=
await
getDevicePages
(endpoint);
return Response.json({ pages });
}
navigator.serviceWorker.getRegistration()
.then((reg) => reg ? reg.pushManager.getSubscription() : null)
.then((sub) => sub ? fetch("/api/push/pages-for-device", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ endpoint: sub.endpoint }),
}) : null)
.then((res) => res?.ok ? res.json() : null)
.then((data) => { if (data?.pages) setPages(data.pages); });
}, []);
if (pages.length === 0) return null;
// ... 渲染入口链接
}
()
:
null
)
.then((sub) => setState(sub ? "subscribed" : "unsubscribed"));
=>
{
/* ... */
});
self.addEventListener("notificationclick", (event) => { /* ... */ });