构建阶段优化(上):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 里的代码打包成一个 vendor chunk
  • 把被至少 2 个 chunk 引用的代码打包成一个 common chunk

这样,用户第一次访问时,下载 vendorapp。后续访问其他页面,只需要下载新增的业务代码,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 可能比较重要,我们希望它提前加载。这时候可以用 preloadprefetch

  • 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 优化、缓存策略。这些也都是实战中非常实用的技巧。