
做跨端开发这些年我一直觉得 PWA 是个被低估的方案。尤其在 Uniapp 生态里很多人把它当成“能编译成 H5 的语法糖”完全没意识到它其实可以做到像原生 App 一样的桌面安装、离线缓存和独立窗口运行。这篇入门教程就是要把这条最基础也最容易被忽略的链路打通用 Uniapp 写一个应用通过 PWA 能力让它变成可安装、可离线使用的桌面应用。整个流程控制在 30 分钟以内零基础也能跟着做完。我会从方案选型讲起把每一步的关键配置、代码逻辑和部署细节都拆开最后附一份常见问题的排查清单。你跟着做完之后会得到一个能双击启动、断网可用、带独立窗口的 Uniapp 应用也能理解这套机制背后的工作原理后续再扩展做离线 IM、本地优先的工具类应用就有底子了。1. 为什么用 Uniapp PWA而不是小程序或原生 App1.1 这个组合解决了什么痛点Uniapp 最常见的交付形态是打包成微信小程序或安卓 APK但这两个方向都有各自的限制。小程序依赖微信的容器用户必须打开微信才能访问你的应用不具备独立的用户入口APK 需要签名、加固、上架审核而且 iOS 端还得走 App Store 的流程一个人开发的时候维护成本很高。PWA 给了一条中间路线保留 Web 的跨平台性和免安装分发便利同时借助浏览器底层的离线缓存和安装接口把体验拉近到原生应用的水平。用户第一次访问你的网站可以收到“添加到桌面”的邀请安装后就像原生应用一样出现在桌面/启动器里点击后通过独立窗口打开不再显示浏览器地址栏和标签页。最关键是之后每次启动都不再需要网络请求全部资源因为静态资源已经在本地缓存了。对 Uniapp 项目来说这意味着你的同一套代码多了一个交付形态编译成 H5 部署到服务器之后再配置好 manifest.json、Service Worker 和安装入口就从一个普通网页升级成了可安装的离线应用。这批用户的获取成本比下载 APK 低得多也更适合工具类、内容类、企业内部应用这类场景。1.2 和微信小程序、安卓 APK 的取舍逻辑先做一个对比帮你判断什么场景该用 PWA对比维度微信小程序安卓 APK/NativeUniapp PWA分发方式微信内搜索/分享应用市场下载网址访问 安装提示审核成本需要提审上架应用市场需要资质无平台审核只要你的服务器合规离线能力有限缓存有容量限制完整本地存储静态资源离线 运行时策略可控入口独立依赖微信桌面图标桌面图标 独立窗口开发语言WXML/WXSS/JS 或 Uniapp原生/Kotlin/Swift 或 Uniapp就是 H5从这张表能看出来PWA 最大的短板是“没有巨头的流量入口”但它的优势恰好是“不依赖任何巨头”。你做一个小工具、一个个人作品集、一个展示型官网或者公司内部的信息看板PWA 都是性价比极高的方案。微信小程序适合需要微信社交裂变的业务APK 适合必须调用系统级硬件能力且需要应用市场流量的产品而如果你的核心诉求是“快速上线 长期独立运行 用户打开即用”PWA 就是最优解。其实这正是 2024 年以来很多团队重新重视 PWA 的原因移动端流量红利见顶Web 端的轻量转化价值回归。加上 Chrome、Edge、Safari 对 PWA 的支持已经相当成熟安装转化率不再是以前那种鸡肋水平了。2. 30 分钟实操路径规划从页面到离线的四步走2.1 核心资源与前置条件动手之前先备齐这些资源用途说明HBuilderX 4.x 或更高版本Uniapp 的编辑器与编译工具也可以用 CLI 方式但零基础建议用 HBuilderX可视化配置省事Node.js 18如果使用 CLI 创建项目时需要HBuilderX 内置了编译环境这一步可跳过一个 HTTPS 域名PWA 安装的前置条件可以用国内云厂商对象存储 CDN或任意支持静态网站的服务器本地开发可用 localhost 绕过 HTTPS 限制一张 512x512 图标桌面图标和启动画面后文有生成办法Chrome/Edge 浏览器测试安装流程Firefox 支持度稍弱Safari 13 也可以但限制较多为什么 HTTPS 是硬条件因为 Service Worker 能拦截网络请求并改写缓存内容如果协议不是 HTTPS就存在中间人篡改脚本的严重风险。所以浏览器规定只有安全上下文HTTPS 或 localhost才允许注册 Service Worker、触发安装弹窗。你在本地用 localhost 调试没有任何问题但部署到线上必须配好证书。2.2 四步路线图30 分钟的时间分配大致是步骤内容预计耗时第一步创建 Uniapp 项目并确认 H5 编译链路5 分钟第二步补齐 manifest.json 里的 PWA 配置5 分钟第三步编写 Service Worker 实现离线缓存10 分钟第四步处理安装入口和部署上线10 分钟这个路线看起来简单但每一环都有容易踩坑的细节。尤其是 Service Worker 的路径问题网上 90% 的报错都出在这一块。下面逐个展开。3. 第一步创建 Uniapp 项目跑通 H5 编译3.1 新建项目和目录结构打开 HBuilderX文件 - 新建 - 项目选择 uni-app 模板输入项目名offline-app。创建完成后目录结构长这样offline-app/ ├── pages/ │ ├── index/ │ │ └── index.vue # 主页面 │ └── ... ├── static/ │ └── logo.png # 页面内展示的图标 ├── unpackage/ # 编译产物目录 ├── index.html # H5 模板入口 ├── main.js ├── manifest.json # 应用配置 ├── pages.json # 页面路由配置 └── App.vue这里的index.html是 H5 编译的模板入口。PWA 相关的外链脚本、Service Worker 注册代码需要在这里或 main.js 里处理。manifest.json除了控制 App 打包的权限之外也承载了 H5 端的 PWA 配置字段这是我们这个项目的核心操作对象。有一个很多新手会困惑的点Uniapp 项目里的manifest.json和 PWA 里的manifest.webmanifest是什么关系其实它们是两回事。Uniapp 的manifest.json是工程配置控制编译行为、权限、App 打包参数PWA 安装清单则是产物里一个独立的 JSON 文件浏览器通过它读取应用名称、图标、启动 URL。HBuilderX 的 H5 编译流程里会把 Uniapp 的manifest.json中的h5节点信息映射生成 PWA manifest。所以你在工程里改的是manifest.json的h5.pwa节点。3.2 写一个足够展示效果的首页为了让安装后的效果直观我在首页放一个自检面板展示当前连接状态、缓存状态、以及缓存文件数量。这样能实时验证离线能力是否生效。pages/index/index.vue的主体代码template view classcontent image src/static/logo.png classlogo/image view classtitleUniapp PWA 离线应用演示/view view classstatus-card view classstatus-row text classlabel网络状态/text text classvalue{{ online ? 在线 : 离线 }}/text /view view classstatus-row text classlabelService Worker/text text classvalue{{ swReady ? 已激活 : 未注册 }}/text /view view classstatus-row text classlabel缓存计数/text text classvalue{{ cacheCount }}/text /view /view button classinstall-btn v-ifinstallVisible clickhandleInstall 安装到桌面 /button /view /template script setup import { ref, onMounted } from vue const online ref(navigator.onLine) const swReady ref(false) const installVisible ref(false) let deferredPrompt null function updateOnlineStatus() { online.value navigator.onLine } async function checkCacheCount() { try { const keys await caches.keys() let total 0 for (const key of keys) { const cache await caches.open(key) const requests await cache.keys() total requests.length } cacheCount.value total } catch (e) { cacheCount.value -1 } } async function handleInstall() { if (deferredPrompt) { deferredPrompt.prompt() // 触发浏览器的安装弹窗 const choice await deferredPrompt.userChoice if (choice.outcome accepted) { console.log(用户接受了安装) } deferredPrompt null installVisible.value false } } onMounted(() { updateOnlineStatus() window.addEventListener(online, updateOnlineStatus) window.addEventListener(offline, updateOnlineStatus) if (serviceWorker in navigator) { navigator.serviceWorker.register(/sw.js) .then(() { swReady.value true }) .catch(() { swReady.value false }) } window.addEventListener(beforeinstallprompt, (e) { e.preventDefault() // 先拦住默认的小气泡 deferredPrompt e installVisible.value true // 显示自定义安装按钮 }) window.addEventListener(appinstalled, () { installVisible.value false console.log(应用已安装到桌面) }) }) /script这段代码里有两个关键动作。第一个是通过监听beforeinstallprompt事件拿到浏览器安装请求的控制权随时准备在你自定义的场景弹出安装按钮。第二个是注册/sw.js这就是后面要写的 Service Worker。如果你不希望每次进页面都重新注册可以把注册逻辑放到 main.js 里但注意路径始终相对于网站根目录。这里出现一个新手最容易犯的错注册路径。我写的/sw.js意思是 sw.js 放在域名根目录下。如果你的网站部署在https://example.com/app/这个子目录就必须注册/app/sw.js并且页面上所有静态资源的引用都不要写绝对根路径否则 Service Worker 的缓存键会把资源存到错误的 URL 下。后面部署章节我会详细讲。4. 第二步配置 PWA 安装清单让浏览器认出你的应用4.1 manifest.json 中的 h5.pwa 配置详解打开manifest.json找到或者新增h5节点。HBuilderX 可视化界面里可以点击 “源码视图” 直接编辑。完整的配置如下{ h5: { title: 离线小助手, router: { mode: history, base: / }, pwa: { name: 离线小助手, short_name: 助手, description: 一个基于 Uniapp 的离线优先示例应用, start_url: /, scope: /, background_color: #f5f5f5, theme_color: #42b983, display: standalone, orientation: portrait, icons: [ { src: https://your-domain.com/static/icons/icon-192.png, sizes: 192x192, type: image/png }, { src: https://your-domain.com/static/icons/icon-512.png, sizes: 512x512, type: image/png } ], appleMobileWebAppCapable: yes, appleMobileWebAppStatusBarStyle: black-translucent } } }依次解释各字段的作用name和short_name安装后桌面图标的完整名称和缩写名。桌面空间有限short_name 控制在 6 个字符内体验最好。start_url用户点击桌面图标后启动的页面地址。默认取根路径。scope控制哪些路径在应用上下文内。默认是站点根目录一般不需要改。displaystandalone表示独立窗口模式隐藏浏览器地址栏。theme_color和background_color影响启动画面和窗口标题栏颜色。theme_color 也会改变手机浏览器的地址栏颜色。icons至少需要 192x192 和 512x512 两种尺寸的图标。Chrome 安装条件要求必须有 192 以上尺寸的图标否则不会触发安装事件。appleMobileWebAppCapable让 iOS Safari 也支持“添加到主屏幕”的行为。4.2 图标生成与常见尺寸坑如果你手头没有设计资源最快的方式是做一个纯色圆角图标然后用工具输出多种尺寸。可以用在线工具也可以装一个 Python 的 Pillow 写几行脚本生成from PIL import Image, ImageDraw def make_icon(size, bg42b983): img Image.new(RGB, (size, size), f#{bg}) d ImageDraw.Draw(img) # 画一个简单的内嵌方块代表“离线仓库” inset size // 4 d.rounded_rectangle( [inset, inset, size - inset, size - inset], radiussize // 8, fill#ffffff, ) img.save(ficon-{size}.png) make_icon(192) make_icon(512)生成完之后放到项目的static/icons/目录下。要注意icons里的src路径在 Uniapp 编译后会被原样搬运吗并不会。如果你在manifest.json写的是相对路径编译后的 H5 产物里会保留这个相对路径相对的是最终 URL 目录。所以稳妥做法是写完整的 CDN 地址或者部署完后确认相对路径相对于域名根目录没有错位。我见过最典型的翻车场景项目部署到https://example.com/h5/但 pwa icon 写的是/static/icons/icon-192.png最后浏览器请求https://example.com/static/...直接 404导致安装条件永远不满足。关于图标还有一点icon-192不能是透明背景的 SVGChrome 安装时要求 png 格式并有实际像素内容。如果图标是纯透明部分版本会出现桌面图标全黑或全白的显示问题。5. 第三步编写 Service Worker把离线能力立起来5.1 认识 Service Worker 的运行机制Service Worker 是一个独立于页面主线程运行的 JS 文件它相当于你网站的网络代理。浏览器在后台启动它所有页面请求都会先经过它由你决定是直接放行、返回缓存、还是去网络上获取。生命周期有三个阶段安装install触发时机是首次注册。适合做预缓存把关键资源先下载到本地。激活activate安装完成后触发。适合清理旧的缓存版本。响应fetch之后每次页面发起请求都会触发。这是真正干活的地方。理解这三个阶段很重要因为 80% 的缓存问题都出在 install 和 fetch 的配合上。比如项目代码更新了但用户在页面上还是旧内容十有八九是 activate 阶段没有做旧缓存清理或者 fetch 阶段的缓存策略没有设置网络优先。5.2 从零写一个可靠的 sw.js 和注册入口新建src/sw.js注意这个文件位置后面有说内容如下const CACHE_NAME offline-app-v1 const PRECACHE_ASSETS [ /, /index.html, /static/js/pages-index-index.js, /static/css/pages-index-index.css ] // 安装阶段预缓存核心资源 self.addEventListener(install, (event) { event.waitUntil( caches.open(CACHE_NAME) .then((cache) cache.addAll(PRECACHE_ASSETS)) .then(() self.skipWaiting()) ) }) // 激活阶段清理旧版本缓存 self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) Promise.all( keys .filter((key) key ! CACHE_NAME) .map((key) caches.delete(key)) ) ).then(() self.clients.claim()) ) }) // 请求响应离线优先针对页面导航 self.addEventListener(fetch, (event) { const request event.request // 只处理同源 GET 请求 if (request.method ! GET) return const url new URL(request.url) if (url.origin ! self.location.origin) return // 导航请求页面跳转离线优先失败回退缓存首页 if (request.mode navigate) { event.respondWith( fetch(request) .then((response) { const clone response.clone() caches.open(CACHE_NAME).then((cache) cache.put(request, clone)) return response }) .catch(() caches.match(request).then((hit) hit || caches.match(/))) ) return } // 其他静态资源缓存优先缓存未命中再请求网络 event.respondWith( caches.match(request).then((hit) hit || fetch(request).then((response) { const clone response.clone() caches.open(CACHE_NAME).then((cache) cache.put(request, clone)) return response }) ) ) })这段代码的核心策略是“导航请求网络优先 静态资源缓存优先”。为什么页面导航不直接用缓存因为页面 HTML 是最容易更新的部分如果你把 HTML 也缓存死了更新就很难生效。而 JS/CSS 图片这类带 hash 的静态资源文件名一变缓存键就变缓存优先是安全且高效的。再回到注册代码。我在第一节里写的注册路径是/sw.js为了让这个文件存在需要在 HBuilderX 的 H5 产物目录里手动放置它。这有点别扭更常见的方式是在项目根目录新建一个public目录如果你用的是 CLI 创建 Vite 工程这是标准做法把 sw.js 放进去编译时它会被原样拷贝到产物根目录。如果你用的是 HBuilderX 可视化创建的项目没有 public 目录可以手动在项目根目录建一个。编译后检查unpackage/dist/build/web/sw.js是否存在存在就说明路径对了。5.3 用 DevTools 验证 Service Worker 是否生效打开 Chrome DevTools - Application 面板左侧列表里能看到 Service Workers 入口。注册成功后状态会显示 activated and running。点击updatetime可以强制更新旁边的push和sync用来调试后台同步。验证离线能力的标准流程是记录当前页面加载的缓存文件数量右侧面板可看到 Cache Storage 里有哪些条目。把它关掉重开一次确保所有资源都已进缓存。打开 Network 面板把网络切换成 Offline。刷新页面如果内容正常渲染离线能力就通了。一个常见问题是你只看到一个 index.html 被缓存刷新离线后就只能看到白屏。这是因为首次访问的页面里还没有把 JS/CSS 的请求缓存下来。我们的PRECACHE_ASSETS里写死的两个静态资源路径可能和实际编译产物的文件名不一致。解决办法是先正常访问一次页面观察 Network 面板里加载了哪些静态资源把带 hash 的实际文件名填进去或者干脆用更宽松的策略install 阶段直接缓存当前所有同源请求的响应。最省事的方式是改成运行时缓存在 install 阶段只缓存/而在 fetch 阶段把每次访问通过的资源都放进缓存。用不了几次循环缓存池就满了。我建议按上面的 PRECACHE 方式写死基础资源然后通过 fetch 策略做补充缓存这样排障时心智负担最小。6. 第四步桌面安装与部署上线的完整链路6.1 触发安装弹窗的三种方式浏览器的安装行为有两类一是浏览器自动弹出“安装应用”的提示气泡二是你的页面在合适的时机主动触发。自动弹窗比较烦人也容易打断用户操作所以我更推荐在页面里放一个显眼的安装按钮在用户点击后触发。这就是我在首页代码里通过beforeinstallprompt和deferredPrompt实现的行为。除了点击事件触发还可以在用户停留一段时间后自动弹出 toast 引导或者在用户触发某个关键操作比如收藏页面后再提示。这些场景只需要稍作包装核心 API 不变。需要注意beforeinstallprompt只有在浏览器判定应用满足安装条件时才会触发。条件包括有合法的 manifest、有 192 及以上图标、完全通过 HTTPS 访问、已经注册 Service Worker。如果这几个条件有一个没满足事件就不会触发你的安装按钮也会永远藏着。这是排查安装入口无效的第一优先级原因。6.2 部署到服务器配置 HTTPS 与路径编译步骤在 HBuilderX 顶部选择发行 - 网站-H5手机版产物会输出到unpackage/dist/build/web目录。把这个目录里的所有文件上传到服务器任意位置保证域名能访问到index.html。如果你用的是 Nginx一个最简配置如下server { listen 443 ssl; server_name your-domain.com; ssl_certificate /path/to/cert.pem; ssl_certificate_key /path/to/key.pem; root /var/www/offline-app; index index.html; # history 路由模式下的 fallback location / { try_files $uri $uri/ /index.html; } # sw.js 不缓存 location /sw.js { expires -1; add_header Cache-Control no-store; } }这里我用try_files解决了 history 路由模式下的刷新 404 问题。如果你不想配 Nginx 的 fallback也可以把 router 模式改成hash这样路径里会带#刷新不会 404但 URL 不够美观且 PWA 安装的 start_url 处理起来要额外注意 hash。关于 HTTPS 证书个人项目可以直接用免费证书也可以用云厂商提供的一年期证书。不推荐自签名证书因为自签名证书无法通过浏览器安全验证Service Worker 不会在真实场景中工作。6.3 部署后的自检清单上传完成后按这个清单过一遍检查项方法预期结果HTTPS 是否有效地址栏查看锁标志无告警manifest 能否正常加载DevTools - Application - Manifest显示你的应用名和图标Service Worker 是否注册DevTools - Application - Service Workers显示 activated安装按钮是否出现页面右下角点击后弹出浏览器安装确认框安装后启动是否独立窗口点击桌面图标无浏览器地址栏的独立窗口断网刷新是否可用Network 面板切 Offline 后刷新页面正常显示并出现提示有一步我吃过亏在这里提醒部署完之后如果之前访问过旧版本第一次打开新版本时 Service Worker 不会立即生效。你需要把浏览器里的 service worker 注销或者使用“硬性重新加载CtrlShiftR”清掉旧缓存再测首屏。否则你会反复看到旧代码。7. 常见问题与排查技巧实录7.1 安装按钮不显示这是出现频次最高的咨询。按顺序排查确认访问协议是 HTTPSlocalhost 除外。确认 manifest 能被正常解析。DevTools Application 面板如果显示“Manifest errors”说明 JSON 语法或字段有问题。最常见是 icons 数组里的 src 路径 404。确认 Service Worker 已经激活。你可以直接在 Console 里执行navigator.serviceWorker.getRegistration()如果返回 undefined说明注册脚本路径有误。确认beforeinstallprompt有没有被触发。在事件回调里打一个 console.log如果没有任何输出只能说明浏览器不认为可安装回到前三条找原因。一个不容易察觉的坑如果页面里有两个beforeinstallprompt监听器其中一个调用了 preventDefault 但没有保存事件另一个也不会生效。同一页面只需要一个控制逻辑。7.2 更新代码后用户还是旧版本Service Worker 的更新机制有个特性即使服务端 sw.js 内容变了浏览器也可能因为旧的 Service Worker 还在运行而暂时不更新。你需要等当前 Service Worker 控制的页面全部关闭新版本才会接管。另外导航请求的网络优先策略只对 HTML 生效如果你改了 JS/CSS 文件内容但文件名不变浏览器可能走缓存。解决办法是给 JS/CSS 文件加上 hash。Uniapp H5 编译产物默认带 hash正常情况下文件名会变不需要额外处理。关键在于 sw.js 本身不能被浏览器缓存所以部署阶段要把 sw.js 的响应头设置成不缓存这也是我在 Nginx 配置里单独写location /sw.js的原因。7.3 缓存了不该缓存的内容页面数据陈旧如果你的页面是数据展示型应该把接口请求排除在缓存策略之外。我们在 Service Worker 里对request.mode navigate之外的请求做了缓存优先这会把 GET 接口的响应也缓存下来。这对新闻资讯、实时数据类应用是灾难。解决方法是给接口请求加一条规则if (url.pathname.startsWith(/api/)) { event.respondWith(fetch(request)) return }如果你确实希望接口也离线可用那就需要实现数据层的离线策略比如 IndexedDB 存储最近一次接口响应。这属于进阶玩法后面教程会单独讲。7.4 iOS Safari 上的特殊限制iOS 从 11.3 开始支持 PWA但限制比桌面端多Safari 不支持beforeinstallprompt只能通过“分享 - 添加到主屏幕”操作。而且独立窗口模式下Service Worker 的cachesAPI 行为与桌面端基本一致但部分系统版本在弱网环境下对缓存配额管理比较保守离线资源较多时可能被自动清理。针对 iOS退而求其次的做法是在页面顶部做一个引导遮罩提示用户手动添加。代码里通过navigator.platform判断 iOS在遮罩中展示操作步骤。这是一个低成本且有效的兼容方案。7.5 HBuilderX 内置浏览器无法测试 PWAHBuilderX 内置预览器本质是一个 WebView不支持部分 PWA API比如安装弹窗和 Service Worker 的完整生命周期。所以测试 PWA 功能一定要用系统的 Chrome 或 Edge 打开编译后的页面或者先启动本地静态服务器再访问。我经常用的命令是npx serve dist或者python3 -m http.server 8080。注意本地测试时要用 localhost 而不是局域网 IP因为只有 localhost 会被当成安全上下文。8. 进阶方向与我的实操体会做完这个基础版项目你会拥有一个可以安装、可以离线运行、带独立窗口的 Uniapp 应用。如果你还想往前走一步我建议按这几个方向深入第一个方向是数据层离线化。静态资源离线只是 PWA 的一半真正的离线应用还要能在无网状态下读写业务数据。IndexedDB 后台同步Background Sync是比较自然的路线Uniapp 的本地存储 API 和 IndexedDB 可以配合使用做到“上传失败自动排队网络恢复后发送”。第二个方向是更新策略精细化。把缓存策略拆成“核心路径缓存优先 非核心资源网络优先 接口永不缓存”再配置个版本管理机制让用户能感知到内容更新。第三个方向是多页面应用适配。Uniapp 目前默认模式是 SPA如果你打算把整个应用拆成多个页面入口比如一个大应用拆成多个子应用PWA 的 route 结构、预缓存列表、scope 都要重新调整。我个人在多个项目里反复验证下来的经验是PWA 最舒服的场景不是替代原生应用而是把“打开即用”的体验和“独立入口”的存在感结合好。像天气工具、记账本、周边查询、物流跟踪、餐厅菜单这类需要频繁使用的轻应用PWA 的安装率和使用深度都相当可观。它不需要你在应用市场里排队审核不需要用户下载几十 MB 的安装包却能给用户提供接近原生的启动速度和脱离浏览器的使用体验。整个实现路径你也可以重新走一遍创建 Uniapp 项目补全 manifest 的 pwa 节点注册 Service Worker触发安装事件部署到 HTTPS 服务器。每一步的坑都在前面列出来了现在动手做吧。遇到问题不要慌先打开 DevTools 的 Application 面板看 manifest 和 Service Worker 两个入口大部分问题都能在这里定位到。