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,直接白屏。排查时一定要记住:本地 vite 和 vite 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 里和预构建相关的字段(optimizeDeps、resolve.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-import 加 unplugin-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,测纯函数飞快;一旦测到组件或者用了 localStorage、window 就得切 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 时的那份耐心。