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.html、manifest.webmanifest、service-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/ 这种子路径也能正常访问。
八、本地构建验证
安装依赖:
构建:
本地预览:
确认 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 仓库
新建一个独立仓库,例如:
不要直接放到 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 分支
先确保已经构建:
然后把 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
|
然后:
- 点击
Settings
- 左侧点击
Pages
Source 选择 Deploy from a branch
Branch 选择 gh-pages
- 文件夹选择
/ (root)
- 点击
Save
等待一两分钟后访问:
1
| https://ouwenwu.github.io/minesweeper/
|
如果能打开页面,说明 GitHub Pages 已经发布成功。
十三、iPhone 添加到主屏幕
在 iPhone 上操作:
- 用 Safari 打开
https://ouwenwu.github.io/minesweeper/
- 等页面完整加载
- 点击 Safari 底部的“分享”按钮
- 选择“添加到主屏幕”
- 名称可以保持“扫雷”
- 点击“添加”
之后桌面会出现一个类似 app 的图标。点击它会以独立窗口打开,不像普通网页那样显示 Safari 地址栏。
十四、离线测试
第一次添加后,建议联网状态下从主屏幕图标打开一次,停留几秒,让 service worker 完成安装和缓存。
然后测试:
- 关闭 Wi-Fi 和蜂窝网络
- 从 iPhone 主屏幕点击应用图标
- 如果页面正常打开,说明离线缓存成功
如果第一次离线打不开,通常是缓存还没安装完成。重新联网打开一次,停留几秒,再断网测试。
十五、更新版本
修改代码后,按这个流程更新:
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 打开后,也可以通过“分享” -> “添加到主屏幕”安装成一个离线可用的小应用。