0%

PWA小游戏发布到GitHub Pages并在iPhone离线使用

PWA小游戏发布到GitHub Pages并在iPhone离线使用

这里记录一个完整流程:把一个普通的 HTML/CSS/JS 小功能改造成 PWA 离线应用,发布到 GitHub Pages,然后在 iPhone Safari 中“添加到主屏幕”。第一次联网打开后,后面断网也可以从主屏幕图标进入。

示例地址:

1
https://ouwenwu.github.io/minesweeper/

其中 minesweeper 是仓库名。以后换成自己的项目时,把它替换为自己的仓库名即可。

一、核心思路

PWA 不需要打包成 iOS 安装包,也不需要 Xcode。它的核心是:

  • 用 HTML/CSS/JS 写好网页功能
  • 加一个 manifest.webmanifest,让系统知道应用名称、图标、启动方式
  • 加一个 service-worker.js,缓存页面、JS、CSS、图标等资源
  • 第一次通过 HTTPS 访问 GitHub Pages
  • 在 iPhone Safari 中点击“分享” -> “添加到主屏幕”
  • 之后从主屏幕图标打开,已经缓存的资源可以离线使用

注意:第一次必须联网访问一次,离线能力是在第一次访问时缓存下来的。

二、项目结构

一个最小结构可以这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
my-game/
├── index.html
├── package.json
├── public/
│ ├── manifest.webmanifest
│ ├── service-worker.js
│ └── icons/
│ ├── icon-192.png
│ ├── icon-512.png
│ └── apple-touch-icon.png
├── src/
│ ├── main.js
│ └── styles.css
└── vite.config.js

如果不是 Vite 项目,也可以直接用静态 HTML,只要最后发布出去的是 index.htmlmanifest.webmanifestservice-worker.js 和相关资源即可。

三、添加 manifest.webmanifest

public/manifest.webmanifest 中写入:

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
{
"name": "扫雷",
"short_name": "扫雷",
"description": "可离线游玩的扫雷小游戏",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#c8d2e1",
"theme_color": "#f3f6fb",
"orientation": "portrait",
"icons": [
{
"src": "./icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "./icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

图标至少准备:

  • public/icons/icon-192.png
  • public/icons/icon-512.png
  • public/icons/apple-touch-icon.png

其中 apple-touch-icon.png 是 iPhone 添加到主屏幕后显示的图标,建议使用 180x180 或更高分辨率的 PNG。

四、在 index.html 中引入 PWA 配置

index.html<head> 中加入:

1
2
3
4
5
6
7
8
<meta name="theme-color" content="#f3f6fb" />
<meta name="description" content="可离线游玩的扫雷小游戏" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="扫雷" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="manifest" href="./manifest.webmanifest" />
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />

这里使用 ./manifest.webmanifest./icons/...,是为了让项目部署到 GitHub Pages 的子路径时也能正常加载。

五、添加 service-worker.js

public/service-worker.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
const CACHE_NAME = 'minesweeper-pwa-v1';
const APP_SHELL = [
'./',
'./index.html',
'./manifest.webmanifest',
'./icons/icon-192.png',
'./icons/icon-512.png',
'./icons/apple-touch-icon.png',
];

self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then(async (cache) => {
const shellUrls = APP_SHELL.map((path) => new URL(path, self.registration.scope));
await cache.addAll(shellUrls);

const indexUrl = new URL('./index.html', self.registration.scope);
const indexResponse = await fetch(indexUrl);
const indexHtml = await indexResponse.clone().text();
await cache.put(indexUrl, indexResponse);

const assetUrls = [...indexHtml.matchAll(/(?:src|href)="([^"]+)"/g)]
.map((match) => new URL(match[1], indexUrl))
.filter((url) => url.origin === self.location.origin);

await cache.addAll(assetUrls);
})
.then(() => self.skipWaiting()),
);
});

self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) =>
Promise.all(cacheNames.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))),
)
.then(() => self.clients.claim()),
);
});

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

event.respondWith(
caches.match(event.request).then((cachedResponse) => {
if (cachedResponse) return cachedResponse;

return fetch(event.request)
.then((networkResponse) => {
const shouldCache =
networkResponse.ok &&
new URL(event.request.url).origin === self.location.origin &&
['document', 'script', 'style', 'image', 'manifest'].includes(event.request.destination);

if (shouldCache) {
const responseCopy = networkResponse.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, responseCopy));
}

return networkResponse;
})
.catch(() => caches.match(new URL('./index.html', self.registration.scope)));
}),
);
});

CACHE_NAME 是缓存版本号。以后修改了 JS、CSS、图标或缓存策略,建议把它改成新的名字,例如:

1
const CACHE_NAME = 'minesweeper-pwa-v2';

这样 iPhone 下次联网打开时会更新缓存。

六、注册 service worker

在入口 JS 中加入:

1
2
3
4
5
6
7
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`${import.meta.env.BASE_URL}service-worker.js`).catch((error) => {
console.info('Service worker registration failed:', error);
});
});
}

如果不是 Vite 项目,可以写成:

1
2
3
4
5
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./service-worker.js');
});
}

七、Vite 项目配置相对路径

GitHub Pages 的项目站点通常是子路径,例如:

1
https://ouwenwu.github.io/minesweeper/

为了避免资源路径从域名根目录开始找,Vite 项目建议加 vite.config.js

1
2
3
4
5
import { defineConfig } from 'vite';

export default defineConfig({
base: './',
});

这样构建后的 JS、CSS 会使用相对路径,部署到 /minesweeper/ 这种子路径也能正常访问。

八、本地构建验证

安装依赖:

1
npm install

构建:

1
npm run build

本地预览:

1
npm run preview

确认 dist/ 中有这些文件:

1
2
3
4
5
6
dist/index.html
dist/manifest.webmanifest
dist/service-worker.js
dist/icons/icon-192.png
dist/icons/icon-512.png
dist/icons/apple-touch-icon.png

九、创建 GitHub 仓库

新建一个独立仓库,例如:

1
minesweeper

不要直接放到 ouwenwu.github.io 这个 Hexo 根站仓库里,避免影响原来的博客。独立仓库发布后访问地址是:

1
https://ouwenwu.github.io/minesweeper/

十、提交源码到 main 分支

在项目目录中执行:

1
2
3
4
5
6
git init
git add .
git commit -m "Add minesweeper PWA"
git branch -M main
git remote add origin https://github.com/ouwenwu/minesweeper.git
git push -u origin main

如果你的仓库名不是 minesweeper,把命令里的仓库地址改成自己的:

1
git remote add origin https://github.com/你的用户名/你的仓库名.git

十一、把 dist 发布到 gh-pages 分支

先确保已经构建:

1
npm run build

然后把 dist/ 单独推送到 gh-pages 分支:

1
2
git subtree split --prefix dist -b gh-pages
git push -u origin gh-pages

如果之前已经有本地 gh-pages 分支,更新时可以先删除本地临时分支再重新生成:

1
2
3
git branch -D gh-pages
git subtree split --prefix dist -b gh-pages
git push origin gh-pages --force

这里的 gh-pages 分支只放构建后的静态文件,main 分支保留源码。

十二、配置 GitHub Pages

进入仓库:

1
https://github.com/ouwenwu/minesweeper

然后:

  1. 点击 Settings
  2. 左侧点击 Pages
  3. Source 选择 Deploy from a branch
  4. Branch 选择 gh-pages
  5. 文件夹选择 / (root)
  6. 点击 Save

等待一两分钟后访问:

1
https://ouwenwu.github.io/minesweeper/

如果能打开页面,说明 GitHub Pages 已经发布成功。

十三、iPhone 添加到主屏幕

在 iPhone 上操作:

  1. 用 Safari 打开 https://ouwenwu.github.io/minesweeper/
  2. 等页面完整加载
  3. 点击 Safari 底部的“分享”按钮
  4. 选择“添加到主屏幕”
  5. 名称可以保持“扫雷”
  6. 点击“添加”

之后桌面会出现一个类似 app 的图标。点击它会以独立窗口打开,不像普通网页那样显示 Safari 地址栏。

十四、离线测试

第一次添加后,建议联网状态下从主屏幕图标打开一次,停留几秒,让 service worker 完成安装和缓存。

然后测试:

  1. 关闭 Wi-Fi 和蜂窝网络
  2. 从 iPhone 主屏幕点击应用图标
  3. 如果页面正常打开,说明离线缓存成功

如果第一次离线打不开,通常是缓存还没安装完成。重新联网打开一次,停留几秒,再断网测试。

十五、更新版本

修改代码后,按这个流程更新:

1
2
3
4
5
6
7
npm run build
git add .
git commit -m "Update PWA"
git push origin main
git branch -D gh-pages
git subtree split --prefix dist -b gh-pages
git push origin gh-pages --force

如果改动涉及缓存资源,记得更新 public/service-worker.js 里的缓存名:

1
const CACHE_NAME = 'minesweeper-pwa-v2';

iPhone 下次联网打开后会拿到新缓存。如果仍然看到旧页面,可以删除主屏幕图标后重新添加,或者在 Safari 中清理网站数据后再访问。

十六、常见问题

1. 为什么不能直接把源码作为 Pages 发布?

Vite 项目的开发版 index.html 通常引用 /src/main.js,浏览器不能像 Vite dev server 那样直接处理模块和打包。因此应发布 npm run build 生成的 dist/

2. 为什么推荐独立仓库?

因为 ouwenwu.github.io 已经是 Hexo 博客根站。小游戏放到独立仓库后,会发布到:

1
https://ouwenwu.github.io/minesweeper/

这样不会影响博客首页:

1
https://ouwenwu.github.io/

3. iPhone 能不能完全不联网使用?

第一次必须联网打开一次,因为需要下载网页资源并安装 service worker。只要缓存成功,后面就可以离线打开已经缓存的应用。

4. 这是不是 iOS 原生 app?

不是。它是 PWA,本质上还是网页应用,但可以添加到主屏幕,并且支持离线缓存。优点是不需要 Xcode 和 Apple 开发者账号;缺点是不能使用所有原生 iOS 能力。

十七、最终效果

完成后,可以把这个地址分享给别人:

1
https://ouwenwu.github.io/minesweeper/

对方在 iPhone Safari 打开后,也可以通过“分享” -> “添加到主屏幕”安装成一个离线可用的小应用。