构建优化
前端工程化的核心议题中,构建优化直接决定了线上产品的加载速度和开发阶段的体验。本文从构建工具的运行原理出发,梳理 Tree Shaking、代码分割、长期缓存等关键优化手段的原理与实践。
Bundle 与 ESM 两种范式
理解构建优化之前,需要先理解 Webpack 和 Vite 在底层运行机制上的本质区别,因为这决定了它们的优化空间和手段完全不同。
Webpack 基于 Bundle 模式:启动时从入口文件开始递归扫描整个依赖图,将所有模块打包成有限数量的 bundle 文件后才能启动开发服务器。项目越大,冷启动越慢,因为每次启动都要重新分析完整的依赖关系。热更新时,Webpack 需要重新编译受影响的模块及其依赖链,然后通知浏览器刷新对应的模块——这个过程在大项目中可能需要数秒。
Vite 在开发环境下基于浏览器原生 ESM(ES Modules)。不进行打包,而是利用浏览器的 <script type="module"> 直接按需加载源文件,浏览器请求哪个模块就返回哪个模块。对于第三方依赖(node_modules 下的库),Vite 使用 Esbuild 进行预构建(Pre-bundling),将几百个小模块合并为少量 bundle,避免浏览器发起大量 HTTP 请求。这种"按需编译"的策略让 Vite 的冷启动不随项目规模线性增长,热更新也只重新编译单个模块,速度通常在百毫秒级别。
但这两种模式在构建优化上的关注点不同。Webpack 时代的大量优化手段(SplitChunks 配置、Bundle Analyzer 分析、Loader 链优化)都是为了解决 Bundle 模式本身的效率问题。Vite 在开发模式下绕过了打包,很多 Webpack 时代的痛点自然消失了。不过 Vite 的生产构建仍然使用 Rollup 进行打包,所以 Tree Shaking、代码分割、长期缓存等优化策略在两种工具中都是通用的。
Tree Shaking
Tree Shaking(死代码消除)是构建工具在打包过程中移除未被引用代码的能力。它的前提条件是使用 ESM 的静态导入(import/export),因为 ESM 的依赖关系在编译期就能确定——编译器可以明确知道哪些导出被使用、哪些没有被任何模块引用。CommonJS 的 require 是运行时动态加载,可以写在 if 条件分支中,编译器无法在构建时判断代码是否真的会被执行,所以难以做到精确的 Tree Shaking。
// ESM:编译器能确定 utils.js 中的 formatDate 没有被使用
import { add } from './utils.js' // 只引用了 add
// CommonJS:编译器无法确定运行时会 require 哪个导出
const utils = require('./utils')
const fn = Math.random() > 0.5 ? utils.add : utils.formatDate在实际项目中,Tree Shaking 可能不如预期生效,最常见的排查方向有两个。一是检查 package.json 中的 sideEffects 字段。如果某个库或文件在导入时有副作用(如修改全局变量、执行 Polyfill 注入),打包工具为安全起见不会将其移除。库作者应正确声明 "sideEffects": false 或列出有副作用的文件路径。二是确认第三方库是否提供了 ESM 版本——很多库的 package.json 中 "module" 字段指向 ESM 入口,"main" 指向 CJS 入口,如果构建工具没有正确解析到 ESM 版本,就会回退到 CJS,Tree Shaking 效果大打折扣。
Vite 通过 optimizeDeps.esbuildOptions 在预构建阶段也能做一定程度的 Tree Shaking,但预构建的目的是合并模块减少请求数而非消除死代码。真正的 Tree Shaking 发生在 Rollup 的生产构建阶段。
代码分割
代码分割(Code Splitting)的目的是将应用拆分为多个较小的 chunk,浏览器可以按需加载而非一次性下载整个应用。这在大型项目中直接影响首屏加载速度——用户只需要下载当前页面所需的代码,其余代码在后续导航时按需获取。
Vite 和 Webpack 都支持三种代码分割策略。最常用的是动态导入(Dynamic Import),通过 import() 语法在代码中按需加载模块:
// 路由级别的代码分割
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('./views/Settings.vue')
}
]import() 返回一个 Promise,在产物中表现为独立的 chunk 文件。浏览器只在执行到这行代码时才发起请求加载对应的 chunk。在 Vue Router 和 React Router 中,路由懒加载就是基于这个机制实现的。
第二种策略是通过 Vite 的 build.rollupOptions.output.manualChunks 或 Webpack 的 optimization.splitChunks 进行分包配置。合理的分包策略需要平衡缓存命中率和请求数量。常见的分包原则是将变化频率不同的代码分开:框架和核心库(Vue、React、axios 等)变化最少,应作为独立的 vendor chunk 利用长期缓存;工具函数和公共组件次之;业务代码变化最频繁,应与稳定的依赖分离,避免业务改动导致 vendor chunk 的缓存失效。
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-vue': ['vue', 'vue-router', 'pinia'],
'vendor-utils': ['axios', 'dayjs'],
}
}
}
}
})第三种是提取公共代码:当多个路由共享某些组件或工具函数时,构建工具会自动将这些公共依赖提取为独立的 chunk,避免同一份代码在多个路由 chunk 中重复出现。
需要注意的是,分包粒度不是越细越好。每个额外的 chunk 都意味着一次额外的 HTTP 请求(在 HTTP/1.1 下尤其明显,HTTP/2 下影响较小但仍有连接建立开销)。过度的代码分割反而可能因为请求数增加和模块加载的串行等待导致加载变慢。工程上通常通过 rollup-plugin-visualizer 可视化分析打包产物的体积分布,找到体积异常的 chunk 进行针对性优化,而非盲目追求细分。
长期缓存
长期缓存(Long-term Caching)的目标是让浏览器尽可能复用已缓存的资源,避免每次部署都重新下载所有文件。实现长期缓存的核心是在文件名中嵌入内容哈希——当文件内容变化时哈希值变化,文件名随之变化,浏览器的缓存自然失效;内容不变的文件名不变,缓存持续有效。
Vite 和 Webpack 都默认在构建产物中使用内容哈希。但要真正实现长期缓存,还需要确保哈希值不受无关因素影响。Webpack 的 optimization.runtimeChunk 将运行时代码提取为独立 chunk,防止入口文件的改动影响所有 chunk 的哈希。合理的手动分包(如将 vendor 和业务代码分离)也能避免业务代码的改动影响 vendor chunk 的哈希值。
另一个容易被忽视的点是 CSS 提取。在开发模式下 Vite 将 CSS 作为 JavaScript 模块注入页面(通过 <style> 标签),但生产构建时默认会将 CSS 提取为独立的 .css 文件。CSS 文件名同样包含内容哈希,变化时浏览器会重新加载,不变时命中缓存。
// vite.config.ts — 生产构建默认行为
export default defineConfig({
build: {
// 产物文件名模式:[name]-[hash].js
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]'
}
}
}
})在 CDN 部署场景中,长期缓存还需要配合正确的 Cache-Control 响应头。静态资源设置较长的 max-age(如一年),HTML 入口文件设置 no-cache 强制每次验证。当新版部署后,HTML 中引用的资源文件名已经变了(哈希值不同),浏览器会自动请求新版本的 JS 和 CSS 文件,旧的缓存自然失效。这个策略的前提是 HTML 入口文件本身不被缓存——它是指向所有其他资源的"索引",必须保证用户每次都能拿到最新版本。