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 打开后,也可以通过“分享” -> “添加到主屏幕”安装成一个离线可用的小应用。