使用纯 HTML/JavaScript 构建一个具备离线访问、桌面安装和推送通知功能的博客系统。
极简博客 PWA 开发教程
本教程将指导你使用纯 HTML/JavaScript 构建一个具备离线访问、桌面安装和推送通知功能的博客系统。
1. 项目结构
在你的项目根目录下创建以下文件:
my-pwa-blog/
├── index.html # 博客主页(UI 与 逻辑)
├── manifest.json # PWA 配置文件(决定安装效果)
├── sw.js # Service Worker(处理缓存与离线)
├── icon-192.png # 应用图标 (192x192)
└── icon-512.png # 应用图标 (512x512)
2. service worker 核心代码实现
📄 index.html
注册
注册是 Service Worker 生命周期的初始步骤:
<!DOCTYPE html>
......
<script>
// 1. 注册 Service Worker
// Don't register the service worker
// until the page has fully loaded
window.addEventListener("load", () => {
// Is service worker available?
if ("serviceWorker" in navigator) {
navigator.serviceWorker
.register("/sw.js")
.then(() => {
console.log("Service worker registered!");
})
.catch((error) => {
console.warn("Error registering service worker:");
console.warn(error);
});
}
});
</script>
......
</html>
此代码在主线程上运行,并执行以下操作:
由于用户首次访问网站时没有注册 Service Worker,因此请等待网页完全加载完毕,然后再注册 Service Worker。这样做有助于避免在服务工件预缓存任何内容时出现带宽争用。 虽然 Service Worker 得到了良好的支持,但快速检查有助于避免在不支持 Service Worker 的浏览器中出现错误。 当页面完全加载后,如果支持 Service Worker,请注册 /sw.js。 以下是一些需要了解的关键事项:
Service Worker 仅通过 HTTPS 或 localhost 提供。 如果服务工件的代码包含语法错误,注册会失败,并且系统会舍弃该服务工件。 提醒:Service Worker 在某个作用域内运行。在这里,作用域是整个来源,因为它是从根目录加载的。 注册开始时,服务工件状态会设为 ‘installing’。 注册完成后,系统便会开始安装。
📄 sw.js
安装
服务工件会在注册后触发其 install 事件。每个服务工件只会调用一次 install,并且在更新之前不会再次触发。您可以使用 addEventListener 在 worker 的作用域中注册 install 事件的回调: Service Worker 是 PWA 的灵魂,负责拦截请求并管理缓存。
// 安装阶段
self.addEventListener("install", (event) => {
const CACHE_NAME = "application-v1.0.0"; // 更新版本号 - 每次修改都要递增
const ASSETS_TO_CACHE = [
"/",
"/index.html",
"/manifest.json",
"/db.js",
"/message.html",
"/offline.html", // 必须缓存回退页
];
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(ASSETS_TO_CACHE);
}),
);
});
这会创建一个新的 Cache 实例并预缓存资源。 我们稍后有机会再讨论预缓存,因此现在我们先重点介绍 event.waitUntil 的作用。event.waitUntil 会接受一个 Promise,并等待该 Promise 解析完毕。在此示例中,该 promise 会执行两项异步操作:
创建一个名为 ‘MyFancyCache_v1’ 的新 Cache 实例。 创建缓存后,系统会使用其异步 addAll 方法预缓存一组资源网址。 如果传递给 event.waitUntil 的 promise 被拒绝,安装将失败。如果发生这种情况,系统会舍弃该服务工件。
如果这些 Promise 解析,安装将成功,并且服务工件的状态将更改为 ‘installed’,然后激活。
激活
如果注册和安装成功,服务工件会激活,其状态变为 ‘activating’。您可以在服务工件的 activate 事件中执行激活期间的工作。此事件中的典型任务是修剪旧缓存,但对于全新的服务工件,这目前并不相关,我们将在讨论服务工件更新时对此进行详细介绍。
对于新的服务工件,activate 会在 install 成功后立即触发。激活完成后,服务工件的状态变为 ‘activated’。请注意,默认情况下,新 Service Worker 不会在下次导航或页面刷新之前开始控制页面。
在以下情况下,浏览器会检查服务工件的更新:
用户导航到 Service Worker 的范围内的网页。 使用与当前安装的服务工件不同的网址调用 navigator.serviceWorker.register(),但请勿更改服务工件的网址! 调用 navigator.serviceWorker.register() 时使用的网址与已安装的服务工件相同,但作用域不同。再次提醒,请尽可能将作用域保留在源的根目录中,以避免出现这种情况。 在过去 24 小时内触发了 ‘push’ 或 ‘sync’ 等事件,但暂时不用担心这些事件。
当浏览器导航到服务工件作用域内的新页面时,会自动执行更新检查。
手动触发更新检查 在这种情况下,可以在主线程中触发手动更新:
navigator.serviceWorker.ready.then((registration) => {
registration.update();
});
const CACHE_NAME = "application-v2.0.0";
与前面的第一个 install 事件示例相比,有两点不同:
系统会创建一个键为 ‘MyFancyCacheName_v2’ 的新 Cache 实例。 预缓存的资源名称已更改。
请注意,更新后的服务工件会与之前的服务工件一起安装
观察更新 “/index.html”
navigator.serviceWorker.register("/sw.js").then((reg) => {
reg.installing; // the installing worker, or undefined
reg.waiting; // the waiting worker, or undefined
reg.active; // the active worker, or undefined
reg.addEventListener("updatefound", () => {
// A wild service worker has appeared in reg.installing!
const newWorker = reg.installing;
newWorker.state;
// "installing" - the install event has fired, but not yet complete
// "installed" - install complete
// "activating" - the activate event has fired, but not yet complete
// "activated" - fully active
// "redundant" - discarded. Either failed install, or it's been
// replaced by a newer version
newWorker.addEventListener("statechange", () => {
// newWorker.state has changed
});
});
});
navigator.serviceWorker.addEventListener("controllerchange", () => {
// This fires when the service worker controlling this page
// changes, eg a new worker has skipped waiting and become
// the new active worker.
});
caches.match(event.request):检查请求是否在缓存中。 如果缓存命中:立即返回缓存的响应,提升加载速度。 如果缓存未命中:发起正常的网络请求 fetch(event.request)。
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse; // 缓存命中,直接返回
}
// 缓存未命中,从网络请求
return fetch(event.request);
}),
);
});
// 清理所有缓存
caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name);
});
});
// 或清理特定缓存
caches.delete("old-cache-name");
过时重新验证
第一次请求资源时,从网络中提取资源 将其放在缓存中,并返回网络响应。
对于后续请求,先从缓存中提供资源,然后再“在后台”提供资源, 从网络重新请求该资产并更新资产的缓存条目。
在此日期之后再提交请求 您将收到从网络提取的最后一个版本(在上一步中放入缓存)。
// Establish a cache name
const cacheName = "MyFancyCacheName_v1";
self.addEventListener("fetch", (event) => {
if (event.request.destination === "image") {
event.respondWith(
caches.open(cacheName).then((cache) => {
return cache.match(event.request).then((cachedResponse) => {
const fetchedResponse = fetch(event.request).then(
(networkResponse) => {
cache.put(event.request, networkResponse.clone());
return networkResponse;
},
);
return cachedResponse || fetchedResponse;
});
}),
);
} else {
return;
}
});
设置 Clear-Site-Data 标头
如果发生以下情况,某些浏览器将取消注册某个源的所有 Service Worker: 已设置值为 ‘storage’ 的 Clear-Site-Data 响应标头。 不过,使用此方法需要注意以下几点:
请注意,此操作会清除关联源的所有存储空间。这包括 localStorage、IndexedDB、sessionStorage 和其他存储空间(但不包括源的 HTTP 缓存)。 并非所有浏览器都支持此标头。
导航预加载
导航预加载是“提前发起网络请求,减少等待”; 普通缓存策略是“已有缓存就直接用,减少网络请求”。 两者可以结合使用:导航预加载加速页面加载,缓存策略保障后续请求更快、更稳定。
addEventListener("activate", (event) => {
event.waitUntil(
(async function () {
// Feature-detect
if (self.registration.navigationPreload) {
// Enable navigation preloads!
await self.registration.navigationPreload.enable();
}
})(),
);
});
addEventListener("fetch", (event) => {
event.respondWith(
(async function () {
// Respond from the cache if we can
const cachedResponse = await caches.match(event.request);
if (cachedResponse) return cachedResponse;
// Else, use the preloaded response, if it's there
const response = await event.preloadResponse;
if (response) return response;
// Else try the network.
return fetch(event.request);
})(),
);
});
1. 结构化存储:IndexedDB 数据库
为了存储成千上万篇文章并支持高效检索,我们使用浏览器内置的 IndexedDB。
📄 db.js (数据库管理)
const DB_NAME = "BlogDB";
const DB_VERSION = 1;
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains("posts")) {
db.createObjectStore("posts", { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
// 保存文章
async function savePostsToDB(posts) {
const db = await dbPromise;
const tx = db.transaction("posts", "readwrite");
const store = tx.objectStore("posts");
posts.forEach((post) => store.put(post));
}
// 读取文章
async function getAllPostsFromDB() {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction("posts", "readonly");
const store = tx.objectStore("posts").getAll();
store.onsuccess = () => resolve(store.result);
});
}
📄 offline.html (离线提示页)
<body>
<h1>📡 你目前处于离线状态</h1>
<p>该内容尚未缓存,请检查网络后重试。</p>
<button onclick="window.location.href='index.html'">返回首页</button>
</body>
5. 如何验证进阶功能
- 检查数据库:DevTools -> Application -> IndexedDB -> BlogDB。
- 验证动态缓存:点击一个新资源,断网后刷新,看它是否依然存在。
- 观察骨架屏:在 Network 面板限制网速为 “Slow 3G” 即可看到加载动画。
- 触发离线页:完全断网后访问一个从未打开过的伪造 URL。
Author: NUPIGM
Date: 2025-01-01