ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue3首屏加载优化实战:从1.8MB到400KB的打包与拆包策略

Vue3首屏加载优化实战:从1.8MB到400KB的打包与拆包策略 1. 先搞清楚首屏到底慢在哪别凭感觉优化接手这个项目的时候线上首页的加载慢到让人想砸电脑。从输入URL到页面出现主内容我这边用无痕模式实测大概是3.4秒用户群里早就炸了锅。有意思的是大部分人的第一反应是后端接口太慢了或者图片没压缩但我在排查之后发现瓶颈根本不在这些地方。很多人做前端首屏加载优化上来就开一个可优化项清单什么图片转webp组件按需加载开启gzip全都做一遍。但做完之后你可能发现首屏耗时确实降下来一点从3.4秒变成了2.6秒可离1秒内打开的目标还差得远。原因很简单你根本没有搞清楚这个首屏时间到底消耗在哪一步。我自己的习惯是先量化再动手。这里必须引入几个关键指标不是为了应付面试而是为了让你在量化之后能精准定位瓶颈。FCPFirst Contentful Paint首次内容绘制浏览器首次绘制出任何文本、图片、非空白canvas的时间。你可以理解成用户终于看到了点东西。LCPLargest Contentful Paint最大内容绘制页面视口内最大的图片、文本块或视频绘制完成的时间。这是用户感知最强的指标现在Google把它当成核心权重指标之一。TTITime to Interactive可交互时间页面已经稳定渲染、主线程空闲、可以响应用户交互的时间。TBTTotal Blocking Time从FCP到TTI之间主线程被长任务阻塞的总时间。我这次优化的项目是一个典型的Vue 3中后台系统技术栈是Vue 3 Vite Vue Router Pinia母体还挂在qiankun微前端架构下。这种系统的首屏优化有个特点它不是某个单一因素导致慢而是资源体积、请求时序、运行时开销三者叠加出来的结果。打开Chrome DevTools的Performance面板录制一次页面刷新过程然后把Lighthouse在移动端模拟下跑一遍结合打包产物的体积分析基本就能把问题归类了。我这里遇到的诊断结果如下诊断项实测数据问题等级首屏加载的JS总大小未压缩1.8 MB严重最大chunk是主应用bundle包含vxe-table全量、echarts全量严重首屏请求接口数6个存在串行依赖中等图片资源体积大部分未压缩轻量构建产物是否拆vendor只拆了react/vue/vue-router三方库全部打进主chunk严重注意衡量优化的效果不能只盯着页面加载总耗时这一个值。我后来一直用LCP和TTI这两个指标作为KPI因为它们更贴近用户真实感受。页面上最大的那张工作台图表渲染出来用户才觉得页面打开了这比白屏时间缩短了更有意义。2. 打包策略改造把首屏JS从1.8MB压到400KB2.1 为什么你的bundle会这么大很多人以为代码层面用了ESM、用了Tree-shaking构建出来就应该是干净的。实际上中后台项目里有两个东西几乎必然会撑大bundleUI组件库全量引入和图表库全量引入。我这次遇到的就是典型的双全量。业务代码里引入了vxe-table来做数据表格这个组件库本身就非常庞大如果直接import VXETable from vxe-table再把全量样式引进来光它一个就能占到300KB~400KB的压缩前体积。echarts更夸张全量引入的话有1MB以上就算压缩gzip之后也有300KB左右。更糟糕的是项目里用的是路由静态导入// 错误示范所有路由组件在应用启动时就全部加载 import Dashboard from /views/Dashboard.vue import DetailPage from /views/DetailPage.vue这些路由组件的代码和依赖会在页面初始化时全部执行首屏自然就被拖垮了。2.2 路由级代码分割的正确姿势改成路由懒加载是最基础但最有效的一步。Vue 3配合Vue Router时直接用动态importconst routes [ { path: /dashboard, name: Dashboard, component: () import(/views/Dashboard.vue) }, { path: /detail/:id, name: DetailPage, component: () import(/views/DetailPage.vue) } ]Vite构建时会把每个动态import的路由打包成独立的chunk。这样用户打开首页只加载首页组件相关的代码其他路由对应的组件代码等真正跳转时才去请求。这里必须提一个细节如果你在组件内部用defineAsyncComponent处理一些异步渲染的重组件要注意设置好loadingComponent和delay。比如工作台页面里那个大表格我把它单独拆出来异步渲染避免它阻塞首页其他内容的展示。2.3 第三方库的按需引入和CDN外置2.3.1 vxe-table怎么避免首屏加载vxe-table是很多中后台项目的刚需组件库但它的体积真的需要小心处理。现在的版本支持按需引入你只需要在自己的项目里配上插件或者手动引入用到的组件和样式。我建议直接使用vite-plugin-style-import或者手动按需引入// 手动按需引入vxe-table核心 import { VxeTable, VxeColumn } from vxe-table import vxe-table/lib/style.css // 按需注册 app.use(VxeTable) app.use(VxeColumn)这样只把用到的VxeTable和VxeColumn打进chunk。如果你的业务里只用到了基础表格那些高级插件如VxeTree、VxeGrid、编辑插件什么的就不要引了它们的体积同样不小。我这边实测仅这一步vxe-table相关的chunk从350KB降到了120KB左右未压缩。2.3.2 echarts的按需引入echarts同样支持按需引入。写法如下// 不要这样 // import * as echarts from echarts // 按需引入 import * as echarts from echarts/core import { LineChart, BarChart, PieChart } from echarts/charts import { GridComponent, TooltipComponent, LegendComponent } from echarts/components import { CanvasRenderer } from echarts/renderers echarts.use([LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])这样的话echarts从全量1MB左右降到200KB左右压缩后更小。2.3.3 CDN外置公共库如果一个第三方库在多个项目中都用而且体积始终不小可以考虑走CDN外置。比如lodash、moment这类工具库没必要打进业务bundle。在Vite里可以用build.rollupOptions.external把它标记成外部依赖然后在index.html里引入CDN地址。但这里有个注意点外部化之后你在代码里依然正常import但要保证全局变量名对得上Vite需要配置output.globals来映射。比如// vite.config.js build: { rollupOptions: { external: [lodash], output: { globals: { lodash: _ } } } }这样做最直接的好处是CDN文件有独立缓存不随业务发版更新用户第二次访问时直接从浏览器缓存取不再重复下载。2.4 拆分vendor但别拆得过于零碎Vite默认会帮你把node_modules里的依赖拆成vendor。但仅仅一个vendor是不够的因为它会很大。我一般在build.rollupOptions.output.manualChunks里做精细化拆包// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks: { vue-vendor: [vue, vue-router, pinia], table-vendor: [vxe-table], chart-vendor: [echarts/core, echarts/charts, echarts/components], utils-vendor: [lodash-es, dayjs] } } } } })但我要特别提醒一下手动拆包不是越多越好。如果你把每个工具函数都拆成一个独立chunkHTTP/2下虽然并发请求不占劣势但请求数量太多会带来额外的解析开销和潜在的缓存失效问题。我习惯的粒度是核心框架一个、大型UI组件库一个、图表库一个、公共工具一个、业务路由组件若干。这样整体请求数控制在10个以内。3. 加载链路优化接口串行改并行、资源预加载与缓存策略3.1 首屏接口的串行依赖优化很多中后台页面的首屏数据流转是进入页面 - 请求用户信息 - 请求菜单权限 - 请求页面主数据。这种写法往往是因为后面的接口需要前面接口返回的参数但真实情况是很多参数并不需要等接口返回而是前端本来就有。我之前排查项目发现工作台首屏有6个请求其中3个可以并行发出来但代码里写成了链式等待。比如用户信息接口返回的是用户ID而后续好几个接口都用这个ID。最终我把用户ID从路由参数或本地缓存中提前拿到前3个接口直接Promise.all并行发出// 并行请求首屏核心数据 const [userInfo, menuData, workbenchData] await Promise.all([ fetchUserInfo(), fetchMenuData(), fetchWorkbenchData() ])这一步对TTI的影响很明显。原本6个接口总耗时粗略算下来是1.2秒的串行时间改成并行后首屏关键数据在400ms左右就全出来了。用户体验上的差异非常直观。3.2 preload、prefetch和connect相关的细节资源加载的时机也能做文章。preload用于当前页面即将使用的关键资源告诉浏览器抓紧加载。prefetch用于将来可能用到的资源比如下一页路由的chunk。dns-prefetch和preconnect可以提前建立域名连接减少DNS解析和TCP握手的时间。在Vite中build.rollupOptions.output.manualChunks拆完包后首页的chunk默认只有preload其他懒加载chunk会在导航时自己加载。如果你知道某个chunk在首页跳转后立刻会被用户打开可以手动加prefetch也可以利用Vite的build.modulePreload配置。我自己的做法是在index.html里给关键第三方CDN域名和接口域名加preconnectlink relpreconnect hrefhttps://cdn.example.com crossorigin link relpreconnect hrefhttps://api.example.com对于qiankun微前端场景下的子应用我会在主应用加载后等浏览器空闲时用requestIdleCallback去preload下一个子应用的资源。这个后面第4节具体讲。3.3 HTTP缓存和本地缓存的配合代码层面做了拆包之后缓存策略才真正有意义。要充分利用浏览器强缓存文件名带hash就是必须的index-abc123.js这种带内容hash的文件名适合设置长缓存。index.html这类入口文件建议用no-cache或短缓存防止更新后用户拿着旧hash去请求不存在的资源。我见过不少项目连这个最基础的都没做好文件名固定叫app.js服务器配置了强缓存结果发版后用户浏览器还在用旧代码只能靠硬刷新。这个必须排查。首屏数据层面对于变化不频繁的配置类接口比如菜单权限、系统配置我加了localStorage缓存 过期时间控制。但有一说一本地缓存只适合配置型数据不适合用户维度的实时数据。有些团队为了首屏快把用户信息也本地缓存了结果用户改个昵称后页面还显示旧昵称。这种体验问题比慢更严重。4. 微前端架构下的首屏递进式优化这个项目挂在qiankun上所以单独开一节讲微前端场景普通单应用项目可以跳过但如果你准备做微前端改造这部分值得反复看。4.1 主应用和子应用的边界划分很多团队做qiankun微前端最容易犯的错误是主应用里塞了一堆业务代码和组件库子应用也重复引入了同一份vue和vxe-table。这样首屏加载时主应用的vendor、子应用的vendor全部重复请求体积直接加倍。我的处理原则是主应用只负责应用注册、公共登录态、导航框架业务页面全部下沉到子应用。主应用的路由表只保留基本的空白布局和子应用挂载点。这样主应用bundle控制在很小的体积首屏能够快速渲染出壳子子应用在壳子基础上异步加载。4.2 qiankun的prefetch配置qiankun默认自带prefetch机制它会在主应用加载完成、浏览器空闲时预加载子应用的HTML和JS。但默认策略是所有已注册子应用都预加载如果你的子应用有几十个这种策略反而会浪费带宽和性能。我一般会改成手动控制import { registerMicroApps, start } from qiankun registerMicroApps([ { name: app1, entry: //localhost:3001, container: #subapp-viewport, activeRule: /app1 }, { name: app2, entry: //localhost:3002, container: #subapp-viewport, activeRule: /app2 } ], { // 关闭默认预加载 prefetch: false }) // 手动按需预加载比如登录后预加载工作台子应用 import { prefetchApps } from qiankun prefetchApps([ { name: app1, entry: //localhost:3001 } ])这样最关键的子应用会在首屏后立刻开始预加载用户点击跳转时基本秒开。4.3 共享公共依赖避免子应用各自打包每个子应用独立打包把vue、vue-router、pinia、vxe-table都再打一份整个体系的总资源会非常夸张。我这里的做法是主应用把vue、vue-router等核心依赖以CDN形式加载并暴露为全局变量external global。子应用构建时同样配置external运行时从主应用的全局变量中获取。Vite下配置external要借助插件比如vite-plugin-externals或自己写一个简单的插件。qiankun的shared机制也可以用来传递公共依赖但个人体验是直接external CDN 全局变量最直观也不容易踩坑。这样做之后子应用的bundle能明显瘦身特别是vxe-table这种大组件库只会在主应用加载一次后续所有子应用共用同一份实例内存占用也下降了。5. 实测数据与避坑清单优化效果必须用数据说话5.1 优化前后的对比我最终在无痕模式、模拟中端移动设备网络环境下Fast 3G 4x CPU降速做了一轮完整对比指标优化前优化后降幅首屏JS请求总大小未压缩1.8 MB420 KB76%首屏JS请求总大小gzip后420 KB130 KB69%FCP1.8s0.8s56%LCP3.4s1.2s65%TTI4.1s1.5s63%白屏时间1.2s0.4s67%说实话这个结果超出了我的预期。本来以为能做到LCP 2秒以内就成功了结果做到了1.2秒。这充分说明优化初期的量化定位有多重要——我这次事件的决策顺序是量化 - 定位 - 优化 - 验证。如果你跳过定位很多优化动作其实是无效功。5.2 几个容易被忽略的深坑坑一SourceMap线上暴露优化过程中我顺手把构建配置里的sourcemap关掉了。有些团队习惯开着sourcemap方便线上排查问题但完整的sourcemap不仅体积巨大有的能占到assets体积的50%以上而且会暴露源码非常不安全。需要线上定位问题时可以仅在错误上报系统里上传sourcemap而不要把它发布到静态服务器。坑二拆包粒度太细导致请求风暴我中间有一版手动把lodash-es的每个函数都拆成了独立chunk结果首屏请求数直接飙升到30多个。HTTP/2虽然能处理并发但浏览器解析和执行这些chunk的开销仍然不可小觑。最后我统一把lodash-es合并成一个chunk请求数回到10个以内加载反而更稳定。坑三preload和prefetch把自己坑了Vite的modulePreload默认会把当前路由依赖的所有chunk全部preload。如果你的首页lazy load出来的子组件又依赖了很多三方库preload的费用反而很高。一个比较稳的验证方式是打开Performance面板看网络瀑布图如果发现很多高优先级的JS长时间占据了下载带宽而其中大部分是首屏根本用不到的代码那就要考虑通过build.modulePreload.resolveDependencies自定义一下需要preload的模块列表或者干脆调整手动拆包的结构让首屏依赖的chunk数量最小化。坑四图片懒加载不等于首屏图片不加载首屏内的图片尤其是LCP那个最大元素一定不能懒加载。我遇到过接手项目里全站图片都加了loadinglazy结果LCP的核心banner图也被lag了首屏直接多出一个等待提前布局的时间。首屏内的图片要直接eager加载首屏外的图片才懒加载。5.3 持续监控别让优化效果在一个版本里丢掉优化的最大敌人是回归。前端团队日常迭代某个同学无心在全量import了一个大组件或者给路由加了一个静态导入优化成果一下就没了。我现在的习惯是在CI里挂Lighthouse CI每次构建都跑一次移动端模拟性能评分把LCP和TTI设定阈值超了就阻断发布。用performance.getEntriesByType(resource)在页面上轮询关键资源体积和加载时长把数据上报到监控平台。定期用rollup-plugin-visualizer生成打包体积报告放进构建产物里每周扫一眼。这个项目优化完之后我在团队里做了个小分享核心只有一句话前端首屏加载优化不是碰运气的调参而是把加载链路拆成可量化的环节然后把每个环节的浪费一点一点拧干。尤其是中后台这类重组件、重依赖的工程只要你肯先把指标测出来、把chunk体积摊开看优化空间基本是肉眼可见的。希望这篇复盘能帮你少走点弯路如果你的项目也是Vue 3加了vxe-table这种重量级组件建议第一步就从按需引入和路由懒加载开始动刀往往能最快看到效果。
返回列表