服务器不在,我替它回

断网警报刚响,Service Worker 就从路边岗亭冲出来,拦下 URL 的请求车:“服务器不在,我替它回。”旧版还没交班,新版又带着一仓库新文件赶到。到底谁有资格站上这个路口?

分享
服务器不在,我替它回

谁在半路拦车?

本文承接上一篇:《主线程今天不接客》

断网警报响起时,URL 的请求车已经冲进隧道。

HTTP 抱着急救箱坐在副驾驶,嘴里还叼着半块饼干。

“目标,服务器。任务,取回首页。”

“前面没网了!”URL 猛踩刹车。

车轮擦出一串火星。

隧道尽头的信号灯全部熄灭。TCP 车库拉下卷帘门,服务器方向只剩一片黑。

HTTP 把饼干咽了下去:“掉头。连接都建不起来,还取什么首页?”

URL 刚要挂倒挡,路边一间小岗亭突然亮起橙色的灯。

门被踹开。

一个穿长风衣的年轻人冲到路中央,左手举停车牌,右手拎着一只纸箱。

“停车!这份请求由我处理。”

HTTP 从车窗里探出头:“你谁?”

“Service Worker。”

“服务器的人?”

“浏览器的人。”

“那你凭什么替服务器回话?”

Service Worker 从纸箱里抽出一份首页响应,拍在挡风玻璃上。

“因为上次有网时,我留了一份。”

URL 看了看黑掉的隧道,又看了看响应:“这也行?”

“不但行,还不用掉头。”

HTTP 一把按住响应:“等等。请求还没出浏览器,怎么先有结果了?你这叫半路拦车。”

“说得不够专业。”Service Worker 整了整风衣,“请叫我网络代理。”

“无证拦车也能叫代理?”

两人正要隔着车窗动手,浏览器老大赶到,一脚踩住掉在地上的停车牌。

“都松手。先查证件。”

亲戚也不能串岗

Web Worker 也从计算分部跑来看热闹。

“亲戚!”

Service Worker 瞥了一眼那顶毛线帽:“上班第一天就想闯 DOM 工地的那个?”

“家丑不必跨部门传播。”

浏览器老大把两张工作牌并排放在车头。

Web Worker 的牌上写着:

接收页面消息 -> 后台计算 -> 发回结果

Service Worker 的牌上写着:

等待事件 -> 处理请求 -> 决定响应从哪里来

HTTP 用指关节敲敲第二张牌:“所以它不负责算五百套折扣?”

“能算,不代表该这么用。”浏览器老大说,“Web Worker 通常由页面创建,替页面承担持续计算。Service Worker 则要先由页面向浏览器注册,主要在页面和网络之间处理 fetch 等事件。”

Web Worker 往岗亭里看了一眼:“连张床都没有。晚上住哪?”

“不住。”Service Worker 说,“有事件时浏览器把我叫醒,事情办完,什么时候让我休息也由浏览器决定。”

“那你存在柜子里的全局变量——”

“可能下次醒来就没了。”

“咖啡呢?”

“喝快点。”

浏览器老大敲了敲岗亭:“Service Worker 不是常驻后台进程。需要长期保存的数据,放 Cache StorageIndexedDB,别指望靠内存里的全局变量熬过下一次唤醒。”

HTML 解析器抱着一棵 DOM 小树赶来:“这位能帮我改页面吗?”

两名 Worker 同时往后退了一步。

“不能。”

“终于有一条家规记得很牢。”

证件呢?

HTTP 仍然不肯放开那份响应。

“工作内容写得挺漂亮。谁批准的?”

浏览器老大从风衣内侧抽出一份入职档案:“证件在这里。三天前,这家商店第一次申请网络岗亭。”

浏览器老大按下回放键,隧道里的断网警报迅速倒退。画面停在一个网络正常的下午。

JavaScript 老大正对着登记台提交申请:

if ("serviceWorker" in navigator) {
    navigator.serviceWorker.register("/sw.js", {
        scope: "/"
    });
}

登记员先看页面地址,又看脚本地址,直接把第一份申请推了回来。

页面:http://shop.example.com
结果:拒绝

JavaScript 老大拍桌子:“文件、路径、括号,一个没错!”

登记员指了指墙上的警示牌:“能拦请求,也能改响应,权限这么大,还想在明文 HTTP 上入职?”

浏览器老大在申请表上盖下红章:

仅限安全上下文

页面换成 HTTPS 后,申请终于通过。

“本地开发时,localhost 等可信的本地地址通常也能用。”登记员补了一句,“正式上路,走安全连接。”

登记员又把页面地址和 /sw.js 并排盖章:“注册脚本还得和页面同源。网络路口的总岗亭,不能从隔壁网站临时雇一个人来守。”

JavaScript 老大又把脚本挪进 /workers/sw.js,却仍然申请管理整个网站。

navigator.serviceWorker.register("/workers/sw.js", {
    scope: "/"
});

登记员看完,第二次把表退了回来。

“这回又怎么了?”

“岗亭修在 /workers/,默认最多管这条路径下面的页面。想管根目录,要么把脚本放到更高的位置,要么由服务器通过 Service-Worker-Allowed 明确扩大范围。”

Service Worker 在地上展开一张地图,用粉笔圈出自己的辖区。

scope 决定哪些页面可以成为受控页面。圈外页面的车,不能因为我嗓门大就拦。”

URL 举手:“那受控页面请求一张跨源图片呢?”

“也可能经过我的 fetch 岗亭。”Service Worker 说,“辖区看的是发起请求的客户端页面,不是只看请求目的地。但别打歪主意,我不能借此拆掉 CORS,也不能凭空获得跨源响应的读取权限。”

浏览器海关在远处抬起头:“谁要拆我?”

“没人。”四个人异口同声。

装好了还不让上?

注册通过,Service Worker 拎包走进安装室。

门外摆着一排准备离线使用的资源:

const STATIC_CACHE = "shop-static-v1";

self.addEventListener("install", (event) => {
    event.waitUntil(
        caches.open(STATIC_CACHE).then((cache) => {
            return cache.addAll([
                "/",
                "/offline.html",
                "/assets/app.8d2c.js",
                "/assets/app.19aa.css"
            ]);
        })
    );
});

安装员数了数:“首页、离线页、脚本、样式。四件。”

Service Worker 已经把一只脚迈出安装室:“那我上岗了。”

安装员拽住风衣后摆:“回来。waitUntil() 里的工作还没完成。”

“开个仓库而已。”

话音刚落,搬运工抬来一张 404 回执。

/assets/app.19aa.css:未找到

整批预缓存当场失败,安装室上方亮起红灯。

Service Worker 的工作牌被收了回去。

“少一张样式而已,让我先拦点别的。”

“不行。”安装员说,“传给 install 事件 waitUntil() 的 Promise 被拒绝,这一版安装失败。否则你承诺离线可用,仓库里却缺关键零件。”

JavaScript 老大修正文件名,再次提交。四份资源全部入库,红灯终于变绿。

Service Worker 冲向岗亭,又被浏览器老大拦住。

“还来?”

“安装成功,接下来是激活。”

“激活完总能接管刚才那个页面了吧?”

“默认也不能。”

Service Worker 沉默了三秒。

“这份工作最主要的内容,是等待吗?”

浏览器老大把刚打开页面时的事故记录扔给他:“这个页面导航进来时,还没有 Service Worker。为保证一次页面加载使用一致的控制方式,通常要等下一次导航,页面才会受你控制。”

JavaScript 老大按下刷新。

这一次,页面和后续资源请求都经过了岗亭。

“也可以在激活后调用 clients.claim(),主动接管辖区内尚未受控的已打开页面。”浏览器老大说,“但页面前半段没经过你,后半段突然换规则,未必安全。别把它当成无脑粘贴的入职口号。”

Service Worker 把这句话写在岗亭门上:

能抢,不等于该抢

回放画面暗了下去。

浏览器老大合上入职档案,路口的断网警报仍在响。

HTTP 又检查了一遍响应,终于把手松开:“证件是真的。这次放行。”

URL 抱着缓存首页冲回页面。服务器方向依旧一片漆黑,商店的灯却重新亮了起来。

几分钟后,隧道里的信号也恢复了。

HTTP 理了理白大褂:“离线这次算你赢。接下来查查有网时,你到底怎么拦车。”

先开仓,还是先上路?

网络恢复后的第一辆车,是一份脚本请求。

岗亭里的 fetch 警铃响起。

Service Worker 抬起栏杆,又立刻放下。

self.addEventListener("fetch", (event) => {
    if (event.request.method !== "GET") {
        return;
    }

    if (event.request.mode === "navigate") {
        event.respondWith(networkFirst(event.request));
        return;
    }

    const url = new URL(event.request.url);

    if (
        url.origin === self.location.origin &&
        url.pathname.startsWith("/assets/")
    ) {
        event.respondWith(cacheFirst(event.request));
    }
});

HTTP 把车门拍得砰砰响:“又拦!这次网络明明是好的。”

“网络好,也不代表每件货都必须重新跑一趟。”

Service Worker 打开两条通道。

左边写着:

cache first

右边写着:

network first

带内容哈希的脚本和样式被送进左边。

Service Worker 先查仓库。命中就直接交货;没有,再放行去网络,回来后留一份副本。

async function cacheFirst(request) {
    const cache = await caches.open(STATIC_CACHE);
    const cached = await cache.match(request);

    if (cached) {
        return cached;
    }

    const response = await fetch(request);

    if (response.ok) {
        await cache.put(request, response.clone());
    }

    return response;
}

HTTP 盯着 clone():“为什么复印一份?”

“响应体是流。原件要交给页面消费,入库需要另一份。”

“你这个仓库会自动听 Cache-Control 的安排吗?”

“别把 Cache Storage 和 HTTP 缓存混在一起。”Service Worker 把仓库钥匙转了一圈,“这里存什么、何时读取、何时更新,由代码明确决定。不会因为名字里都有 Cache,就自动替开发者选好策略。”

页面导航则被送进右边。

Service Worker 先尝试网络,拿到新页面便更新备份;网络失败,才去找旧页面或离线提示。

const PAGE_CACHE = "shop-pages-v1";

async function networkFirst(request) {
    const cache = await caches.open(PAGE_CACHE);

    try {
        const response = await fetch(request);

        if (response.ok) {
            await cache.put(request, response.clone());
        }

        return response;
    } catch {
        return (
            (await cache.match(request)) ||
            (await caches.match("/offline.html"))
        );
    }
}

URL 左看右看:“为什么不全部优先走缓存?快啊。”

“为什么不全部优先走网络?新啊。”HTTP 立刻反驳。

“所以你们两个不适合当调度员。”Service Worker 把两人推出岗亭,“先看运的是什么,再决定先开仓库还是先去服务器。”

争论刚结束,屏幕外又滑进来一块新路牌:

/api/products -> cache first

Service Worker 抬头:“谁加的?”

“商品接口也走缓存,打开最快。”开发者的声音从屏幕外传来。

HTTP 敲了敲路牌:“价格变了呢?”

“鞋价又不会天天变。”

Service Worker 犹豫了一下,还是把路牌挂到了左边通道。

699 怎么还在?

三天后,运营把一双鞋从 699 元降到 399 元。

主人打开页面,鞋子仍然卖 699 元。

投诉单像雪片一样飞进浏览器总部。

运营跟在投诉单后面冲进来,把后台价格表拍在 Service Worker 桌上:“服务器上明明是 399,页面为什么还敢卖 699?”

HTTP 立刻翻开运输记录。

没有超时,没有报错,甚至没有这次商品接口的出车记录。

HTTP 把空白记录转向 Service Worker:“这辆请求车根本没出过隧道。你在路口把它截了?”

Service Worker 翻开规则簿:“商品接口,cache first。命中旧响应,按章办事。”

旧商品响应从仓库探出头:“三天没人来问过我是不是过期。我以为自己表现稳定。”

HTTP 气得白大褂都鼓了起来:“缓存不是越多越好。你把错误策略执行得这么快,事故来得也很快。”

Service Worker 不服:“我只提供决定权,不替人决定业务能忍受多旧的数据。”

HTTP 一把扯下 /api/products 的路牌:“带内容哈希的脚本换了内容,地址也会跟着换。鞋子从 699 降到 399,请求地址可没改。旧响应准备靠闻味道发现降价?”

Service Worker 没接话,默默把商品接口移到 network first 通道,又在离线数据旁挂上“内容可能不是最新”的提示。

开发者在屏幕外小声抗议:“可 cache first 快。”

“错得也快。”HTTP 说。

旧商品响应被移出常用仓库时,还在小声抗议:

“699 也不是不能卖……”

HTTP 抬脚把门关上。

旧版凭什么不走?

几天后,sw.js 有了变化。

浏览器发现脚本内容不同,带回一名新版 Service Worker。

新版穿着更长的风衣,身后跟着一支搬运队。新的脚本、样式和离线页整整齐齐进入 shop-static-v2

安装员盖章:“安装成功。”

新版昂首走向网络岗亭。

门没开。

里面坐着旧版。

“劳驾,交班。”新版敲了敲窗。

旧版捧着咖啡:“没到点。”

“新缓存已经装好。”

“两个购物页面还归我管。”

“你的脚本都旧了。”

“它们的页面也是按旧版加载的。”

新版转身找浏览器老大投诉:“它都闲得在杯底画地图了,凭什么占着 active 岗位?”

浏览器老大调出登记表:

旧版:active
新版:installed, waiting
受控页面:2

“Service Worker 空闲时,执行环境可以被停掉,但旧版和这些页面的控制关系还在。需要处理事件时,浏览器仍会按旧版规则把它叫醒。”

新版指着两张页面登记卡:“让它们刷新。”

第一张页面刷新了。

旧页面尚未完全离场,新导航已经来取响应,两张登记卡在交接窗口撞到一起。

第二张页面从角落探出头:“我还开着呢。”

新版的脸色越来越难看。

“正常情况下,要等旧版不再控制任何客户端,我才能激活?”

“对。关掉相关标签页、让页面导航离开,等旧控制关系真正结束。”

旧版冲新版举了举咖啡:“不是我不想走,是它们不肯关。”

页面立刻反驳:“购物车填了一半,凭什么赶人?”

新版一拍等候室的桌子:“那我就在这里看你们买到明年?”

抢班抢出事故了

新版从风衣里抽出一张加急命令:

self.skipWaiting();

旧版的咖啡还没放下,新版已经越过等候室,强行进入激活流程。

“看见没有?”新版接过岗亭钥匙,“等待不是不能跳过。”

旧版被挤出门外:“年轻人,先看页面版本。”

新版没听见,转身清理旧仓库。

shop-static-v1 被整间推走,只留下 shop-static-v2

恰在这时,一张仍按旧版脚本运行的购物页面点下“结算”。

页面伸手索要延迟加载的文件:

/assets/checkout.v1.js

新版翻遍 v2 仓库。

没有。

隧道偏偏又断了网。

结算按钮当场躺平。

页面冲进岗亭:“我的文件呢?”

“新文件叫 checkout.v2.js。”

“可我是 v1 页面!”

“现在归 v2 管。”

“谁同意的?”

新版默默把 skipWaiting() 藏到身后。

浏览器老大没有解释,先把被推走的 v1 仓库拖了回来。

“重新演一遍。页面,你刚才变过吗?”

“没有。我从打开到现在,一直是 v1。”

“岗亭呢?”

新版低声说:“中途换成了 v2。”

“仓库呢?”

“只剩 v2。”

浏览器老大指了指躺平的结算按钮:“三套东西,两个版本。现在知道旧版为什么不肯立刻让位了?”

新版看着一地碎片:“它不是舍不得工作牌,是在等旧页面把这一班走完。”

旧版在门外咳了一声:“这句话请刻在岗亭正面。”

“那我永远不能跳过等待?”

“能。”浏览器老大把 skipWaiting() 从新版身后抽出来,“先回答一个问题:旧页面突然归你管,会不会出事?”

新版看了一眼结算按钮。

结算按钮把自己往墙角挪了挪。

“这次会。”

页面还没点头呢

第二次交班,新版老老实实坐进等候室。

门外的购物页面收到一张通知:

新版本已准备好,是否刷新更新?

页面看了看填到一半的地址:“不更。”

新版隔着玻璃跳起来:“我连新离线页都熨平了!”

“地址填完再说。”

“只要一下!”

“上一个说‘只要一下’的,把我的结算按钮弄死了。”

新版坐了回去。

主人保存好购物车,终于点下“更新”。JavaScript 老大这才把交班通知送进等候室:

updateButton.addEventListener("click", () => {
    registration.waiting?.postMessage("PLEASE_ACTIVATE");
});

新版拆开通知,取出那张一直想用的命令。

self.addEventListener("message", (event) => {
    if (event.data === "PLEASE_ACTIVATE") {
        self.skipWaiting();
    }
});

这一次,页面没有骂人。

旧版交出岗亭钥匙,走出网络路口。Service Worker 拿着钥匙进入激活室,JavaScript 老大则在页面门口挂好交接铃:

navigator.serviceWorker.addEventListener(
    "controllerchange",
    () => location.reload()
);

页面盯着铃铛等了半天,一点动静也没有。

“钥匙不是已经交了吗?”

浏览器老大指了指激活室:“人还没出来。旧仓没清完之前,这次交班就不算结束。”

等等,铲错了!

Service Worker 开来铲车,准备在激活时把旧缓存全部推走。

铲斗刚举起来,三扇卷帘门同时打开。

shop-static-v1
shop-pages-v1
photo-editor-thumbnails

Service Worker 扫了一眼:“旧的,全清。”

“你敢!”

第三间仓库里冲出一群缩略图,抱住铲车轮胎。

“我们是同一个源下那套图片编辑器的!”

Service Worker 愣住:“怎么会住在我的 Cache Storage 里?”

“谁说是你的?”缩略图们齐声喊,“同一个源上的仓库都在这片库区!”

浏览器老大从铲斗底下抢回操作杆:“清带 shop- 前缀的旧版本。别人的名字看不懂,就别碰。”

Service Worker 只好重新填写清理单:

const CURRENT_CACHES = new Set([
    "shop-static-v2",
    "shop-pages-v2"
]);

self.addEventListener("activate", (event) => {
    event.waitUntil(
        caches.keys().then((names) => Promise.all(
            names
                .filter((name) => {
                    return name.startsWith("shop-") &&
                        !CURRENT_CACHES.has(name);
                })
                .map((name) => caches.delete(name))
        ))
    );
});

清仓时,请求车在岗亭外按起了喇叭。

“怎么还不开门?”

Service Worker 伸手去抬栏杆,浏览器老大用操作杆敲了敲 event.waitUntil()

“新仓还没核对完,旧仓正在拆。现在放车进去,准备让它停在哪堆砖下面?”

Service Worker 只好继续等。

最后一间旧仓关闭,激活工作完成,控制者变更的铃声终于响了。

JavaScript 老大立刻让页面重新导航。请求车第一个冲进新岗亭,页面带着 v2 的脚本、v2 的样式和 v2 的控制规则重新回来,结算按钮原地蹦了两下。

“还活着。”

Service Worker 把钥匙挂到腰间:“原来 skipWaiting() 不是不能用,是不能闭着眼睛用。”

缩略图们冲 Service Worker 做了个鬼脸,推着自家仓库走了。

岗亭怎么没了?

浏览器老大重新拉下断网闸,又推进来一台刚拆封的测试机。

“最终验收。第一项,新设备首次访问。”

URL 在新机器里输入商店地址,一脚油门冲向网络路口。

前方没有亮灯。

没有栏杆。

连岗亭都没有。

URL 在黑暗里转了一圈:“Service Worker 呢?”

Service Worker 隔着原来那台机器的玻璃拼命挥手:“我在这里!”

“那是另一台机器。”HTTP 把 Service Worker 的手按了下去,“新设备以前没打开过商店,页面没机会注册你,安装室也没机会往本地仓库放资源。”

Service Worker 还想争辩:“现在注册不行吗?”

HTTP 指了指漆黑的隧道:“连页面和 /sw.js 都取不回来,谁替你提交注册?”

测试机上的 URL 等了半天,只能空车回来。

浏览器老大在第一项后面画了一个叉。

“第一次访问就断网,Service Worker 救不了。下一项,已经受控的页面访问一份没缓存过的资源。”

镜头切回原来的购物页面。

主人点开一件从未访问过的商品。请求车顺利停在 Service Worker 的岗亭前。

这次有岗亭,也有人值班。

Service Worker 拉开仓库门。

对应货架是空的。

Service Worker 又把请求放向网络,隧道里仍然没有信号。

URL 趴在栏杆上:“有岗亭也不行?”

“我只能交出存过的响应,或者提前准备好的离线提示。”

Service Worker 从角落取出 /offline.html,递给页面。

页面总算没有一片空白,却也没能显示那件商品。

HTTP 在两份验收记录中间画了一条线:

从未安装:没有岗亭
已经接管但没有资源:仓库没货

Service Worker 盯着那条线看了一会儿,撕掉墙上“离线什么都能开”的广告。

这钱你也敢收?

主人把一双鞋加入购物车,点击付款。

一辆请求车停在岗亭前:

POST /api/orders

Service Worker 看了看断网灯,准备把请求推进 Cache Storage。

按照岗亭原来的规则,非 GET 请求本该直接放行。可隧道已经断了,放行也只会撞进黑暗里。

仓库门“砰”地弹开,把车顶了回来。

只接收 GET 请求

Service Worker 揉着鼻子:“要不先回一个付款成功?网络恢复后再补订单。”

HTTP 一把夺过响应纸:“你敢替服务器把钱收了?”

“先让页面高兴一下。”

“库存谁扣?”

“以后扣。”

“身份谁验?”

“以后验。”

“网络恢复后鞋卖完了呢?”

Service Worker 不说话了。

JavaScript 老大推来一只写着 IndexedDB 的保管箱:“确实需要离线提交,可以先把待办数据放这里。但页面必须说清楚——只是等待提交,不是已经成功。”

保管箱接过订单,贴上一张黄色纸条:

待发送

HTTP 又补了一张:

重试时小心重复订单

Service Worker 盯着两张纸条:“离线买双鞋,怎么比在线还多两道手续?”

“因为服务器不在。”HTTP 把“付款成功”的假响应撕成两半,“你可以保管请求,不能冒充结果。”

仓库还能被拖走?

验收快结束时,库区忽然响起倒车提示音。

一名存储管理员开着清理车进来,在整片库区门口挂上“存储压力清理演练”的牌子,又把拖车绳套在大门上。

Service Worker 扑过去:“里面全是我的离线库存!”

“假设整个浏览器都在喊空间不够。”存储管理员递出一张容量表,“你拿什么保证这些仓库永远留在设备上?”

“我已经存进 Cache Storage 了。”

“Cache Storage 占的还是设备空间。空间紧张,或者主人主动清理站点数据,这片库区都可能保不住。”

Service Worker 抱住仓库大门:“至少每次都提前通知我?”

存储管理员没有回答,收起拖车绳,在容量表的“无法保证”一栏打了勾。

HTTP 在验收表上写下最后一句:

缓存是退路,不是承诺

Service Worker 看了很久,拿笔把“不是承诺”划掉,改成了四个字:

不是服务器

“这回服了?”HTTP 问。

“只服一半。”Service Worker 把那份缓存首页递给 URL,“服务器不在时,至少这条走过的路,我还能留一盏灯。”

HTTP 接过首页,检查一遍,终于放行。

“行。灯可以亮。”

“车也可以拦?”

“按规则拦。”

“成交。”

服务器终于急了

隧道外的信号灯重新亮起。

积压的请求车一辆接一辆驶向服务器。商品页为了拿到新消息,每隔几秒就派 URL 跑一趟。

“有新订单吗?”

“没有。”

三秒后。

“有新订单吗?”

“没有!”

又三秒。

“现在呢?”

服务器终于拍桌子。

“每次都得等页面先问,我才能回答?”

HTTP 理了理白大褂:“请求在前,响应在后。一直是这个规矩。”

“可我有新消息时,为什么不能主动开口?”

服务器从墙里拽出一根长长的双向线路,扔到 TCP 老司机面前。

“给我接上。”

TCP 老司机没动,先从抽屉里拿出一摞表格。

最上面一张写着:

长连接保活、断线重连与消息积压登记表

服务器的眼皮跳了一下。