Skip to content
概述
前六篇已经拆解了 Vite 的核心机制——ESM 支持、依赖预构建、HMR、配置入口、静态资源处理和插件体系。这一篇的目标是把这些能力串起来,形成一个可以直接运行的工程结构,并覆盖从开发、调试、构建到部署前检查的完整链路。写法上不会重新解释基础概念,直接从项目视角展开。
项目结构
使用 create-vite 创建的初始模板很简洁,但没有业务层面的目录约定。下面这个结构适用于中小型前端应用,去掉了脚手架中无实际约束的默认文件。
project-root/
├── .env # 所有环境共享的变量
├── .env.development # dev 模式覆盖
├── .env.production # prod 模式覆盖
├── index.html
├── vite.config.ts
├── vite.base.config.ts
├── vite.dev.config.ts
├── vite.prod.config.ts
├── public/
│ └── favicon.ico
├── src/
│ ├── main.ts
│ ├── App.vue
│ ├── assets/
│ │ └── logo.svg
│ ├── components/
│ │ └── ...
│ ├── views/
│ │ └── ...
│ ├── hooks/
│ │ └── ...
│ ├── stores/
│ │ └── ...
│ ├── utils/
│ │ └── ...
│ ├── styles/
│ │ ├── variables.css
│ │ └── theme.css
│ └── types/
│ └── env.d.tssrc 目录的职责划分与命名
src 下按职责分层,而不是按文件类型:
components/:可复用组件。每个组件一个同名目录,入口为index.vue或index.tsx。平铺所有组件会让定位变慢。views/:与路由对应的页面级组件。如果路由嵌套较深,views 内也可按路由段建立子目录。hooks/:不涉及 UI 的逻辑复用,组合式函数放这里。stores/:全局状态。使用 Pinia 时每个 store 一个文件。utils/:纯函数工具,不含 UI 依赖。styles/:全局样式、CSS 变量、主题定义。组件级别的样式仍放在组件目录内(scoped)。types/:仅在 TypeScript 项目中放全局类型声明,比如扩展ImportMetaEnv的env.d.ts。业务类型定义放在对应业务模块旁。
命名上:目录名用小写和连字符(如 user-profile);文件名用 camelCase 或 kebab-case,同项目内保持一致。构建工具不关心命名,但人在多个文件间跳转时会依赖一致性。
public 与 assets 的边界
这两个目录的分工在第四篇静态资源处理中已解释,这里只补充工程层面的结论:
public中的文件原样复制到构建输出的根目录,不经过任何处理。适合放favicon.ico、robots.txt,或者第三方库要求固定路径的静态文件。src/assets中的文件会被 Vite 纳入模块图:小于assetsInlineLimit的转成 base64 内联,其余文件在构建时生成哈希文件名并重写引用路径。业务代码中通过import引用的图片、字体、样式都放这里。
判断方法:如果一个文件需要被源码引用、获得哈希命名和缓存失效能力,它属于 src/assets;如果它需要一个与部署域名和路由无关的固定路径(通常为了兼容外部系统),它属于 public。
这个结构解决了什么
这个结构并不声称“最优”,只针对性解决几个问题:
- 模块定位速度:按职责分层后,找组件、找逻辑、找状态、找工具分别去对应目录,不需要在同一个目录里翻大量文件。
- 构建边界清晰:Vite 构建时,
src以外的文件不会被自动纳入模块图。把所有源码放在src下可以避免非预期文件被解析。 - 环境变量与配置分离:
.env文件和vite.*.config.ts的拆分方式互相对应,避免切换环境时遗漏变量。下一节展开。
配置拆分与环境管理
单文件 vite.config.ts 在个人小项目中足够,但一旦涉及多环境代理、不同的构建输出路径、条件式插件加载,单文件会迅速变得杂乱。
配置文件拆分
拆成三个文件:
vite.base.config.ts:所有环境共用的配置。别名、CSS 预处理选项、路径解析规则、插件数组的基础部分。vite.dev.config.ts:开发环境专属。server配置(端口、代理、HMR)、低成本的 sourcemap 设置。vite.prod.config.ts:构建环境专属。build配置(输出目录、target、压缩选项)、构建专用插件(如 visualizer)。
defineConfig 与 mergeConfig 的组合方式
Vite 从 2.6 开始导出了 mergeConfig,专门用来合并两份 Vite 配置。它的合并逻辑不是 lodash 的深合并,数组字段的处理方式和普通对象不同。
ts
// vite.base.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': '/src'
}
},
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables" as *;`
}
}
}
})ts
// vite.dev.config.ts
import { defineConfig, mergeConfig } from 'vite'
import baseConfig from './vite.base.config'
export default defineConfig(
mergeConfig(baseConfig, {
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})
)ts
// vite.prod.config.ts
import { defineConfig, mergeConfig } from 'vite'
import baseConfig from './vite.base.config'
export default defineConfig(
mergeConfig(baseConfig, {
build: {
outDir: 'dist',
sourcemap: 'hidden'
}
})
)根目录的 vite.config.ts 根据 mode 返回不同的合并结果:
ts
// vite.config.ts
import { defineConfig, mergeConfig } from 'vite'
import baseConfig from './vite.base.config'
import devConfig from './vite.dev.config'
import prodConfig from './vite.prod.config'
export default defineConfig(({ mode }) => {
if (mode === 'development') {
return mergeConfig(baseConfig, devConfig)
}
return mergeConfig(baseConfig, prodConfig)
})vite.config.ts 仍然是 Vite 的唯一配置入口,拆分只影响开发者组织,不影响命令行使用。vite dev 和 vite build 照常执行。
环境模式与 .env 文件加载顺序
Vite 使用 dotenv 加载环境变量,优先级从高到低为:
.env.[mode].local(不进入版本控制).env.[mode](可进入版本控制,但不建议放密钥).env.local.env
mode 由 --mode 参数或命令名决定:vite dev 对应 development,vite build 对应 production。也可以自定义 mode,例如 vite build --mode staging 会加载 .env.staging 等文件。
常见做法:把非敏感、与环境强绑定的变量(如 VITE_API_BASE)写入 .env.development / .env.production 并提交;密钥类变量放在 .env.local 并在 .gitignore 排除。
环境变量注入作用域与客户端注意事项
Vite 只会将 VITE_ 前缀的变量暴露给客户端代码(通过 import.meta.env)。不带前缀的变量仅 Node 端可访问,可在 vite.config.ts 中通过 process.env 读取,但不会注入浏览器端。
这意味着任何以 VITE_ 开头的变量都会被打包进客户端产物。API 密钥、数据库连接字符串这类敏感信息绝不能用 VITE_ 前缀。它们应当留在服务端,或由构建时的 Node 环境变量处理。
TypeScript 项目需要扩展 ImportMetaEnv 来获得类型提示:
ts
// src/types/env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}常用功能配置组合
以下配置片段来自实际项目中出现频率最高的需求。各自的背景原理在前几篇文章中已覆盖,这里只给出配置样本和行为说明。
开发服务器代理与 Mock 场景
ts
server: {
proxy: {
'/api': {
target: 'http://backend:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}代理只在开发服务器生效。Mock 方案的选择取决于是否需要拦截请求并返回构造数据。轻量场景可以用 vite-plugin-mock,或直接在 vite.config.ts 中写一个自定义插件,在 configureServer 中挂载中间件。
别名配置与解析顺序
ts
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@components': fileURLToPath(new URL('./src/components', import.meta.url))
}
}别名的解析顺序就是数组或对象的定义顺序。如果两个别名匹配到了同一路径前缀,先定义的优先。因此 @components 要放在 @ 前面——否则 @ 会先匹配所有 @components 开头的路径,导致 @components 别名失效。
CSS 预处理与多主题样式接入
SCSS 变量全局注入:
ts
css: {
preprocessorOptions: {
scss: {
additionalData: `@use "@/styles/variables" as *;`
}
}
}additionalData 会在每个 SCSS 文件编译前注入。注意不要注入会被重复执行的规则(如 @import,用 @use 更安全),否则构建产物会膨胀。
多主题通常通过 CSS 变量切换实现,而非 SCSS 变量。把主题色值定义为 CSS 自定义属性,切换时在根元素覆盖即可,不需要重新编译样式。
代码分割与动态导入
Vite 基于 Rollup 构建,代码分割通过动态 import() 触发。手动控制分割粒度时使用 build.rollupOptions.output.manualChunks:
ts
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia'],
ui: ['ant-design-vue']
}
}
}
}manualChunks 要谨慎使用:过细的分割会增加并行请求数,过粗则丧失按需加载的优势。大多数场景下,依赖图表(如 visualizer 生成的分析)来调优比凭经验硬分割更可靠。
开发阶段常见问题排查
HMR 不生效或更新异常的排查路径
HMR 失效时,不必从原理图开始排查,按以下路径检查:
- 确认文件是否在 Vite 的监听范围内。
node_modules中的文件不会被监听。如果修改的是通过npm link引入的本地包,需要确认该包配置了watch或不使用 symlink。 - 打开浏览器 DevTools 的 Network 面板,勾选 WS 过滤,观察 WebSocket 连接状态。如果连接断开(状态不是 101),HMR 无法工作。
- 检查 HMR 的边界。Vite 的 HMR 只处理它认识的模块类型(
.vue单文件组件的<template>和<style>、CSS 文件、通过import.meta.hot.accept()明确声明的 JS 模块)。如果一个.ts文件被修改但没有模块声明接受热更新,表现会是页面完全刷新而非局部更新。 - 如果用的是框架封装(如 Vue 的
<style scoped>),检查该框架插件是否正确注册。移除所有插件后逐步加回,定位是哪个插件破坏了 HMR 路径。
依赖预构建错误与缓存重置
预构建在 node_modules/.vite 目录缓存结果。以下症状通常指向缓存问题:
- 更新依赖版本后,开发服务器使用了旧版本
- 加入了新的依赖后首次启动报模块解析错误
- 某个依赖在浏览器中以非 ESM 格式抛出错误
处理方式:删除 node_modules/.vite 目录并重启 dev server。Vite 3.x 之后预构建的缓存校验已经更智能,但切换版本或切换分支后手动清理仍然是可靠的解法。
请求卡死与模块加载失败
开发服务器上某个模块的请求一直处于 pending 状态(在 Network 面板可以看到),通常是该模块的依赖发生了循环引用,而依赖树中包含未预构建的 CommonJS 包。
排查步骤:
- Network 面板找到 pending 请求,确认是哪个模块
- 在该模块文件顶部加
console.log,看是否进入死循环 - 如果该模块依赖了 CommonJS 包,检查该包是否被预构建(通常在预构建日志中可见)
- 没有预构建的 CJS 包可以手动加入
optimizeDeps.include
浏览器 Network 面板的检查方法
开发阶段,浏览器 Network 面板是观察 Vite 行为的直接窗口:
- 检查请求的 MIME 类型:
.vue文件应当以application/javascript或text/javascript返回(经过了编译),不能是text/html。 - 检查响应头:
Content-Type不正确会导致浏览器拒绝执行模块。 - 观察模块加载的瀑布:如果大量模块不是并行加载而是形成很长的请求链,说明代码存在过多的静态导入链,可能需要做动态导入拆分,或者检查依赖预构建是否正常覆盖了
node_modules中的包。
构建错误诊断手册
构建流程从开发模式的时间敏感调试变成了批处理全量检查。开发阶段能跑通的代码在构建时仍可能报错,原因包括编译范围、模块解析规则、路径处理在两种模式下不完全一致。
模块解析失败与 external 误用
最常见的一类构建错误:
Error: Could not resolve './some-module'如果该模块存在且路径正确,检查 build.rollupOptions.external 配置。external 标记的模块不会被 Rollup 打包进产物,但必须通过运行时的全局变量或 AMD/UMD 加载。如果将一个内部模块误标为 external,构建阶段就会抛出解析失败。
确认方法:检查 external 数组中是否用正则或字符串匹配到了不属于外部依赖的模块路径。external 的正确使用场景是把 Vue、React 这类通过 CDN 引入的运行时库标记为外部依赖。
路径错误与大小写敏感问题
macOS 和 Windows 的文件系统默认不区分大小写,所以 import Foo from './foo.vue' 和 import Foo from './Foo.vue' 在开发环境下都能通过。但构建阶段的路径解析遵循严格匹配,CI 环境通常运行在 Linux 上,文件名大小写不匹配会导致构建失败。
同一类问题还包括 Windows 上的反斜杠路径(\)。始终在代码中使用正斜杠(/),并保持导入路径的大小写与实际文件名一致。
outDir 清理与多页产物冲突
默认构建输出目录为 dist。如果项目有多个构建入口(比如同时构建主应用和一个独立的 admin 应用),两个构建使用同一个 outDir 会导致产物相互覆盖或残留旧文件。
解决方式:
- 每个构建入口使用独立的
outDir(dist/main、dist/admin) - 在每次构建前手动删除(或通过脚本删除)旧产物,而不是依赖 Vite 自带的清理机制
build.emptyOutDir 默认为 true(当 outDir 位于项目根目录内时),但它只清理当前构建写入的内容,跨构建的残留不会自动消除。
资源缺失与 404 的定位顺序
部署后出现资源 404,按以下顺序排查:
- 检查
base配置。如果部署在子目录/app/,base必须设为/app/,否则所有资源路径会指向根。 - 检查
public目录中的文件是否被正确复制。它们会出现在dist的根目录。 - 检查
assets中的资源是否被源码引用。没有被任何模块import的资源不会出现在构建产物中——这是 Rollup 的 tree-shaking 行为。 - 如果使用了动态路径拼接(如
import(./images/${name}.png)),确保路径模式在构建时能被 Rollup 静态分析,否则该资源不会被包含在构建输出中。
使用 vite --debug 定位构建问题
vite build --debug 会输出详细日志,包括:
- Rollup 的输入和输出配置
- 插件在哪些阶段被调用
- 每个模块的解析来源和转换结果
manualChunks的实际分组
当构建失败且错误信息不足以定位时,打开 debug 日志,搜索错误模块名,追踪它在哪个插件中经历了什么转换。这比反复阅读 vite.config.ts 更直接。
构建产物分析与性能观察
产物体积构成
构建完成后,dist 目录的体积构成可以在终端中查看,但基于数字很难判断哪个模块占了主要空间。更需要回答的问题是:哪个 chunk 过大,它由哪些依赖组成。
使用 rollup-plugin-visualizer 生成体积报告
ts
// vite.prod.config.ts 的关键追加
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig(
mergeConfig(baseConfig, {
build: { /* ... */ },
plugins: [
visualizer({
open: false,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html'
})
]
})
)运行 vite build 后在 dist/stats.html 中生成 treemap。重点关注:
- 单个 chunk 超过 500KB(未压缩)时,考虑拆分为更小的懒加载块。
- 同一个库的多个版本意外共存(通常由依赖版本冲突引起)。
node_modules中的遗留文件被打包(如测试文件、文档、license)。
sourcemap 的开启策略与使用场景
开发阶段使用 devtool: 'eval-cheap-module-source-map',构建快,映射质量足够定位错误行。
构建时:
hidden:生成 sourcemap 但不添加引用注释。适合上传到错误追踪服务,但不同时暴露给普通用户。- 不生成:适合对体积和安全性都有严格控制的项目。
sourcemap: true:在产物末尾追加//# sourceMappingURL,浏览器加载时会下载对应的 map 文件。只有在内网管理后台这类场景才考虑。
sourcemap 文件不应部署到 CDN 的公开路径下。它们应当上传到 Sentry 或其他监控平台后删除,或者存储在需要鉴权才能访问的位置。
部署前的检查清单
部署阶段的问题通常不是 Vite 本身的问题,而是 Vite 的默认配置与部署环境的约束不匹配。以下四点可以提前规避。
base 路径与子目录部署
vite build 默认 base 为 /。如果应用部署在 https://example.com/my-app/,base 必须显式设为 /my-app/。这会影响:
index.html中的<script>和<link>标签的路径前缀- CSS 中引用的图片和其他资源路径
- 使用
import.meta.env.BASE_URL拼接的路由路径
动态设置 base 的方式:
ts
export default defineConfig({
base: process.env.VITE_BASE ?? '/'
})history 模式路由的 fallback 配置
如果前端路由使用 HTML5 History 模式(Vue Router 的 createWebHistory、React Router 的 BrowserRouter),部署服务器必须将所有未知路径 fallback 到 index.html。
Nginx 配置示例:
location / {
try_files $uri $uri/ /index.html;
}没有这个 fallback,用户在非首页路径刷新时会直接得到 404,因为服务器上并不存在对应的静态文件。
静态资源绝对路径与 CDN 场景
如果静态资源托管在独立的 CDN 域名,需要在 base 中指定完整 URL:
ts
export default defineConfig({
base: 'https://cdn.example.com/static/'
})这种方式下,所有构建产物中的静态资源引用都会带有完整 CDN 路径。注意这意味着开发环境的资源也会指向 CDN,如果 CDN 尚未部署或不包含开发阶段的资源,会在本地开发时引起资源加载失败。此时可以通过环境变量区分开发和构建的 base:
ts
base: process.env.CDN_URL ?? '/'浏览器兼容与 legacy 插件
Vite 的默认构建目标为支持 modules 的浏览器(即支持动态 import() 和 import.meta 的现代浏览器)。如果需要支持 IE 11 或低版本 Android WebView,需要引入 @vitejs/plugin-legacy。
ts
import legacy from '@vitejs/plugin-legacy'
export default defineConfig({
plugins: [
legacy({
targets: ['defaults', 'not IE 11']
})
]
})plugin-legacy 会生成两套产物:一套给现代浏览器,另一套包含 polyfill 和转译后的代码给老旧浏览器。代价是构建时间显著增加,且需要服务端正确响应 X-Content-Type-Options 等与 legacy 产物相关的头部。是否启用 legacy 取决于项目的浏览器覆盖要求——如果目标用户中老旧浏览器占比低于可接受的阈值,可以不做兼容。
参考链接
- Vite 官方文档 - 配置参考:https://vitejs.dev/config/
- Vite 官方文档 - 环境变量与模式:https://vitejs.dev/guide/env-and-mode.html
- Rollup 配置选项:https://rollupjs.org/configuration-options/
rollup-plugin-visualizer:https://github.com/btd/rollup-plugin-visualizer@vitejs/plugin-legacy:https://github.com/vitejs/vite/tree/main/packages/plugin-legacy- Vite 调试指南:https://vitejs.dev/guide/troubleshooting.html
