PWA DEMO

PWA 开发实战:核心实现与多端适配(PC/移动端/WebView)

PWA(Progressive Web App,渐进式网页应用)并非单一技术,而是整合 Service Worker、Web App Manifest、离线缓存、推送通知等技术的解决方案,旨在让网页具备原生 APP 级的体验(离线可用、添加到桌面、推送通知等)。本文从核心技术实现、多环境适配差异(PC/移动端/移动端WebView)两个维度,梳理完整的 PWA 开发流程与适配要点。

一、PWA 核心概述

1. 核心特性

  • 离线可用:基于 Service Worker 实现资源缓存,断网仍可访问核心内容;
  • 原生体验:通过 Web App Manifest 实现“添加到主屏幕”、全屏显示、自定义图标/主题色;
  • 推送通知:支持系统级推送(部分浏览器受限);
  • 性能优化:结合懒加载、缓存策略提升加载速度。

2. 兼容性核心结论

技术特性 Chrome/Firefox(PC/移动端) Safari(macOS/iOS) 移动端 WebView
Service Worker 完全支持 iOS 14.3+/macOS 11+ Android 支持,iOS 14.3+ 支持
Web App Manifest 完全支持 iOS 15+/macOS 12+ Android 支持,iOS 部分支持
推送通知 完全支持 不支持 Android 支持,iOS 不支持
添加到主屏幕 支持(快捷方式) iOS 15+ 支持 几乎不支持(受 APP 限制)

二、PWA 核心技术实现(完整优化版)

1. 项目目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
demo-pwa/
├── icons/ # 不同尺寸图标(适配多端)
│ ├── icon-32.png
│ ├── ...
│ └── icon-512.png
├── data/
│ └── list.js # 示例数据(游戏列表)
├── img/ # 静态图片
│ ├── logo.png
│ ├── bg.png
│ └── 游戏封面图...
├── fonts/ # 字体文件
├── index.html # 主页面
├── manifest.json # Web App 配置
├── app.js # 注册 Service Worker/通知授权
├── sw.js # Service Worker 核心逻辑
├── page.js # 页面渲染/懒加载
└── style.css # 样式文件

2. 核心文件实现(优化版)

(1)index.html(主页面)

优化点:规范 meta 标签、补充 PWA 必要配置、修复资源引用路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>PWA DEMO</title>
<meta name="description" content="Progressive Web App: PWA DEMO">
<meta name="author" content="csorz">
<!-- PWA 核心:主题色(适配状态栏/导航栏) -->
<meta name="theme-color" content="#B12A34">
<!-- 移动端视口适配 -->
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<!-- 社交分享/图标适配 -->
<meta property="og:image" content="icons/icon-512.png">
<!-- 快捷方式图标 -->
<link rel="shortcut icon" href="favicon.ico">
<!-- 样式文件 -->
<link rel="stylesheet" href="style.css">
<!-- PWA 核心:Manifest 配置 -->
<link rel="manifest" href="manifest.json">
<!-- iOS 添加入主屏幕适配(Safari 兼容) -->
<link rel="apple-touch-icon" href="icons/icon-192.png">
<meta name="apple-mobile-web-app-title" content="PWA">
<meta name="apple-mobile-web-app-status-bar-style" content="#B12A34">
<meta name="apple-mobile-web-app-capable" content="yes">

<!-- 业务脚本(defer 确保DOM加载完成后执行) -->
<script src="data/list.js" defer></script>
<script src="app.js" defer></script>
<script src="page.js" defer></script>
</head>
<body>
<header>
<p><a class="logo" href="//yhorz.cn"><img src="img/logo.png" alt="pwa"></a></p>
</header>
<main>
<h1>Game List</h1>
<p class="description">List of games submitted to the <a href="http://js13kgames.com/aframe">A-Frame category</a> in the <a href="http://2017.js13kgames.com">js13kGames 2017</a> competition. You can <a href="https://github.com/mdn/pwa-examples/blob/master/js13kpwa">fork js13kPWA on GitHub</a> to check its source code.</p>
<button id="notifications">Request dummy notifications</button>
<section id="content"></section>
</main>
<footer>
<p>&copy; js13kGames 2012-2018, created and maintained by <a href="http://end3r.com">Andrzej Mazur</a> from <a href="http://enclavegames.com">Enclave Games</a>.</p>
</footer>
</body>
</html>

(2)manifest.json(Web App 配置)

优化点:补充注释、规范字段、适配 iOS/Safari 兼容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
"name": "PWA DEMO", // 完整应用名(添加到桌面/通知显示)
"short_name": "PWA", // 短名称(主屏幕图标下方显示)
"description": "Progressive Web App: PWA DEMO", // 应用描述
"icons": [ // 多尺寸图标(适配不同设备)
{
"src": "icons/icon-32.png",
"sizes": "32x32",
"type": "image/png"
},
{
"src": "icons/icon-64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "icons/icon-96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "icons/icon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "icons/icon-168.png",
"sizes": "168x168",
"type": "image/png"
},
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable any" // 适配安卓面具图标(圆角裁剪)
},
{
"src": "icons/icon-256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "/demo-pwa/index.html", // 启动入口(添加入主屏幕后打开的页面)
"display": "standalone", // 显示模式:standalone(独立窗口,类似原生APP)
// display 可选值:
// - fullscreen:全屏;
// - standalone:独立窗口(无浏览器地址栏);
// - minimal-ui:极简UI(保留部分浏览器控件);
// - browser:普通浏览器模式。
"theme_color": "#B12A34", // 主题色(状态栏/导航栏)
"background_color": "#B12A34", // 启动画面背景色
"orientation": "portrait-primary", // 首选方向(竖屏)
"scope": "/demo-pwa/" // PWA 作用域(限制SW控制的路径)
}

(3)data/list.js(示例数据,补充缺失依赖)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 游戏列表数据(page.js/app.js 依赖的 games 数组)
const games = [
{
slug: "aframe-demo",
name: "A-Frame Demo",
author: "csorz",
twitter: "csorz",
website: "yhorz.cn",
github: "github.com/csorzhub"
},
{
slug: "webvr-game",
name: "WebVR Game",
author: "mdn",
twitter: "mdn",
website: "developer.mozilla.org",
github: "github.com/mdn"
}
];

(4)app.js(注册 Service Worker/通知授权)

优化点:补充错误处理、兼容iOS、优化通知逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
/* eslint-disable */
// ========== 1. 注册 Service Worker ==========
// 兼容性检测 + 错误处理
if ('serviceWorker' in navigator) {
// 页面加载完成后注册(避免阻塞主线程)
window.addEventListener('load', async () => {
try {
const registration = await navigator.serviceWorker.register('./sw.js', {
scope: '/demo-pwa/' // 与manifest的scope一致
});
console.log('Service Worker 注册成功:', registration.scope);
} catch (error) {
console.error('Service Worker 注册失败:', error);
}
});
}

// ========== 2. 通知授权与推送 ==========
// 兼容性检测
const isNotificationSupported = 'Notification' in window && 'serviceWorker' in navigator;
// 按钮点击触发授权(替代自动请求,提升用户体验)
document.getElementById('notifications').addEventListener('click', async () => {
if (!isNotificationSupported) {
alert('当前浏览器不支持通知功能');
return;
}

try {
const permission = await Notification.requestPermission();
// 授权结果:
// - granted:允许;denied:拒绝;default:未操作(视为拒绝)
if (permission === 'granted') {
// 随机发送测试通知
randomNotification();
} else if (permission === 'denied') {
alert('通知权限已拒绝,无法发送通知');
}
} catch (error) {
console.error('请求通知权限失败:', error);
}
});

// 随机发送测试通知
function randomNotification() {
if (!('Notification' in window) || Notification.permission !== 'granted') return;

const randomItem = Math.floor(Math.random() * games.length);
const game = games[randomItem];
const notifOptions = {
body: `Created by ${game.author}.`, // 通知正文
icon: `icons/icon-192.png`, // 通知图标
image: `data/img/${game.slug}.jpg`,// 通知大图(部分浏览器支持)
tag: 'pwa-demo-notification', // 通知标签(相同标签覆盖旧通知)
renotify: false, // 相同标签是否重新通知
requireInteraction: false // 是否需要用户手动关闭
};

// 显示通知
const notif = new Notification(game.name, notifOptions);
console.log('通知已发送:', notif);

// 30秒后再次发送(仅测试用,生产环境需结合Service Worker推送)
setTimeout(randomNotification, 30000);
}

(5)sw.js(Service Worker 核心逻辑)

优化点:补充缓存版本管理、过期缓存清理、离线回退策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
/* eslint-disable */
// ========== 1. 导入依赖数据 ==========
self.importScripts('data/list.js');

// ========== 2. 缓存配置 ==========
const cacheName = 'pwa-demo-v202012161654'; // 缓存版本(修改即更新缓存)
// 核心资源清单(APP Shell)
const appShellFiles = [
'/demo-pwa/',
'/demo-pwa/index.html',
'/demo-pwa/app.js',
'/demo-pwa/page.js',
'/demo-pwa/style.css',
'/demo-pwa/fonts/graduate.eot',
'/demo-pwa/fonts/graduate.ttf',
'/demo-pwa/fonts/graduate.woff',
'/demo-pwa/favicon.ico',
'/demo-pwa/img/logo.png',
'/demo-pwa/img/bg.png',
'/demo-pwa/icons/icon-32.png',
'/demo-pwa/icons/icon-64.png',
'/demo-pwa/icons/icon-96.png',
'/demo-pwa/icons/icon-128.png',
'/demo-pwa/icons/icon-168.png',
'/demo-pwa/icons/icon-192.png',
'/demo-pwa/icons/icon-256.png',
'/demo-pwa/icons/icon-512.png'
];
// 动态内容缓存(游戏图片)
const gamesImages = games.map(game => `data/img/${game.slug}.jpg`);
// 合并缓存清单
const contentToCache = [...appShellFiles, ...gamesImages];

// ========== 3. 安装阶段:缓存核心资源 ==========
self.addEventListener('install', (e) => {
console.log('[Service Worker] 开始安装');
// 等待缓存完成后激活
e.waitUntil(
caches.open(cacheName)
.then(cache => {
console.log('[Service Worker] 缓存核心资源');
// 忽略缓存失败的资源(避免整个安装失败)
return Promise.all(
contentToCache.map(url =>
cache.add(url).catch(err => console.error(`缓存失败: ${url}`, err))
)
);
})
.then(() => {
// 跳过等待,直接激活新SW(避免用户刷新才生效)
self.skipWaiting();
})
);
});

// ========== 4. 激活阶段:清理旧缓存 ==========
self.addEventListener('activate', (e) => {
console.log('[Service Worker] 开始激活');
e.waitUntil(
// 清理非当前版本的缓存
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(name => {
if (name !== cacheName) {
console.log('[Service Worker] 删除旧缓存:', name);
return caches.delete(name);
}
})
);
}).then(() => {
// 接管所有受控页面(无需刷新)
return self.clients.claim();
})
);
});

// ========== 5. 拦截请求:优先缓存,回退网络 ==========
self.addEventListener('fetch', (e) => {
// 忽略跨域请求(如第三方API)
if (!e.request.url.startsWith(self.location.origin)) return;

// 缓存优先策略(离线可用)
e.respondWith(
caches.match(e.request)
.then(cachedResponse => {
// 缓存存在则返回,同时后台更新缓存
if (cachedResponse) {
// 后台请求最新资源并更新缓存
fetch(e.request).then(fetchResponse => {
caches.open(cacheName).then(cache => {
cache.put(e.request, fetchResponse.clone());
});
}).catch(err => console.error('更新缓存失败:', err));
return cachedResponse;
}

// 缓存不存在则请求网络,并存入缓存
return fetch(e.request).then(fetchResponse => {
// 仅缓存成功的响应
if (!fetchResponse || fetchResponse.status !== 200) {
return fetchResponse;
}
// 克隆响应(响应流只能使用一次)
const responseClone = fetchResponse.clone();
caches.open(cacheName).then(cache => {
cache.put(e.request, responseClone);
});
return fetchResponse;
}).catch(() => {
// 网络失败:返回兜底页面(可选)
// return caches.match('/demo-pwa/offline.html');
return new Response('离线模式下无法访问该资源', {
status: 503,
headers: { 'Content-Type': 'text/plain' }
});
});
})
);
});

(6)page.js(页面渲染/图片懒加载)

优化点:修复模板字符串语法错误、补充注释、优化懒加载逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/* eslint-disable */
// ========== 1. 渲染游戏列表 ==========
// 模板字符串(修复原引号冲突问题)
const template = `
<article>
<img src="data/img/placeholder.png" data-src="data/img/SLUG.jpg" alt="NAME">
<h3>#POS. NAME</h3>
<ul>
<li><span>Author:</span> <strong>AUTHOR</strong></li>
<li><span>Twitter:</span> <a href="https://twitter.com/TWITTER">@TWITTER</a></li>
<li><span>Website:</span> <a href="http://WEBSITE/">WEBSITE</a></li>
<li><span>GitHub:</span> <a href="https://GITHUB">GITHUB</a></li>
<li><span>More:</span> <a href="http://js13kgames.com/entries/SLUG">js13kgames.com/entries/SLUG</a></li>
</ul>
</article>
`;

// 渲染列表到页面
let content = '';
for (let i = 0; i < games.length; i++) {
let entry = template
.replace(/POS/g, (i + 1))
.replace(/SLUG/g, games[i].slug)
.replace(/NAME/g, games[i].name)
.replace(/AUTHOR/g, games[i].author)
.replace(/TWITTER/g, games[i].twitter)
.replace(/WEBSITE/g, games[i].website)
.replace(/GITHUB/g, games[i].github);
// 替换空链接(website为空时显示'-')
entry = entry.replace('<a href="http:///">-</a>', '-');
content += entry;
}
document.getElementById('content').innerHTML = content;

// ========== 2. 图片懒加载(Intersection Observer) ==========
const imagesToLoad = document.querySelectorAll('img[data-src]');

// 加载单张图片
function loadImages(image) {
if (!image) return;
const src = image.getAttribute('data-src');
if (!src) return;
// 设置真实src
image.setAttribute('src', src);
// 加载完成后移除data-src
image.onload = () => {
image.removeAttribute('data-src');
// 添加加载完成样式(可选)
image.classList.add('loaded');
};
// 加载失败降级
image.onerror = () => {
image.setAttribute('src', 'data/img/placeholder.png');
};
}

// 兼容性处理:优先使用Intersection Observer(性能更优)
if ('IntersectionObserver' in window) {
// 配置观察器
const observerOptions = {
root: null, // 视口作为根
rootMargin: '0px',
threshold: 0.1 // 图片进入视口10%时加载
};

const observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
loadImages(entry.target);
// 停止观察已加载的图片
observer.unobserve(entry.target);
}
});
}, observerOptions);

// 观察所有懒加载图片
imagesToLoad.forEach(img => observer.observe(img));
} else {
// 降级方案:页面滚动时检测
window.addEventListener('scroll', () => {
imagesToLoad.forEach(img => {
if (img.getBoundingClientRect().top < window.innerHeight) {
loadImages(img);
}
});
});
// 初始加载可见区域图片
imagesToLoad.forEach(img => {
if (img.getBoundingClientRect().top < window.innerHeight) {
loadImages(img);
}
});
}

三、PWA 在不同环境的使用差异(核心重点)

1. 核心差异对比表

维度 PC 端(Chrome/Firefox/Safari) 移动端原生浏览器(Chrome/Safari) 移动端 WebView(APP 内嵌)
Service Worker Chrome/Firefox 完全支持;
Safari(macOS) 11.1+ 支持
Chrome/Edge 完全支持;
iOS Safari 14.3+ 支持
Android WebView(Chrome 内核)支持;
iOS WKWebView 14.3+ 支持(需配置);
低版本 iOS 不支持
Manifest 生效 Chrome/Firefox:添加到桌面为快捷方式,图标/主题色生效;
Safari:仅部分字段生效
Chrome/Edge:添加入主屏幕(原生APP图标),全屏/主题色生效;
iOS Safari 15+:添加入主屏幕,无启动画面
Android:部分生效;
iOS:几乎不生效;
无法添加入主屏幕
推送通知 Chrome/Firefox 支持;
Safari 不支持
Chrome/Edge 支持;
iOS Safari 不支持
Android:需 APP 授予权限,依赖 WebView 配置;
iOS:完全不支持
离线缓存 完全支持(缓存到浏览器) 完全支持(缓存到手机本地) Android:支持;
iOS:14.3+ 支持,缓存空间受限
交互体验 独立窗口运行,通知在系统通知中心 全屏/独立窗口,通知在手机通知栏,支持手势返回 受 APP 容器限制,无独立窗口,手势由 APP 控制
权限管理 通知/缓存权限由浏览器控制 通知权限需用户手动授权,iOS 需多次确认 权限由 APP 配置(如 WebView 设置允许 JS/缓存)

2. 各环境适配要点

(1)PC 端适配

  • 兼容重点
    • Safari(macOS):Manifest 的 display: standalone 无效(仍显示浏览器地址栏),推送通知不支持;
    • 不同桌面分辨率适配(独立窗口的布局不会自适应,需 CSS 媒体查询)。
  • 体验优化
    • 添加入桌面的快捷方式图标建议适配 256x256 尺寸;
    • 通知内容适配桌面端显示(避免移动端专属文案)。

(2)移动端原生浏览器适配

  • 兼容重点
    • iOS Safari:
      • 需添加 apple-touch-icon/apple-mobile-web-app-* 系列 meta 标签;
      • Manifest 的 background_color/theme_color 需与 iOS 专属 meta 一致;
      • 推送通知完全不支持,需降级为“站内通知”;
    • Android:
      • 图标需添加 purpose: maskable(适配安卓面具图标裁剪规则);
      • display: standalone 需配合 scope 避免路径问题。
  • 体验优化
    • 适配移动端触摸交互(如点击/滑动);
    • 启动画面背景色与页面主题色一致,提升原生感。

(3)移动端 WebView 适配(APP 内嵌)

  • Android WebView 配置(需 APP 开发配合)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 启用 JS(必须)
    webSettings.setJavaScriptEnabled(true);
    // 启用 DOM 存储(缓存依赖)
    webSettings.setDomStorageEnabled(true);
    // 启用缓存
    webSettings.setAppCacheEnabled(true);
    // 允许 Service Worker
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
    webSettings.setAllowUniversalAccessFromFileURLs(true);
    }
    // 允许混合内容(HTTPS 页面加载 HTTP 资源)
    webSettings.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW);
  • iOS WKWebView 配置(需 APP 开发配合)
    1
    2
    3
    4
    5
    6
    7
    8
    // 启用 JS
    webView.configuration.preferences.javaScriptEnabled = true
    // 启用缓存
    webView.configuration.websiteDataStore = WKWebsiteDataStore.default()
    // iOS 14.3+ 允许 Service Worker
    if #available(iOS 14.3, *) {
    webView.configuration.defaultWebpagePreferences.allowsContentJavaScript = true
    }
  • 核心限制
    • 无法实现“添加入主屏幕”;
    • 推送通知需通过 APP 原生推送实现;
    • Service Worker 可能被 APP 退出后销毁,离线缓存不稳定。

四、部署与调试注意事项

1. 部署要求

  • HTTPS 环境:Service Worker/Manifest 仅在 HTTPS(localhost 除外)下生效,生产环境需配置 SSL 证书;
  • 路径一致性manifest.jsonstart_url/scope 需与部署路径一致,避免 SW 控制范围异常;
  • 缓存清理:更新 SW 时需修改 cacheName,否则浏览器会使用旧缓存。

2. 调试工具

  • Chrome DevTools:Application 面板可调试 Service Worker/Manifest/缓存;
  • Safari 调试:开启“开发”菜单后,Develop > 显示 Web 检查器
  • 移动端调试:Chrome DevTools 的 Remote Devices 可远程调试手机端 PWA。

五、参考资料

  1. MDN PWA 官方文档:https://developer.mozilla.org/zh-CN/docs/Web/Progressive_web_apps/Introduction
  2. Web App Manifest 规范:https://w3c.github.io/manifest/
  3. Service Worker 最佳实践:https://web.dev/service-workers/
  4. PWA 多端兼容指南:https://web.dev/pwa-checklist/

总结

  1. 核心技术:PWA 实现的关键是 Service Worker(离线缓存)+ Manifest(原生体验)+ 通知授权(交互增强),三者缺一不可;
  2. 环境适配
    • PC 端:优先兼容 Chrome/Firefox,Safari 降级处理推送/Manifest;
    • 移动端:iOS Safari 需补充专属 meta 标签,放弃推送通知;
    • WebView:依赖 APP 端配置,核心保障 Service Worker/离线缓存生效;
  3. 开发原则:PWA 是“渐进式增强”,需保证无 PWA 支持的浏览器仍能正常访问核心内容,避免兼容性导致页面不可用。

通过以上实现与适配方案,可在多环境下落地具备离线可用、原生体验的 PWA 应用,兼顾 PC/移动端/WebView 的核心体验。


PWA DEMO
https://cszy.top/20201215-h5-pwa/
作者
csorz
发布于
2020年12月15日
许可协议