引言

最近接手一个运行了 3 年的 Vue2 后台管理系统,首屏加载体验极差:白屏时间超过 3 秒,点击按钮卡顿,LCP(最大内容绘制)高达 3.4s,远低于谷歌优秀标准(<2.5s)。

本文将完整记录从问题发现到逐个击破的全过程,重点分享如何用 Chrome DevTools 定位瓶颈老项目最小改动优化以及那些网上教程很少提到的真实踩坑点。

一、第一步:问题发现与定位 —— 不要上来就瞎优化

优化的第一步永远是定位问题,而不是上来就加各种配置。我用三个工具,10 分钟就找到了所有瓶颈。

1.1 Network 面板:发现冗余资源

打开 Chrome DevTools → Network → 勾选 Disable cache → 刷新页面:

  • 首屏请求了 40+ 个资源,其中 10+ 个是弹窗专用的 CSS/JS(如 luckysheet.css、vxe-table.js),首页根本用不到
  • 单个 app.js 体积超过 2MB,下载耗时 1.2s
  • 接口串行请求,总耗时超过 2s

1.2 webpack-bundle-analyzer:发现大文件

安装打包分析插件后,发现:

  • echarts、vxe-table、luckysheet 三个库占了 app.js 体积的 60%
  • 这三个库都只在某个弹窗中使用,却被全局打进了首屏 bundle

1.3 Performance 面板:发现长任务

录制页面加载过程,发现:

  • 主线程有一个 218ms 的超长任务,导致大量丢帧
  • 这个任务是列表数据返回后,一次性渲染 100+ 行表格导致的
  • FPS 栏有大量红色块,页面卡顿严重

1.4 核心瓶颈总结

  1. 全局资源阻塞:index.html 全局引入了大量非首屏资源
  2. 第三方库全局打包:只在某个页面用的库被全局引入
  3. 接口串行 + 同步渲染:串行接口拉长等待时间,同步渲染产生长任务

二、第二阶段:全局资源清理与延迟加载

2.1 问题:index.html 全局引入弹窗专用资源

原来的 index.html 中,全局引入了 10+ 个弹窗专用的 CSS/JS:

<!-- 错误写法:全局引入,阻塞首屏渲染 -->
<link rel="stylesheet" href="/css/luckysheet.css">
<script src="/js/luckysheet.js"></script>

这些资源首页根本用不到,却会阻塞首屏解析和渲染。

2.2 CSS 延迟加载的正确姿势

很多人以为 CSS 可以像 JS 一样加 defer 延迟加载,实际上 defer 只对 script 标签生效,link 标签加 defer 会被浏览器直接忽略。

最优方案:preload + 动态启用样式

<!-- 正确写法:必须放在<head>内!放在body会导致preload失效 -->
<link rel="preload" href="/css/luckysheet.css" as="style" id="luckysheetCss">

弹窗打开时启用样式:

document.getElementById('luckysheetCss').rel = 'stylesheet'

✅ 原理:

  • rel="preload":告诉浏览器后台空闲时下载这个 CSS,不阻塞渲染、不生效
  • 弹窗打开时修改 rel 为 stylesheet,样式瞬间生效,复用预下载的缓存

2.3 踩坑实录:preload 的三大致命坑

坑 1:preload 标签放在 body 里

浏览器规范硬性要求link[rel=preload] 必须写在 <head> 标签内,放在 body 中会被 Chrome 直接丢弃 preload 语义,降级为普通 stylesheet,优先级强制 Highest 并阻塞渲染。

坑 2:路径带 ./dist/

public 目录下的资源,打包后根路径直接以 / 开头,不能写 ./dist/xxx,否则会导致路径解析异常,preload 失效。

坑 3:误以为 Type=stylesheet 就是 preload 没生效

Chrome DevTools 的 Type 列显示的是资源的 MIME 类型,只要返回头是 text/css,不管是 preload 还是 stylesheet,Type 永远是 stylesheet。

正确判断 preload 生效的三个标准

  1. Initiator 列显示 preload(不是 parser)
  2. 瀑布图无红色阻塞段
  3. 不点弹窗时,Elements→Styles 面板找不到对应 CSS 规则

2.4 JS 资源优化

弹窗专用的 JS 保留 defer 属性,不阻塞 HTML 解析:

<script defer src="/js/luckysheet.js"></script>

如果想要完全做到不用不下载,可以在弹窗打开时动态创建 script 标签:

function loadJs(url) {
  return new Promise(resolve => {
    if (document.querySelector(`script[src="${url}"]`)) return resolve()
    const script = document.createElement('script')
    script.src = url
    script.onload = resolve
    document.body.appendChild(script)
  })
}

三、第三阶段:全局引入的第三方库懒加载

3.1 问题:main.js 全局引入只在某个页面用的库

原来的 main.js 中,全局引入了 echarts、vxe-table、luckysheet:

// 错误写法:全局引入,打进首屏 bundle
import ECharts from 'echarts'
import VXETable from 'vxe-table'
import 'vxe-table/lib/style.css'

Vue.prototype.$echarts = ECharts
Vue.use(VXETable)

这三个库占了首屏 JS 体积的 60%,却只在某个弹窗中使用。

3.2 解决方案:按需导入 + 按需引入

对于 echarts 这种全局挂载的库,使用按需导入 +按需引入

// 新建按需引入js文件  ========= chart.js ============



// 引入 echarts 核心模块,核心模块提供了 echarts 使用必须要的接口。
import * as echarts from 'echarts/core';
// 引入柱状图图表,图表后缀都为 Chart
import { BarChart } from 'echarts/charts';
// 引入标题,提示框,直角坐标系,数据集,内置数据转换器组件,组件后缀都为 Component
import {
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent
} from 'echarts/components';
// 标签自动布局、全局过渡动画等特性
import { LabelLayout, UniversalTransition } from 'echarts/features';
// 引入 Canvas 渲染器,注意引入 CanvasRenderer 或者 SVGRenderer 是必须的一步
import { CanvasRenderer } from 'echarts/renderers';

// 注册必须的组件
echarts.use([
  TitleComponent,
  TooltipComponent,
  GridComponent,
  DatasetComponent,
  TransformComponent,
  BarChart,
  LabelLayout,
  UniversalTransition,
  CanvasRenderer
]);

export default echarts;

四、第四阶段:接口与渲染优化

4.1 问题:串行接口 + 同步渲染导致长任务

原来的页面初始化逻辑:

// 错误写法:串行请求,拉长等待时间
async created() {
  await getBaseInfo1()
  await getBaseInfo2()
  await getBaseInfo3()
  await getBaseInfo4()
  await getBaseInfo5()
  const list = await getTableList()
  this.tableData = list // 一次性渲染100+行,产生218ms长任务
}

4.2 解决方案 1:接口串行改并行

将独立的基础接口改为并行请求,网络时间重叠:

async mounted() {
  // 等DOM初次渲染完毕再请求,避开首屏关键周期
  await this.$nextTick()
  
  // 并行请求基础接口
  const [res1, res2] = await Promise.all([getBaseInfo1(), getBaseInfo2(), ......])
  // 基础数据就绪后再请求列表
  const list = await getTableList()
}

4.3 解决方案 2:列表分片渲染,拆分长任务

一次性渲染 100+ 行表格会产生超长任务,使用 requestIdleCallback 分片渲染:

// 拿到全量数据后,不要直接v-for
this.rawList = list
let index = 0
const batchNum = 30 // 每次渲染30条

const renderChunk = () => {
  requestIdleCallback(() => {
    const end = Math.min(index + batchNum, this.rawList.length)
    this.renderList.push(...this.rawList.slice(index, end))
    index = end
    if (index < this.rawList.length) renderChunk()
  })
}

renderChunk()

✅ 效果:原本 218ms 的超长任务被拆分成多个 <16ms 的小任务,FPS 红色块基本消失。

五、优化成果与指标对比

表格

指标 优化前 优化后 评级
LCP(最大内容绘制) 3.4s 2.39s ✅ 优秀
Load(页面满载) 3.8s 2.6s ✅ 良好
FCP(首次内容绘制) 2.8s 2.04s ✅ 良好
首屏 JS 体积 2.1MB 890KB ✅ 优秀
FPS 最长丢帧 218ms 31ms ✅ 流畅

六、Vue2 老项目优化通用方法论

  1. 先定位后优化:用 Network、Bundle Analyzer、Performance 三个工具找到瓶颈,不要盲目优化
  2. 资源分层:首屏只加载必须的资源,非首屏资源全部懒加载
  3. 最小改动原则:优先使用不改动业务代码的优化方案(如全局代理、preload)
  4. 拆分长任务:将耗时的 JS 运算、DOM 渲染拆分成小任务,避免阻塞主线程
  5. 适度优化:零散 30ms 丢帧属于正常损耗,不用过度追求零丢帧

写在最后

老项目优化不是推翻重写,而是在尽可能少改动业务代码的前提下,解决最影响用户体验的瓶颈问题。

本次优化通过资源分层、按需加载、拆分长任务等手段,用一周时间将 LCP 从 3.4s 优化到 2.39s,所有历史代码几乎零改动,非常适合 Vue2 老项目的快速优化。

更多推荐