网页pwa开发遇见的问题
本文档基于实际项目(Ghost Relationship Test)中遇到的问题整理,提炼出具有普遍性的规律与扩展性的解决思路。
Bug 1:移动端输入框无法输入 / 只能输入部分字符
症状
- 手机端:完全无法输入任何内容
- PC端:只能输入数字,字母/中文无效
根本原因
| 场景 | 原因 |
|---|---|
| 手机虚拟键盘 | 不触发 keydown 事件(或触发的是合成事件),只触发 input/compositionend |
| PC 中文 IME | 输入拼音阶段 e.key === 'Process',不是真实字符;数字键不经过 IME 直接触发 |
| 特殊字符 | e.key.length === 1 对 IME 合成字符无效 |
通用解决方案
// ❌ 错误做法:依赖 keydown 捕获字符
input.addEventListener('keydown', (e) => {
if (e.key.length === 1) handleChar(e.key); // 手机/IME 下失效
});
// ✅ 正确做法:用 onChange/input 事件统一处理
// React 受控组件方案
const [displayText, setDisplayText] = useState('');
const handleChange = (e) => {
const newVal = e.target.value;
if (newVal.length > displayText.length) {
// 有新字符输入
processNewInput();
}
};
<textarea value={displayText} onChange={handleChange} />扩展原则
- 所有输入处理优先使用
onChange/oninput,而非keydown/keypress keydown只用于处理"功能键"(Backspace、Enter、Arrow 等),不用于捕获字符内容- 受控组件(controlled component)是防止用户绕过限制的最可靠方式
Bug 2:PWA 安装提示(beforeinstallprompt)不触发
症状
点击安装按钮,系统原生 PWA 安装对话框不出现,直接走降级路径。
根本原因(多层叠加)
原因 A:Manifest 图标无效
浏览器严格校验 manifest 中的图标,任何一个图标加载失败或格式不符,整个 manifest 被认为无效。
| 问题 | 症状 |
|---|---|
| 使用 SVG 作为唯一图标 | Chrome 要求至少一个 PNG ≥192×192 |
sizes 声明与实际尺寸不符 | 校验失败,manifest 无效 |
| 图标文件路径错误 | 404,manifest 无效 |
原因 B:事件监听注册太晚
beforeinstallprompt 在页面加载后很早触发(通常在 DOMContentLoaded 后),如果在某个子组件的 useEffect 里监听,组件挂载时事件可能已经触发并消失。
时间线:
页面加载 → beforeinstallprompt 触发 ← 此时 EndingB 组件还未挂载
...
用户进入结局 → EndingB 挂载 → addEventListener ← 太晚了,事件已错过原因 C:Chrome 的 Installability Criteria
Chrome 要求满足以下所有条件才触发:
- HTTPS(或 localhost)
- 有效的 manifest.json(含 ≥192×192 PNG 图标)
- 已激活的 Service Worker
- 用户与页面有一定互动(30秒以上)
通用解决方案
// ✅ 在应用根组件(App.jsx)尽早捕获事件
export default function App() {
const deferredPwaPrompt = useRef(null);
useEffect(() => {
const handler = (e) => {
e.preventDefault(); // 阻止浏览器自动弹出
deferredPwaPrompt.current = e; // 保存事件供后续使用
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
// 通过 props/context 传递给需要安装按钮的子组件
return <ChildComponent deferredPwaPrompt={deferredPwaPrompt} />;
}
// ✅ 子组件中使用
const handleInstall = async () => {
const prompt = deferredPwaPrompt?.current;
if (prompt) {
prompt.prompt();
const { outcome } = await prompt.userChoice;
if (outcome === 'accepted') setInstalled(true);
} else {
// 降级处理:已安装 / 浏览器不支持
setInstalled(true);
}
};// ✅ manifest.json 最小合法配置
{
"name": "应用名称",
"short_name": "短名",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}扩展原则
- 任何全局事件(
beforeinstallprompt、visibilitychange、online/offline)都应在根组件监听,通过 props/Context 向下传递 - PWA 安装不是"点击触发"而是"满足条件后才能触发",务必提供降级 UI
- 图标检查:Chrome DevTools → Application → Manifest,任何红色警告都会阻止安装提示
Bug 3:重新部署后 JS 模块加载报 MIME type 错误
症状
Failed to load module script: Expected a JavaScript-or-Wasm module script
but the server responded with a MIME type of "text/html"根本原因
Vite 等现代构建工具使用内容 hash 命名输出文件(如 index-C4V28QSk.js)。重新部署后:
旧 index.html → 引用 index-C4V28QSk.js(旧 hash)
新 build → 只有 index-Xm8k2Lop.js(新 hash)
浏览器/SW 缓存了旧 index.html
↓
请求 index-C4V28QSk.js → nginx 找不到 → try_files 返回 index.html(text/html)
↓
浏览器:这不是 JS,拒绝执行 → 白屏Service Worker 会将资源缓存,包括 index.html,导致即使服务器已更新,浏览器仍读旧版本。
通用解决方案
方案 1:SW 不缓存 index.html(推荐)
// sw.js
self.addEventListener('fetch', (e) => {
const url = e.request.url;
// index.html 始终走网络,绝不缓存
if (url.endsWith('/') || url.includes('/index.html')) {
e.respondWith(fetch(e.request));
return;
}
// 其他资源:网络优先,失败回落缓存
e.respondWith(
fetch(e.request).then(res => {
caches.open(CACHE_NAME).then(c => c.put(e.request, res.clone()));
return res;
}).catch(() => caches.match(e.request))
);
});方案 2:每次部署递增 SW 缓存版本号
// 每次部署时修改此版本号,触发旧缓存清除
const CACHE_NAME = 'app-v3'; // v1 → v2 → v3 ...
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});方案 3:nginx 禁止缓存 SW 和 manifest
# sw.js 绝不缓存,确保每次获取最新版本
location = /sw.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
expires 0;
}
# manifest.json 不缓存
location = /manifest.json {
add_header Cache-Control "no-cache";
expires 0;
}扩展原则
- 带 hash 的静态资源可以长期缓存,
index.html绝对不能缓存 - SW 缓存策略要区分"壳文件"(index.html,随版本变化)和"资产文件"(带 hash 的 JS/CSS,内容不变则 hash 不变)
- 每次重新部署都要考虑缓存失效问题
- 如果使用 vite-plugin-pwa,Workbox 会自动处理 precache manifest,避免此类问题
通用 Debug 清单
移动端输入问题
PWA 问题
部署后白屏
附:关键配置文件模板
sw.js(安全版)
const CACHE_NAME = 'app-v1'; // 每次部署递增
self.addEventListener('install', e => {
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
const url = e.request.url;
if (!url.startsWith('http')) return; // 跳过 chrome-extension 等
if (url.endsWith('/') || url.includes('index.html')) {
e.respondWith(fetch(e.request)); // HTML 永远走网络
return;
}
e.respondWith(
fetch(e.request).then(res => {
caches.open(CACHE_NAME).then(c => c.put(e.request, res.clone()));
return res;
}).catch(() => caches.match(e.request))
);
});manifest.json(最小合法版)
{
"name": "应用全称",
"short_name": "应用短名",
"description": "应用描述",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}