跳到正文
前端知识库
性能

构建体积与性能治理

从产物分析、依赖治理、代码分割、Tree Shaking、CSS/图片压缩到预算和 CI 回归,建立可持续的构建性能闭环。

7 分钟Bundle · Code Splitting · Tree Shaking · 依赖治理 · CI · Webpack · Vite · 预算

构建体积与性能治理

1. 先把“包大”拆成三个问题

  1. 首屏传输大:入口 chunk、关键 CSS、字体和 LCP 图片字节数高。
  2. 运行时执行重:下载不大,但解析、编译、执行或初始化耗时长。
  3. 构建/发布慢:loader、插件、压缩、source map、依赖预构建或 CI 缓存效率低。

Bundle 大小只是线索。应同时查看传输大小(gzip/Brotli)、未压缩大小、解析/执行时间、缓存命中率和真实页面路径。

2. 建立可重复的产物基线

2.1 产物可视化

Webpack 可用 webpack-bundle-analyzer,Rollup/Vite 可用 rollup-plugin-visualizer

// vite.config.ts(只在分析命令中开启)
import { visualizer } from 'rollup-plugin-visualizer'

export default {
  plugins: [
    process.env.ANALYZE
      ? visualizer({ filename: 'tmp/stats.html', gzipSize: true, brotliSize: true })
      : undefined,
  ].filter(Boolean),
}

固定 Node、包管理器、锁文件、环境变量和构建命令;否则不同机器的压缩、依赖解析或 source map 会让比较失真。每次报告至少记录:入口/路由 chunk、重复依赖、最大模块、传输与压缩大小、构建耗时。

2.2 源文件体积扫描

源文件扫描适合发现异常图片、字体或生成文件,但不能替代构建分析:

import fs from 'node:fs'
import path from 'node:path'

function walk(dir, result = []) {
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
    const file = path.join(dir, entry.name)
    if (entry.isDirectory() && !['node_modules', 'dist', '.git'].includes(entry.name)) walk(file, result)
    else if (entry.isFile()) result.push({ file, bytes: fs.statSync(file).size })
  }
  return result
}

const files = walk('src').sort((a, b) => b.bytes - a.bytes)
console.table(files.slice(0, 20))

不要把 Markdown、测试 fixture、source map 或临时分析目录算进线上预算;扫描脚本应明确 include/exclude,并把异常结果作为待调查项而不是自动删除。

3. 代码分割与长缓存

3.1 路由边界优先

const SettingsPage = lazy(() => import('./pages/SettingsPage'))

function AppRoutes() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Routes>
        <Route path="/settings" element={<SettingsPage />} />
      </Routes>
    </Suspense>
  )
}

先按用户访问路径动态导入,再按公共依赖和更新频率设计共享 chunk。分包过细会增加请求、调度和解析开销;过粗则首屏和缓存收益差。用真实导航瀑布和缓存命中率验证,而不是以 chunk 数量为目标。

3.2 Webpack SplitChunks

optimization: {
  splitChunks: {
    chunks: 'all',
    minSize: 20 * 1024,
    cacheGroups: {
      vendors: {
        test: /[\\/]node_modules[\\/]/,
        name: 'vendors',
        priority: -10,
      },
    },
  },
  runtimeChunk: 'single',
}

runtimeChunk 抽离运行时代码可减少业务模块变化导致的缓存失效;具体 minSize、请求数和 cache group 应根据页面类型和协议调优。Vite/Rollup 的 manualChunks 同样应按访问边界与更新频率,而不是简单按库名切割。

3.3 文件指纹与部署顺序

output: {
  filename: '[name].[contenthash:8].js',
  chunkFilename: '[name].[contenthash:8].chunk.js',
}

静态资源使用内容 hash 才能安全长缓存;HTML 不应引用不存在的 chunk。发布顺序应为“上传新资源 -> 校验可访问 -> 切换 HTML -> 保留旧资源兼容窗口”,并记录构建 release 以支持回滚和 Source Map 定位。

4. Tree Shaking 与副作用

Tree Shaking 依赖 ESM 的静态导入导出、构建模式和副作用声明:

{
  "sideEffects": [
    "**/*.css",
    "src/polyfills.ts"
  ]
}

常见失效原因:

  • 使用 CommonJS 或动态字符串导入,静态分析信息不足;
  • sideEffects: false 错误地删除了 polyfill、全局注册或 CSS;
  • 从聚合入口导入整个库,或库本身没有可摇树的 ESM 产物;
  • 代码在模块顶层执行副作用,且未在包元数据中声明。

sideEffects 是契约,不是“越严格越好”的体积开关。修改前后用产物分析和运行时回归确认样式、注册器和 polyfill 仍然生效。

5. 依赖治理

5.1 未使用依赖分析的边界

静态扫描可以收集 ESM、CommonJS、动态导入和配置文件中的包名,但以下情况可能误报:

  • CLI 命令、插件自动加载、框架约定目录;
  • package.json 的 exports、side effects、types 或 peer dependency;
  • 字符串拼接动态导入、运行时按平台加载;
  • 仅在 SSR、测试、脚本或特定构建模式使用的包。

安全流程:

  1. 先列候选,不自动卸载;
  2. 搜索源码、配置、脚本、锁文件和发布包入口;
  3. 在最小分支移除一个依赖,执行类型检查、构建、测试和关键页面冒烟;
  4. 观察生产错误、样式和按需加载,再合并变更。

5.2 依赖替换和 peerDependencies

  • moment 换成 dayjs 可能减小体积,但要核对时区、locale、插件和 API 语义;
  • lodash 换成 lodash-es 只有在构建链能处理 ESM、且使用方式支持 Tree Shaking 时才有收益;
  • 组件库/公共库应把宿主已提供的 React/Vue 等放入 peerDependencies,避免打进两份运行时;
  • peer dependency 的版本范围、消费者安装方式和单例要求必须写清,不能只移动字段。

5.3 重复代码与无引用文件

重复代码工具(如 jscpd)适合发现可抽取的业务逻辑,但相似片段不必强行合并:先确认抽象边界、发布影响和可读性。无引用文件扫描要考虑路由约定、动态导入、组件自动注册、样式入口和 public 资源,扫描结果只能作为人工清理候选。

6. CSS、图片和字体构建优化

// PostCSS 生产配置示意
import cssnano from 'cssnano'

export default {
  plugins: [
    cssnano({ preset: 'default' }),
  ],
}
  • 删除无用 CSS 前先覆盖动态 class、主题、CSS Modules 和 SSR 输出;
  • 用 Brotli/gzip 压缩文本,图片在源头按格式、尺寸和质量优化;
  • 对字体做字符集子集化,避免把全部语言字形放进首屏;
  • CSS 提取和按路由加载要兼顾 FOUC、关键 CSS 和缓存粒度。

7. 构建速度优化

7.1 先找耗时来源

使用 Webpack profile、构建日志、插件 timing 或 Vite debug 输出,把耗时拆成依赖预构建、loader/transform、插件、source map、压缩和 I/O。没有 profile 时,不要直接添加并行插件。

7.2 常见手段及代价

手段 适用 代价/风险
缩小 include/exclude loader 处理范围过大 漏处理文件,需覆盖扩展名
filesystem cache 重复构建和 CI 缓存键失效或占磁盘
并行 transform/压缩 CPU 资源充足 进程开销、内存峰值、日志复杂
减少插件/关闭非必要 source map 构建瓶颈明确 调试和错误定位能力下降
依赖预构建缓存 Vite 开发冷启动 软链包、条件导出兼容问题
固定 Node/锁文件 可复现构建 升级需要维护兼容矩阵

Webpack 的 loader/plugin 生命周期和 Vite/esbuild 的边界见 Webpack 构建流程Webpack 面试进阶Vite 面试进阶esbuild 面试进阶

8. 性能预算与 CI 门禁

预算应按页面和设备类别设置,而非只设一个全局 MB 数:

{
  "routes": {
    "/": { "initialJsGzipKb": 220, "lcpP75Ms": 2500 },
    "/editor": { "initialJsGzipKb": 420, "inpP75Ms": 200 }
  }
}

CI 可以做三类门禁:

  1. 产物门禁:入口/路由 chunk、CSS、字体和图片超过预算时失败或要求评审;
  2. 实验室回归:固定 Lighthouse 条件,对 LCP/TBT/CLS 等设置容差;
  3. 现场回归:发布后按 release 观察 RUM P75/P95、错误率和 chunk 加载失败率,超阈值自动暂停灰度或回滚。

预算不是越低越好:过严会阻止必要功能,过松则失去反馈。每次豁免记录原因、影响路径、到期时间和后续偿还计划。

9. 面试追问速答

Q: 你如何证明做过包体积优化?

A: 先给构建版本、页面和设备条件,再展示分析报告中最大模块/重复依赖,说明采用路由懒加载、公共依赖拆分、Tree Shaking 或依赖替换的具体改动,最后给出传输字节、解析执行、LCP 和缓存命中率的前后对比。没有现场数据时明确标为实验室结果或待验证假设。

Q: 为什么 sideEffects: false 可能出问题?

A: 它告诉打包器未引用模块可整体删除;若模块注册全局能力、注入 polyfill 或导入 CSS,删除会改变运行时行为。应把真实副作用列入白名单,并通过样式/功能回归验证。

Q: 分包是不是越细越好?

A: 不是。分包要平衡首屏字节、请求/调度、解析执行和缓存复用;优先按路由和更新频率切,再通过瀑布图与现场数据调整粒度。

Q: 如何判断一个依赖真的没用?

A: 静态扫描只给候选。还要检查配置、脚本、自动注册、动态导入、SSR/测试和 peer dependency,在隔离分支跑类型检查、构建、测试和关键页面,再观察发布后的错误和资源变化。

10. 资料来源与现有专题

本篇综合 前端性能优化详解.pdf 关于 Rollup visualizer、源文件体积、未使用依赖、重复代码、无引用文件、peerDependencies、依赖替换和 CSS 压缩的内容,以及三份性能 PDF 关于代码分割、Tree Shaking、缓存和构建优化的内容。相关专题:前端性能优化知识地图前端项目架构设计组件库工程化