Vite实战

3 阅读13分钟

从原理到源码,从踩坑到落地,一个 Webpack 老用户的 Vite 深度学习记录。

我写 Webpack 配置写了三年。三年里,每次 npm run dev 后盯着终端转圈等 30 秒,我都告诉自己"习惯就好"。直到有天我接手一个用 Vite 搭的项目——冷启动不到 1 秒,改一行代码页面瞬间更新——那种丝滑感让我有点恍惚,好像之前三年一直在骑自行车上高速。

我决定花两周系统学一遍 Vite。不是看两篇文章就上手那种,而是从"它为什么快"一路啃到"源码里到底干了啥"。这篇文章是我整个学习过程的浓缩,不讲怎么安装怎么起项目(官方文档写得够好了),只聊那些让我真正建立起认知的东西。

一、Vite 到底为什么快

这是我学 Vite 的第一个问题,也是最重要的一个。不搞明白这个,后面所有的配置和优化都是在背参数。

Webpack 的问题出在哪

Webpack 的开发模式有一个根深蒂固的设计:先把所有模块打成一个 Bundle,再启动 Dev Server。项目小的时候无所谓,项目大了之后,光启动就要把整个依赖图走一遍,该编译的编译,该打包的打包。几千个模块的项目,这个过程可能要一两分钟。

更痛苦的是 HMR。你改了一个组件,Webpack 需要重新走一遍受影响的模块链,重新生成 chunk。项目一大,改一行代码等两三秒是常态。

问题的根源很简单:Webpack 在浏览器请求之前做了太多事情

Vite 换了一个思路

Vite 的核心思路可以用一句话概括:不打包,直接发 ESM 给浏览器

现代浏览器原生支持 <script type="module">,能自己解析 import 语句。既然浏览器自己会处理模块依赖,那为什么还要在服务端帮它打包?

Vite 的开发模式是这样工作的:

浏览器:我要 /src/main.ts
   ↓
Vite Dev Server:收到,让我编译一下这个文件
   ↓(esbuild 编译 TS → JS,耗时几毫秒)
Vite:给你,这是一个标准的 ES Module
   ↓
浏览器:好的,我解析到里面有 import App from './App.vue'
浏览器:我要 /src/App.vue
   ↓
Vite:收到,编译中...
   ↓(vue 插件把 .vue 转成 JS)
...以此类推

关键区别在于:Vite 只编译浏览器实际请求的模块。首屏只用到 10 个文件?那就只编译 10 个。另外 200 个组件?没人请求就不编译。这就是所谓的"懒编译"。

我打开 DevTools 的 Network 面板验证了这一点。Vite 的开发模式下,每个 .vue 文件、每个 .ts 文件都是一个独立的网络请求,Response 是标准的 ES Module 代码。这不是打包产物——这就是你写的源码,只做了最小限度的转换。

Bare Import 重写

但有一个问题。我在代码里写 import { ref } from 'vue',浏览器是看不懂这个 'vue' 的——它不是一个合法的 URL。浏览器只认识 ./xxx 或者 /xxx 这种路径。

Vite 在这里做了一件聪明的事。它有一个内部插件叫 importAnalysis,会扫描每个模块的 import 语句,把 bare import 重写成实际路径:

// 你写的代码
import { ref } from 'vue'

// Vite 重写后浏览器收到的代码
import { ref } from '/node_modules/.vite/deps/vue.js?v=a1b2c3d4'

后面那个 ?v=a1b2c3d4 是版本 hash,用来做浏览器强缓存的。依赖没变,hash 不变,浏览器直接用缓存,连请求都省了。

esbuild:快到离谱的编译器

Vite 在开发时用 esbuild 编译 TypeScript 和 JSX。esbuild 用 Go 语言写的,编译速度是 Babel 的 10 到 100 倍。

这个速度差不是优化级别的差距,是架构级别的。Babel 是用 JavaScript 写的,单线程,解释执行。esbuild 是编译型语言实现的,原生多线程并行处理。就像一个是自行车,另一个是摩托车,优化自行车齿轮比没有意义。

不过 esbuild 也有代价:它不做类型检查。所以 Vite 的 dev 命令能在有类型错误的情况下正常启动——它压根不检查类型。类型检查需要单独跑 vue-tsctsc,一般放在 CI 流水线里。这一点刚开始会觉得"不安全",用久了反而觉得合理:开发时要的是速度,类型安全交给 CI 守门

二、依赖预构建:Vite 启动时唯一在做的事

如果 Vite 真的什么都不打包就启动,那第一次 npm run dev 的时候是不是应该瞬间完成?

并不是。第一次启动会比后续慢一点,因为 Vite 要做依赖预构建

为什么需要预构建

原因有三个,每一个都很实际。

第一,格式不兼容。 npm 上大量的包还是 CommonJS 格式(用 module.exportsrequire)。浏览器的 ES Module 不认识 require,必须转成 import/export。比如 axios 就是 CommonJS 的,Vite 用 esbuild 把它转成 ESM。

第二,请求数爆炸。 lodash-es 这个包是 ESM 格式的,理论上浏览器能直接用。但它有 600 多个独立的小文件。如果不做处理,浏览器要发 600 多个网络请求才能加载完 lodash。预构建会把这 600 个文件合并成 1 个,一个请求搞定。

第三,缓存一致性。 预构建后的文件统一放在 node_modules/.vite/deps/ 目录下,每个文件的 URL 带版本 hash。只要 package-lock.json 没变,hash 就不变,浏览器可以永久缓存。

预构建的流程

Vite 启动
  ↓ 扫描 index.html 和入口文件中的所有 import 语句
  ↓ 收集所有 bare import(vue、axios、lodash-es...)
  ↓ 对每个依赖用 esbuild 做 bundle
  ↓ 输出到 node_modules/.vite/deps/
  ↓ 计算 lockfile 的 hash,写入元数据
  ↓ 下次启动时对比 hash,没变就跳过

你可以在 npm run dev 后检查 node_modules/.vite/deps/ 目录,里面就是预构建的产物。打开 vue.js 看看,你会发现 Vue 的所有模块都被合并到了一个文件里。

什么时候需要手动干预

大多数时候预构建是自动的。但有一个场景经常遇到:动态 import 的依赖。

Vite 在启动时通过静态分析收集依赖。如果一个依赖是在运行时才 import() 的,Vite 扫描不到它,就不会预构建。这时候页面加载到那里会突然卡一下——Vite 在补做预构建。

解决办法很简单,在配置里显式告诉 Vite:

// vite.config.ts
optimizeDeps: {
  include: ['some-dynamic-dep']
}

还有一个坑:改了 optimizeDeps 配置后,必须删掉缓存或者用 --force 参数重启,否则旧的缓存不会更新:

npx vite --force   # 强制重新预构建
# 或者手动删
rm -rf node_modules/.vite

三、HMR 比你想的复杂

Hot Module Replacement 是开发体验的灵魂。改了一行代码,不刷新页面就能看到效果,组件状态还保留着——这才是现代开发该有的样子。

但我在学 Vite 的过程中发现,大多数人(包括之前的我)对 HMR 的理解停留在"它能热更新"。真正深入之后,才知道里面有多少讲究。

Vue 组件的 HMR:你不需要操心

如果你只写 Vue 组件,HMR 基本是开箱即用的。@vitejs/plugin-vue 会自动注入 HMR 处理代码。你改模板,组件原地更新;你改样式,CSS 即时替换;你改 <script setup>,组件重新执行但 ref 的值尽量保留。

这些都不需要你写一行 HMR 相关的代码。

普通 .ts 文件的 HMR:必须手动处理

问题出在非组件文件上。假设你有一个 timer.ts,里面启动了一个 setInterval

let count = 0
const timer = setInterval(() => {
  count++
  console.log(count)
}, 1000)

export function getCount() {
  return count
}

你修改了这个文件,Vite 的 HMR 会用新模块替换旧模块。但旧模块的 setInterval 还在跑!没人停掉它。于是你会看到控制台同时有两个定时器在打日志——每改一次文件多一个,这就是经典的HMR 内存泄漏

正确的做法是用 import.meta.hot API 手动处理:

let count = 0
let timer: ReturnType<typeof setInterval> | null = null

export function startTimer(callback: (n: number) => void) {
  stopTimer()
  timer = setInterval(() => { count++; callback(count) }, 1000)
}

export function stopTimer() {
  if (timer) { clearInterval(timer); timer = null }
}

// HMR 处理
if (import.meta.hot) {
  // 接受自身更新,不刷新整页
  import.meta.hot.accept()
  
  // 模块被替换前,清理副作用
  import.meta.hot.dispose((data) => {
    stopTimer()        // 停掉旧的定时器
    data.count = count // 把当前计数传给新模块
  })
  
  // 从旧模块接收传递的数据
  if (import.meta.hot.data.count) {
    count = import.meta.hot.data.count
  }
}

三个关键 API:

  • accept():告诉 Vite "这个模块能自己处理更新,不要刷新整页"
  • dispose(cb):模块被替换之前调用,用来清理定时器、移除事件监听、关闭 WebSocket 之类的副作用
  • data:一个在新旧模块之间传递数据的对象。在 dispose 里写入,在新模块里读取

说实话,日常开发中需要手动处理 HMR 的场景不多。绝大多数代码都是 Vue/React 组件,框架插件已经处理好了。但理解这个机制很重要——下次 HMR 表现异常的时候,你至少知道该去哪里排查

HMR 的完整链路

贴一张我自己整理的流程图:

文件保存
  → chokidar 监听到文件变化
  → Vite 查 moduleGraph 找到该模块的依赖链
  → 向上冒泡寻找 HMR 边界(有 accept() 的模块)
  → 找到边界 → 通过 WebSocket 发送更新通知
  → 浏览器收到通知,动态 import 新模块(URL 带 ?t=时间戳 绕过缓存)
  → 执行模块注册的 accept 回调
  → 框架层(plugin-vue)做组件替换
  
  找不到边界 → 整页刷新(fallback)

四、手写三个 Vite 插件

Vite 插件的 API 继承自 Rollup,但额外增加了一些开发模式独有的钩子。我在学习过程中手写了几个插件,这是真正帮我理解 Vite 工作机制的方式——光看文档记不住,自己写一遍就全通了。

插件的骨架

每个 Vite 插件就是一个返回对象的函数:

import type { Plugin } from 'vite'

function myPlugin(): Plugin {
  return {
    name: 'vite-plugin-xxx',  // 必填,报错时显示
    enforce: 'pre',            // 可选,在核心插件之前执行
    apply: 'serve',            // 可选,只在 dev 时生效
    
    // 下面是各种钩子...
  }
}

enforceapply 这两个选项很有用。enforce: 'pre' 让你的插件在 Vite 内置插件之前执行,apply: 'serve' 让它只在开发时生效。Mock 插件就应该加 apply: 'serve'——你肯定不希望生产包里还带着 Mock 数据。

实战一:虚拟模块——自动路由生成

虚拟模块是 Vite 插件里最"魔法"的能力。它让你可以 import 一个不存在的模块,插件在运行时动态生成代码。

我写了一个自动路由插件:扫描 src/pages/ 目录下的 .vue 文件,自动生成路由配置。

import type { Plugin } from 'vite'
import { readdirSync } from 'fs'
import { resolve, basename } from 'path'

const VIRTUAL_ID = 'virtual:auto-routes'
const RESOLVED_ID = '\0' + VIRTUAL_ID  // \0 前缀是 Rollup 约定

export function autoRoutesPlugin(): Plugin {
  return {
    name: 'vite-plugin-auto-routes',

    // resolveId:拦截模块请求
    resolveId(id) {
      if (id === VIRTUAL_ID) return RESOLVED_ID
    },

    // load:返回模块代码
    load(id) {
      if (id !== RESOLVED_ID) return

      const dir = resolve(process.cwd(), 'src/pages')
      const files = readdirSync(dir).filter(f => f.endsWith('.vue'))

      const routes = files.map(file => {
        const name = basename(file, '.vue')
        const path = name === 'Home' ? '/' : `/${name.toLowerCase()}`
        return `  { path: '${path}', name: '${name}', component: () => import('/src/pages/${file}') }`
      })

      return `export default [\n${routes.join(',\n')}\n]`
    },
  }
}

使用的时候直接 import:

import routes from 'virtual:auto-routes'
// routes 是动态生成的路由数组,不需要手动维护

这里有个细节值得聊聊:\0 前缀。这是 Rollup 生态的约定——加了 \0 前缀的模块 ID 表示"这是虚拟的,其他插件别来处理它"。如果不加,Vite 的其他插件可能会尝试从文件系统加载这个模块,然后报"文件不存在"的错。

实战二:Mock 服务——configureServer 的妙用

Vite 有一个 Rollup 没有的独有钩子:configureServer。它让你直接操作开发服务器的中间件栈,相当于往 Express/Koa 里加中间件。

我用它做了一个 Mock 服务插件:

import type { Plugin } from 'vite'

interface MockRoute {
  method?: string
  url: string
  delay?: number
  response: ((params: { query: any; body: any }) => any) | any
}

export function mockPlugin(routes: MockRoute[]): Plugin {
  return {
    name: 'vite-plugin-mock',
    apply: 'serve',  // 关键:只在开发时生效

    configureServer(server) {
      server.middlewares.use(async (req, res, next) => {
        const url = req.url?.split('?')[0] || ''
        const method = req.method?.toUpperCase() || 'GET'

        const mock = routes.find(r => r.url === url && (r.method || 'GET') === method)
        if (!mock) return next()  // 没匹配到,交给 Vite 处理

        if (mock.delay) await new Promise(r => setTimeout(r, mock.delay))

        const data = typeof mock.response === 'function'
          ? mock.response({ query: {}, body: {} })
          : mock.response

        res.setHeader('Content-Type', 'application/json')
        res.end(JSON.stringify({ code: 200, data }))
      })
    },
  }
}

使用:

// vite.config.ts
plugins: [
  mockPlugin([
    { url: '/api/users', response: [{ id: 1, name: '张三' }] },
    { url: '/api/login', method: 'POST', delay: 500, response: { token: 'xxx' } },
  ])
]

apply: 'serve' 确保这段代码在生产构建时完全不存在。不是"不执行",是根本不会被打包进去

实战三:自定义文件格式——transform 钩子

transform 是用得最多的钩子。每个模块被加载后都会经过 transform,你可以在这里做任何代码转换。

我写了一个让 .md 文件可以直接 import 为 HTML 字符串的插件:

export function markdownPlugin(): Plugin {
  return {
    name: 'vite-plugin-markdown',

    transform(code, id) {
      if (!id.endsWith('.md')) return null  // 不是 .md 就跳过

      // 简易 Markdown → HTML 转换
      const html = code
        .replace(/^## (.+)$/gm, '<h2>$1</h2>')
        .replace(/^# (.+)$/gm, '<h1>$1</h1>')
        .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
        .replace(/`(.+?)`/g, '<code>$1</code>')

      // 返回一个 JS 模块,默认导出 HTML 字符串
      return {
        code: `export default ${JSON.stringify(html)}`,
        map: null,
      }
    },
  }
}

然后在组件里:

<script setup>
import content from './readme.md'
</script>
<template>
  <div v-html="content" />
</template>

transform 的精髓在于:它的输入是源代码字符串,输出也是代码字符串,但必须是合法的 JavaScript/ESM 模块。你可以把任何文件格式"翻译"成 JS 模块。

钩子速查

写了三个插件之后,我对钩子的选择形成了直觉:

我想做什么用哪个钩子
凭空生成一个模块resolveId + load
转换已有文件的内容transform
修改 HTMLtransformIndexHtml
添加开发服务器中间件configureServer
操作构建产物generateBundle
修改 Vite 配置config

五、工程化:从能用到好用

原理搞懂了,插件会写了,接下来是另一个层面的问题:怎么在团队项目里把 Vite 用好

目录结构不是个人偏好,是团队契约

我在企业级模板里用的目录结构:

src/
├── api/            # 接口层,按业务模块拆分
│   └── request.ts  # axios 封装(拦截器集中在这里)
├── composables/    # Vue 组合式函数(跨组件复用逻辑)
├── components/     # 通用组件
├── layouts/        # 布局组件
├── router/         # 路由
├── stores/         # Pinia 状态管理
├── styles/         # 全局样式和变量
├── types/          # TypeScript 类型定义
├── utils/          # 工具函数
└── views/          # 页面组件

看起来很"标准",但每一层的职责边界要想清楚。比如 api/request.ts 只做三件事:创建 axios 实例、请求拦截、响应拦截。业务接口定义放在 api/modules/ 下按模块拆分。不要把业务逻辑塞进拦截器里。

axios 拦截器的分层错误处理

这是我在实际项目中打磨出来的模式。错误分三层,每层处理不同的事:

const service = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 15000,
})

// 请求拦截器:只做 token 注入
service.interceptors.request.use((config) => {
  const token = localStorage.getItem('token')
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
})

// 响应拦截器:分层处理错误
service.interceptors.response.use(
  (response) => {
    const { code, message, data } = response.data

    // 第一层:业务错误(后端返回 HTTP 200,但 code 不是 0)
    if (code !== 0) {
      // 特殊处理:token 过期
      if (code === 401) {
        localStorage.removeItem('token')
        window.location.href = '/login'
        return Promise.reject(new Error('登录已过期'))
      }
      // 其他业务错误:弹提示
      console.error(`[API] ${message}`)
      return Promise.reject(new Error(message))
    }

    return data  // 只返回 data 层,调用方不用写 res.data.data
  },
  (error) => {
    // 第二层:HTTP 错误(404、500、网络断开等)
    if (error.response) {
      console.error(`[HTTP] ${error.response.status}`)
    } else if (error.code === 'ECONNABORTED') {
      console.error('[HTTP] 请求超时')
    } else {
      console.error('[HTTP] 网络异常')
    }
    return Promise.reject(error)
  }
)

这样设计的好处:调用方的代码非常干净。

// 调用方只关心业务数据,错误已经在拦截器里统一处理了
const users = await request.get<User[]>('/users')

环境变量的安全机制

Vite 有一个常被忽略的安全设计:只有 VITE_ 前缀的环境变量才会暴露给前端代码

# .env
SECRET_KEY=abc123             # 前端代码读不到!
VITE_API_URL=http://xxx       # 前端可以读到

这是故意的。前端代码是公开的,任何人都能在浏览器里看到你的 JS。如果不做前缀过滤,SECRET_KEY 这种东西直接就暴露了。

vite.config.ts 里可以通过 loadEnv() 读取所有变量(包括没有 VITE_ 前缀的),因为配置文件在 Node.js 端运行,不会被打包到前端。

// vite.config.ts
export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')  // 第三个参数为空 = 读取所有变量
  console.log(env.SECRET_KEY) // 可以读到,但不会暴露给前端
})

CSS 方案我最终选了什么

试了一圈之后,我的选择是 Scss + <style scoped> + PostCSS autoprefixer。理由:

  • Scss 的变量和 mixin 在大项目里确实好用,团队接受度也高
  • Vue 的 scoped 对于组件隔离已经够用了,不需要 CSS Modules 那么重
  • autoprefixer 是必备品,不管用什么 CSS 方案
  • UnoCSS/Tailwind 适合快速原型和个人项目,但大团队的接受度参差不齐

如果是 React 项目,没有 scoped 可用,那 CSS Modules 是更好的选择。

六、踩坑备忘录

这些坑每一个我都真实遇到过(或者在 code review 里看别人踩过)。

打包后白屏

现象npm run dev 一切正常,npm run build && npm run preview 也正常,但部署到服务器上打开就是白屏。

原因:用了 createWebHistory() 的 history 路由模式。用户直接访问 /about 时,Nginx 试图找 /about/index.html,找不到就返回 404。

解决:Nginx 配置加一行:

location / {
  try_files $uri $uri/ /index.html;
}

这行的意思是:先找精确文件,找不到就返回 index.html,让前端路由接管。

server.proxy 只在开发有效

现象:开发时 /api/users 代理到后端没问题,上线后接口全 404。

原因server.proxy 是 Vite Dev Server 的能力,构建产物是纯静态文件,没有代理能力。

解决:生产环境必须在 Nginx(或其他网关)配代理:

location /api/ {
  proxy_pass http://backend:8080/;
  proxy_set_header Host $host;
}

这个坑很常见,因为开发时一切正常,你以为 proxy 配置会"跟着走"到生产——并不会。

环境变量的类型陷阱

现象if (import.meta.env.VITE_DEBUG) 永远为 true,即使 .env 里写的是 VITE_DEBUG=false

原因.env 文件里的值全部是字符串'false' 是一个非空字符串,在 JS 里是 truthy。

解决:用字符串比较:

const isDebug = import.meta.env.VITE_DEBUG === 'true'

或者封装一个工具函数:

function getEnvBoolean(key: string): boolean {
  return import.meta.env[key] === 'true'
}

alias 和 tsconfig paths 双配置

现象vite.config.ts 里配了 alias: { '@': '/src' },代码能跑,但 VS Code 满屏红线报 Cannot find module '@/xxx'

原因:Vite 的 resolve.alias 和 TypeScript 的 compilerOptions.paths两套独立的系统。Vite 负责运行时路径解析,TypeScript 负责编辑器类型检查。两边都要配,缺一不可。

// vite.config.ts
resolve: {
  alias: { '@': resolve(__dirname, 'src') }
}
// tsconfig.json
{
  "compilerOptions": {
    "paths": { "@/*": ["./src/*"] }
  }
}

漏配哪个都有问题:只配 Vite → IDE 报错;只配 TS → 运行报错。

legacy 插件的隐性依赖

现象:装了 @vitejs/plugin-legacy,构建时报 terser not found

原因:legacy 插件需要 terser 来压缩 legacy 产物,但它没有把 terser 列为依赖。你必须手动安装。

npm install -D @vitejs/plugin-legacy terser

另外,开启 legacy 模式会让构建时间翻倍(要生成现代版 + legacy 版两套代码),产物体积也明显增大。如果你的用户全是现代浏览器,不要开这个。

写在最后

学完这一整圈下来,我对 Vite 的态度从"快就完了"变成了"确实设计得好"。

它快,不是因为做了多少缓存优化,而是从架构层面改变了开发模式的工作方式——把打包这件事从开发阶段移到了构建阶段,开发时直接用浏览器原生的 ESM 能力。这不是优化,是范式转换。

如果你还在犹豫要不要迁移,我的建议是:

  • 新项目:无脑 Vite,没有理由再用 Webpack 起新项目了
  • 中小型存量项目:建议迁移,迁移成本不高,收益明显
  • 大型存量项目(复杂 Webpack 配置):评估成本。如果有深度定制的 Webpack loader、Module Federation 之类的需求,Vite 的生态可能还没完全覆盖

最后说一个体感:当你习惯了 Vite 的速度之后,再回去用 Webpack,会生理性不适。这大概就是科技进步的代价——回不去了


本文基于我两周系统学习 Vite 的过程整理而成。如果对你有帮助,欢迎点赞收藏。有问题可以在评论区交流,我都会看。