1. 先别急着动代码弄清Uniapp PWA首屏加载的瓶颈在哪1.1 Uniapp H5端首屏加载到底加载了什么去年年中我把一个用Uniapp写的商城项目打包成H5应用顺手接上了PWA的manifest和Service Worker觉得既然能装到主屏体验应该没跑了。结果用户连续反馈首屏转圈我打开Chrome无痕窗口访问了一次Lighthouse的Performance得分只有三十多分连续好几个几百毫秒的长任务卡在主线程上。问题出在哪说白了就是首屏资源太重缓存策略又几乎等于没做。先说清楚Uniapp H5端是一个什么样的产物。它本质上就是一个标准的Vue SPApages.json里声明的页面会被编译成路由表App.vue和main.js负责拉起整个应用。打包后你会在dist目录里看到这些文件index.html入口、chunk-vendors.jsVue、Vue Router这类第三方库的集合、app.jsUniapp运行时加你的全局逻辑、若干页面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/CSSCache 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过度膨胀。比如你的项目里某个页面用到了图表库EChartsWebpack在默认配置下很可能把它归入公共依赖所有页面共享结果首屏为了可能用到的图表功能被迫加载一整套图表引擎。这种隐形的冗余比显式的合包更难受因为它藏得很深不仔细看根本发现不了。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 ShakingUniapp生产构建默认会压缩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/svgxml;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面板把体积排前几名的资源抓出来按照这个顺序一样样做效果基本不会让你失望。