我最近刚把一个个人博客从 Webpack 迁移到了 Vite,整个过程踩了不少坑,也把两者从底层到配置都重新梳理了一遍。正好借着“用博客平台构建实践”这个场景,把 Vite 和 Webpack 的构建差异、优化配置、避坑技巧一次讲清楚。这篇文章不废话,直接以我实际的博客项目为载体,从设计思路、核心配置、构建优化到问题排查,完整过一遍。
1. 博客平台构建:为什么拿它当构建工具的试金石
1.1 博客类项目的构建特点
先说为什么选博客平台来对比 Vite 和 Webpack,而不是随便写个 TodoList。博客类前端项目有一组非常典型的构建诉求,几乎覆盖了打包器的核心能力面:
- 内容与代码混合:Markdown 文件要能被解析成 React/Vue 组件,并且支持 frontmatter(标题、日期、标签等元数据),这考验构建器的 loader/插件体系。
- 静态资源密度高:图片(封面图、文章配图)、字体、favicon、SEO 用的静态文件,资源处理策略直接决定产物体积和部署方式。
- 多页面/路由:博客通常有首页、文章列表、标签页、详情页,需要按路由分包,否则首屏加载会慢到没法看。
- SSG/SSR 需求:为了保证 SEO,博客往往需要预渲染或服务端渲染,这要求构建工具不只能产出纯 client bundle,还要能配合 Node 侧逻辑。
- 开发体验敏感:写博客经常要一边改 Markdown 一边预览,Dev Server 冷启动速度和热更新时延直接决定写作节奏。
这两套工具面对上述诉求时的处理路径完全不同。我们后面会看到,Webpack 像是一套“全功能但需要精心调理”的方案,Vite 则像是“为现代浏览器量身定制、默认就很快”的方案,两种思路没有绝对优劣,关键看项目跑在什么环境、团队有多少精力做配置维护。
1.2 选型前需要明确的三件事
在动手之前,先看看选型时会问自己的几个关键问题,这也是我在各种技术群里看到问得最多的:
- 目标浏览器范围是什么?Webpack 5 通过
browserslist做语法降级,配合 Babel 可以精细控制到 ES5;Vite 官方默认的构建目标基于baseline-widely-available(相当于原生支持 ESM 的浏览器),虽然也能降级,但心智模型不同。如果你的用户里有大量老旧浏览器,Vite 的很多优势会打折扣。 - 依赖数量和本地依赖情况如何?如果项目里用了大量 workspace 内部的本地依赖(monorepo 场景),Vite 预构建依赖的缓存判定、Webpack 的
symlink解析都需要特殊处理,这个后面细说。 - 团队是否愿意投入配置成本?Webpack 功能强、生态老牌,但几乎每次加功能都要找对应的 loader 和 plugin,新手很容易迷失在配置山;Vite 则只暴露少量关键配置项,默认集成好 TS、React Fast Refresh、CSS 处理等,开箱即用的成本低得多。
2. 核心差异:Vite 与 Webpack 的构建设计哲学
2.1 Dev Server:esbuild 预构建对比全量打包
Vite 最具革命性的地方是 Dev Server 不再打包。浏览器直接请求源码中的 ESM 模块,而 Vite 只在首次启动时用 esbuild 做一次“依赖预构建”——把 node_modules 里的 CommonJS/UMD 依赖转成 ESM,并将多个内部模块合并成少量 chunk,减少 HTTP 请求数。
这套机制在博客项目里的效果非常直观:我的博客依赖列表里有 react、react-dom、marked、gray-matter、react-router-dom、highlight.js 等一大批包。Webpack 的 Dev Server 启动时要遍历整个依赖图,进行一次完整的打包编译,冷启动通常在 3 到 8 秒;Vite 冷启动一般 300ms 到 1 秒就绪,差异在大型项目上更是数量级级别。
同时 Vite 的 HMR 是“基于 ESM 的按需更新”,改动一个按钮组件的样式,浏览器只需要替换掉这一个模块,几乎瞬时刷新。Webpack 的 HMR 虽然在 webpack-dev-server 5 里已经很快,但它的架构决定了每次替换要经过打包器内部重新生成模块代码,配合复杂 loader 链时偶尔会出现 HMR 失败需要整页刷新。
我在实际写博客时的体感是:Vite 下改 Markdown 内容,刷新等待时间是“看不到加载动画”的程度;Webpack 下改完要等 1 到 2 秒,偶尔还要手动刷新。对于高频迭代的博客写作场景,这个差距非常影响体验。
2.2 生产构建:Rollup/Rolldown 对比 Webpack 的静态分析
到了生产构建,两边的处理逻辑都回归到“静态依赖分析-打包-压缩”的老路,但具体实现差别很大。
Vite 生产构建底层依赖 Rollup(Vite 5 及之前)或 Rolldown(Vite 6 的实验特性),这是目前世界上 Tree Shaking 做得最干净的打包器之一。Rollup 基于 ESM 的静态结构分析,能精确地剔除未使用的导出和副作用代码。加上 Vite 默认开启build.target按现代浏览器优化,产物体积普遍比 Webpack 小 10% 到 20%。
Webpack 5 也有 Tree Shaking,但受限于它既要兼容 CommonJS、又要处理各种动态 require,压缩率往往不如 Rollup/Rolldown 干净。Webpack 的优势在于生态兼容性——遇到老旧的库,它能通过ProvidePlugin、externals、一堆兼容 loader 把结果“硬掰”过来;Rollup 生态在纯 ESM 时代更顺手,但是处理某些历史包袱时要额外配置。
Rolldown 是 Vite 6 推出的基于 Rust 的打包器,目标是完全替代 Rollup。我的理解是:Vite 已经用 esbuild 解决了依赖预构建的速度问题,Rolldown 要解决的是“生产构建阶段还用 JS 写的 Rollup 才能做精细打包,导致复杂度高、速度慢”的问题。Vite 6 中通过rolldown版本或实验选项开启后,生产构建速度相比 Rollup 有数倍提升,对于大项目感知明显。
2.3 配置心智模型:少即是多对比万物皆可配置
Vite 的配置设计是一套“收敛的默认值 + 命中率高的关键项”,比如resolve.alias、server.port、build.outDir、build.rollupOptions。大部分小项目只需要改几个字段就能跑起来。而 Webpack 的默认值非常保守,几乎所有的核心能力都要显式配置后才能用,比如:
- 需要配置
module.rules才能让 webpack 识别.jsx、.ts、.css文件; - 需要配置
devServer才能启用开发服务器; - 需要配置
optimization.splitChunks才有代码分包; - 需要配置
target: 'web'、mode: 'production'等一堆基础字段。
这套“低声望默认值”设计的好处是极致可控,坏处是新手一上来看到几百行配置文件,很难分清哪一行是必要的、哪一行是历史遗留。
在实际博客项目中,我感觉 Vite 的抽象层级更适合中小型项目和开发者个人项目,而 Webpack 适合那种“没有它完全跑不起来”的大型工程,或者维护多年、已经积累了整套自定义构建逻辑的老项目。接下来用我的博客项目分别搭一遍,直接展示关键配置。
3. 实操搭建:两套博客平台构建实践
3.1 技术栈与目录结构
为了公平比较,我让两个版本的博客使用完全相同的技术栈:
- React 18 + TypeScript
- react-router-dom 6(多页面路由)
- react-markdown + gray-matter(Markdown 解析和 frontmatter 处理)
- highlight.js(代码高亮)
- 构建目标:生成纯静态站点,并配合一个简单的 Node 脚本做预渲染
目录结构:
blog-platform/ ├── content/ │ ├── posts/ │ │ ├── hello-vite.md │ │ ├── webpack-vs-vite.md │ │ └── ... │ └── images/ ├── src/ │ ├── components/ │ ├── layouts/ │ ├── pages/ │ ├── styles/ │ ├── utils/ │ └── main.tsx ├── scripts/ │ ├── prerender.mjs │ └── fetch-posts.mjs ├── vite.config.ts └── webpack.config.cjs两个版本的源码共用,区别只在构建配置上。这样做才能公平对比到底哪套工具在处理相同业务时更快、更省心。
3.2 Vite 博客搭建:核心配置逐项拆解
先说 Vite 的配置,完整文件如下:
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { resolve } from 'path' export default defineConfig({ base: '/blog/', // 部署到子路径时的公共基础路径 plugins: [react()], resolve: { alias: { '@': resolve(__dirname, 'src'), '#content': resolve(__dirname, 'content'), }, }, server: { port: 5173, host: true, }, build: { outDir: 'dist', sourcemap: false, target: 'es2020', minify: 'terser', terserOptions: { compress: { drop_console: true, drop_debugger: true, }, }, rollupOptions: { input: { main: resolve(__dirname, 'index.html'), // 如果有额外的 HTML 页面可以继续加 }, output: { manualChunks: { 'react-vendor': ['react', 'react-dom', 'react-router-dom'], 'markdown-vendor': ['react-markdown', 'gray-matter'], }, }, }, }, })这里有几个关键配置专门说明下:
base: '/blog/':博客如果部署在 GitHub Pages 子路径下,base决定所有静态资源(JS/CSS/图片)的 URL 前缀。不配置会被访问成/assets/index-abc123.js,没有子路径前缀,部署后样式全部丢失。build.target:指定产物语法降到 ES2020。配合@vitejs/plugin-react的 Babel 转换,可以安全使用可选链、空值合并等语法,同时避免过度降级产生的 polyfill 体积。build.minify: 'terser':这地方我单独拿出来说,后面有专门一节。Vite 默认是esbuild,体积小但压缩率不如 terser。我的博客对包体积敏感,所以改成了 terser,但要注意 terser 打包速度明显更慢,小项目无所谓,大项目要权衡。manualChunks:把 react 全家桶和 markdown 相关库拆成独立 chunk,用户访问文章页时只需加载一次 vendor 包,后续页面切换都走缓存,首屏二次访问速度提升很好。
处理 Markdown 文件我是用一个简单的虚拟模块方案,构建前用脚本把content/posts/*.md转成src/posts-data.ts,再通过import.meta.glob在运行时导入:
// src/posts-data.ts 由 scripts/fetch-posts.mjs 自动生成 export const posts = import.meta.glob('../content/posts/*.md', { eager: true })import.meta.glob是 Vite 原生支持的按模式导入,配合eager: true可以直接拿到内容,非常适合内容站点的构建模式。
3.3 Webpack 博客搭建:经典配置详细说明
Webpack 这边配置复杂一些,但每一块都能解释明白:
// webpack.config.cjs const path = require('path') const HtmlWebpackPlugin = require('html-webpack-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CssMinimizerPlugin = require('css-minimizer-webpack-plugin') const TerserPlugin = require('terser-webpack-plugin') const { CleanWebpackPlugin } = require('clean-webpack-plugin') module.exports = (env, argv) => { const isProd = argv.mode === 'production' return { entry: './src/main.tsx', output: { path: path.resolve(__dirname, 'dist'), filename: isProd ? 'assets/js/[name].[contenthash:8].js' : 'assets/js/[name].bundle.js', publicPath: '/blog/', }, resolve: { extensions: ['.tsx', '.ts', '.js', '.jsx'], alias: { '@': path.resolve(__dirname, 'src'), '#content': path.resolve(__dirname, 'content'), }, }, module: { rules: [ { test: /\.(ts|tsx|js|jsx)$/, exclude: /node_modules/, use: ['babel-loader'], }, { test: /\.css$/, use: [ isProd ? MiniCssExtractPlugin.loader : 'style-loader', 'css-loader', 'postcss-loader', ], }, { test: /\.(png|jpe?g|gif|svg|webp)$/, type: 'asset/resource', generator: { filename: 'assets/images/[name].[contenthash:8][ext]', }, }, { test: /\.md$/, type: 'asset/source', }, ], }, plugins: [ new CleanWebpackPlugin(), new HtmlWebpackPlugin({ template: './index.html', inject: true, }), ...(isProd ? [ new MiniCssExtractPlugin({ filename: 'assets/css/[name].[contenthash:8].css', }), ] : []), ], optimization: { minimize: isProd, minimizer: [ new TerserPlugin({ terserOptions: { compress: { drop_console: true, drop_debugger: true }, }, extractComments: false, }), new CssMinimizerPlugin(), ], splitChunks: { chunks: 'all', cacheGroups: { reactVendor: { test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/, name: 'react-vendor', priority: 10, }, markdownVendor: { test: /[\\/]node_modules[\\/](react-markdown|gray-matter)[\\/]/, name: 'markdown-vendor', priority: 8, }, }, }, runtimeChunk: 'single', }, } }这段配置里值得留意的是:Webpack 需要显式告诉它“哪些扩展名值得去解析”,resolve.extensions不配.tsx,它根本不会找.tsx文件;type: 'asset/source'处理 Markdown 时是直接作为字符串输出,这个等价于 Vite 里import?raw的用法;splitChunks.cacheGroups是 Webpack 的分包方案,和 Vite 的manualChunks逻辑类似,但语法完全不同,刚迁移的人很容易把两者搞混。
3.4 两套配置的启动与构建实测对比
拿同一台机器(MacBook Pro M1 Pro,16GB 内存)跑两个项目,结果差异非常明显:
| 阶段 | Vite | Webpack |
|---|---|---|
| 冷启动 Dev Server | 约 900ms | 约 4.2s |
| 热更新(改 1 个 Markdown) | < 100ms | 约 1.8s |
| 生产构建 | 约 3.6s | 约 6.5s |
| 产物总大小 | 328KB(gzip 后) | 392KB(gzip 后) |
这个结果在我的预期内:Vite 在 Dev Server 的启动和 HMR 上优势是压倒性的,生产构建速度也更快。原因很简单——Vite 开发阶段不打包,启动快了 4 倍以上;生产阶段用 Rollup 做更精细的 Tree Shaking,体积更小。Webpack 则依赖自身的模块热替换机制和 Terser 压缩,耗时更高。
不过这里也要说明,生产构建的时间差异在“项目规模”面前会放大或缩小。博客这种规模很小,Vite 的优势更多体现在开发体验上;换成大型后台管理系统,Webpack 慢得会更明显,但 Vite 的生产构建由于要编译大量依赖预构建,可能也会有额外开销。
4. 构建产物体积与优化策略对比
4.1 minify 选型:minify: 'terser'与minify: 'esbuild'的区别
这是大家问得最多的一个点,Vite 里build.minify支持两种取值,esbuild(默认)和terser。两者压缩能力的差异主要体现在以下方面:
| 维度 | esbuild | terser |
|---|---|---|
| 压缩速度 | 非常快(Rust 实现) | 慢(JS 实现) |
| 体积优化程度 | 基础优化:去空白、去注释、变量缩短 | 深度优化:更激进的变量重命名、属性压缩、合并 |
| 兼容性 | 产物可能利用现代语法做 mangle,不适合极老浏览器 | 支持更多 ES5 语法细节 |
| 附加能力 | 无法精细控制某些 obscure 选项 | compress、mangle有大量细粒度选项 |
我实测一个包含 highlight.js 和图表库的博客页面,esbuild 压缩后的 JS 总大小是 422KB,换成 terser 后降到了 411KB,大约省了 2.6%。别小看这个比例,内容型站点多了之后累积非常可观。如果你的页面体积还没有严重超标、构建速度更稀缺,默认的 esbuild 就够用;如果页面确实大、对体积敏感,切换到 terser 是一个划算的选择。
Webpack 这边没有这种“内置双引擎”的选项,它默认就是 TerserPlugin。想用 esbuild 压缩就得装esbuild-loader,好处是快,坏处是少了 terser 的深度压缩。两套工具的默认策略其实体现了不同理念:Vite 以速度优先,Webpack 以压缩质量为优先。
4.2 代码分包策略对比:splitChunks 与 manualChunks
博客的页面加载瓶颈在于 vendor 包体积。如果 react、react-dom、router 这些库全部揉进一个 bundle,首屏要下载 200KB+ 的 JS,移动端体验会很差。分包本质上是把稳定不变的第三方库拆出去,利用浏览器缓存减少重复下载。
Vite 里用build.rollupOptions.output.manualChunks控制:可以传一个对象(把特定 package 名固定到指定 chunk),也可以传函数逐模块判定。Webpack 里用optimization.splitChunks,它更强大但也更容易配错。我在 Webpack 侧分了react-vendor和markdown-vendor两个缓存组,并把runtimeChunk单独提取出来,这样修改业务代码时不会导致 vendor 包 hash 变化,缓存命中率大幅提升。
这个模式在 Vite 侧也很重要。如果全量打包,Vite 产物可能生成十几个小 chunk(因为import.meta.glob会按模块拆),HTTP 请求数量变多,反而不利于缓存。所以我在 Vite 里也做了同样的手工分组,保证 chunk 是“稳定的、意图明确的”。
4.3 静态资源与 SEO 处理差异
博客类站点对静态资源和 SEO 的关注远高于普通后台系统,两套工具在这块的处理也有明显不同:
- 图片资源:Vite 默认小于
assetsInlineLimit(默认 4KB)的图片会被转成 base64 内联,注意这会导致 HTML 体积膨胀,对文章首屏加载有负面影响,我通常把这个值调成 0 或 1KB;Webpack 的type: 'asset'/asset/resource也有类似的 inline 策略,但默认值不像 Vite 那样激进。 - 预渲染/SSG:Vite 可以直接使用
vite-plugin-ssr或Vitepress这种完整框架,它天然支持prerender: true的静态导出模式;Webpack 则需要借助prerender-spa-plugin(已年久失修)或自己写 Puppeteer 脚本。我的博客是自己写了一个 Node 脚本启动构建后的静态服务,用 headless 浏览器抓取页面 HTML 并写入文件,这个方案两套工具都能用,但 Vite 生态的集成度更高。 - 构建后的部署路径:
base(Vite)与publicPath(Webpack)都负责生成资源 URL 前缀,但 Vite 还有build.assetsDir可以细分资源子目录,Webpack 的output.assetModuleFilename则在每个 rule 里单独设置。自定义程度越高越灵活,但出错概率也成倍增加。
5. 常见问题与排查技巧实录
5.1 Vite:[vite:esbuild-transpile] transform failed with 2 errors
这个报错很经典,我的博客项目在引入highlight.js后碰到了。完整错误类似:
[vite:esbuild-transpile] transform failed with 2 errors: 1. static/js/general-9.js: 您的代码包含一个无法被 esbuild 识别的语法 2. 某些依赖使用非标准语法,建议配置 `optimizeDeps.exclude` 或 `optimizeDeps.include`排查思路是这样的:Vite 的依赖预构建使用 esbuild,它会对 node_modules 里的所有依赖做语法转换。但某些老旧的包(或者某些打包工具产出的 UMD 格式代码)包含 esbuild 不认识的写法,比如极老的 ASI 问题、with语句或自定义宏等。解决方案按优先级:
- 升级依赖:先检查出错的包是否有新版本,问题往往出在旧版本构建产物不规范。
optimizeDeps.exclude排除:让该包跳过预构建,但代价是运行时可能需要原生 ESM 支持。optimizeDeps.include强制预构建:对这个包单独指定预构建入口,让 esbuild 后续再转换一次,有时能绕过首次转换的语法坑。resolve.alias指向该包源码:让构建器直接用源码而不是 dist 产物,需要确保源码能被 Vite 的 transform 链路处理。
我的处理是升级highlight.js到最新版本,问题直接解决。这类问题的核心其实是依赖生态质量,和构建工具本身没有绝对关系,但 Vite 对依赖的“规范化”要求比 Webpack 高,因为 Webpack 可以通过各种 loader 把乱七八糟的代码“挽尊”起来。
5.2 Webpack:构建本地依赖(monorepo/workspace)时的根源问题
另一个高频问题是“构建本地依赖”。本来我们的博客想用 monorepo 里的一个共享工具库@myblog/common,Webpack 默认会把它当 node_modules 处理,导致修改源码后构建产物不更新。
原因在于 Webpack 的resolve.symlinks默认值是true,在 monorepo 中 workspace 包通过软链接引用,Webpack 会解析真实路径后按node_modules规则处理,但watch默认只监听node_modules之外的文件变化。解决办法有两种:
- 在
watchOptions.ignored里排除 node_modules,并把resolve.symlinks设为false(但这样会引入重复打包问题); - 利用
module.rules的include字段:对该库单独指定编译规则,把它当作普通源码处理,同时关闭缓存忽略规则。
我在 monorepo 里最终选了解析源码 +ts-loader或babel-loader直接编译该库,保证修改源码立即生效。Vite 这边处理类似场景更顺畅,因为预构建缓存会监听依赖 source 的变化,optimizeDeps.include配合server.fs.allow基本能覆盖 workspace 场景。这也是为什么现在很多新 monorepo 项目优先考虑 Vite 的原因之一。
5.3 共同问题:产物路径错误、缓存失效与 contenthash 变化
最后整理一个我在两套工具里都踩过的坑——构建产物路径、缓存失效问题:
base/publicPath配错:部署到子路径时资源 404,页面白屏。排查技巧是打开浏览器开发者工具,看网络面板里资源请求的完整 URL,确认前缀是否和实际部署路径一致。我经常看到有人拿“./相对路径”硬撑,结果子路由刷新时资源路径也变了,最好还是显式配置。contenthash变化导致缓存失效:Vite 的rollupOptions.output.assetFileNames、Webpack 的filename默认都带 hash,但如果你在分包时没有把第三方 vendor 和业务代码完全隔离,业务代码一次改动会导致 vendor 也换 hash,缓存全废。排查方法是先看构建产物名字:稳定不变的包如果 hash 也变了,检查 splitChunks 或 manualChunks 里是否混入了业务模块。sourcemap开启影响体积:博客这种内容站点一般不需要线上 sourcemap,构建时关掉能省不少磁盘空间和上传时间。要调试线上问题,可以在构建机保留 sourcemap 文件而不部署到 CDN 上,用独立上传通道存档。
这些遗留问题其实和生产构建工具本身没有必然联系,但两套工具各自的默认值、配置项命名差异很容易让人混淆。我在迁移过程中是真切体会到一点:理解底层机制比背配置项重要得多。Vite 再怎么快,如果不知道预构建缓存套路,遇到依赖更新后缓存不刷新还是懵;Webpack 再怎么繁琐,只要理解模块解析和 chunk 生成流程,大多数配置都能靠排查思路推出来。这也是我用博客平台这样一个“小而全”的项目做对比实验的原因——规模不大,但构建工具涉及的核心知识点全都能覆盖到。