如果你在两套构建工具之间切换过,一定感受过这种撕裂感:同样的项目,webpack 冷启动可能要等二三十秒,改一行代码热更新也要晃两下才反应过来;换成 vite 之后,冷启动往往不到一秒,保存代码的瞬间页面就更新了。很多人把这个差异简单归因于“vite 用了 esbuild 所以快”,但真正的原因没这么简单——它背后是两套工具对文件处理的基本思路完全不同。
这篇文章想从文件处理的视角把 webpack 和 vite 的差异完整拆一遍,包括模块图的构建方式、依赖预构建、静态资源路径、生产分包与 tree-shaking、HMR 的更新边界,以及迁移时容易踩的坑。适合正在做构建工具选型、准备从 webpack 迁到 vite、或者单纯想搞清楚 dev server 为什么这么快的前端开发者。不会只给你结论,重点讲清每一步背后的原理。
1. 构建模型的分岔口:先全部打包,还是按需处理
1.1 构建优先与请求驱动的本质区别
webpack 的核心心智是“打包器”。它不管你的项目最终要不要访问某个路由,也不管某个组件是否真的在首屏加载,启动时都会从入口开始,顺着 import 关系把整个依赖图扫一遍,把所有文件读入内存,经过 loader 转换、插件加工,最终生成一个或多个 bundle,再交给浏览器执行。开发服务器启动速度慢,根源就在这里——它必须先把整棵依赖树做完,才能响应浏览器的第一个请求。
vite 走的是另一条路。它利用了现代浏览器原生支持 ES Module 这个前提,启动时只需要做两件事:一是对 node_modules 里的依赖做一次预构建,二是起一个静态文件服务器。浏览器请求页面后,页面里的<script type="module" src="/src/main.ts">会触发浏览器自己去请求模块,vite 只在请求真正到达时,才对那一个文件做转换并返回。没有请求就没有编译,这就是“按需处理”。
用一句大白话概括:webpack 是“你要什么我都先做好放着”,vite 是“你要什么我才现做给你”。两者对“文件”的调度哲学完全不同,后续的所有差异几乎都从这个分岔口延伸出来。
1.2 模块图构建:一次扫完还是随请求推进
webpack 在启动阶段会把整个项目抽象成一张 ModuleGraph,节点是模块文件,边是 import/require 关系。这张图会被反复用于 HMR 的依赖分析、tree-shaking 的符号追踪、代码分割的 chunk 归属判断。图构建得越完整,后续处理越方便,但代价是启动时的一次性全量 IO 和 CPU 开销。
vite 开发时并不需要完整模块图。它把“构建模块图”这件事推迟到了生产构建阶段,交给 Rollup 去做。开发服务器里,vite 维护的是一份“访问过的模块缓存 + 依赖关系表”,只有当你用 import 链真正触达某个文件,它才会读取、转换、缓存。这也是为什么 vite 项目里,某一个深层模块第一次被请求时会稍微顿一下,之后就像本地文件一样秒开。
这里有一个值得注意的点:vite 的按需处理不是“永远不分析依赖”。它也需要知道某个模块被哪些模块依赖,才能在处理 HMR 时确定更新边界。只是它把这些分析分散到了请求过程中,而不是集中在启动那一刻。理解了这一点,就不会再问“vite 项目启动这么快,生产构建是不是也这么快”这种问题了——生产构建面前,vite 同样要把整张图建完。
2. 依赖与源码:两条不同的转换管线
2.1 依赖预构建为什么只有 vite 有
vite 开发环境下有一个 webpack 没有的环节:依赖预构建。它会把 node_modules 里的包用 esbuild 预先打包成 ESM 格式,并合并小模块,结果缓存在项目根的node_modules/.vite目录里。这件事有两个直接目的:第一,让浏览器能直接加载依赖,因为很多 npm 包是 CommonJS 格式,浏览器原生不认识module.exports;第二,减少请求数量,比如某些库内部拆了几百个 ESM 小文件,如果不预先合并,浏览器可能要为加载一个库发起几百个请求。
webpack 不需要这个环节,因为它天生就能把 CommonJS 包进自己的模块 runtime 里。webpack 处理依赖的方式是:通过 resolve 规则找到文件,交给对应 loader 转换,最后统一打包成一个 bundle,依赖的模块格式在 bundle 内部被统一成 webpack 自己的模块体系。所以 webpack 不存在“浏览器直接加载依赖”的需求,自然也就没有预构建这一步。
实际使用中要留意预构建缓存带来的坑:vite 判断缓存是否失效,主要看 lockfile、package.json 和 optimizeDeps 配置的变化。如果你手动修改了 node_modules 里某个包的源码,vite 不会感知到,开发服务器也不会重新预构建。排错时如果发现依赖改动没生效,先删掉node_modules/.vite再重启,比各种玄学定位都有效。
2.2 源码转换的管道布置:loader 链与 transform 钩子
webpack 中处理文件转换的核心是 loader。它的规则面向文件类型,比如.ts文件走 ts-loader 或 babel-loader,.scss文件先经过 sass-loader 再到 css-loader,最后用 style-loader 注入页面。loader 是一个管道链,文件从磁盘读出来后,会依次经过链路上每个 loader 的加工,再进入插件系统的生命周期。规则一旦配置错位,比如把 babel-loader 同时匹配了.js和.ts,很容易出现重复转换或者类型丢失的问题。
vite 开发时用插件钩子transform完成同样的工作,它和 Rollup 插件体系同源。vite 内置了对 TypeScript、JSX、CSS、静态资源的转换,额外能力通过插件扩展。对于 TypeScript,vite 直接用 esbuild 转译,速度比 tsc 快一个量级,但不做类型检查。webpack 配合 ts-loader 默认做类型检查,但代价是编译更慢;想兼顾效率的话,webpack 生态一般用 babel-loader 转译 + fork-ts-checker-webpack-plugin 在独立进程做类型检查。
在代码发生错误时,两者的表现也不同。webpack 的 loader 链往往在构建阶段就暴露问题,错误信息会堆在终端里;vite 开发时是浏览器请求到该模块才执行 transform,错误会直接体现在浏览器请求响应里,控制台能看到更贴近源代码的错误位置。从排错体验上说,vite 的“请求即编译”模式更容易定位是哪一段代码出问题。
2.3 缓存机制的设计差异对开发体验的影响
webpack 5 引入了持久化缓存,配置cache: { type: 'filesystem' }后,会把模块的转换结果、chunk 生成结果缓存到磁盘的node_modules/.cache目录。重启开发服务器后,如果源码没有变化,可以跳过大量重复编译。但缓存的命中判断依赖一堆快照信息,文件时间戳、配置项、依赖版本只要有一部分变化,就可能触发比较大范围的重新构建。
vite 的缓存分两层:预构建的依赖缓存依赖上述的.vite目录,浏览器端还有一层强缓存。vite 会在返回已预构建依赖时设置Cache-Control: max-age=31536000, immutable,因为依赖内容基本不变,让浏览器直接命中缓存能大幅减少请求耗时。源码文件不设强缓存,因为你要频繁改动。这两层的配合,是 vite 开发时“改代码反应快、刷新页面也快”的另一个关键因素。
还有一个差别容易被忽略:webpack dev server 会把生成的 bundle 写到内存文件系统,所以项目目录里看不到真实产物,但内存占用会随着依赖图变大而明显上涨;vite 开发时没有 bundle 这个概念,内存缓存的主要是 transform 后的源码结果和依赖预构建产物。对于非常大的项目,vite 的内存占用通常更可控,除非某个超大依赖预构建时把 esbuild 的临时产物堆积起来。
3. 静态资源与路径处理:目录约定和指向逻辑的差异
3.1 资源内联、URL 解析与 public 目录
webpack 5 之后推荐用 asset modules 处理静态资源。比如图片、字体这类文件,你可以用type: 'asset'让 webpack 根据文件大小决定内联还是输出为独立文件,通过parser.dataUrlCondition.maxSize控制内联阈值;也可以指定type: 'asset/resource'强制输出独立文件,并用generator.filename控制产物的命名规则。每个模块都会被打上 hash,内容变化后文件名跟着变,这样部署时能安全做长缓存。
vite 的默认行为要更“开箱即用”一些:build.assetsInlineLimit默认 4KB,小于这个体积的资源会直接转成 base64 内联到代码里;超过阈值则输出到build.assetsDir对应的目录,并带 hash 后缀。你不需要为每种资源类型单独写转换器,只要正常 import 一个图片或字体文件,vite 就会返回一个可用的 URL。
另一个差异在 public 目录的处理上。vite 有一个约定:项目根目录下的public文件夹里的文件不会被 import 和分析,它们会被原样拷贝到构建产物的根目录,引用时也直接写'/logo.png'这种绝对路径。webpack 没有这个内置约定,你想把一个静态文件原样拷贝到 dist 根目录,得靠 copy-webpack-plugin 或者自己配插件。这个差异在迁移时特别容易造成“资源 404”,因为原来的项目可能大量依赖 public 目录做直出文件。
3.2 路径解析的隐藏差异
路径解析是开发中最容易出问题的地方。webpack 的resolve.extensions默认是['.js', '.json', '.wasm'],如果用了 TypeScript,还得手动补上.ts、.tsx、.mjs等;vite 内置的 extensions 列表更完整,包含['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json']。这带来一个最直观的区别:在 vite 里写import './component'能自动补全.tsx后缀,webpack 不配 extensions 就做不到。
还有目录默认解析。webpack 遇到import './utils'时,会按顺序尝试./utils、./utils/index.js等,目录入口的解析是被默认支持的;vite 在开发模式下如果直接 import 一个目录,可能不会像 webpack 那样自动找到index.ts,更稳妥的写法是显式写上./utils/index.ts。这类差异不是 bug 而是设计取向,但确实会让从 webpack 迁过来的人觉得“明明路径没问题却解析不到”。
resolve.alias的配置方式两边几乎一样,但替换优先级和路径拼接逻辑有细微差别。比如 vite 里配置'@': '/src',这里的根是项目根目录而不是文件系统根目录;webpack 里通常配成path.resolve(__dirname, 'src')。如果项目里原来用了@/依赖别名,迁移时不要只改配置,还得检查代码里是否存在“别名拼接相对路径”的用法,这类写法在两边常常表现不一致。
3.3 生产与开发环境中的路径指向
静态资源路径在生产环境会进一步受 base 配置影响。webpack 用output.publicPath控制所有资源 URL 的前缀,动态 import 的 chunk 也会基于它拼接加载路径,部署在子路径时必须设置好。vite 用base配置统一控制开发和生产环境的基础路径,默认是'/',部署到子路径时改成'/your-app/'即可。相比起来,vite 的路径配置更收敛,webpack 因为每个 loader、插件都可能涉及资源路径,配置面更广,出现问题排查范围也更大。
开发环境的路径表现上,vite 会通过 HTTP 服务直接用根路径映射源码,浏览器地址栏看到的是/src/main.ts这种真实文件路径;webpack dev server 提供的是内存中的 bundle,地址栏对应的只是服务入口 URL,你看不到源码文件的真实路径。这些不是功能问题,但会直接影响调试体验——vite 里打开 Network 面板,能清楚看到每个模块请求和对应文件,排查加载顺序和依赖关系时会直观很多。
4. 生产构建中的文件“手术”:分包、摇树与产物形态
4.1 代码分割策略:SplitChunks 与 manualChunks
webpack 生产构建的代码分割非常强大,核心是optimization.splitChunks。它会根据模块的来源、体积、被引用次数,把所有 chunk 分成 initial、async 等类别,在 cacheGroups 里手动指定“哪些库要单独打成 vendor 包”。比如你可以把 react、react-dom 单独打进一个react-vendorchunk,利用浏览器缓存减少二次访问时的下载量。这种灵活性在大型应用里几乎不可替代,但配置复杂度也高,调不好会出现 chunk 碎片化或者重复打包。
vite 生产构建默认交给 Rollup,代码分割能力相对简化。Rollup 会自动把动态 import 的模块拆成独立 chunk,也会把 node_modules 里的依赖单独分块,但如果你想做更精细的分包,必须用build.rollupOptions.output.manualChunks手动指定。比如:
// vite.config.js export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('lodash-es')) return 'lodash'; if (id.includes('react')) return 'react-vendor'; return 'vendor'; } }, }, }, }, });需要注意,Rollup 的 manualChunks 返回的是 chunk 名称,不能像 webpack 那样做“按引用次数自动调整”的动态策略。从 webpack 项目迁移时,最稳妥的做法是先用默认分包跑一遍,用构建产物分析工具看 chunk 分布,再针对超大的 vendor 包做 manualChunks 拆解,而不是一上来就复刻 webpack 的分包配置。
4.2 Tree Shaking:标记删除与原生 ESM 静态分析
webpack 的 tree-shaking 依赖代码标记和侧副作用分析。它在构建阶段会给每个导出标记“是否被使用”,再由压缩工具在执行过程中删除没有用到的代码。这套机制配合 package.json 里的sideEffects: false才能生效,如果你的依赖没有声明 sideEffects,webpack 很可能保留大量看似没用、实际有隐式副作用的代码,导致产物体积膨胀。
vite 生产构建时用的 Rollup 天然构建在 ESM 静态分析之上。ESM 的 import/export 结构在 parse 阶段就能确定,没有 CommonJS 的运行时 require 动态性问题,所以 Rollup 的 tree-shaking 更干净。但前提依然成立:你不要写带副作用的顶层代码,不要在模块里偷偷改全局变量。另外,vite 源码中如果大量使用 CommonJS 风格的require,开发时 esbuild 能转,但生产构建时 Rollup 对 CommonJS 的处理能力有限,必须依赖插件预处理。
实际项目里一个容易忽略的点是:webpack 打包时如果你用babel-plugin-import这类插件做按需加载,tree-shaking 其实是靠插件把import { Button } from 'ui-lib'转换成import Button from 'ui-lib/es/button'来实现的。vite/Rollup 下这类库最好直接用原生 ESM 入口并配合分包,否则很容易出现“整个库都被打进产物”的结果。
4.3 产物输出形态的对比
webpack 默认产物是 IIFE 或 AMD 风格的 bundle,浏览器通过添加<script>标签加载。多入口项目里,webpack 会生成 entry chunk、runtime chunk、vendor chunk 若干文件,运行时通过 JSONP 机制加载异步 chunk,这个 runtime 体积不大但你很难彻底去掉。vite 默认输出 ESM 格式的 chunk,通过<script type="module">加载,异步 chunk 用原生import()导入,浏览器可以直接分析依赖关系,产物结构更清晰。
如果目标浏览器比较老,vite 需要配合@vitejs/plugin-legacy生成传统 bundle 作为降级方案,webpack 则可以通过output.environment和target配置控制输出语法。另一个差异是文件命名:webpack 用[name].[contenthash:8].js这种模板,vite 默认用[name]-[hash].js,CSS 文件名规则也类似。内容 hash 都是基于文件内容生成,核心目的都是让浏览器缓存到“内容变则 URL 变”的安全状态。
5. 开发体验背后的文件机制:HMR 与增量编译
5.1 热更新边界:补丁是打给谁看的
webpack 的热更新非常精确。dev server 和浏览器之间维护了一个 HMR runtime,当某个模块内容变化,webpack 会从模块图里找到这个模块以及所有把它加入依赖的父级模块,重新编译受影响的部分,生成一个 update 补丁推给浏览器。浏览器端 runtime 收到补丁后,会沿着模块图判断哪些模块需要替换,触发组件的更新逻辑。这套机制的优势是粒度细,但依赖整张模块图的正确性,任何一个模块关系混乱都可能让 HMR 失效。
vite 的 HMR 建立在 ESM 请求链上。它通过 WebSocket 通知浏览器“某个文件变了”,浏览器会重新请求这个文件对应的模块,并在模块依赖边界上触发更新。vite 还支持import.meta.hot.accept的显式声明,告诉 vite 当前模块可以接受自身更新。本质区别在于:webpack 需要重新编译并注入补丁代码,vite 只需要让浏览器重新拉取一个模块文件,自然更轻快。
实际使用中,如果你改动的是组件文件,两个工具都能做到保留状态热更新;如果你改了入口文件、路由配置文件,或者手动改了 vite.config 本身,大概率还是会整页刷新,这并不代表 HMR 坏了,而是工具判断“这个变更影响面太大,保状态更新风险太高”的保守策略。
5.2 缓存失效与去重策略的实际差异
webpack 的持久化缓存失效条件比较多:修改配置、新增依赖、安装新包、插件版本变化,都可能导致缓存部分失效。好处是配置搭好之后,日常改业务代码很少触发全量重建,效率稳定;坏处是排查“为什么我改了代码却不生效”时,第一反应经常是清缓存、删.cache、重启。我见过不少项目因为缓存问题浪费大量时间,最后发现是某个 loader 缓存配置和 webpack 持久化缓存互相干扰。
vite 的缓存失效要更简单直接:依赖预构建的缓存只认 lockfile 和依赖内容本身,源码模块的缓存只认文件内容变化。如果手动改了依赖源码,vite 不会感知;如果改了 vite 配置里的 optimizeDeps,重启后大概率会重新预构建。遇到“依赖更新了但页面还是旧 bundle”的问题,第一步永远是删除node_modules/.vite和浏览器缓存验证,不要先怀疑源码。
5.3 磁盘 IO 与内存占用的现实压力
webpack dev server 每次启动都要把项目源码从磁盘读出来,经过 loader 转换后全部写入内存文件系统。项目文件越多,磁盘 IO 越高,配置了持久化缓存后首次构建依然躲不开全量读取。vite 开发启动只读取入口文件附近的一小部分源码,加上部分依赖预构建的磁盘读写,整体 IO 压力小很多,这也是它在网络挂载盘、NAS、CI 环境中表现更“轻盈”的物理原因。
内存占用方面,webpack 启动后内存开销会随着模块图增长稳定上升,几十万行的项目很容易吃掉几个 GB 内存;vite 因为只保留“请求过的模块”,内存占用增长相对平缓,但如果你把一个大依赖预构建过并频繁访问,esbuild 的产物也会常驻内存。两者都要求开发者在超大项目里注意内存监控,不是说换到 vite 就能无限承受膨胀的依赖体量。
6. 迁移时需要关注的文件处理细节对照
6.1 loader 与插件能力对照
从 webpack 迁到 vite,最花时间的不是改配置,而是把思维从“为每个文件类型配置 loader”切换到“用插件处理特殊模块”。下面这张表是我自己迁移两个项目时整理的基础对照:
| 能力 | webpack 方案 | vite 方案 |
|---|---|---|
| TS 转译 | ts-loader / babel-loader + preset-typescript | 内置 esbuild 转译,需要类型检查时配 fork-ts-checker |
| CSS 处理 | css-loader + style-loader / MiniCssExtractPlugin | 内置 CSS 处理,自动提取与代码分割 |
| Sass/Less | sass-loader / less-loader | 内置支持,需安装 sass 或 less |
| 图片字体 | asset modules / file-loader / url-loader | 内置 asset 处理,assetsInlineLimit 控制内联 |
| 静态文件拷贝 | copy-webpack-plugin | public 目录约定 |
| 环境变量注入 | DefinePlugin / process.env | import.meta.env + define 配置 |
| worker 加载 | worker-loader / 需额外配置 | 内置new Worker(new URL(...))支持 |
如果你在 webpack 里高度依赖某个 loader 生态插件,迁移前要去查一下 vite 社区有没有对应实现,别以为 vite 的“开箱即用”能覆盖所有场景。特别是那些对文件名、来源、代码内容做深度自定义处理的老 loader,往往要写一个自定义 vite 插件才能等价替代。
6.2 配置项上的直接对照
很多 webpack 配置项在 vite 里有对应物,但名称和语义有出入。我自己整理了一份常用对照,帮迁移时可以快速定位:
| 目的 | webpack | vite |
|---|---|---|
| 路径别名 | resolve.alias | resolve.alias |
| 扩展名补全 | resolve.extensions | resolve.extensions(默认更全) |
| 外部化依赖 | externals | build.rollupOptions.external |
| 资源内联阈值 | parser.dataUrlCondition.maxSize | build.assetsInlineLimit |
| 输出目录 | output.path | build.outDir |
| 公共路径 | output.publicPath | base |
| 代码分割 | optimization.splitChunks | build.rollupOptions.output.manualChunks |
| 全局常量注入 | DefinePlugin | define |
特别注意环境变量的注入方式。webpack 项目里经常看到process.env.NODE_ENV或process.env.API_BASE,vite 默认用import.meta.env系列变量,process.env在浏览器端默认不可用。如果代码里大量直接使用process.env,迁移时要么通过 define 配置临时兜底,要么统一改成import.meta.env,不处理的话很容易出现 “process is not defined” 的运行时错误。
6.3 很容易踩的坑,附排查思路
第一个坑是 public 目录的引用路径不一致。vite 中public下的文件是直接放在根路径,如果你原来的项目用相对路径或者 hash 后的文件名引用静态资源,迁移后会出现大量 404。排查方法很简单:构建完成后检查 dist 根目录,看文件实际叫什么名字,再对照页面里的请求路径。
第二个坑是依赖预构建和 CJS 支持。vite 在生产构建时对 CommonJS 支持弱于 webpack,如果某些依赖是 CJS 且没有被预构建,生产构建可能直接把require保留下来,导致浏览器报错。开发阶段很多 CJS 问题不暴露,因为 esbuild 兜底了;上生产才发现,这是迁移过程中很典型的现象。建议迁移前先列一份依赖清单,把所有依赖都检查一遍是否提供了 ESM 入口。
第三个坑是图片内联阈值不同导致的体积“隐性膨胀”。webpack 老项目中用的 url-loader 可能默认把所有小于 8KB 的资源都内联,vite 默认是 4KB,两个阈值不一样,迁移后同样一张 6KB 的图片,vite 会生成一个资源文件而不是 base64 字符串。这本身不是错误,但如果没有意识到阈值变了,很容易把一些本应内联的小图标变成了额外的网络请求,影响首屏加载。
第四个坑是动态导入语法兼容性。webpack 支持import(变量)加注释做魔法注释配置 chunk 名,vite 只支持静态可分析的动态 import,如果你的动态导入路径不是固定字符串,vite 可能无法正确拆分 chunk。排查时看构建警告里是否有“dynamic import cannot be analyzed”之类的提示。
一点个人收尾
把两套工具放在一起对比,并不是要分出谁高谁低,更多是帮自己理解“构建工具到底在替我们处理什么”。我在实际项目中会这样选型:全新项目、中后台系统、站点类应用,直接用 vite,凭它的开发效率和体验能把迭代速度提一个档;而那些依赖复杂 loader 链、历史包袱重、生产环境要精细控制产物形态的老项目,留在 webpack 上未必是坏事,强行迁移反而可能引入一堆兼容问题。
最后分享一个我自己的习惯:不管用哪套工具,我都会在项目里留一份构建产物分析脚本,定期看 bundle 体积和分包结构。因为构建工具对文件处理再怎么优化,最终还是要回到“产物体积是不是合理”“缓存命中是不是高效”这两个基本问题上。文件处理的方式可以换,但这套验证意识不要丢。