跳转到内容
123xiao | 无名键客

《前端性能实战:基于 Lighthouse 与代码分割的中大型 Web 应用加载优化方案》

字数: 0 阅读时长: 1 分钟

前端性能实战:基于 Lighthouse 与代码分割的中大型 Web 应用加载优化方案

中大型 Web 应用做久了,团队常会遇到一个很“熟悉”的场景:功能越堆越多,包越来越大,首屏越来越慢,Lighthouse 分数越来越难看。大家也不是没做优化,压缩、缓存、CDN、图片懒加载都做了,但一到业务高峰期,页面还是会出现白屏久、交互迟钝、切路由卡顿的问题。

这篇文章我想换一个更偏“实战落地”的角度来讲:不要一上来就盲目减包,而是先用 Lighthouse 找到真正影响用户体验的加载瓶颈,再用代码分割把“非首屏必需代码”从首屏剥离出去。 这套方法特别适合中大型 React / Vue / 通用 SPA 项目,也适合有多个业务模块、权限中心、数据报表、富文本、图表编辑器等重型依赖的场景。


背景与问题

在中大型 Web 应用里,性能问题通常不是“单点故障”,而是几个问题叠加:

  • 主 bundle 过大,首屏 JS 下载时间长
  • 单页应用初始化逻辑过多,主线程被长任务阻塞
  • 某些页面依赖图表库、编辑器、地图 SDK,但被打进公共包
  • 路由切换时一次性加载整页模块,造成明显卡顿
  • 组件库、工具库、polyfill 使用不当,重复或冗余引入
  • Lighthouse 报告里问题很多,但团队不知道先改哪个最值

很多同学会先问:“是不是把所有页面都做懒加载就行了?”

答案是不够。代码分割是工具,不是目的。真正的目标是:

  1. 减少首屏必须下载、解析、执行的 JS
  2. 降低主线程阻塞时间
  3. 提升首次内容绘制与可交互速度
  4. 在不明显增加请求开销和维护复杂度的前提下,平衡加载体验

我之前在一个后台管理项目里就踩过坑:把几十个页面都拆成异步 chunk 后,Lighthouse 分数的确提升了,但切换页面时出现大量小请求,弱网下反而更抖。后来重新按“业务域”合并 chunk、预加载核心模块,体验才真正稳定下来。


前置知识与环境准备

本文示例以 Webpack + React 为主,但思路同样适用于 Vue、Vite、Rspack 等工程体系。

你需要大致了解:

  • Lighthouse 报告怎么看
  • ES Module 的 import() 动态导入
  • 路由级别懒加载
  • 浏览器缓存与 HTTP/2 的基本概念
  • 如何查看打包产物(如 webpack-bundle-analyzer

建议准备:

  • Chrome 浏览器
  • Node.js 16+
  • 一个可运行的 Webpack / CRA / 自建 React 项目
  • 本地可执行 Lighthouse 的环境

核心原理

1. Lighthouse 不是“分数工具”,而是诊断入口

Lighthouse 最有价值的不是最后那个总分,而是它帮你回答:

  • 首屏慢,慢在下载、解析还是执行?
  • 是图片、字体、脚本还是第三方资源拖后腿?
  • 主线程是不是有长任务?
  • 是否存在未使用的 JS?
  • 是否有 render-blocking 资源?

对中大型应用来说,“Reduce unused JavaScript”“Eliminate render-blocking resources”“Minimize main-thread work”“Avoid enormous network payloads” 这几项通常非常关键。

2. 代码分割的本质:按需加载,而不是平均切碎

代码分割本质上是把一个大 bundle 拆成多个 chunk,在合适的时机再加载。常见方式有三类:

  • 路由级分割:不同页面在进入时再加载
  • 组件级分割:大型弹窗、编辑器、图表等按需加载
  • 依赖级分割:把重型库独立成 vendor chunk 或异步 chunk

不是拆得越碎越好。拆太碎会带来:

  • 请求数量增加
  • chunk 管理复杂
  • 缓存失衡
  • 页面切换时抖动

所以更推荐按“访问路径”和“业务域”拆,而不是按文件大小机械拆。

3. 性能优化应该围绕关键指标

中大型应用更该关注这些指标:

  • FCP(First Contentful Paint):首个内容出现的时间
  • LCP(Largest Contentful Paint):最大内容渲染时间
  • TBT(Total Blocking Time):主线程阻塞总时间
  • TTI(Time to Interactive):可交互时间
  • CLS(Cumulative Layout Shift):布局偏移

对于后台系统、控制台、运营平台这类应用,TBT 和可交互速度往往比纯展示站点更重要,因为用户很快就要点击、搜索、切换 tab。


一个可执行的优化思路

先给一张总流程图,帮助你建立完整路径。

flowchart TD
    A[运行 Lighthouse] --> B[识别关键瓶颈]
    B --> C{问题类型}
    C -->|首屏 JS 过大| D[路由级代码分割]
    C -->|重型组件拖慢| E[组件级懒加载]
    C -->|第三方依赖过重| F[拆分 vendor / 按需引入]
    C -->|主线程阻塞| G[减少初始化逻辑]
    D --> H[重新打包分析]
    E --> H
    F --> H
    G --> H
    H --> I[二次 Lighthouse 验证]
    I --> J[监控真实用户指标]

第一步:先跑 Lighthouse,别急着改代码

在 Chrome DevTools 里打开 Lighthouse,选择:

  • Performance
  • Best Practices
  • Progressive Web App(可选)
  • 设备建议先测 Mobile,再测 Desktop

如果你要更稳定,可以用命令行:

npx lighthouse http://localhost:3000 --view

关注报告中的几个区域:

  1. Opportunities

    • 是否提示减少未使用 JS
    • 是否提示移除阻塞渲染资源
    • 是否有大体积网络负载
  2. Diagnostics

    • Main-thread work 是否过多
    • JavaScript execution time 是否过长
    • DOM size 是否过大
  3. Treemap / Bundle 分析

    • 哪些模块体积最大
    • 是否有图表库、编辑器、日期库、国际化包被打进首屏

如果 Lighthouse 明确指出首屏里有大量未使用的 JS,那么代码分割通常就是最高性价比的优化手段。


第二步:用打包分析确认“谁最重”

在 Webpack 项目里安装分析器:

npm install -D webpack-bundle-analyzer

配置示例:

// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

module.exports = {
  mode: 'production',
  plugins: [
    new BundleAnalyzerPlugin()
  ]
};

运行打包后,你会看到可视化产物图。此时重点看:

  • 首页是否引入了图表库,如 echarts
  • 富文本编辑器是否提前进入主包
  • 大型工具库是否整包引入
  • 多个业务模块是否被打进同一主 chunk

这一步很重要,因为很多优化失败,根本原因不是“不会拆”,而是没有看清到底该拆谁


第三步:先做路由级代码分割

路由是最自然、收益最大的切分点。下面用 React Router 演示。

优化前

// src/App.jsx
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './pages/Home';
import Report from './pages/Report';
import Settings from './pages/Settings';

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/report" element={<Report />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </BrowserRouter>
  );
}

这种写法会让 HomeReportSettings 很可能都进入首屏依赖图,尤其当页面中间又各自引入很多重型模块时,首包膨胀会非常明显。

优化后:路由懒加载

// src/App.jsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

const Home = lazy(() => import('./pages/Home'));
const Report = lazy(() => import('./pages/Report'));
const Settings = lazy(() => import('./pages/Settings'));

function PageLoading() {
  return <div>页面加载中...</div>;
}

export default function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageLoading />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/report" element={<Report />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

这样做之后:

  • 首次访问 / 时,只优先加载首页所需代码
  • reportsettings 在真正进入时再下载
  • 首屏 JS 体积通常会直接下降一截

这里有个经验点:高频二级路由可以考虑分组,而不是每个页面都单独拆。 否则切换很频繁的工作台应用会出现太多懒加载请求。


第四步:对重型组件做组件级分割

很多时候首页本身不大,真正拖慢的是某个“平时不打开,但一打开很重”的模块,比如:

  • 图表大屏
  • 富文本编辑器
  • 文件预览器
  • 地图组件
  • 导出面板
  • 权限配置树

这类组件非常适合按需加载。

示例:点击时再加载图表模块

// src/components/ChartPanel.jsx
import React, { useState, Suspense, lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

export default function ChartPanel() {
  const [visible, setVisible] = useState(false);

  return (
    <div>
      <button onClick={() => setVisible(true)}>打开图表</button>

      {visible && (
        <Suspense fallback={<div>图表加载中...</div>}>
          <HeavyChart />
        </Suspense>
      )}
    </div>
  );
}
// src/components/HeavyChart.jsx
import React, { useEffect, useRef } from 'react';
import * as echarts from 'echarts';

export default function HeavyChart() {
  const chartRef = useRef(null);

  useEffect(() => {
    const chart = echarts.init(chartRef.current);
    chart.setOption({
      xAxis: {
        type: 'category',
        data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
      },
      yAxis: {
        type: 'value'
      },
      series: [
        {
          type: 'line',
          data: [120, 200, 150, 80, 70]
        }
      ]
    });

    return () => chart.dispose();
  }, []);

  return <div ref={chartRef} style={{ width: '100%', height: 400 }} />;
}

这段代码的意义很直接:echarts 只有在用户真正打开图表时才加载。

如果你的报表页是“少数用户偶尔访问”,这个收益会非常明显。


第五步:用 Webpack 控制 chunk 策略

代码分割不是只写 import() 就结束了。对于中大型项目,splitChunks 的策略会直接影响:

  • 公共依赖是否复用
  • 缓存命中率是否稳定
  • chunk 数量是否过多
  • 构建结果是否可控

下面是一份比较实用的配置示例。

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',
      minSize: 20000,
      maxInitialRequests: 10,
      maxAsyncRequests: 20,
      cacheGroups: {
        reactVendor: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
          name: 'react-vendor',
          priority: 30,
          reuseExistingChunk: true
        },
        chartVendor: {
          test: /[\\/]node_modules[\\/](echarts)[\\/]/,
          name: 'chart-vendor',
          priority: 25,
          reuseExistingChunk: true
        },
        common: {
          minChunks: 2,
          name: 'common',
          priority: 10,
          reuseExistingChunk: true
        }
      }
    },
    runtimeChunk: {
      name: 'runtime'
    }
  }
};

这份配置解决了什么问题?

  • React 相关基础框架独立成稳定 chunk,利于长期缓存
  • 图表库独立出去,不污染首页主包
  • 多页面复用逻辑进入 common
  • runtime 单独拆出,减少内容哈希连带失效

如果项目体量再大一点,我建议按“业务域”再细分 cacheGroups,比如:

  • 用户中心模块
  • 报表模块
  • 编辑器模块
  • 大屏可视化模块

这样比简单的“node_modules 全扔 vendor”更可控。


加载时序是怎么变化的?

下面用时序图看一下优化前后的区别。

sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant S as 服务器

    U->>B: 访问首页
    B->>S: 请求 HTML
    S-->>B: 返回 HTML
    B->>S: 请求 main.js(含所有页面/图表/设置模块)
    S-->>B: 返回大体积 JS
    B->>B: 解析/执行大量 JS
    B-->>U: 页面可见但交互延迟
sequenceDiagram
    participant U as 用户
    participant B as 浏览器
    participant S as 服务器

    U->>B: 访问首页
    B->>S: 请求 HTML
    S-->>B: 返回 HTML
    B->>S: 请求 home.js + react-vendor.js
    S-->>B: 返回首屏必要资源
    B->>B: 快速解析并展示首页
    B-->>U: 首页可交互
    U->>B: 点击报表页
    B->>S: 请求 report.js + chart-vendor.js
    S-->>B: 返回异步 chunk
    B-->>U: 报表模块加载完成

这就是代码分割最核心的收益:把“所有人都被迫等待的成本”,变成“只有需要的人才支付的成本”。


第六步:逐步验证,而不是一次性大改

我更推荐按下面顺序推进:

  1. 先做路由级分割
  2. 再拆重型组件
  3. 再调整第三方依赖引入方式
  4. 最后微调 splitChunks

原因很简单:这样更容易验证收益,也更不容易引入加载异常。

一份实用的验证清单

每做完一步,都检查:

  • Lighthouse 的 FCP / LCP / TBT 是否改善
  • 首屏 bundle 体积是否下降
  • 首次进入页面是否更快
  • 页面切换时是否出现明显 loading 抖动
  • 网络请求数量是否异常增多
  • 异步 chunk 是否成功缓存
  • 生产环境 sourcemap 是否影响包大小

实战代码:一个可运行的最小示例

下面给一个简化后的可运行结构,帮助你从零复现思路。

目录结构

src/
  App.jsx
  main.jsx
  pages/
    Home.jsx
    Report.jsx
    Settings.jsx
  components/
    ChartPanel.jsx
    HeavyChart.jsx

main.jsx

// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')).render(<App />);

Home.jsx

// src/pages/Home.jsx
import React from 'react';
import ChartPanel from '../components/ChartPanel';

export default function Home() {
  return (
    <div>
      <h1>首页</h1>
      <p>这里是首屏内容,先保持轻量。</p>
      <ChartPanel />
    </div>
  );
}

Report.jsx

// src/pages/Report.jsx
import React from 'react';

export default function Report() {
  return (
    <div>
      <h1>报表页</h1>
      <p>报表模块通常较重,适合路由级懒加载。</p>
    </div>
  );
}

Settings.jsx

// src/pages/Settings.jsx
import React from 'react';

export default function Settings() {
  return (
    <div>
      <h1>设置页</h1>
      <p>配置类页面一般不需要进入首页主包。</p>
    </div>
  );
}

你可以先跑一次基线 Lighthouse,再加入 React.lazy() 和重型组件懒加载,对比优化前后的结果。


常见坑与排查

这部分我建议你认真看,中大型项目里很多性能优化不是“不会做”,而是“做了没效果”或者“做了出问题”。

1. 懒加载后白屏时间反而更明显

表现:

  • 路由跳转出现 loading
  • 首屏快了,但切页体验差

原因:

  • 拆分过细,导致异步 chunk 太多
  • fallback 设计过于简陋,用户感知更差
  • 没有对高频页面做预加载

建议:

  • 高频路由按业务组合 chunk
  • 给 loading skeleton,而不是纯文本
  • 对用户高概率访问的页面做预取

例如:

const Report = lazy(() => import(/* webpackPrefetch: true */ './pages/Report'));

不过注意:prefetch 不是越多越好,低优先级加载也会消耗带宽。


2. 公共依赖重复进入多个 chunk

表现:

  • 打包后多个异步包都很大
  • 缓存命中率低
  • Lighthouse 网络负载下降不明显

原因:

  • splitChunks 配置不合理
  • 动态导入边界过多,复用依赖被切碎

排查方法:

  • 看 bundle analyzer
  • 检查第三方依赖是否在多个异步 chunk 中重复出现

建议:

  • 对重型共享依赖单独建 vendor chunk
  • 避免同一类能力在多个页面各自封装一份实现

3. 首页虽然减包了,但 TBT 还是高

表现:

  • Lighthouse 中 Total Blocking Time 仍然很高
  • 用户感觉“看得到,但点不动”

原因:

  • 初始化逻辑太多
  • 大量同步计算在首屏执行
  • 数据处理、权限计算、表格初始化都在页面 mount 阶段集中发生

建议:

  • 推迟非关键逻辑
  • 拆分初始化步骤
  • 把重计算放到 Web Worker 或延后到交互后

代码分割只能减少“加载成本”,不能自动解决“执行成本”。


4. 开发环境看起来正常,生产环境异常

表现:

  • 本地懒加载没问题,上线后 chunk 404
  • 刷新子路由报错
  • CDN 缓存旧资源,页面加载失败

排查方向:

  • publicPath 是否正确
  • 服务端是否支持 SPA fallback
  • 文件名 hash 与缓存策略是否匹配

Webpack 里常见配置:

module.exports = {
  output: {
    publicPath: '/'
  }
};

如果部署到 CDN 子路径,比如 /static/app/,这里就不能乱写。


5. 只看 Lighthouse 分数,忽略真实用户体验

这是最容易掉进去的坑。

Lighthouse 是实验室环境指标,它很重要,但不等于真实线上体验。尤其是后台系统、内网系统、企业级平台,经常会遇到:

  • 用户设备性能差异大
  • 网络环境复杂
  • 第三方登录、权限接口、埋点 SDK 干扰加载路径

所以建议同时接入真实用户监控(RUM),至少采集:

  • 首屏时间
  • 资源加载失败
  • JS 错误
  • 长任务
  • 路由切换耗时

安全/性能最佳实践

这一节我把一些经常一起出现、又容易被忽略的实践集中说一下。

1. 优先优化“首屏关键路径”

把以下资源尽量留在首屏:

  • 当前路由必须执行的 JS
  • 首屏首块可见 UI
  • 核心样式
  • 首次用户交互所需最小逻辑

把以下资源尽量移出首屏:

  • 不在首屏展示的业务模块
  • 大型图表库、编辑器、预览器
  • 次级设置页
  • 低频操作弹窗
  • 管理员专用模块

2. 对第三方库做按需引入

很多包不是库本身重,而是引入方式重。

比如:

  • 日期库整包导入
  • 工具库全量导入
  • icon 全量注册
  • 组件库没有按需加载

错误示例:

import _ from 'lodash';

更好的方式:

import debounce from 'lodash/debounce';

虽然现代构建工具有 tree shaking,但前提很多,不能把它当万能保险。


3. 为异步边界设计合理的加载体验

代码分割不是只为了分数,更是为了体验。建议:

  • 路由级 loading 用骨架屏
  • 组件级 loading 尽量保持区域稳定,避免布局抖动
  • 高概率访问页面可以适度 prefetch
  • 用户点击后再加载的模块,按钮状态要可反馈

4. 缓存策略要和分包策略一起设计

如果你把稳定依赖拆分出来,但文件名没带 hash,缓存收益会很有限。

建议至少做到:

  • 文件名内容哈希化
  • runtime 独立
  • 长期稳定 vendor 单独缓存
  • HTML 不长缓存
  • CDN 合理配置 immutable

5. 减少无意义的首屏执行

很多性能问题不在“下载”,而在“执行”。比如:

  • 一进页面就初始化所有 tab
  • 一次性注册所有事件监听
  • 首屏先拉十几个接口
  • 表格渲染上千行数据
  • 埋点、AB 实验、第三方脚本全部同步进来

如果这些问题不处理,单靠代码分割很难把体验拉上去。


一个状态视角:优化过程如何演进

stateDiagram-v2
    [*] --> 基线测量
    基线测量 --> 识别首屏瓶颈
    识别首屏瓶颈 --> 路由级分割
    路由级分割 --> 组件级分割
    组件级分割 --> 依赖治理
    依赖治理 --> 缓存与预取优化
    缓存与预取优化 --> 二次验证
    二次验证 --> 线上监控
    线上监控 --> [*]

这个过程不是一次性的,而是一个持续迭代闭环。中大型项目的性能优化,最怕“一次冲刺,半年不管”。


边界条件:什么时候代码分割收益不大?

也要说清楚,不是所有项目都适合大拆特拆。

以下场景收益可能有限:

  • 页面本身很小,主包只有几百 KB
  • 业务路径极短,用户几乎会完整走完整个应用
  • 网络环境优于主线程瓶颈,真正问题是重计算
  • SSR / 同构项目中,首屏主要受服务端渲染链路限制
  • 内部系统只在高性能桌面设备使用,切换流畅更重要

这时你更该关注:

  • 渲染性能
  • 列表虚拟化
  • 接口并发与缓存
  • 数据计算下沉
  • 重绘与回流控制

总结

如果你想把中大型 Web 应用的加载优化做得“既有效又可持续”,我建议按这条主线推进:

  1. 先用 Lighthouse 找准问题,不靠感觉优化
  2. 先做路由级代码分割,拿到最直接收益
  3. 再拆图表、编辑器、地图这类重型组件
  4. 结合 bundle analyzer 调整 splitChunks 策略
  5. 不要只盯分数,要验证 TBT、切页体验和真实用户数据
  6. 把缓存、预取、依赖治理和执行时机一起考虑

如果只给一条最实用的建议,那就是:

优先把“首屏不需要、但今天却被迫加载”的代码找出来。

这类代码,往往就是中大型应用性能优化里最值得下手的部分。

性能优化很少有“一招见效”的银弹,但 Lighthouse + 代码分割这套组合,确实是我在实际项目里反复验证过、投入产出比很高的一条路径。先跑基线、再分层拆包、每次只改一类问题,你会比一口气“全项目懒加载”稳得多。


分享到:

上一篇
《Java Web 开发中基于 Spring Boot + MyBatis 的后台权限管理系统实战:从 RBAC 设计到接口鉴权落地》
下一篇
《区块链节点数据索引实战:从链上事件解析到高性能查询服务搭建》