渐进式Web应用(PWA)之所以能像原生应用一样离线可用、可被安装到主屏,关键在于两套JavaScript机制:一是Service Worker提供的后台网络代理与缓存控制,二是Web App Manifest驱动的展示与安装行为。本文从零梳理在不借助任何构建框架的情况下,如何用原生JavaScript写出PWA的核心逻辑。

一、Web App Manifest的配置与关联
Manifest是一个JSON文件,用来告诉浏览器该Web应用的名称、图标、启动样式等信息。虽然它不属于JavaScript执行逻辑,但必须由页面通过link标签引入,否则系统不会弹出安装提示。我们通常在HTML头部写入如下关系:
注意这里提到的<link>标签是文档元信息声明,不是脚本,也不是函数调用。它的rel属性值固定为manifest,href指向json文件路径。当浏览器检测到合规manifest且已注册Service Worker,才会在地址栏显示安装图标。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <link rel="manifest" href="/manifest.json"> <title>简易PWA示例</title> </head> <body> <script src="/sw-register.js"></script> </body> </html>
对应的manifest内容需要包含name、short_name、icons与start_url等字段。icons至少要提供一张192像素和一张512像素的PNG,否则部分安卓设备会忽略安装请求。start_url建议写成相对路径,避免不同部署子目录导致跳转异常。
{
"name": "我的离线笔记",
"short_name": "笔记",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#3367d6",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
二、Service Worker的注册逻辑
Service Worker本质是一个独立于页面主线程的脚本,它能拦截当前域下的网络请求。注册动作必须在页面脚本里通过navigator.serviceWorker完成,且只能在HTTPS或localhost环境执行。下面的sw-register.js展示了最基础的注册方式。
代码中我们首先判断navigator对象上是否存在serviceWorker属性,避免在不支持的老浏览器中报错。注册成功后,控制台会显示Worker作用域。需要强调的是,register()接收的路径相对于域名根,而非调用脚本所在目录,写错路径会导致作用域受限。
// sw-register.js
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker.register('/sw.js')
.then(function (registration) {
console.log('Service Worker注册成功,作用域为:', registration.scope);
})
.catch(function (error) {
console.log('Service Worker注册失败:', error);
});
});
}
注册本身不缓存任何资源,真正的缓存策略写在sw.js里。分离注册与逻辑文件,有利于后期只更新缓存脚本而不动页面引用。若希望更新立即生效,可在注册时追加updatefound事件监听,但生产环境通常依赖浏览器静默更新机制。
三、Service Worker生命周期与缓存管理
sw.js内部通过监听install、activate和fetch三个事件实现核心控制。install阶段适合预缓存核心静态文件,activate阶段负责删除过期缓存,fetch阶段决定如何响应请求。以下示例采用缓存优先并回退网络的策略。
在install中使用event.waitUntil()保证缓存操作完成前Worker不进入下一阶段。cache.addAll()接受的URL数组必须全部请求成功,只要一个404就会让安装失败,因此建议只放必定存在的骨架文件。activate中遍历所有缓存名,剔除非当前版本号缓存,防止存储空间无限增长。
// sw.js
const CACHE_NAME = 'pwa-cache-v1';
const PRECACHE_URLS = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./icon-192.png'
];
self.addEventListener('install', function (event) {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function (cache) {
return cache.addAll(PRECACHE_URLS);
})
);
});
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
keys.filter(function (key) {
return key !== CACHE_NAME;
}).map(function (key) {
return caches.delete(key);
})
);
})
);
});
这种版本号命名方式(pwa-cache-v1)让每次发布只需改常量即可触发旧缓存清理。如果项目迭代频繁,可以把版本写入构建变量,由服务端模板注入,避免手动维护失误。activate之后旧Worker才会停止,因此用户第二次访问才能拿到新缓存。
四、fetch事件的请求拦截策略
fetch监听是PWA离线能力的核心。每当页面发起请求,Worker都会先收到事件。下面代码演示缓存优先逻辑:先查缓存,命中则返回,未命中再走网络并把响应克隆存入缓存,保证下次离线可用。
注意response.clone()的使用,因为响应流只能读取一次,直接返回的同时无法再写缓存。对于HTML导航请求,也可改为网络优先、失败回退缓存的策略以提升实时性。若请求是POST或非同源,通常直接放行,不参与缓存,避免数据错乱。
self.addEventListener('fetch', function (event) {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
caches.match(event.request).then(function (cached) {
if (cached) {
return cached;
}
return fetch(event.request).then(function (response) {
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
var responseToCache = response.clone();
caches.open(CACHE_NAME).then(function (cache) {
cache.put(event.request, responseToCache);
});
return response;
}).catch(function () {
// 离线且缓存未命中时返回兜底页面
return caches.match('./index.html');
});
})
);
});
实际项目中,可针对API请求与静态资源使用不同缓存桶。例如图片走缓存优先,接口走网络优先并短期缓存。通过解析event.request.url或destination属性做分支,能显著改善弱网体验,也不会让过期数据长期滞留。
五、调试与更新注意事项
开发时打开Chrome开发者工具的Application面板,可查看Service Worker状态、缓存存储及Manifest解析结果。若修改了sw.js,需手动点击SkipWaiting或重新加载触发更新。由于缓存优先,页面逻辑更新后若未升版本号,用户可能长期看不到变化。
另一个常见误区是在fetch里缓存了跨域字体或脚本却未校验response.type,导致opaque响应无法读取状态而缓存失败。建议对跨域资源单独命名缓存,并在服务器端配置正确的CORS头。只要遵循生命周期与缓存隔离原则,原生JavaScript就能支撑起稳定的PWA核心逻辑。
PWAservice_workermanifest_json修改时间:2026-08-05 18:06:35