Vite 5 工具链实践:快不是唯一目标,稳定才是工程价值

Vite 最早被很多人记住,是因为“启动快”。相比传统 webpack 项目,Vite 在开发阶段利用浏览器原生 ESM,避免了每次启动都打完整 bundle,体验确实明显更好。

但到 2024 年再看 Vite,它已经不只是一个快启动工具。越来越多项目把它作为默认前端构建底座,配合 React、Vue、Svelte、Vitest、Storybook、组件库构建一起使用。这个阶段,工程价值不只看快不快,还要看配置是否稳定、插件各自的职责是否清楚、构建产物是否可控。

开发快和构建快不是一回事

Vite 开发阶段快,主要是因为它按需处理模块。浏览器请求哪个模块,开发服务器再转换哪个模块。

生产构建则仍然需要打包。Vite 默认使用 Rollup 做生产构建:

1npm run build

所以不要误以为“Vite 开发很快,生产构建一定也快”。如果项目依赖很多、组件库很大、图表库和编辑器都被打进主包,生产构建仍然可能慢,产物仍然可能大。

我手上有个中后台项目,dev server 启动两三秒,团队都觉得 Vite 真香。结果上 CI 跑 build 要将近三分钟,最后定位下来是 echarts、monaco-editor、还有一个内部的图标库全被静态 import 进了入口,Rollup 在 tree-shaking 和压缩这几个大依赖上花了大量时间。dev 阶段这些模块根本没被请求到,所以一点感觉都没有——这就是开发快和构建快是两回事的典型例子。

还有一点容易被忽略:dev 用的是 esbuild 转换(不做类型检查、不做完整的语法降级),build 用的是 Rollup。两条链路走的不是同一套代码路径。所以会出现 dev 跑得好好的,build 报错或者产物行为不一致的情况。我遇到过最坑的一次,是一个依赖在 dev 下走了 CJS 兼容路径正常,build 时 Rollup 选了它的 ESM 入口,那个 ESM 入口有 bug,直接白屏。排查时一定要记住:本地 vitevite build 是两个世界,验收前一定要本地 vite build && vite preview 过一遍,别只信 dev。

排查构建问题时,我一般分开看:

  • dev server 启动慢
  • HMR 更新慢
  • build 时间长
  • bundle 体积大
  • 首屏资源加载慢

这几类问题原因不同,不能都归因到 Vite。比如 HMR 突然变慢,多半不是 Vite 的锅,而是某个被频繁修改的文件被太多模块依赖,改一下触发一大片重新求值;首屏加载慢更可能是产物体积或网络问题,跟构建速度没半毛钱关系。把现象拆开看,能少走很多弯路。

再往底层挖一层,这两条链路的分裂是有规范层面的根因的。dev 阶段 Vite 把每个源文件当作一个 ESM 模块原样吐给浏览器,靠的是浏览器对 <script type="module">import 的原生支持——它只做最小转换(TS 去类型、JSX 编译),不做模块图打包,也不主动帮你抹平 CommonJS。而 node_modules 里大量包仍然是 CJS(module.exports),浏览器根本不认 require。Vite 靠预构建阶段用 esbuild 把这些 CJS 包转成 ESM,并分析出它们的具名导出,这一步叫 CJS interop。问题就出在这:esbuild 的 CJS-to-ESM 转换和 Rollup(生产构建用的 @rollup/plugin-commonjs)对具名导出的探测算法不完全一样。

这类差异会实际引发线上问题。典型场景是:某个工具库 dev 下 import { isEmpty } from 'xxx' 一切正常,build 后线上报 isEmpty is not a function。扒开看,那个包的入口是 CJS,导出写成了运行时动态挂载(module.exports.isEmpty = ... 在一个 IIFE 里赋值)。esbuild 预构建时做了静态分析,"猜"出了 isEmpty 这个具名导出并正确转译;而 Rollup 的 commonjs 插件默认的 defaultIsModuleExports 行为不同,把整个模块当成 default export,具名导出探测失败,于是 isEmpty 在 build 产物里是 undefined。处理办法是给那个包加 optimizeDeps.include 的同时,在 build.commonjsOptions.include 里也显式列上它,让两条链路用一致的策略。

1export default defineConfig({
2  optimizeDeps: {
3    include: ['legacy-utils'],
4  },
5  build: {
6    commonjsOptions: {
7      // 让生产构建也强制走 commonjs 转换,和 dev 行为对齐
8      include: [/legacy-utils/, /node_modules/],
9      transformMixedEsModules: true, // 处理同一个文件里混用 require 和 import 的包
10    },
11  },
12})

这个教训我记得很牢:dev 跑通只能证明 esbuild 这条链路 OK,不能证明 Rollup 那条 OK。涉及第三方 CJS 包的具名导入,验收时一定要 vite build && vite preview 走真实产物。

依赖预构建

Vite 开发阶段会对依赖做预构建,常见配置是 optimizeDeps

1import { defineConfig } from 'vite'
2import react from '@vitejs/plugin-react'
3
4export default defineConfig({
5  plugins: [react()],
6  optimizeDeps: {
7    include: ['lodash-es'],
8  },
9})

通常不需要手动配置,Vite 会自动扫描。但有些依赖是动态引入、条件导出、或者被内部包间接使用,扫描不到时就可能出现开发阶段首次访问很慢,甚至报模块解析错误。

预构建做的事其实很实在:把那些发布成一堆零碎 CJS 或者内部有几百个小文件的依赖(lodash 是经典例子),用 esbuild 提前打成一个 ESM bundle,省得浏览器在 dev 下发起成百上千个请求。我踩过最典型的坑,是 monorepo 里 packages/ui 引了 lodash-es,但项目入口没直接 import,Vite 首次扫描时漏掉了它。结果点进某个页面瞬间卡几秒,network 面板里几百个 lodash-es/xxx.js 请求刷屏。把它写进 include 之后立刻顺了:

1optimizeDeps: {
2  // ui 包间接依赖,入口扫不到,手动补上避免首访瀑布请求
3  include: ['lodash-es', 'ui-kit > some-cjs-dep'],
4}

注意 'a > b' 这种写法,是告诉 Vite 去预构建 a 包内部用到的 b,处理那种被嵌套引用、扫描器够不着的依赖很有用。

另外提一个排查信号:如果你发现改了 node_modules 里的东西或者切了分支后行为诡异,可以删掉 node_modules/.vite 缓存目录,或者 vite --force 强制重新预构建。这个缓存偶尔会过期不一致,我每次遇到“明明改了依赖却没生效”都会先清它。

值得说清楚的是这个缓存到底是怎么判断"过期"的,否则你只会盲目地清。Vite 在 node_modules/.vite/deps 下会写一个 _metadata.json,里面有个 hash 字段。这个 hash 是根据一组输入算出来的:package.json 里的 dependencies、lockfile 内容、以及 config 里和预构建相关的字段(optimizeDepsresolve.alias 等)。下次启动时 Vite 重新算一遍,对不上才重新预构建。

这就解释了几个我以前觉得玄学的现象:手动改了 node_modules 里的源码(比如本地 patch 调试),但 package.json 和 lockfile 没动,hash 不变,Vite 直接用旧缓存,你的改动根本没生效——这时必须 --force。反过来,monorepo 里用 workspace 协议链接的本地包,它的改动也不一定进 hash 计算,改了 packages/ui 的源码 dev 不更新,也是同一个原因。理解了 hash 的输入是什么,"该不该清缓存"就不再靠运气:是不是改了 hash 不覆盖的东西?是就 --force

1// node_modules/.vite/deps/_metadata.json 摘要
2{
3  "hash": "a1b2c3d4",          // 输入变了它才变
4  "browserHash": "e5f6...",     // 决定浏览器侧 ?v= 查询参数,影响缓存失效
5  "optimized": {
6    "lodash-es": { "src": "...", "file": "...", "needsInterop": false }
7  }
8}

顺带一提那个 browserHash:Vite 给预构建产物的请求 URL 加 ?v=xxx 就是用它,浏览器据此强缓存。当 Vite 在运行时发现扫到了新依赖、触发二次预构建,browserHash 会变,于是它会让你看到那句 "new dependencies optimized, reloading" 并刷新页面——这不是 bug,是它在保证你不会用到旧的依赖缓存。早期我老以为这种自动 reload 是配置出问题了,其实是预构建的正常自愈机制。

这时可以明确 include。反过来,如果某个依赖预构建有问题,也可以 exclude:

1export default defineConfig({
2  optimizeDeps: {
3    exclude: ['some-esm-only-package'],
4  },
5})

我的建议是:不要一开始就写一堆 optimizeDeps。先让默认行为工作,遇到明确问题再加配置,并在注释里说明原因。

环境变量要分清客户端和服务端

Vite 只会把以 VITE_ 开头的环境变量暴露给客户端代码:

1VITE_API_BASE_URL=https://api.example.com

使用时:

1const apiBaseUrl = import.meta.env.VITE_API_BASE_URL

不要把密钥写成:

1VITE_SECRET_KEY=xxxx

只要前缀是 VITE_,它就可能被打进前端产物。前端环境变量不是安全存储,只是构建时替换。import.meta.env.VITE_SECRET_KEY 在 build 后会被字面量替换成 "xxxx" 直接写进 JS 文件里,打开 dist 搜一下就能看见。我之前 review 别人代码时真见过有人把第三方支付的 secret 配成 VITE_ 暴露出去,幸好上线前拦下来了。

还有个容易混的点:.env.env.local.env.production 的加载顺序和优先级。.env.local 会覆盖 .env,并且应该加进 .gitignore(本地私密配置放这里);带 mode 的文件(如 .env.production)只在对应 mode 下生效。我习惯的约定是:.env 放可提交的公共默认值,.env.local 放每个人本地不同的东西(比如指向自己的 mock 服务),CI/CD 上的真实配置走平台注入的环境变量,不落盘到仓库。

如果想在构建期就拦住误配,可以在 config 里加一道校验,避免把不该暴露的东西打进产物:

1import { defineConfig, loadEnv } from 'vite'
2
3export default defineConfig(({ mode }) => {
4  const env = loadEnv(mode, process.cwd(), 'VITE_')
5  if (Object.keys(env).some((k) => /SECRET|TOKEN|PASSWORD/i.test(k))) {
6    throw new Error('检测到疑似密钥使用了 VITE_ 前缀,会被打进前端产物,请检查 .env')
7  }
8  return { /* ... */ }
9})

loadEnv 第三个参数传 'VITE_' 表示只读取这个前缀的变量,刚好用来做这种自检。

我一般会把环境变量分成两类:

  • 客户端可见:API base URL、站点名称、公共开关
  • 服务端私密:数据库地址、密钥、第三方 token

如果是纯前端静态项目,就不要幻想把密钥藏在环境变量里。真正私密的调用必须放到服务端。

路径别名要同时照顾 TS

Vite 配路径别名很常见:

1import { defineConfig } from 'vite'
2import path from 'node:path'
3
4export default defineConfig({
5  resolve: {
6    alias: {
7      '@': path.resolve(__dirname, 'src'),
8    },
9  },
10})

但 TypeScript 也需要知道:

1{
2  "compilerOptions": {
3    "baseUrl": ".",
4    "paths": {
5      "@/*": ["src/*"]
6    }
7  }
8}

否则编辑器、类型检查和构建工具看到的世界不一致。项目里很多“能运行但编辑器报错”或“编辑器正常但构建失败”的问题,本质上都是工具配置没有对齐。

这里维护两份配置很容易写着写着就漂移:Vite 里加了 @components 别名,忘了同步 tsconfig,于是运行没问题但 VS Code 满屏红线。我后来干脆上 vite-tsconfig-paths,让 Vite 直接读 tsconfig 的 paths,单一数据源,省去两边对齐:

1import tsconfigPaths from 'vite-tsconfig-paths'
2
3export default defineConfig({
4  plugins: [react(), tsconfigPaths()],
5})

不过要提醒一句:Vite 默认不做类型检查,vite build 是不跑 tsc 的,类型错误它照样让你打包成功。所以 paths 配对了不代表类型就安全。我的做法是 CI 里单独跑一条 tsc --noEmit(或者用 vite-plugin-checker 在 dev 阶段把类型错误顶到浏览器和终端),构建归构建、类型校验归类型校验,两件事分开但都不能少。

代码分割要贴着业务模块切

Vite 生产构建基于 Rollup,可以做手动分包:

1export default defineConfig({
2  build: {
3    rollupOptions: {
4      output: {
5        manualChunks: {
6          react: ['react', 'react-dom'],
7          charts: ['echarts'],
8        },
9      },
10    },
11  },
12})

但手动分包不是越细越好。切得太碎,会增加请求数量和缓存复杂度;切得太粗,首屏又可能下载不必要代码。

我对 manualChunks 的态度是:只拿来稳定那些“几乎不变的大块”,比如 react-dom、第三方 UI 库,把它们单独切出去,这样业务代码每次发版,用户的 vendor chunk 还能命中缓存不用重下。除此之外的业务拆分,我更信任路由级懒加载,让 Rollup 自己根据 import() 去切,比手写映射可靠。

用对象形式写 manualChunks 有个隐藏的坑要小心:分包之间如果有共享依赖,处理不好会出现循环引用或者初始化顺序错乱,线上偶发地报 Cannot access 'X' before initialization。我被这个坑过一次后,复杂场景改成函数形式按路径归类,至少能看清每个 id 被分到哪:

1manualChunks(id) {
2  if (id.includes('node_modules')) {
3    if (id.includes('echarts')) return 'echarts'
4    if (/react|react-dom|scheduler/.test(id)) return 'react-vendor'
5    return 'vendor'
6  }
7}

但说实话,能不手写就不手写。大部分项目把路由懒加载做好,产物就已经很健康了。

更稳的方式是按业务模块懒加载:

1import { lazy, Suspense } from 'react'
2
3const AdminPage = lazy(() => import('./pages/AdminPage'))
4
5export function App() {
6  return (
7    <Suspense fallback={<p>Loading...</p>}>
8      <AdminPage />
9    </Suspense>
10  )
11}

比如后台管理的富文本编辑器、图表大屏、地图组件,不应该进入普通用户首页主包。

懒加载有两个代价必须提前想清楚,不是接上 lazy 就万事大吉。第一个代价是发版会导致旧 chunk 失效。生产构建默认给每个 chunk 文件名加内容 hash(AdminPage-[hash].js),内容变了 hash 就变,这是为了让 CDN 长期强缓存又能在更新时精准失效。但这套机制有个时间窗口的隐患:用户打开页面时加载的是旧版本 index.js,里面记录的动态 import 指向旧 hash 的 chunk 文件;如果这期间发布了新版本,CDN 上旧 hash 的文件被清掉,用户长时间停留后再触发这个懒加载点,浏览器请求的还是记忆里的旧文件名——404,页面报 Failed to fetch dynamically imported module,直接白屏。这类问题的典型特征是本地和预发都复现不了,只有线上停留时间够长、又恰好撞上发版窗口的用户才会遇到,刷新一下就恢复正常。

第二个代价是懒加载点切得太靠近叶子组件时,会把一次交互拆成一次网络等待,用户每点一个按钮都要多等一轮请求,体验反而碎。更稳的粒度是收到路由层级,配合 Suspense 给一个像样的骨架屏。

懒加载只是把代码挪到了另一个 chunk,它不会凭空变小。如果那个编辑器本身就 1.5MB,懒加载只是让它不进首屏,真要它快还得看 SDK 本身能不能按需引、能不能 CDN 外置。

发版窗口期的 chunk 404 要从架构层面接住

上面提到的 chunk 404,处理起来分两层,值得展开说,因为它不是加个 try/catch 就能完全解决的,需要客户端补救和部署策略两边配合。

第一层是客户端补救,别让它白屏。给懒加载包一个带重试和提示刷新的包装,捕获到这类加载错误就引导用户刷新(刷新后 index.html 会重新拉到最新的 entry,里面指向的就是新 hash 了):

1import { lazy } from 'react'
2
3// 动态 import 失败时,先重试一次(防偶发网络抖动),
4// 仍失败则判定是发版导致的旧 chunk 失效,强制刷新拿最新 entry
5function lazyWithRetry<T extends React.ComponentType<any>>(
6  factory: () => Promise<{ default: T }>,
7) {
8  return lazy(async () => {
9    try {
10      return await factory()
11    } catch (err) {
12      const key = 'chunk-reload-once'
13      if (!sessionStorage.getItem(key)) {
14        sessionStorage.setItem(key, '1')
15        window.location.reload()
16        // reload 会中断,这里返回个空壳避免类型报错
17        return new Promise<{ default: T }>(() => {})
18      }
19      // 已经刷过一次还失败,说明不是发版问题,往上抛交给错误边界
20      throw err
21    }
22  })
23}
24
25const AdminPage = lazyWithRetry(() => import('./pages/AdminPage'))

sessionStorage 那个标记很关键,是防止"刷新后还是 404"演变成无限刷新死循环。如果不加这道保险,一旦 CDN 本身在抖动,页面会陷入不断自我刷新的状态,比原来的白屏还糟糕,所以重试次数一定要有上限并且落盘记录,不能纯靠内存变量。

第二层是从根上缩小这个窗口:发版时不要立刻删旧 chunk,让新旧产物在 CDN 上共存一段时间(保留几个历史版本),老页面还能拿到它记忆里的旧 chunk,等用户自然刷新后过渡到新版本。这需要 CI 部署脚本配合——上传新产物但不清空目录,靠定时任务清理 N 天前的旧文件。客户端补救和部署策略两层都做了之后,这类报错基本可以归零。

顺便说一个 Vite 5 内置的相关能力:build.modulePreload 默认会给入口预加载它直接依赖的 chunk,但不会预加载所有懒加载点(那样就失去懒加载意义了)。所以真正的远水救不了近火,补救逻辑该写还得写。

还有一种更彻底但成本也更高的思路:把 HTML 入口和静态资源分开缓存策略。index.html 用极短缓存甚至 no-cache,保证用户随时能拿到最新的资源清单;带 hash 的 JS/CSS 文件用超长强缓存,反正内容一变文件名就变,不存在“缓存了旧内容”的问题。这样即使用户长时间停留没刷新,只要下一次导航触发了新的 HTML 请求,就能拿到最新的 chunk 映射,从源头减少命中旧 hash 的概率。这个策略要在 CDN 或反向代理层配置,跟 Vite 本身关系不大,但只在 Vite 这一层做补救而不管 HTML 缓存头,问题依然会在某些边缘场景里冒出来。

插件要少而明确

Vite 插件生态很丰富,但插件越多,构建链路越复杂。

我会把插件分成几类:

  • 框架插件:React、Vue、Svelte
  • 语法或资源插件:MDX、SVG、WASM
  • 构建增强:压缩、分析、PWA
  • 内部约定:自动路由、组件导入、主题生成

每引入一个插件,都应该能回答三个问题:

第一,它解决了什么明确问题?

第二,它是否影响开发和生产两条链路?

第三,出了问题时团队是否知道如何排查?

不要为了少写几行 import 引入一个维护不活跃的自动导入插件,也不要为了配置优雅把构建过程变成黑盒。

这里有个具体的反面教材。早期我在项目里上了 unplugin-auto-importunplugin-vue-components,写代码确实爽,ref、computed、组件都不用 import。但代价是:新人接手一脸懵——“这个 ref 哪来的?”;类型提示要靠插件生成的 auto-imports.d.ts,那文件还得记得提交,不然别人拉下来满屏报错;CI 上偶尔生成时机不对导致类型检查挂掉。省下的几行 import,最后用排查时间几倍奉还。现在这类“魔法”插件我会非常克制,团队规模大、流动性高的时候尤其不碰。

还有个经验:插件的执行顺序有时是会咬人的,特别是涉及代码转换的插件(比如 SVG 转组件、宏展开)。Vite 插件有 enforce: 'pre' | 'post'apply: 'serve' | 'build' 可以控制时机和作用范围。我遇到过一个 SVG 插件和框架插件抢同一个 .svg?react 文件、转换顺序错了导致 build 报错的情况,把它 enforce: 'pre' 之后才正常。配置插件时顺手确认一下它跑在哪条链路、什么时机,能省掉很多玄学排查。

想真正搞懂"顺序为什么会咬人",得知道 Vite 插件本质上是 Rollup 插件的超集。它沿用了 Rollup 的钩子模型,几个核心钩子的语义值得记牢:resolveId(决定一个 import 解析到哪个文件,第一个返回非空的插件赢)、load(读这个 id 的内容)、transform(拿到内容做转换,所有插件的 transform 是链式串行、前一个的输出喂给后一个)。enforce 影响的就是插件在这条链里的排位——pre 的插件排在 Vite 核心插件(包括框架插件)前面,post 排在后面,不写的按声明顺序夹在中间。

SVG 那个坑就清楚了:.svg?react 这种带 query 的 id,svg 插件要在 transform 阶段把它变成一个 React 组件模块,但框架插件也想 transform 它(按 JSX 处理)。如果 svg 插件排在框架插件之后,框架插件先拿到原始 SVG 文本当 JSX 编译,直接语法报错。enforce: 'pre' 让 svg 插件先把它变成合法的组件源码,框架插件再接手编译,链路就顺了。

写个最小的转换插件就能把这套机制摸透,比如把 .txt 当字符串导入:

1import type { Plugin } from 'vite'
2
3function rawText(): Plugin {
4  return {
5    name: 'raw-text',
6    enforce: 'pre', // 抢在默认 asset 处理前接管 .txt
7    transform(code, id) {
8      if (!id.endsWith('.txt')) return null // 返回 null 表示"我不处理,交给后面的插件"
9      // 注意 id 可能带 ?query,真实插件里要先剥掉
10      return {
11        code: `export default ${JSON.stringify(code)}`,
12        map: null, // 不生成 sourcemap,告诉 Rollup 不要尝试合并映射
13      }
14    },
15  }
16}

transform 返回 null 而不是原样返回 code,这俩有本质区别:返回 null 是"放弃处理、保留上游结果",返回 code 是"我处理过了,这就是结果"。我见过有人在条件不匹配时 return code,结果把 sourcemap 链路打断了,调试时断点全飘——细节但要命。

Vitest 的价值

Vite 生态里,Vitest 是很自然的测试选择。它复用 Vite 的转换能力,配置成本低,适合前端项目。

1import { describe, expect, it } from 'vitest'
2
3function formatPrice(value: number) {
4  return `${value.toFixed(2)}`
5}
6
7describe('formatPrice', () => {
8  it('formats number', () => {
9    expect(formatPrice(12)).toBe('¥12.00')
10  })
11})

复用 Vite 的转换能力这点很实在:你 vite.config 里配的别名、插件、环境变量,Vitest 直接拿来用,不用像 Jest 那样再单独配一套 transform、moduleNameMapper、babel,省了一大坨重复配置。如果想让测试和 Vite 配置彻底共用,用 defineConfig 合并即可,Vitest 也支持单独的 vitest.config.ts,按需分开:

1import { defineConfig } from 'vitest/config'
2
3export default defineConfig({
4  test: {
5    environment: 'jsdom', // 测组件、碰 DOM 的就需要它,纯函数用默认 node 更快
6    globals: true,
7    setupFiles: './src/test/setup.ts',
8  },
9})

environment 这个我踩过坑:默认是 node,测纯函数飞快;一旦测到组件或者用了 localStoragewindow 就得切 jsdom,否则报 window is not defined。但全局开 jsdom 会拖慢纯函数测试,所以我一般默认 node,需要 DOM 的文件用文件顶部 // @vitest-environment jsdom 单独声明。

如果项目已经是 Vite,工具函数、hooks、组件逻辑都可以逐步补测试。不要一开始追求覆盖率 90%,先覆盖最容易出错的纯函数、请求封装、权限判断、金额计算。vitest --watch 配合 Vite 的转换,改一个函数只重跑相关用例,几十毫秒出结果,这种即时反馈是我愿意持续写测试的关键。

测试的价值不是证明代码永远没错,而是在重构时告诉你哪些原有的假设被打破了。

构建产物要定期看

很多团队迁移到 Vite 后,就不再关心 bundle。直到某天发现首页首屏下载了 2MB JavaScript。

光看 npm run build 末尾打印的那串文件大小列表是不够的,那只是个粗略提示。我习惯挂一个可视化分析插件,把产物结构摊开看:

1import { visualizer } from 'rollup-plugin-visualizer'
2
3export default defineConfig({
4  plugins: [
5    react(),
6    visualizer({ open: true, gzipSize: true, brotliSize: true }),
7  ],
8})

build 完会弹一张 treemap,哪个依赖占了多大、有没有重复打包,一眼就看出来。这里特别要看 gzip / brotli 后的大小而不是原始体积——线上走的是压缩传输,原始 2MB 压完可能只有 400KB,盯错指标会做无用功。

我靠这张图抓出过两类问题:一是 moment 把全部 locale 都打进来了,换成 day.js 直接掉了一大块;二是 lodash 被某处写成 import _ from 'lodash' 全量引入,而别处又用了 lodash-es,结果两份都在产物里。这种重复打包不看 treemap 根本发现不了。

重点看:

  • 主包是否过大
  • 大型依赖是否进入首屏
  • 是否重复打包
  • 图片和字体是否被合理处理
  • sourcemap 是否只在需要时开启

如果引入图表库、富文本编辑器、地图 SDK,一定要确认它们是否被懒加载。大依赖不是不能用,而是不能无意识地进入所有页面。

Vite 5 的价值不只是快启动,而是让前端工具链更轻、更直接、更容易组合。真正用好 Vite,需要关注依赖预构建、环境变量该暴露给谁、路径别名一致性、代码分割、插件数量和构建产物。

工具链的成熟不是配置越来越多,而是团队知道每一项配置为什么存在。dev server 两三秒启动带来的那种爽快感,从来替代不了对着 treemap 一点点揪出 echarts 和两份 lodash 时的那份耐心。