为什么有些网站可以像App一样离线用?

地铁里信号差,朋友圈刷不出来?不对------为什么我刚才进站前刷过的新闻,还能继续看?飞机上没网,为什么有些App还能用?

答案是一个叫 Service Worker 的技术。但与其直接讲技术,不如用一个生活场景来说清楚:

预制菜。

你下班前把菜放进微波炉,定时加热。到点了直接吃,不用现做。离线用的网页就像预制菜------提前准备好内容,等你需要的时候直接"加热"给你,完全不需要厨师(服务器)在场。


原文地址

墨渊书肆/为什么有些网站可以像App一样离线用?


先说说为什么以前的网页不行

普通网页的逻辑很简单:

md 复制代码
你想看新闻 → 浏览器发请求 → 服务器返回内容 → 显示

没网的时候,浏览器发出去请求,收到的只有"无法连接"四个字。内容?对不起,服务器连不上,什么都没法看。

问题出在哪?普通网页完全依赖厨师------厨师下班了(服务器挂了),你就没得吃(网页一片空白)。

Service Worker 改变了这个逻辑。它不是在网页里跑,而是常驻在后厨。即使厨师不在(没网),它也能从冰箱里把提前备好的菜端出来给你。


Service Worker 是什么

它本质上是一个独立于网页的代理层,夹在浏览器和网络之间。

yaml 复制代码
没有Service Worker:
浏览器 → 发请求 → 网络 → 服务器

有Service Worker:
浏览器 → Service Worker拦截
              ├── 查缓存 → 直接返回(没网也能)
              └── 没有缓存 → 发网络请求 → 服务器

它能干三件普通 JavaScript 干不了的事:

1. 拦截请求 ------ 网络请求从它手里过,它决定放行还是返回缓存 2. 持久存储 ------ Cache Storage,容量比 LocalStorage 大得多 3. 后台推送 ------ 不用打开网页也能收到通知

唯一限制:只能在 HTTPS 下使用(localhost 除外)。因为它能拦截所有请求,权限太大,必须是安全环境才给开。


Service Worker 的生命周期:三步走

理解 PWA 的核心就是理解 Service Worker 的生命周期。

第一步:安装------把菜备好

Service Worker 文件被浏览器下载解析成功后,触发 install 事件。这就像厨师上班后先备菜------把核心食材准备好。

这个阶段通常用来预缓存内容。把页面的核心资源(HTML骨架、CSS、JS图标)提前存起来,这样即使服务器挂了,网页的"壳"还能用。

js 复制代码
const CACHE_NAME = 'news-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js', '/logo.png'];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(ASSETS))
  );
});

event.waitUntil 是关键------它告诉浏览器"等我把菜备齐了,再告诉我准备好了"。如果中途出错,Service Worker 直接进入错误状态,不会激活。

第二步:激活------交接班

旧版 Service Worker 在控制着页面,新版下载好了但不会立即生效------要等所有用旧版的页面都关闭后,新版才接管。这就像餐厅交接班:等最后一桌客人走了,才能换班。

激活阶段通常做清理旧缓存的工作。新版上线了,旧食材该扔掉就扔掉。

js 复制代码
self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys().then(names =>
      Promise.all(
        names.filter(n => n !== 'news-v1').map(n => caches.delete(n))
      )
    )
  );
});

这一步不做的话,旧缓存会越堆越多,既占空间,又可能导致加载过期内容。

第三步:干活------接单出餐

激活之后,Service Worker 正式上岗。每次页面发请求,都会触发 fetch 事件------它来决定:走缓存还是走网络?

js 复制代码
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      if (cached) return cached;
      return fetch(event.request);
    })
  );
});

这就是最基础的策略------Cache-First(缓存优先):先看看冰箱里有没有,有就直接上;没有再去做。


三大缓存策略:什么时候走缓存,什么时候去现做

Service Worker 的核心能力就是在这两种方式之间做选择。不同场景有不同的最优策略。

策略一:Cache-First(缓存优先)

思路:先看冰箱,有就直接吃;没有再去做,顺便存一份。

适合场景:静态资源(CSS、JS、图片、字体)。这些基本不变,存一份能用很久。

md 复制代码
请求来了 → 查缓存
              ├── 有 → 直接返回(秒开)
              └── 无 → 发请求 → 返回 + 写入缓存

这个策略的代价是:万一内容更新了,用户可能一直吃的是旧菜 。解决方案是给缓存加版本号(news-v1news-v2),新版本发布时主动清除旧缓存。

策略二:Network-First(网络优先)

思路:优先去买新鲜食材现做,买不到(没网)就从预制菜里凑合一顿。

适合场景:频繁变化的数据(新闻列表、个人信息)。你总想看到最新的,旧的不太介意。

js 复制代码
self.addEventListener('fetch', event => {
  if (event.request.url.includes('/api/')) {
    event.respondWith(
      fetch(event.request)
        .then(response => {
          const clone = response.clone();
          caches.open('api-cache').then(cache => cache.put(event.request, clone));
          return response;
        })
        .catch(() => caches.match(event.request))
    );
  }
});

断网了?这个策略天然降级------网络走不通就回退到缓存。

策略三:Stale-While-Revalidate(过期更新)

思路:先吃预制菜垫肚子,同时去买新鲜食材,下一顿再吃新的。

适合场景:既想要速度快,又不想内容太过时的场景。这是用户体验最好的策略,很多 CDN 默认就用这个思路。

yaml 复制代码
请求来了 → 立刻返回缓存(不用等)
             同时发网络请求 → 用来更新缓存

用户感受到的是"秒开",内容在后台悄悄更新好了,下次访问就是新的。

三种策略对比

策略 速度 新鲜度 离线能力 适合什么
Cache-First 最快 可能旧 ✅ 强 CSS、JS、字体、图片
Network-First 取决于网 总是最新 一般(降级) 频繁变化的数据
Stale-While-Revalidate 快(缓存) 最终一致 ✅ 强 经常访问但偶尔更新的内容

实战中通常是混合策略 :静态资源 Cache-First,接口数据 Stale-While-RevalidateNetwork-First


App Shell:预制菜的"餐具"

App ShellPWA 的性能关键。

它的思路是:把餐具和桌子先摆好,再上菜。页面的"壳"(骨架)先缓存起来,用户一打开就看到内容,而不是盯着白屏等加载。

yaml 复制代码
第一次访问:
  用户打开网页 → 骨架先显示 → API数据加载 → 填满骨架

Service Worker缓存了:
  App Shell(HTML/CSS/JS骨架)
  骨架渲染 → 秒开
  数据再填充

第二次访问(即使没网):
  用户打开网页 → 骨架秒开 → 数据从缓存来 → 正常显示

就像去餐厅:桌椅餐具(App Shell)早就摆好了,你坐下菜就能上;不用等厨房先把桌子搬出来。


Web App Manifest:让网站"装"到桌面

Service Worker 解决了离线可用,Manifest 解决的是让网站看起来像个App

加一个 manifest.json

json 复制代码
{
  "name": "离线新闻",
  "short_name": "新闻",
  "start_url": "/",
  "display": "standalone",
  "icons": [
    { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
    { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
  ]
}

在 HTML 里引入:

html 复制代码
<link rel="manifest" href="/manifest.json">

搞定之后,用户在浏览器里会看到"添加到主屏幕"的选项。添加到桌面后,点开------没有地址栏,没有浏览器按钮,看起来和原生 App 一模一样。

display 字段决定打开方式:

模式 效果
standalone 独立窗口,无地址栏,最像原生 App
minimal-ui 保留少量系统 UI
browser 普通浏览器模式

消息推送:厨师主动敲门

Service Worker 还有一个杀手级能力:后台推送

不用打开 App,厨师也能主动来敲门告诉你菜好了。服务器通过推送服务(比如 Firebase Cloud Messaging)发消息,Service Worker 在后台收到后弹出系统通知。

js 复制代码
self.addEventListener('push', event => {
  const data = event.data ? event.data.json() : {};
  event.waitUntil(
    self.registration.showNotification(data.title || '新消息', {
      body: data.body || '您有一条新通知',
      icon: '/icon-192.png'
    })
  );
});

self.addEventListener('notificationclick', event => {
  event.notification.close();
  event.waitUntil(clients.openWindow('/'));
});

这需要用户授权,而且不同浏览器有不同的推送通道 ------ Chrome 用 FCM,Safari 用 APNs。但对于新闻、社交、效率类 App,这能显著提升用户留存。


工程化:用 Workbox 不用手写

手写 Service Worker 不难,但当项目变大有几十条路由规则时,自己维护一堆缓存逻辑会很痛苦。

Workbox 是 Google 推出的 PWA 开发工具,把各种缓存策略封装成简洁的 API:

js 复制代码
import { registerRoute } from 'workbox-routing';
import { CacheFirst, StaleWhileRevalidate } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

// 静态资源 Cache-First,过期30天
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({ cacheName: 'images', plugins: [new ExpirationPlugin({ maxAgeSeconds: 30 * 24 * 60 * 60 })] })
);

// API 数据 Network-First,断网时降级到缓存
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/'),
  new StaleWhileRevalidate({ cacheName: 'api-data' })
);

不用手写 fetch 监听器,路由匹配、过期清理、并发控制 Workbox 都帮你做好了。生产环境推荐使用。


完整示例:从零实现一个离线新闻App

综合以上所有内容,一个最小可用的离线新闻 PWA 结构:

yaml 复制代码
/app
  ├── index.html
  ├── styles.css
  ├── app.js
  ├── sw.js    ← Service Worker
  └── manifest.json

index.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>离线新闻</title>
  <link rel="manifest" href="/manifest.json">
</head>
<body>
  <h1>新闻列表</h1>
  <div id="news">加载中...</div>
  <script>
    if ('serviceWorker' in navigator) {
      navigator.serviceWorker.register('/sw.js')
        .then(reg => console.log('已注册', reg.scope))
        .catch(err => console.log('注册失败', err));
    }
  </script>
</body>
</html>

sw.js:

js 复制代码
const CACHE = 'news-v1';
const ASSETS = ['/', '/index.html', '/styles.css', '/app.js'];

// 安装时预缓存核心资源
self.addEventListener('install', e => {
  e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS)));
  self.skipWaiting();
});

// 激活时清理旧缓存
self.addEventListener('activate', e => {
  e.waitUntil(
    caches.keys().then(ns => Promise.all(ns.filter(n => n !== CACHE).map(c => caches.delete(c))))
  );
  self.clients.claim();
});

// 干活时根据请求类型走缓存或网络
self.addEventListener('fetch', e => {
  if (e.request.url.includes('/api/')) {
    e.respondWith(
      fetch(e.request)
        .then(r => { caches.open(CACHE).then(c => c.put(e.request, r.clone())); return r; })
        .catch(() => caches.match(e.request))
    );
  } else {
    // 其他请求走缓存或网络
    e.respondWith(
      caches.match(e.request).then(r => r || fetch(e.request))
    );
  }
});

这段代码实现了:

  • 静态资源 Cache-First,秒开
  • API 数据 Network-First,断网时降级到缓存
  • Service Worker 注册后立即接管(skipWaiting + clients.claim
  • 新版本自动清理旧缓存

回到地铁那个场景

现在你应该明白了。进站前刷了新闻,Service Worker 把内容和骨架都存了下来。等地铁进了地下没信号,Service Worker 照常工作------骨架秒开,数据从缓存来,你完全感知不到网络断了。

就像预制菜:提前备好,随时能吃,不需要厨师一直在场。

这才是 PWA 的核心价值------让网页真正变成一个"离线也能用、还能推送、还能装到桌面"的存在。

相关推荐
Rabbit_c4 小时前
前端基于JSON Schema 配置驱动的DSL架构实践
前端
星栈4 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
前端·后端·全栈
anyup4 小时前
uni-app X 全屏引导页组件,一套支持 App、H5、小程序多端引导
前端·架构·uni-app
a1117765 小时前
动森UI组件(开源 html animal-island-ui )
前端·javascript·ui·开源·html
KaMeidebaby5 小时前
卡梅德生物技术快报|真核蛋白表达信号肽筛选实验全流程复盘
服务器·前端·数据库·人工智能·算法
万少5 小时前
万少的 Claude Code 入门教程
前端·人工智能·后端
এ慕ོ冬℘゜6 小时前
JS 前端基础高频面试题
开发语言·前端·javascript
放下华子我只抽RuiKe56 小时前
React 从入门到生产(八):测试与部署
前端·javascript·深度学习·react.js·前端框架·ecmascript·集成学习
蜡笔小电芯6 小时前
【Electron】第2章—BrowserWindow 与 Electron 窗口机制
前端·javascript·electron