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入口文件都是实时编译的。

相关推荐
nnnnna2 小时前
props 、emits 、组件上的v-model(详细版)
前端·javascript
三年三月2 小时前
Tailwind CSS 入门介绍
前端
余生H2 小时前
前端技术新闻(WTN-1):React.js & Next.js 爆出 CVSS 10.0 级严重漏洞,历史风险回顾与代码级深度分析
前端·javascript·react.js
1024肥宅2 小时前
JavaScript 原生方法实现:数学与数字处理全解析
前端·javascript·ecmascript 6
烟袅2 小时前
深入理解 JavaScript 内存机制与闭包原理
前端·javascript
烟袅2 小时前
JavaScript 内存三空间协同机制:代码空间、栈空间与堆空间如何联合运行
前端·javascript
lqj_本人2 小时前
DevUI高频组件(Form 组件)深度用法与避坑指南
前端·javascript
live丶2 小时前
从零实现一个低代码 H5 页面编辑器(Vue3 + 拖拽)
前端·vue.js
黑臂麒麟3 小时前
华为云 DevUI初体验:如何快速入门项目搭建
前端·ui·华为云·devui