☰
Uniapp + PWA实战:预加载与离线阅读,让资讯App弱网秒开
2026/10/8 15:36:09 网站建设 项目流程

Uniapp + PWA 这条线做到第五期,手头这个资讯类项目也终于过了“能打开、能安装、能推消息”的阶段。前几期陆续搞定了PWA基础接入、Web App Manifest配置、离线包校验和推送链路,本以为后面都是小修小补,直到我把真机丢进弱网环境跑了一轮,才发现最影响体验的根本不是首屏加载,而是从列表页点进详情页那几秒钟的白屏等待——用户在地铁上刷着刷着,WiFi断了、4G变虚,文章一直转圈,很多人不等数据回来就直接划走了。这期把重心放在两个方向:预加载热门内容,让用户点进来的那一刻数据已经在本地;离线阅读体验升级,让用户在没有网络时也能把缓存内容读完。全文基于Uniapp编译到H5、再以PWA方式增强的落地实践,适合资讯类、内容社区类、工具文档类项目参考。

文章按这个顺序展开:先聊为什么预加载和离线是一体两面;再讲Uniapp工程里Service Worker的注入和构建细节;然后给出预加载队列的设计与实现;接着落到离线阅读的缓存策略和页面渲染;最后把上线后踩到的坑一并交代清楚。文中大部分代码做了脱敏,拿过去改改路径和接口名就能用。

1. 为什么这期专门做预加载和离线:从用户操作时间线说起

1.1 一条用户时间线,暴露80%的等待时延

一个典型的阅读场景:用户打开首页,看到推荐列表,滑动,点击感兴趣的文章,读正文,返回列表,再点下一篇。这个过程里真正让用户等的是第二个动作——从点击到正文渲染完成。列表页我们早就做了分页加载和懒加载,但详情页每次都是白手起家:发请求、等响应、渲染正文。如果把用户从“手指落下”到“正文出现”的这段耗时拆开看,网络请求占掉了80%以上的等待时延。

我用Chrome DevTools的Slow 4G模拟做了一轮采样,同一批20篇正文接口,平均响应时间约2.8秒,加上正文图片的加载时间,用户普遍要等4秒以上才能看到完整内容页。这个数据放在WiFi环境里不明显,放到公交、地铁场景就非常致命。弱网下面用户跳出率比WiFi环境高出接近一倍,而用户一旦中断阅读动作,下一次再打开App的意愿也会跟着降。

这也是我在上一篇末尾预告的优化方向:与其优化详情页接口本身的响应速度,不如让内容“先人一步”到用户手里。预加载就是从客户端层面把这段等待时间直接抹掉。

1.2 预加载和离线,是同一件事的两面

很多人把预加载和离线阅读当成两个独立功能,其实它们在工程上是耦合的:预加载做的事情是提前拉取数据,离线阅读做的事情是当请求失败时用本地数据兜底。两者的数据源和存储层完全一致,只是触发时机和消费方式不同。

我把这个关系整理成了下面这张表,方便你评估自己的项目是否需要做这套优化:

场景优化前只做预加载只做离线缓存预加载+离线
正常网络点击详情页2.8s等待0.2s本地渲染2.8s等待0.2s本地渲染
弱网点击详情页4s+或失败0.2s本地渲染走网络,慢或失败0.2s本地渲染
完全离线点击详情页白屏/错误页白屏/错误页本地渲染本地渲染
用户消耗流量每次请求都走网络预加载时多花流量无额外流量预加载时多花流量

所以预加载负责的是“提升体验上限”,离线缓存负责的是“兜住体验下限”。缺了离线,预加载的成果在断网时全部作废;缺了预加载,离线缓存只能覆盖用户看过一次的内容,热门新内容在弱网下依然打不开。两者一起做,才能把用户粘性真正的短板补上。

2. Uniapp工程里的PWA改造:Service Worker的注入与H5构建细节

2.1 注册脚本放哪里,其实很讲究

在Uniapp里做PWA,和普通Vue/React项目最大的区别在于工程入口。纯前端项目一般在src目录下放一个public/index.html,然后直接在html里写注册逻辑;Uniapp H5的主入口逻辑是HelloWorld式的main.js,页面模板是编译器内部生成并注入的。如果直接往main.js里塞SW注册代码,逻辑上能跑,但没法在构建时把Service Worker脚本原样输出到根目录。

我这边采用的方式是自定义H5模板:在项目根目录创建一个template.h5.html,在package.json或manifest.json里配置让H5编译器使用这个模板。模板内容就是Uniapp H5标准模板的简化版,然后在 之前插入一段注册脚本:

<script> if ('serviceWorker' in navigator) { window.addEventListener('load', function () { navigator.serviceWorker.register('./sw.js').catch(function (err) { console.warn('[PWA] SW register failed:', err); }); }); } </script>

这里有个容易踩的细节:注册路径不要在js里写死成绝对路径。如果你的站点部署在域名根目录,写/sw.js没问题;但很多Uniapp项目是部署在类似https://example.com/h5/这种子路径下的,此时应该用相对路径./sw.js,注册成功后Service Worker的scope会默认限制在/h5/下。

同时要在manifest.json的h5节点里指定模板:

{ "h5": { "template": "template.h5.html", "router": { "base": "/h5/" } } }

这个配置决定了编译产物里资源的相对引用方式,也直接影响SW脚本的路径计算。遇到过不少同学只改router.base不换SW注册路径,导致资源文件全部404,页面白屏,排查半天才发现是模板没跟上。

2.2 部署路径和scope作用域

Service Worker的作用域是一个很隐形的坑。假设你的PWA部署在/h5/路径下,那么默认scope就是/h5/,这意味着SW只能拦截/h5/开头的请求。如果项目里部分接口走的是/api/路径,那这些请求就不受SW控制,离线兜底策略会直接失效。

解决办法有两种:一是把接口路径也部署在同一个Prefix下,比如/h5/api/,通过反向代理转发到实际接口服务;二是在SW响应头里加Service-Worker-Allowed: /,把scope扩展到根路径。第二种方式需要服务器支持自定义响应头,如果是静态托管(比如OSS、对象存储),改响应头往往不方便,优先考虑第一种。

另外,无论用哪种方式,都建议在页面端注册后立刻打印一下scope:

navigator.serviceWorker.ready.then(function (reg) { console.log('[PWA] scope:', reg.scope); });

我上线前就因为没确认scope,导致fetch事件完全没生效,页面一直走网络缓存,离线测试始终过不了。打印出来一看,scope指向了/h5/而接口在/api/,立刻就知道问题在哪了。

2.3 开发环境里最容易翻车的三个细节

本地localhost下注册SW基本是顺畅的,但Uniapp开发者常用局域网IP让手机真机联调,比如http://192.168.1.5:8080。这种环境下Chrome会拒绝注册Service Worker,因为非localhost的HTTP站点不被认为是安全上下文。折腾半天最后只能让手机在开发者模式里把站点加入安全白名单,或者干脆用HTTPS的测试域名,这个前置条件在项目启动时就要想清楚,否则后面每次联调都会被卡一下。

第二个坑是H5的devServer热更新和SW缓存互相打架。开发阶段我通常直接在build命令里注入一个环境变量,比如process.env.NODE_ENV === 'development'时直接跳过SW注册,避免改一行代码页面就给你回放旧的缓存版本。

第三个坑是先上线了SW,后来又改接口字段。Service Worker的缓存策略如果写的是Cache First,线上老用户会一直拿到旧数据。你的代码改了接口字段,但老版本缓存里的JSON结构还是旧的,页面渲染直接崩。后面专门讲版本控制时会展开。

2.4 给sw.js加上版本管理,越早越好

这个是我吃过亏之后强烈建议补上的。sw.js发布之后,浏览器会定期检查这个文件是否有更新(最多24小时强制检查一次,但实际有很多因素影响)。如果你在sw.js里写死了缓存的cache名称,比如cache-name: 'article-v1',那么下次上线改缓存结构时,旧缓存不会被清理,新缓存的key又对不上,用户就会处在一个“本地有两份缓存但永远读到旧的那份”的尴尬状态。

我更推荐的做法是构建时生成版本号,然后注入到sw.js顶部:

// sw.js const VERSION = '20250603-001'; const CACHE_NAMES = { static: `static-${VERSION}`, article: `article-${VERSION}`, image: `image-${VERSION}` };

这样每次发布新版本,缓存名自然变成新的,activate阶段可以遍历并删除所有旧名称的缓存。这一步看起来是纯工程细节,但对后续“预加载内容是否该做版本迁移”影响很大。

3. 预加载热门内容:请求调度、网络感知与队列优先级实现

3.1 热门内容从哪来:接口设计要区分“列表数据”和“详情数据”

预加载的第一件事是确定“热门内容”的范围。我见过不少团队直接把首页列表接口的第一屏数据当成预加载源,这有个问题:列表接口返回的通常是摘要字段(标题、封面、摘要等),而用户点击后详情页需要的完整正文并没有被拉回来。

所以预加载的资源应该拆成两类:

  • 预加载列表数据:用于用户打开首页时秒开,对应列表接口。
  • 预加载详情数据:用于用户点击文章后秒开,对应详情接口,这才是预加载的“主力”。

热门内容的数据源,我用的是一个独立的推荐位接口,服务端返回当天运营配置的热门文章ID列表,再拿ID去拉详情。这样热门池的更新不依赖首页个性化分页,预加载队列的输入是明确且可控的。接口样例:

{ "code": 0, "data": { "hotArticleIds": [1001, 1002, 1003, 1004, 1005] } }

后端控制在5到10个ID比较合理,预加载太多会浪费流量和带宽,太少又覆盖不了用户停留期间的点击需求。这个数字我们后来是根据命中率统计调出来的,后面踩坑那节会讲。

3.2 预加载队列的基本实现

预加载的本质是“提前发请求”。但你不能一股脑把10个详情请求同时发出去——弱网环境下会互相争抢带宽,把首页正在加载的资源和正在进行的用户请求全部拖慢。必须做一个带并发控制的队列。

我在项目里写了一个轻量版预加载队列,核心思想是分批串行,每批并发数限制在2:

class PreloadQueue { constructor(concurrency = 2) { this.concurrency = concurrency; this.queue = []; this.activeCount = 0; this.results = new Map(); } push(task) { this.queue.push(task); this.schedule(); } schedule() { while (this.activeCount < this.concurrency && this.queue.length > 0) { const task = this.queue.shift(); this.activeCount++; task() .then(() => { this.activeCount--; this.schedule(); }) .catch(() => { this.activeCount--; this.schedule(); }); } } }

每个task里面封装的是请求详情接口并写入缓存的操作。这里有几个细节要说明:一是同一个文章ID在队列里要幂等,预加载过程中用户自己点了这文章,请求可能已经发出去了,要防止重复请求和重复缓存;二是队列要支持最高优先级任务插队,比如用户主动点击的文章详情,应该立刻插到队列最前面,而不是排在后面等热门预加载慢慢执行。

插队逻辑很简单,push的时候加个unshift的入口,用户点击时调用即可。

3.3 网络感知与流量控制:别在2G下预加载

预加载的直接代价是流量。如果对所有人无条件预加载,月流量账单会很难看,用户也会因为这个功能产生积怨。我的处理是用网络类型做门控:

function isGoodNetwork() { const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (!conn) return true; const type = conn.effectiveType; const saveData = conn.saveData === true; if (saveData) return false; return ['4g', '3g'].indexOf(type) > -1; }

WiFi环境直接放行;4G下做预加载,但预加载数量从5个降到3个;3G继续降;2G和节能模式下直接跳过。effectiveType这个字段在主流的Chromium内核浏览器和Android WebView里都支持,iOS Safari对它的支持要看版本,兜底逻辑就是没有这个API时不限制。

实测下来这个门控很重要。上线预加载功能第一周,我拉过后端日志,全量用户里约15%的流量用在预加载上,加了网络门控之后,这个数字降到6%,而核心页面的预加载命中率没有明显下降。理由很简单:弱网环境下的用户本来就不太可能去点大段图片的文章列表,省下的流量花在该花的地方。

3.4 触发时机:不是越早越好

预加载的触发时机同样要精细控制。很多人上来就在页面onLoad里直接预加载,这会导致首页首屏和预加载请求同时抢网络。我的触发策略改成了三个时机:

  • 首页首屏渲染完成后,等requestAnimationFrame下一个空闲帧再启动预加载列表数据。
  • 列表页滚动距离超过屏幕高度的0.8倍时,说明用户有继续阅读的兴趣,预加载热门详情。
  • 页面visibilityState变成hidden之前(比如用户切后台),如果还有队列没执行完,截断掉,不再发起新的预加载。

“用户停留超过若干秒后预加载”也是一个常用触发点。资讯类App里,用户如果在一个列表页停留超过10秒,点击当前页某篇文章的概率会大幅上升,这时候再动手预加载也不迟。我把这个方案放在滚动触发之后作为兜底,两种触发方式不冲突,同一批ID会被幂等过滤掉。

用requestIdleCallback是理想方案,但兼容性有限,尤其在部分国产浏览器里等于没有。我在工程里用一个setTimeout加事件监听做了个简易空闲检测,在用户停止滚动或输入3秒后执行队列排空。效果差不了太多,胜在可控。

3.5 预加载数据的落点:为什么我选了IndexedDB

预加载拿到数据后,存哪里决定了离线的体验上限。Uniapp的uni.setStorageSync底层在H5端对应localStorage,有5MB左右的硬上限,而且同步读写大数据时会阻塞主线程。文章正文字段动辄几十KB上百KB,列表摘要还好,存正文很容易爆掉,所以我放弃了localStorage这条路线。

IndexedDB是浏览器提供的异步数据库,容量远大于localStorage,支持索引和事务,用来存文章JSON正合适。Uniapp H5没有封装IndexedDB API,我直接用了idb这个库(也可以用原生api,量少时差别不大):

import { openDB } from 'idb'; const dbPromise = openDB('article-db', 1, { upgrade(db) { if (!db.objectStoreNames.contains('articles')) { db.createObjectStore('articles', { keyPath: 'id' }); } } }); export async function saveArticle(article) { const db = await dbPromise; await db.put('articles', { id: article.id, title: article.title, content: article.content, cover: article.cover, savedAt: Date.now() }); } export async function getArticle(id) { const db = await dbPromise; return db.get('articles', id); }

IndexedDB里的数据是给Uniapp页面渲染用的,Service Worker里的Cache Storage则是给网络请求兜底用的。两层分工明确:页面读数据优先从IndexedDB查,请求拦截兜底走Cache Storage。这不是重复存储,是因为两者的消费方不同——SW拦截的是Fetch请求,Uniapp页面代码拿不到Cache Storage里那层透明的响应对象,还得靠自己的存储层读数据来渲染。

4. 离线阅读的具体实现:从缓存策略到页面兜底渲染

4.1 三种缓存策略怎么选:没有万金油

Service Worker的fetch事件里,缓存策略一般分三派:

策略流程适用场景风险
Cache First先查缓存,命中直接返回,没命中走网络并写入缓存静态资源、图片缓存内容可能陈旧
Network First先走网络,失败后回退缓存API接口、需要实时性的数据弱网下依然慢
Stale-While-Revalidate先返回缓存,同时后台拉新并更新缓存列表页、资讯瀑布流短时间看到旧数据

对文章详情这种“重读价值高、实时性要求低”的内容,我一开始用的Cache First,用户体验最好,但问题很快暴露:文章被运营修改标题或者更新内容后,老用户永远看到旧版。后来改成Network First with timeout,即给网络请求一个超时时间,超出后立刻回退缓存。这个方案兼顾了实时性和离线可用性:

self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); if (url.pathname.indexOf('/api/article/detail') === -1) return; event.respondWith( fetch(event.request) .then((response) => { const clone = response.clone(); caches.open(CACHE_NAMES.article).then((cache) => { cache.put(event.request, clone); }); return response; }) .catch(() => { return caches.match(event.request).then((hit) => { return hit || new Response(JSON.stringify({ code: -1, msg: 'offline' }), { status: 200, headers: { 'Content-Type': 'application/json' } }); }); }) ); });

这个catch分支是核心。网络完全断掉时,fetch会抛异常,此时回退到Cache Storage里查有没有缓存过的响应;如果没有,也不要直接返回404,因为页面端拿到404后很容易进入空白错误态。返回一个带有标记的JSON,让Uniapp页面走IndexedDB兜底渲染路径。

4.2 离线阅读页的渲染流程

文章详情页的渲染逻辑我分成了三条路径:

  • 有IndexedDB数据:直接用本地数据渲染,页面秒开,同时后台静默请求最新数据并更新页面里的“更新时间”。
  • 无本地数据但网络正常:走正常请求,渲染后顺手写入IndexedDB和Cache Storage。
  • 无本地数据且网络断开:显示离线提示页,推荐几篇本地已有的热门文章给用户继续阅读。

离线提示页很重要,它是用户离线状态下唯一还能产生链路的地方。不要只给一句“网络不可用”就完事,把IndexedDB里存着的热门文章ID列表拿出来,渲染一个简单的推荐列表,让用户知道“不是App坏了,是没网,但还有这些能看”。这个体验差异对口碑的影响非常明显。

离线阅读页里我额外做了一件事:把正文里的图片地址拆出来,通过一个自定义组件按需加载。网络断开时,图片如果没缓存过也没关系,组件会显示预设的“图片走丢了”占位图,而不是裂图。这一点很多文章不会提,但实际体验里,一张裂图比一整个空白页更劝退用户,因为它制造了一种“App坏了”的错觉。

4.3 图片和静态资源的离线处理

详情页图片的CDN地址通常是https://cdn.example.com/xxx.jpg,这些请求默认不在SW控制范围内,需要在fetch事件中对CDN域名单独处理。我的策略是对图片施行Cache First:只要缓存过,就永远不会重新走网络,既减少流量又提升加载速度。

值得注意的是,图片缓存容易把Cache Storage撑爆。浏览器对Cache Storage的容量没有统一标准,但配额管理是硬性的。我加了两个限制:一是图片缓存单独放在image缓存库里,和文章数据隔离,方便单独清理;二是在install或activate阶段,检查缓存条目总数,超过500条就批量删除最早的一半。

这个数字500不是拍脑袋定的,是拿主力机型测试后得出的阈值。超过这个数量后,缓存命中率开始下降、容量告警频发,而维持在这个数量以内,缓存基本不会触发浏览器清理。

4.4 过期缓存怎么清,版本怎么升

缓存清理逻辑和版本管理强相关。我的做法是每隔三天,前端主动请求一个/meta/version.json,用里面的版本号去比对本地的缓存库版本。版本不一致就把旧的article库和image库清掉,然后重新预加载热门内容。

这个“三天”是一个折衷值。太频繁会白白消耗流量,太慢又会让用户在功能升级后继续读到旧数据。更激进一点的做法是在service worker的fetch事件里,每次命中缓存时主动检查响应头的Last-Modified,但那个需要后端配合,而且判断逻辑会复杂不少。对资讯类项目来说,定期清版本已经足够。

5. 上线后真正会踩的坑:版本、登录态与打包配置

5.1 用户永远看到旧内容,是SW版本更新的老问题

上线预加载功能后,我收到过几次“文章内容不对”的反馈。排查到最后,都是Service Worker版本没及时更新的问题:sw.js改动了,但老用户的浏览器在一天之内可能不会主动拉新sw.js,而旧SW还在积极返回缓存数据。

解决方案是把版本检查提到页面启动时,用一个独立接口返回当前资源版本号:

const remoteVersion = await fetch('/meta/version.json').then(r => r.json()).catch(() => null); if (remoteVersion && remoteVersion.version !== localVersion) { const reg = await navigator.serviceWorker.getRegistration(); if (reg) { await reg.update(); // 返回新版本后,主动告诉用户刷新一次 } }

同时,在页面顶部做一个无感知提示条:“内容已更新,点击刷新”,而不是强制刷新页面。强制刷新会打断正在阅读的用户,提示条让用户在自然动作(比如返回列表)时顺手刷新。

还有一个不起眼但很实用的细节:在unregister()旧SW之前,不要把缓存直接清掉。先让新SW接管,等它activate完成后再清旧缓存,否则会出现“新SW还没就绪,旧缓存已经被清了”的真空期,用户打开页面直接白屏。

5.2 离线状态下的登录态失效:别把用户卡死在登录页

绝大多数Uniapp项目都有登录体系。文章详情页如果请求带token,那么token过期后,离线状态下预加载请求会失败,而且失败原因往往被Service Worker统一catch住,返回未认证状态。用户看到的就是一片空白,根本不知道为什么。

排查后我的处理方案是:离线兜底时绕过登录校验。Service Worker在catch到网络失败后,直接进缓存查询,不需要判断token有效期;页面端再次兜底,如果IndexedDB里有这篇文章,直接渲染,不弹登录框。为什么不弹?因为离线状态下根本做不了登录取新token的操作,弹了也没用。

但这里有个安全边界:登录态和金币、支付相关的敏感接口绝不能做离线缓存,也不要在SW的缓存策略里对这类接口做任何触达。离线缓存只覆盖“可公开阅读的资讯内容”,个人中心、订单、钱包等页面一律排除。给SW的fetch事件加白名单时,宁可少配,不可错配。

5.3 条件编译处理多端:PWA是H5专属能力

Uniapp最大的价值是多端编译,但也是最大的坑:Service Worker、Cache Storage、preload逻辑在微信小程序端、App端是完全不存在的能力。如果不加处理,这些代码在编译到小程序或App时会直接报错,比如navigator未定义、window未定义。

所以预加载队列和离线缓存的模块,我在入口处加了一层条件编译:

// #ifdef H5 import { initPreload } from './preload'; initPreload(); // #endif

小程序端则走它自己的本地存储和分包预下载逻辑,逻辑完全不同。这里要特别提一下“uniapp 微信小程序打包 source size 2612kb exceed max limit 2mb”这个网上高频问题:小程序主包有2MB硬限制,很多人想塞的东西都塞不下,预加载逻辑更不可能放到小程序端去;而H5/PWA端没有包体限制,反而可以更宽松地把预加载模块、离线页面都打进主包。两端的优化思路完全不是一个赛道,动手前一定要分清你在优化哪一端。

5.4 预加载的流量和命中率:如何量化这套方案的价值

预加载不是做了就完事,它需要数据证明“预加载的内容真的是用户会点的内容”。我在文章详情页埋了一个事件:渲染来源是local_cache还是network,把这两个值按天拉出来算命中率。

命中率 = 本地缓存渲染的文章数 / 全部文章渲染数。首周统计下来大概在62%左右,也就是每三次点击有接近两次命中缓存。看起来不错,但细看后发现问题:预加载5篇里有很多是用户根本没点的,点击集中在第一、第二篇热门内容上。于是我把预加载数量从5调整到3,同时让运营侧只放那种真正会引发点击的内容(比如早间热点、突发新闻),命中率反而提升到71%,流量则下降了三分之一。

另一个重要指标是“弱网环境下的详情页加载成功率”。上线前这个数字在弱网下只有82%,上线后稳定在95%以上。这10几个百分点的提升,对用户流失率的拉动力比任何首屏性能优化都明显。

优化到这里,我心里基本有数了。预加载和离线阅读不是锦上添花的功能,而是内容类产品的体验底线。最后再分享一个小技巧:预加载队列里我特意留了一个“用户行为权重”的口子——如果用户在列表页频繁点击某个分类的文章,那么下次预加载队列会自动增加该分类的比例,这个逻辑代码量不大,但对命中率的提升肉眼可见。后面有时间我会把这部分的模型迭代单独写一篇,这期的内容就到这,希望对正在做Uniapp H5和PWA优化的朋友有帮助。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询