1. 先别急着动代码,弄清Uniapp PWA首屏加载的瓶颈在哪
1.1 Uniapp H5端首屏加载到底加载了什么
去年年中我把一个用Uniapp写的商城项目打包成H5应用,顺手接上了PWA的manifest和Service Worker,觉得既然能装到主屏,体验应该没跑了。结果用户连续反馈首屏转圈,我打开Chrome无痕窗口访问了一次,Lighthouse的Performance得分只有三十多分,连续好几个几百毫秒的长任务卡在主线程上。问题出在哪?说白了就是首屏资源太重,缓存策略又几乎等于没做。
先说清楚Uniapp H5端是一个什么样的产物。它本质上就是一个标准的Vue SPA,pages.json里声明的页面会被编译成路由表,App.vue和main.js负责拉起整个应用。打包后你会在dist目录里看到这些文件:index.html入口、chunk-vendors.js(Vue、Vue Router这类第三方库的集合)、app.js(Uniapp运行时加你的全局逻辑)、若干页面chunk、还有一堆CSS、图片和字体。很多人有个错觉,以为默认构建会把每个页面拆成独立文件,不用管就是按需加载。但实际打开Network面板一看,首屏往往要同时拉回一个体积不小的vendors文件。如果项目里又装了图表库、视频播放器、富文本编辑器这类重量级依赖,chunk-vendors.js很容易膨胀到四五百KB甚至更多。这个文件在首屏就要被下载、解析、执行,一套下来,手机CPU已经被占掉一大截。
需要注意,资源的下载速度和解析速度是两回事。文件从服务器传过来,就算gzip压缩到100KB,浏览器还得把它还原成JavaScript源码,然后分词、编译、执行。这个过程几乎不受网络带宽影响,纯粹吃设备算力。低端安卓机上,一个300KB的JS文件解析几十毫秒到上百毫秒是很正常的事。所以首屏优化不能只看传输体积,还要看主线程到底执行了多少代码。这也是为什么后面路由懒加载的收益那么明显——它直接砍掉了首屏主线程的主要工作。
1.2 为什么PWA场景下首屏慢比普通H5更扎眼
普通H5页面其实有一种隐形的缓存优势:用户第二次访问时,浏览器会利用HTTP缓存把静态资源直接打到磁盘,网络请求大量减少。但PWA从智能手机主屏上点开时,很多浏览器会把它当成一个独立的App来冷启动,如果Service Worker里什么缓存策略都没配,这次冷启动和第一次访问几乎没有任何区别,所有JS、CSS、图片都得重新从服务器拉一遍。
更要命的是,用户对PWA的预期不是"网页",而是App。一个装了美团、京东这类原生应用的人,点开你的PWA却要等两三秒白屏,他会马上关掉。弱网环境下更糟糕,3G网络配合几百KB的JS,首屏出图遥遥无期。所以PWA首屏优化的目标很明确:把每次冷启动必须完成的工作量降到最低,让从主屏点开图标到看到内容的时间尽量接近原生App。做到这一步,靠的就是预缓存、路由懒加载和资源压缩这三个手段,它们分别解决"从哪取资源""取多少资源""资源有多大"这三个核心问题。
1.3 用哪些指标来量化现在的瓶颈
动手改代码之前,我强烈建议先把现状数据记录下来,否则优化完连有没有变好都说不清。我一般会开Chrome DevTools的Lighthouse,重点看Performance和Progressive Web App两个分类下的分数;再切到Network面板,按Transfer Size排序,找出体积最大的几个请求;最后在Performance面板里跑一次录制,看主线程的长任务耗时和FCP、LCP出现的时间点。
Lighthouse里FCP和LCP是最直观的首屏指标,FCP意味着用户看到第一个像素,LCP意味着主要内容出来了。Performance面板的Main Thread长任务如果超过200毫秒,就会让用户感觉到卡顿,这个数字通常比下载时间更能反映问题。把这些数据存到表格里,做完每一板斧后跑一遍,数字会告诉你哪一步真正有效,哪一步只是心理安慰。
2. 第一板斧:预缓存——把App Shell腌进浏览器
2.1 注册Service Worker的时机与前提
Service Worker本质上是一个跑在浏览器后台的独立线程,可以拦截页面的网络请求,决定响应是来自缓存还是网络。预缓存,就是在Service Worker安装阶段,提前把首屏必需的静态资源写进CacheStorage。打个比方:第一次访问时正常加载,同时服务员把一桌菜提前腌好放进后厨柜子,第二次你再进店,不用等灶台起火,直接上菜。
在Uniapp项目里,注册代码可以放在main.js里,也可以放在App.vue的onLaunch生命周期中,这两处都能保证应用启动时执行:
if ('serviceWorker' in navigator && window.location.protocol.startsWith('http')) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js', { scope: '/' }) .then(registration => { console.log('SW registered', registration.scope); }) .catch(err => console.warn('SW register failed', err)); }); }这里有两个前提要注意。第一,PWA要求HTTPS环境(本地调试时localhost除外),如果部署到内网HTTP地址,Service Worker注册会被浏览器直接拒绝,别在这上面浪费排查时间。第二,不要把注册逻辑写在开发环境里,否则热更新和SW同时存在会互相打架。所以我一般会在外面套一层process.env.NODE_ENV === 'production'的判断,只在生产构建里启用。
2.2 预缓存清单怎么列
预缓存最容易犯的错误是贪多,恨不得把全站文件一次性都塞进缓存。但预缓存越多,安装阶段写CacheStorage的时间越长,反而拖慢了首次启动。PWA社区经典的App Shell模型值得照抄:只缓存构成应用外壳的入口HTML、核心JS/CSS、manifest和图标,让首屏骨架能秒开。页面内容和动态数据永远按需加载。
手写一个sw.js,骨架大致是这个样子:
const CACHE_VERSION = 'app-shell-v1'; // 改这个字符串来触发缓存更新 const PRECACHE_URLS = [ '/', '/index.html', '/manifest.json', '/static/css/app.css', '/static/js/chunk-vendors.js', '/static/js/app.js', '/static/icons/icon-192.png', '/static/icons/icon-512.png' ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_VERSION) .then(cache => cache.addAll(PRECACHE_URLS)) .then(() => self.skipWaiting()) ); }); self.addEventListener('activate', event => { event.waitUntil( caches.keys() .then(keys => Promise.all( keys.filter(key => key !== CACHE_VERSION) .map(key => caches.delete(key)) )) .then(() => self.clients.claim()) ); });install阶段负责写入预缓存清单,activate阶段负责把旧版本的缓存清掉。skipWaiting和clients.claim这两个方法很关键,它们能让新SW尽快接管页面,避免用户继续使用旧逻辑。PRECACHE_URLS里写死文件名有个隐患——构建产物的hash一变,这个清单就失效了。这个问题我会在第5节专门展开,先记住这里不是重点,重点是App Shell的思路。
2.3 运行时缓存策略:别把所有请求一视同仁
预缓存解决的是静态壳的问题,运行时还有大量请求进来,必须给它们分配不同的处理策略。如果对所有请求都无脑Cache First,你会发现用户信息接口返回昨天的数据、订单列表永远不更新,最后还得加班收拾烂摊子。
按照资源类型来分,一般是这样一张表:
| 资源类型 | 推荐策略 | 原因 |
|---|---|---|
| HTML入口 | Network First(配合服务器304) | 保证拿到最新入口文件,避免旧入口引用不存在的chunk |
| 带hash的JS/CSS | Cache First + 后台更新 | 文件名一变自然重新拉取,不存在旧内容问题 |
| 图片/图标 | Stale-While-Revalidate | 缓存秒开,后台偷偷更新保持新鲜 |
| API接口 | Network Only 或 Network First | 避免缓存到过期数据 |
对应的fetch监听逻辑大概是这样的:
self.addEventListener('fetch', event => { const url = new URL(event.request.url); if (event.request.mode === 'navigate') { event.respondWith( fetch(event.request).then(response => { const clone = response.clone(); caches.open(CACHE_VERSION).then(cache => cache.put('/', clone)); return response; }).catch(() => caches.match('/')) ); return; } if (event.request.method === 'GET' && url.origin === location.origin) { event.respondWith( caches.match(event.request).then(cached => { const networkPromise = fetch(event.request).then(response => { if (response && response.status === 200) { const clone = response.clone(); caches.open(CACHE_VERSION).then(cache => cache.put(event.request, clone)); } return response; }).catch(() => cached); return cached || networkPromise; }) ); } });页面导航用Network First很好理解:用户每次点开App,都要确保从服务器拿到最新的index.html。而带hash的JS/CSS用Cache First也安全,因为文件名变了,缓存自然命中不了新资源,浏览器会去服务器拉新的。图片这类资源用Stale-While-Revalidate比较合适,先返回旧缓存保证显示速度,同时后台把新图片更新进缓存,下次访问就是新的了。
3. 第二板斧:路由懒加载——首屏只买单页的账
3.1 默认打包结果可能让你意外
Uniapp编译到H5端,很多人默认以为每个页面天然就是独立chunk,按需加载肯定自动生效。我在不同版本的项目里观察到的结果其实不完全一致,有的版本确实会按页面拆分,有的版本则把大量逻辑合进app.js,或把第三方依赖全部塞进chunk-vendors.js。与其听别人说,不如你自己打开Network面板看一眼,判断标准很简单:首屏发起的请求里,有没有出现其他页面的代码?如果你打开首页,却看到订单页、会员页的chunk也在加载,那就是没拆干净。
即便页面拆分生效了,还有一个更隐蔽的坑:vendors过度膨胀。比如你的项目里某个页面用到了图表库ECharts,Webpack在默认配置下很可能把它归入公共依赖,所有页面共享,结果首屏为了可能用到的图表功能,被迫加载一整套图表引擎。这种"隐形的冗余"比显式的合包更难受,因为它藏得很深,不仔细看根本发现不了。
3.2 用subPackages把低频页面隔离出去
Uniapp在原生小程序端很早就有分包的概念,其实H5端同样可以利用。pages.json里的subPackages字段会被编译成路由级的按需加载模块,只有访问到对应路径时才拉取该分包的JS。一个实际项目的配置长这样:
{ "pages": [ { "path": "pages/index/index", "style": { "navigationBarTitleText": "首页" } }, { "path": "pages/detail/detail", "style": { "navigationBarTitleText": "详情" } } ], "subPackages": [ { "root": "pages/order/", "pages": [ { "path": "list", "style": { "navigationBarTitleText": "订单列表" } }, { "path": "confirm", "style": { "navigationBarTitleText": "确认订单" } } ] } ] }分包的原则是:低频、重依赖的页面往里放,比如订单列表、个人中心、设置页;高频且轻量的页面留在主包。这样首屏只加载首页和详情页的资源,其他页面等用户真正点进去再加载。配合前面说的预缓存,用户跳转时会感觉这些分包页面几乎是瞬开的,体验很接近原生。
3.3 动态import与第三方库拆分
如果某个页面必须留在主包里,但它内部引用了大库,那就把大库改成动态import,让它在需要时才加载。比如图表库,可以封装成一个异步加载函数:
async function loadChartLibrary() { const echarts = await import('echarts'); return echarts; }同时可以在vue.config.js里通过splitChunks把这类大库单独抽成异步chunk,避免它和其他第三方依赖混进同一个vendors包里:
module.exports = { chainWebpack: config => { config.optimization.splitChunks({ chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10 }, chartLib: { test: /[\\/]node_modules[\\/]echarts[\\/]/, name: 'chart-lib', chunks: 'async', priority: 20 } } }); } };chunks: 'async'意味着这个库只从异步加载的页面中去收集,首屏同步引入的代码不会把它算进来。这样一来,主bundle的体积被砍掉一大块,图表页面打开时才会单独下载chart-lib chunk。整体记账方式就一句话:首屏资源体积等于入口HTML加核心JS/CSS加首屏页面chunk加首屏真正用到的图片字体,其他一切都必须延迟到需要时再加载。
4. 第三板斧:资源压缩——在字节层面抠出首屏时间
4.1 JS/CSS压缩与Tree Shaking
Uniapp生产构建默认会压缩JS和CSS,这一步通常不用额外配置。但压缩只解决传输体积,真正浪费首屏性能的往往是被打包进去但从来没执行过的代码。Lighthouse的Unused JavaScript指标会告诉你答案,如果首屏JS里有30%以上没有执行,说明模块拆分不够细,或者入口文件里引入了太多只用到一次的库。
Tree Shaking是Webpack对ES Module静态分析的产物,它能自动去掉没有引用的导出。所以平时写代码尽量统一用import和export,少用require这种CommonJS写法,后者会阻碍静态分析。另外,别在App.vue的全局位置引入太多第三方库,比如把axios挂在Vue.prototype上没问题,但把一个完整的UI组件库全量引入到全局,就会拖慢所有页面的首屏。按需引入组件库,或者用动态import加载首屏不需要的组件,都是更合理的方案。
4.2 图片与字体:最容易忽略的体积黑洞
图片和字体的体积问题常常比JS还严重。设计稿里一张1920宽的全屏背景图可能超过1MB,直接放在CSS里当背景,首屏就会多出一整轮大请求。Uniapp的image组件有lazy-load属性,但它只管滚动加载,首屏大图依然要尽快出图。我的建议是:首屏图优先转成WebP,装饰性的图形尽量用SVG,超过10KB的位图都过一遍压缩工具再放进去。
字体同样是坑。中文网页如果引用一套完整字体文件,动辄几MB,而且很多项目的代码里只是偶尔用了几个特殊字符。把这些需求做字体子集化,只保留实际用到的字形,体积能降下来一个数量级。同时给字体规则加上font-display: swap,让浏览器先用默认字体渲染文字,字体文件加载完再做替换,用户就不会看到大段空白文字。注意这个属性在部分浏览器上支持有限,但它依旧是性价比很高的操作。
4.3 服务端压缩和部署侧的配合
前端资源压缩得再好,到了服务器传输环节如果没开压缩,等于白干。很多云厂商的CDN默认不开启gzip,或者只针对HTML生效,JS和CSS依然裸奔传输。可以手动在Nginx里加上这段配置:
gzip on; gzip_comp_level 6; gzip_min_length 1024; gzip_types text/plain text/css application/javascript application/json image/svg+xml;gzip_min_length 1024的意思是小于1KB的文件不压,避免浪费CPU却得不到明显收益。级别6是性价比比较高的档位,再往上CPU消耗增加但体积缩减有限。如果服务器支持Brotli,效果比gzip更好,压缩率能再低10%到20%,但Nginx需要额外编译对应模块。
这里有一个容易被忽略的细节:sw.js本身绝对不能被缓存。如果服务器给sw.js返回了带缓存头的响应,浏览器拿到旧版Service Worker,后续更新就永远不生效。Nginx里可以单独给它一个策略:
location = /sw.js { add_header Cache-Control "no-cache, no-store, must-revalidate"; expires -1; }部署侧的配合还需要注意manifest.json里的start_url和scope必须和真实部署路径一致,否则PWA安装后从主屏点开,可能落到一个Service Worker管理不到的范围,导致预缓存全部失明。这类问题症状很隐蔽,但排查起来往往只需要看Network面板里SW是否接管了请求。
5. 三板斧合体后的效果复盘,以及我踩过的坑
5.1 优化效果的复盘方式
前面那张记录着FCP、LCP和主线程长任务的表格,现在派上用场了。我手头那个商城项目,优化前Lighthouse Performance大概35分,FCP在4秒左右,Network面板前几个请求明显是几百KB的JS。做完预缓存,二次访问的FCP立刻降到1秒内,因为App Shell已经从CacheStorage直接命中。再把路由懒加载落地后,首次访问的主资源体积也瘦了一圈,FCP大概进了1.5秒左右。最后加上图片压缩和gzip,弱网下的体验差距很明显。
不同项目的瓶颈不一样,这些数字只能作为趋势参考,不要照搬着给自己设定目标。唯一可靠的做法是盯着你自己的横向对比数据,看每一步改动后FCP、LCP、请求数、传输体积哪一个发生了变化。如果某一步做完数字基本没动,说明这个方向对你的项目不是主要矛盾,及时调整,别死磕。
5.2 坑一:预缓存旧HTML导致新版白屏
这是我最先踩到、也最经典的坑。当我把index.html加入预缓存后,二次访问直接命中旧缓存里的HTML,里面的JS文件名还是上一个构建版本的hash,而真正部署到服务器的新静态资源已经换了新hash。结果就是浏览器拿着旧入口去请求不存在的chunk,加载失败,白屏。虽然从缓存看是秒开,但用户看到的是永远转不完的loading。
解决办法有两个方向。要么对HTML使用Network First,让它每次都先问服务器要最新入口,拿不到再回退缓存;要么在构建时通过Service Worker更新版本号,配合旧缓存清理,把旧入口一并清掉。我个人更推荐前者,简单直接,不至于引入新的更新时序问题。
5.3 坑二:误把接口缓存成静态资源
第一次手写fetch策略时,我图省事对同源GET请求一律Cache First,结果没过多久就有用户反映"订单页数据一直不更新"。问题就出在订单列表接口也被缓存了,而且Cache First策略下,在缓存有效期内根本不会走到网络。后来我把所有以/api/开头的请求全部排除在缓存之外,只在离线时才允许它们回退到缓存数据。如果你的业务需要真正的离线数据能力,那应该给每个接口设计单独的缓存策略,比如Network First加过期时间,而不是一股脑套用静态资源的缓存逻辑。
5.4 坑三:sw.js更新不生效,改完代码线上不更新
Service Worker更新有一个很隐蔽的机制:浏览器检查到新sw.js时,会用新版本替换,但默认要等所有页面全部关闭后新SW才会接管。如果不调用skipWaiting,用户打开App时看到的永远是上一版逻辑,你还以为是部署没生效。所以install事件里那句self.skipWaiting()非常关键,配合activate阶段的self.clients.claim(),才能让新SW立刻控制页面。同时记住,服务器端sw.js务必no-cache,否则浏览器压根不会去拿新版,那就只能手动换sw.js路径来强制更新了。
5.5 坑四:预缓存清单写死文件名,构建后对不上
PRECACHE_URLS里写死文件名,在单次构建里没问题,但团队协作、版本迭代之后,chunk的hash一变,清单就要改。这个坑在每次发版时都会心累一次。比较省心的做法是构建后用脚本扫描dist目录,自动生成预缓存清单。简单写一个Node脚本,在package.json的build流程里串起来:构建完成后遍历dist下所有带hash的静态文件,生成一个sw-precache清单注入到sw.js里。门槛不高,但一次配置长期受益,比每天手动改文件名靠谱得多。
最后说句实在话。三板斧不是魔法,它帮你去掉的是那些完全可以避免的等待:资源多拉了几百KB、逻辑多解析了几十毫秒、缓存没做导致每次都要回源。光做完预缓存,二次访问就会明显快一截;加上路由懒加载,首次访问也会瘦一圈;再配上压缩,网络差的环境也能扳回一局。如果你的Uniapp PWA首屏还在被用户吐槽,先从Network面板把体积排前几名的资源抓出来,按照这个顺序一样样做,效果基本不会让你失望。