构建阶段优化(上):Webpack/Vite配置优化——Tree Shaking原理与实践、代码分割与动态导入
好,咱们进入构建阶段。这是真正能“动手”优化的地方。
很多同学觉得,代码写完了,打包是构建工具的事,跟我没关系。其实不然。我见过太多项目,明明业务逻辑不复杂,打包出来却有好几兆。为什么?因为构建配置没做好。
今天咱们聊两个核心点:Tree Shaking 和 代码分割。这两兄弟,一个帮你“减重”,一个帮你“分拆”。配合好了,加载速度能提升一个档次。
一、Tree Shaking:把没用的代码摇掉
Tree Shaking,直译过来就是“摇树”。你想想看,一棵树,你使劲一摇,枯枝败叶就掉下来了。代码也一样——那些没有被引用的模块,就应该在打包时被“摇掉”。
这个机制最早由 Rollup 提出,后来 Webpack 2 也支持了。Vite 基于 Rollup,天然支持。但支持归支持,你得会用。
1.1 原理:ES Module 的静态分析
Tree Shaking 能工作的前提,是你的代码必须使用 ES Module(即 import / export)。为什么 CommonJS 不行?因为 require 是动态加载的,你可以在条件语句里写 if (condition) { require('a') },构建工具在编译时根本不知道你到底用没用。
而 ES Module 是静态的。import 必须写在文件顶部,依赖关系在编译时就确定了。构建工具可以遍历整个依赖树,标记哪些导出被使用了,哪些没有。没被使用的,在压缩阶段(比如 Terser)就会被移除。
核心条件:
- 使用 ES Module 语法
- 确保没有副作用(side effects)
- 生产模式下启用(Webpack 的
mode: 'production'或 Vite 的build命令)
1.2 实践:Webpack 配置
在 Webpack 5 中,Tree Shaking 默认是开启的。但你得注意几个点。
首先,sideEffects 字段。这个字段在 package.json 里。如果你确定你的包没有任何副作用(比如修改全局变量、添加原型方法等),可以设置为 false,告诉 Webpack:放心摇,不会出事。
// package.json
{
"name": "my-library",
"sideEffects": false
}
如果某些文件有副作用,比如一个 CSS 文件或者一个 polyfill,可以这样写:
{
"sideEffects": [
"*.css",
"./src/polyfill.js"
]
}
我曾经在一个项目中,因为没配 sideEffects,导致一个工具库里的 CSS 被全部摇掉了。页面样式全乱了。排查了半天才发现,是 sideEffects 默认值的问题。嗯,这个坑我替你们踩过了。
1.3 实践:Vite 配置
Vite 基于 Rollup,Tree Shaking 是内置的。你几乎不需要额外配置。但有一个常见问题:第三方库的 Tree Shaking。
比如你从 lodash 里只引用了 debounce:
import { debounce } from 'lodash'
但打包后你会发现,整个 lodash 都被打包进来了。为什么?因为 lodash 用的是 CommonJS 格式,不支持 Tree Shaking。
解决方案:使用 lodash-es(ES Module 版本)。
import { debounce } from 'lodash-es'
或者用 babel-plugin-lodash 做按需加载。我个人习惯直接换 lodash-es,省心。
小技巧:在 Vite 中,可以通过 optimizeDeps 配置预构建的依赖。如果你发现某个库的 Tree Shaking 不生效,可以检查它是否被预构建了。预构建会把 CommonJS 转成 ES Module,但有时候会丢失 Tree Shaking 信息。
二、代码分割(Code Splitting):别让用户一次性下载所有代码
Tree Shaking 解决的是“体积”问题。但即使你把没用的代码都摇掉了,剩下的代码可能还是很大。比如你的应用有 100 个页面,用户打开首页,却要下载所有页面的代码。这合理吗?不合理。
代码分割,就是把代码拆成多个小块(chunk),按需加载。用户访问哪个页面,就下载哪个页面的代码。
2.1 动态导入(Dynamic Import)
动态导入是代码分割的核心。语法很简单:
// 静态导入
import { Chart } from './Chart.vue'
// 动态导入
const Chart = () => import('./Chart.vue')
Webpack 和 Vite 遇到 import() 语法,会自动把它拆成一个独立的 chunk。当代码执行到这一行时,才会去加载这个 chunk。
在 Vue 中,最常见的用法就是路由懒加载:
// router/index.js
const routes = [
{
path: '/dashboard',
component: () => import('@/views/Dashboard.vue')
},
{
path: '/settings',
component: () => import('@/views/Settings.vue')
}
]
这样,用户访问 /dashboard 时,只会加载 Dashboard 的代码。Settings 的代码,等用户点击了再说。
2.2 Webpack 的 SplitChunks 配置
动态导入能帮你拆业务代码。但第三方库呢?比如 Vue、Vue Router、Element Plus 这些,它们体积很大,而且很少变化。如果每个页面都打包一份 Vue,那也太浪费了。
这时候就需要 SplitChunksPlugin。Webpack 4+ 内置了这个插件,默认配置已经不错了。但我们可以优化。
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
name: 'vendor',
test: /[\\/]node_modules[\\/]/,
priority: 10,
chunks: 'initial'
},
common: {
name: 'common',
minChunks: 2,
priority: 5,
reuseExistingChunk: true
}
}
}
}
}
这个配置的意思是:
- 把所有
node_modules里的代码打包成一个vendorchunk - 把被至少 2 个 chunk 引用的代码打包成一个
commonchunk
这样,用户第一次访问时,下载 vendor 和 app。后续访问其他页面,只需要下载新增的业务代码,vendor 会被浏览器缓存。
注意:不要把所有第三方库都塞进一个 vendor 里。如果某个库特别大(比如 ECharts),建议单独拆出来。否则用户打开首页,却要下载一个 2MB 的 vendor,体验很差。
2.3 Vite 的代码分割
Vite 基于 Rollup,代码分割也是内置的。你只需要用动态导入,Vite 会自动处理。
但 Vite 的 build.rollupOptions 也提供了手动控制的能力:
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('echarts')) {
return 'echarts'
}
if (id.includes('element-plus')) {
return 'element-plus'
}
return 'vendor'
}
}
}
}
}
}
这样,ECharts 和 Element Plus 会被拆成独立的 chunk。用户访问不需要图表的页面时,就不用下载 ECharts 了。
2.4 预加载与预获取
代码分割之后,有些 chunk 可能比较重要,我们希望它提前加载。这时候可以用 preload 和 prefetch。
- preload:告诉浏览器,这个资源很重要,尽快加载。适用于当前页面马上要用到的资源。
- prefetch:告诉浏览器,这个资源以后可能用到,空闲时加载。适用于下一个页面可能用到的资源。
在 Webpack 中,可以通过魔法注释实现:
const Chart = () => import(/* webpackPreload: true */ './Chart.vue')
const Settings = () => import(/* webpackPrefetch: true */ './Settings.vue')
在 Vite 中,可以用 vite-plugin-prefetch 插件,或者手动在 HTML 中添加 <link rel="prefetch">。
我的建议:preload 要慎用。用多了反而会占用带宽,影响首屏加载。我一般只对首屏关键组件使用 preload。prefetch 可以大胆用,反正是在空闲时加载。
三、避坑指南
说了这么多,最后分享几个我踩过的坑。
坑一:动态导入的 chunk 命名混乱。
默认情况下,动态导入生成的 chunk 名字是数字(0.js、1.js)。调试时根本分不清哪个是哪个。建议给每个动态导入加一个注释:
const Chart = () => import(/* webpackChunkName: "chart" */ './Chart.vue')
这样生成的 chunk 名字就是 chart.js,一目了然。
坑二:Tree Shaking 把 CSS 摇掉了。
前面提到过,如果 sideEffects 设置为 false,CSS 文件会被当作无副作用的模块,直接被摇掉。解决方案就是在 sideEffects 里把 CSS 文件排除掉。
坑三:vendor 包太大。
我曾经遇到一个项目,vendor 包有 3MB。查了半天,发现是某个库把整个 moment.js 都打包进来了,而我只用了它的日期格式化功能。后来换成了 dayjs,vendor 直接降到 500KB。
所以,选第三方库时,尽量选支持 Tree Shaking 的、体积小的。这也是优化的一部分。
小结
今天咱们聊了 Tree Shaking 和代码分割。Tree Shaking 帮你“减重”,代码分割帮你“分拆”。两者结合,能让你的应用加载速度提升不少。
下一节,咱们继续聊构建阶段的优化:图片压缩、CSS 优化、缓存策略。这些也都是实战中非常实用的技巧。