Vite开发环境按需编译是怎么实现的?

前言

Vite的快,我们并不陌生,主要体现在开发环境时的体验。

而相较于其他构建工具,Vite核心是依靠了现代浏览器对于原生esm模块的支持+按需实时编译将性能达到了极致。

我们基于源码来看看esbuild编译的完整过程。

核心流程图

bash 复制代码
Browser Request
    ↓
Vite DevServer (Connect 中间件)
    ↓
请求路由判断
    ├─ /.vite/client → 注入客户端代码
    ├─ /@modules/* → node_modules 导入
    ├─ /src/* → 源代码文件
    └─ *.json, *.css 等 → 特殊处理
    ↓
ModuleGraph 缓存检查
    ├─ 命中缓存 → 返回
    └─ 未命中 → esbuild 编译
    ↓
TransformPlugin 流程
    ├─ pre plugins
    ├─ esbuild transform
    └─ post plugins
    ↓
发送给浏览器 (ES Modules)

DevServer入口代码

这里初始化了开发服务器、模块图(缓存系统)、很多中间件(用于拦截实时编译)。

ts 复制代码
// packages/vite/src/node/server/index.ts

import connect from 'connect'
import { createPluginContainer } from './pluginContainer'

export async function createServer(inlineConfig: InlineConfig = {}) {
  const config = await resolveConfig(inlineConfig, 'serve')
  
  // 创建 Express-like 应用
  const middlewares = connect()
  const httpServer = createHttpServer(middlewares)
  
  // 创建模块图(缓存系统)
  const moduleGraph = new ModuleGraph((url) =>
    pluginContainer.resolveId(url)
  )
  
  // 创建插件容器(执行插件)
  const pluginContainer = await createPluginContainer(config)
  
  // 核心中间件们
  middlewares.use(timeMiddleware)
  middlewares.use(cors)
  middlewares.use(transformMiddleware(server))  // ⭐ 重点
  middlewares.use(servePublicDir)
  middlewares.use(serveRawFs)
  
  const server = {
    middlewares,
    httpServer,
    moduleGraph,
    pluginContainer,
    ws: createWebSocketServer(httpServer),
    // ... 其他属性
  }
  
  return server
}

Transform中间件(请求拦截)

这里是一个很经典的例子,从浏览器发起第一次main.ts请求开始,Vite做了ts文件的转换。

而后续的请求会从main.ts中发起。

ts 复制代码
// packages/vite/src/node/server/middlewares/transform.ts

export function transformMiddleware(server: ViteDevServer) {
  return async (req: IncomingMessage, res: ServerResponse, next: NextFunction) => {
    if (req.method !== 'GET' || isSkipped(req.url)) {
      return next()
    }

    let url = req.url
    const { pathname, search, hash } = new URL(url, `http://${req.headers.host}`)
    
    // 示例:/src/main.ts?t=123 → /src/main.ts
    url = pathname + search + hash

    try {
      // ⭐ 核心:调用加载和转换
      const result = await transformRequest(url, server, {
        raw: req.headers['accept']?.includes('application/octet-stream'),
      })

      if (result) {
        const type = isDirectCSSRequest(url) ? 'text/css' : 'application/javascript'
        res.setHeader('Content-Type', type)
        res.setHeader('Cache-Control', 'no-cache')
        res.setHeader('ETag', getEtag(result.code))
        
        return res.end(result.code)
      }
    } catch (e) {
      // 错误处理
      if (e.code === 'ENOENT') {
        return next()
      }
      // HMR 错误通知浏览器
      server.ws.send({
        type: 'error',
        event: 'vite:error',
        err: e,
      })
    }

    next()
  }
}

请求转换核心逻辑

这里是核心的源码转换逻辑,基于源码优先从模块缓存表中取,如果没有才走该模块的首次转换,最后会落到缓存中。

ts 复制代码
// packages/vite/src/node/server/transformRequest.ts

export async function transformRequest(
  url: string,
  server: ViteDevServer,
  options?: TransformOptions,
) {
  // 1️⃣ 获取文件内容 + 元数据
  const { code: raw, map } = await loadRawRequest(url, server)
  
  let code = raw
  const inMap = map

  // 2️⃣ 检查缓存
  const cached = server.moduleGraph.getModuleByUrl(url)
  if (!server.config.command === 'serve' && cached?.transformedCode) {
    return {
      code: cached.transformedCode,
      map: cached.map,
    }
  }

  // 3️⃣ 执行插件转换
  const result = await pluginContainer.transform(code, url)
  if (result) {
    code = result.code
  }

  // 4️⃣ 特殊处理:自动导入注入
  if (!options?.raw) {
    code = injectHelper(code, url)
  }

  // 5️⃣ 缓存结果
  server.moduleGraph.updateModuleInfo(url, {
    transformedCode: code,
    map: result?.map,
  })

  return { code, map: result?.map }
}

加载原始请求(磁盘读写)

而加载和编译源码则是直接通过esbuild能力来实现。

ts 复制代码
// packages/vite/src/node/server/transformRequest.ts

async function loadRawRequest(url: string, server: ViteDevServer) {
  let id = decodeURIComponent(parseUrl(url).pathname)
  
  // ⭐ 调用插件的 resolveId hook
  const resolveResult = await server.pluginContainer.resolveId(id)
  
  if (resolveResult?.id) {
    id = resolveResult.id
  }

  // 从文件系统读取
  let code = await fs.promises.readFile(id, 'utf-8')
  let map: SourceMap | null = null

  // 如果是 TypeScript,用 esbuild 转译
  if (id.endsWith('.ts') || id.endsWith('.tsx')) {
    const result = await esbuildService.transform(code, {
      loader: 'ts',
      target: 'esnext',
      sourcemap: true,
    })
    code = result.code
    map = result.map
  }

  return { code, map }
}

因此一次完整的编译流程如下:

ruby 复制代码
// 实际请求处理过程

// 浏览器请求:GET /src/main.ts
// ↓
// transformMiddleware 拦截
// ↓
// transformRequest('/src/main.ts', server)
// ↓
// loadRawRequest: 从磁盘读取 main.ts
// ├─ 如果是 .ts,用 esbuild 转译为 .js
// └─ 返回 { code, map }
// ↓
// pluginContainer.transform(code, '/src/main.ts')
// ├─ vue plugin: .vue 转换为 { script, template, style }
// ├─ css-in-js plugin: 处理 styled-components 等
// ├─ import-analysis plugin: 分析依赖,重写为 /@modules/xxx
// └─ ...其他插件
// ↓
// 返回转换后的代码给浏览器
// ↓
// 浏览器 import './main.ts' 
// → 收到 ESM 代码,正常执行

依赖解析重写

Vite如果这样设计,会面临一个问题:请求的数量特别大,导致浏览器首屏时间反而更久。

Vite做了一层设计,将多个模块合并到一个模块,即依赖解析重写,如vue -> @modules/vue?v=xxx

ts 复制代码
// packages/vite/src/node/plugins/importAnalysis.ts

export function importAnalysisPlugin(): Plugin {
  return {
    name: 'vite:import-analysis',
    
    async transform(code: string, id: string) {
      // 匹配 import/export 语句
      const imports = parse(code) // 用 es-module-lexer 解析
      
      let s = new MagicString(code)
      
      for (const imp of imports) {
        // 例如:import { ref } from 'vue'
        const source = imp.source
        
        if (isRelative(source)) {
          // 相对路径,保持不变
          // import Foo from './foo.ts'
        } else if (isBuiltin(source)) {
          // Node 内置模块,忽略
        } else {
          // ⭐ NPM 包,重写为 /@modules/xxx
          // import { ref } from 'vue'
          // ↓
          // import { ref } from '/@modules/vue?v=xxx'
          
          const resolved = await resolveImport(source)
          const rewritten = `/@modules/${resolved.id}`
          
          s.overwrite(imp.startPos, imp.endPos, 
            `import {...} from '${rewritten}'`
          )
        }
      }
      
      return {
        code: s.toString(),
        map: s.generateMap(),
      }
    }
  }
}

处理node_modules三方库请求

既然将三方库依赖路径重写,那处理对应的请求也需要进行一次路径转换。

ts 复制代码
// 当浏览器请求 /@modules/vue?v=xxx 时

middlewares.use('/@modules/', async (req, res, next) => {
  const moduleName = req.url.split('/')[2]?.split('?')[0]
  
  // /@modules/vue → node_modules/vue/dist/vue.esm.js
  const modulePath = require.resolve(moduleName, {
    paths: [config.root],
  })
  
  const code = await fs.promises.readFile(modulePath, 'utf-8')
  
  // 继续执行 transform 中间件处理
  // 确保 node_modules 中的代码也被正确处理
  res.end(code)
})

HMR热更新

那按照这样的设计,所有模块只要经过一次编译,就会保存在模块缓存表中,热更新如何处理呢?

Vite做的也比较通俗易懂,当文件系统监听到文件变化,则清除该模块相关缓存信息,然后websocket通知浏览器,Vite client runtime会重新发起相关改动模块的请求。

ts 复制代码
// packages/vite/src/node/server/hmr.ts

// 当文件变更时
watcher.on('change', async (file) => {
  const url = urlFromFile(file, config.root)
  
  // 1️⃣ 清除模块缓存
  server.moduleGraph.invalidateModule(url)
  
  // 2️⃣ 收集受影响的模块
  const affectedModules = server.moduleGraph.getImporters(url)
  
  // 3️⃣ 通过 WebSocket 通知浏览器
  server.ws.send({
    type: 'update',
    event: 'vite:beforeUpdate',
    updates: affectedModules.map(m => ({
      type: m.isSelfAccepting ? 'js-update' : 'full-reload',
      event: 'vite:beforeUpdate',
      path: m.url,
      acceptedPath: url,
      timestamp: Date.now(),
    }))
  })
})

HMR客户端脚本注入

这就是客户端热更新的核心代码。

ts 复制代码
// packages/vite/src/client/client.ts

// 注入到每个 HTML 的脚本
const hotModule = import.meta.hot

if (hotModule) {
  hotModule.accept(({ default: newModule }) => {
    // 接收模块更新
    // 执行自定义 HMR 逻辑或完整重载
  })
  
  // 监听服务器推送
  hotModule.on('vite:beforeUpdate', async (event) => {
    if (event.type === 'js-update') {
      // 动态 import 新版本模块
      await import(event.path + `?t=${event.timestamp}`)
    } else {
      // 完整页面刷新
      window.location.reload()
    }
  })
}

因此热更新的流程总结如下:

scss 复制代码
用户编辑文件保存
    ↓
文件系统监听器检测变化
    ↓
清除 ModuleGraph 缓存
    ↓
WebSocket 通知浏览器
    ↓
浏览器发起新请求(带时间戳)
    ↓
transformMiddleware 拦截
    ↓
loadRawRequest (esbuild 编译 TS/JSX)
    ↓
pluginContainer.transform (执行插件 Vue/CSS 等)
    ↓
返回最新的 ESM 代码
    ↓
浏览器执行 HMR 回调更新页面

结尾

这就是Vite开发环境的核心机制!按需编译+缓存+HMR推送,相比于Webpack,少了最早的整个bundle的构建,自然而然会快非常多,因为Vite在初始化根本就没有build的过程,甚至连main.ts入口文件都是实时编译的。

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅5 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊5 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax