Vite 开发服务器启动时,如何将 client 注入 HTML?

当执行 vite 命令启动开发服务器,并在浏览器中打开 http://localhost:5173 时,页面会神奇地具备热模块替换(HMR)能力。这一切的起点,就是 Vite 在返回的 HTML 中悄悄注入了一个特殊的脚本:/@vite/client。这个脚本负责建立 WebSocket 连接、监听文件变化并触发模块热更新。

整体流程概览

Vite 将 client 注入 HTML 的过程可以概括为以下几个步骤:

  1. 服务器启动:创建 Vite 开发服务器,初始化插件容器。
  2. 注册插件:内置插件被激活。
  3. 请求拦截 :浏览器请求 index.html,Vite 的 HTML 中间件接管。
  4. HTML 转换 :调用所有插件的 transformIndexHtml 钩子。
  5. 注入标签clientInjectionsPlugintransformIndexHtml 中返回需要注入的 script 标签。
  6. 模块解析 :浏览器解析 HTML 后请求 /@vite/client,经过 resolveIdload 钩子返回实际代码。
  7. 代码转换:client 源码中的占位符被替换为当前服务器的实际配置(如 HMR 端口、base 路径等)。
  8. 客户端执行:浏览器执行 client 代码,建立 WebSocket 连接,HMR 就绪。

启动服务器到 client 在浏览器中运行的全过程

clientInjectionsPlugin

负责在客户端代码中注入配置值和环境变量,确保客户端代码能够正确访问 Vite 配置和环境信息,特别是热模块替换 (HMR) 相关的配置。

客户端核心入口 :处理 /@vite/client/@vite/env 文件,先注入配置值,再替换 define 变量。

buildStart钩子

vite/packages/vite/src/node/plugins/clientInjections.ts

js 复制代码
function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
  // 存储配置值替换函数,在 buildStart 钩子中初始化
  let injectConfigValues: (code: string) => string

  // 返回一个函数,每个构建环境(如 client 和 ssr)分别创建 define 替换函数
  const getDefineReplacer = perEnvironmentState((environment) => {

    const userDefine: Record<string, any> = {}

    for (const key in environment.config.define) {
      // import.meta.env.* is handled in `importAnalysis` plugin
      // 过滤掉 import.meta.env.* 前缀的变量(这些由 importAnalysis 插件处理
      if (!key.startsWith('import.meta.env.')) {
        userDefine[key] = environment.config.define[key]
      }
    }
    const serializedDefines = serializeDefine(userDefine)
    const definesReplacement = () => serializedDefines
    return (code: string) => code.replace(`__DEFINES__`, definesReplacement)
  })

  return {
    name: 'vite:client-inject',
    // 初始化插件,在 buildStart 钩子中创建配置值替换函数
    async buildStart() {

      // 生成一个函数
      // 用于接收客户端源码字符串,将其中的占位符(如 __BASE__、__HMR_PORT__、__MODE__ 等)替换为实际的值
      injectConfigValues = await createClientConfigValueReplacer(config)
    },
    // 转换客户端代码,注入配置值和环境变量
    async transform(code, id) {
      const ssr = this.environment.config.consumer === 'server'
      const cleanId = cleanUrl(id)

      // 客户端核心入口:/@vite/client 和 /@vite/env
      if (cleanId === normalizedClientEntry || cleanId === normalizedEnvEntry) {
        const defineReplacer = getDefineReplacer(this)
        return defineReplacer(injectConfigValues(code))

        // 其他文件中的 process.env.NODE_ENV 替换
      } else if (!ssr && code.includes('process.env.NODE_ENV')) {
        // replace process.env.NODE_ENV instead of defining a global
        // for it to avoid shimming a `process` object during dev,
        // avoiding inconsistencies between dev and build
        const nodeEnv =
        // 优先使用用户定义的值
          this.environment.config.define?.['process.env.NODE_ENV'] ||
          // 回退到系统环境变量
          // 最终回退到 Vite 模式
          JSON.stringify(process.env.NODE_ENV || config.mode)

        return await replaceDefine(this.environment, code, id, {
          'process.env.NODE_ENV': nodeEnv,
          'global.process.env.NODE_ENV': nodeEnv,
          'globalThis.process.env.NODE_ENV': nodeEnv,
        })
      }
    },
  }
}
js 复制代码
function perEnvironmentState<State>(
  initial: (environment: Environment) => State,
): (context: PluginContext) => State {

  const stateMap = new WeakMap<Environment, State>()

  return function (context: PluginContext) {
    const { environment } = context
    // 尝试从 stateMap 中获取当前环境的状
    let state = stateMap.get(environment)
    if (!state) {
      // 调用 initial 函数初始化状态,并将其存储到 stateMap 中
      state = initial(environment)
      stateMap.set(environment, state)
    }
    return state
  }
}

indexHtmlMiddleware 中间件

当浏览器请求 index.html 时,Vite 开发服务器会通过中间件(packages/vite/src/node/server/middlewares/html.ts)处理。

  1. 请求拦截与过滤
  2. 完整打包模式(Full Bundle Dev Environment) 或 普通文件系统模式
  3. 通过 send 发送返回 HTML。

全量环境

  1. 文档类请求的 SPA 回退 :若请求头的 sec-fetch-destdocumentiframe 等类型,并且满足以下任一条件:

    (1)当前 bundle 已过时,会重新生成 bundle);

    (2)或者文件原本不存在(file === undefined);则调用 generateFallbackHtml(server) 生成一个默认的 index.html 作为文件内容。

  2. 最终将文件内容(字符串或 Buffer)通过 send 返回,并携带 etag 用于缓存。

js 复制代码
  if (fullBundleEnv) {
    const pathname = decodeURIComponent(url)
    // 打包根目录的文件路径 index.html
    const filePath = pathname.slice(1) // remove first /

    let file = fullBundleEnv.memoryFiles.get(filePath)
    if (!file && fullBundleEnv.memoryFiles.size !== 0) {
      return next()
    }
    const secFetchDest = req.headers['sec-fetch-dest']
    // 处理文档类请求(SPA 回退)
    if (
      [
        'document',
        'iframe',
        'frame',
        'fencedframe',
        '',
        undefined,
      ].includes(secFetchDest) &&
      // 检查当前 bundle 是否过期
      ((await fullBundleEnv.triggerBundleRegenerationIfStale()) ||
        file === undefined)
    ) {
      // 生成一个 fallback HTML 作为文件内容
      // 生成一个默认的 HTML 入口
      file = { source: await generateFallbackHtml(server as ViteDevServer) }
    }
    if (!file) {
      return next()
    }

    const html =
      typeof file.source === 'string'
        ? file.source
        : Buffer.from(file.source)
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers
    return send(req, res, html, 'html', { headers, etag: file.etag })
  }

发送

js 复制代码
async function getHmrImplementation(
  config: ResolvedConfig,
): Promise<string> {

  // 读取client脚本文件
  const content = fs.readFileSync(normalizedClientEntry, 'utf-8')
  const replacer = await createClientConfigValueReplacer(config)
  return (
    replacer(content)
      // the rolldown runtime cannot import a module
      .replace(/import\s*['"]@vite\/env['"]/, '')
  )
}
js 复制代码
 async function importUpdatedModule({
    url, // 补丁文件的 URL,例如 "/hmr_patch_0.js"
    acceptedPath, // 需要热更新的模块路径
    isWithinCircularImport,
  }) {
    const importPromise = import(base + url!).then(() =>
      // 从 Rolldown 运行时中提取模块的导出
      // @ts-expect-error globalThis.__rolldown_runtime__
      globalThis.__rolldown_runtime__.loadExports(acceptedPath),
    )
    if (isWithinCircularImport) {
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` +
            `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`,
        )
        pageReload()
      })
    }
    return await importPromise
}

【示例】修改文件,client 的 websocket 收到更新

json 复制代码
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_0.js",
            "path": "src/pages/home/index.vue",
            "acceptedPath": "src/pages/home/index.vue",
            "timestamp": 1775992132341
        }
    ]
}

【示例】修改样式变量文件

浏览器客户端收到websocket消息,会加载补丁文件。

json 复制代码
{
    "type": "update",
    "updates": [
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "acceptedPath": "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "acceptedPath": "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "timestamp": 1775994912031
        },
        {
            "type": "js-update",
            "url": "hmr_patch_3.js",
            "path": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "acceptedPath": "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less",
            "timestamp": 1775994912031
        }
    ]
}

浏览器端成功加载一个模块(包括动态 import())后,客户端会主动发送 vite:module-loaded 事件。

json 复制代码
{
    "type": "custom",
    "event": "vite:module-loaded",
    "data": {
        "modules": [
            "src/pages/home/index@logs.vue?vue&type=style&index=0&scoped=9217440f&lang.less",
            "src/pages/home.vue?vue&type=style&index=0&scoped=f940dfa7&lang.less",
            "src/pages/home/index.vue?vue&type=style&index=0&scoped=2c5296db&lang.less"
        ]
    }
}

服务器接收到该事件,提取出本次加载的模块列表(payload.modules),并将其注册到开发引擎(devEngine)中,关联到当前客户端 ID。

js 复制代码
this.hot.on('vite:client:disconnect', (_payload, client) => {
  const clientId = this.clients.delete(client)
  if (clientId) {
    this.devEngine.removeClient(clientId)
  }
})

普通文件模式

  1. 解析请求的文件路径
  2. 开发模式下的访问权限检查
  3. 读取 HTML 文件并进行转换,最终通过 send 返回 HTML。
js 复制代码
  // 根据请求 URL 确定 HTML 文件的实际文件系统路径
  let filePath: string

  // 如果是开发服务器且 URL 以 FS_PREFIX 开头(表示直接访问文件系统路径)
  if (isDev && url.startsWith(FS_PREFIX)) {
    filePath = decodeURIComponent(fsPathFromId(url))
  } else {
    // 将 URL 与服务器根目录连接,解析为绝对路径
    filePath = normalizePath(
      path.resolve(path.join(root, decodeURIComponent(url))),
    )
  }

  if (isDev) {
    const servingAccessResult = checkLoadingAccess(server.config, filePath)
    // 如果路径被拒绝访问,返回 403 错误
    if (servingAccessResult === 'denied') {
      return respondWithAccessDenied(filePath, server, res)
    }
    // 
    if (servingAccessResult === 'fallback') {
      return next()
    }
    // 确保路径被允许访问
    servingAccessResult satisfies 'allowed'
  } else {
    // `server.fs` options does not apply to the preview server.
    // But we should disallow serving files outside the output directory.
    if (!isParentDirectory(root, filePath)) {
      return next()
    }
  }

  if (fs.existsSync(filePath)) {
    const headers = isDev
      ? server.config.server.headers
      : server.config.preview.headers

    try {
      // 读取 HTML 文件内容
      let html = await fsp.readFile(filePath, 'utf-8')
      if (isDev) {
        // 开发环境下,对 HTML 进行转换
        html = await server.transformIndexHtml(url, html, req.originalUrl)
      }
      // 发送 HTML 内容
      // 这里使用 send() 方法,而不是 res.end(),因为它会自动处理响应头和编码
      return send(req, res, html, 'html', { headers })
    } catch (e) {
      return next(e)
    }
  }

执行中间件

读取html文件内容

server.transformIndexHtml 其实就是执行 applyHtmlTransforms,之前中间件已经处理 createDevHtmlTransformFn

createDevHtmlTransformFn

  1. plugin.transformIndexHtml 获取具有 transformIndexHtml 钩子的插件,排序。
  2. 构建转换钩子管道,在 applyHtmlTransforms 中按顺序执行。
  3. applyHtmlTransforms 根据插件钩子,生成相关 tag 注入 html 中。

vite/packages/vite/src/node/server/middlewares/indexHtml.ts

js 复制代码
function createDevHtmlTransformFn(
  config: ResolvedConfig,
): (
  server: ViteDevServer,
  url: string,
  html: string,
  originalUrl?: string,
) => Promise<string> {

  // 从配置的插件中解析出 HTML 转换钩子
  const [preHooks, normalHooks, postHooks] = resolveHtmlTransforms(
    config.plugins,
  )

  // 构建转换钩子管道
  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

  // 创建插件上下文
  const pluginContext = new BasicMinimalPluginContext(
    { ...basePluginContextMeta, watchMode: true },
    config.logger,
  )
  return (
    server: ViteDevServer,
    url: string,
    html: string,
    originalUrl?: string,
  ): Promise<string> => {

    // 将所有转换钩子应用到 HTML 内容上
    return applyHtmlTransforms(html, transformHooks, pluginContext, {
      path: url,
      filename: getHtmlFilename(url, server),
      server,
      originalUrl,
    })
  }
}

traverseHtml

server.transformIndexHtml 对html进行转换 ------> createDevHtmlTransformFn confige 解析阶段收集了插件transformIndexHtml钩子 ------〉applyHtmlTransforms 执行上述收集的hook.handler------> injectToHead 将标签插入头部

收集所有插件的 transformIndexHtml 钩子返回的修改(可能是 HTML 字符串的替换,或者要插入的标签数组),然后将其应用到原始 HTML 上。

js 复制代码
  const transformHooks = [
    preImportMapHook(config), // 处理导入映射的前置钩子
    injectCspNonceMetaTagHook(config), // 注入 CSP nonce 元标签
    ...preHooks,
    htmlEnvHook(config), // 注入环境变量到 HTML 中
    devHtmlHook, // 开发环境特定的 HTML 转换
    ...normalHooks,
    ...postHooks,
    injectNonceAttributeTagHook(config), // 注入 nonce 属性到标签中
    postImportMapHook(), // 处理导入映射的后置钩子
  ]

【示例】执行devHtmlTransformFn

【示例】执行 htmlEnvHook

【示例】 devHtmlHook

处理 html 节点

处理head节点

处理 meta 节点

处理 link 节点

applyHtmlTransforms

injectToHead

vite/packages/vite/src/node/plugins/html.ts

js 复制代码
async function applyHtmlTransforms(
  html: string,
  hooks: IndexHtmlTransformHook[],
  pluginContext: MinimalPluginContextWithoutEnvironment,
  ctx: IndexHtmlTransformContext,
): Promise<string> {
  for (const hook of hooks) {
    const res = await hook.call(pluginContext, html, ctx)
    if (!res) {
      continue
    }
    if (typeof res === 'string') {
      html = res
    } else {
      let tags: HtmlTagDescriptor[]
      if (Array.isArray(res)) {
        tags = res
      } else {
        html = res.html || html
        tags = res.tags
      }

      let headTags: HtmlTagDescriptor[] | undefined
      let headPrependTags: HtmlTagDescriptor[] | undefined
      let bodyTags: HtmlTagDescriptor[] | undefined
      let bodyPrependTags: HtmlTagDescriptor[] | undefined

      for (const tag of tags) {
        switch (tag.injectTo) {
          case 'body':
            ;(bodyTags ??= []).push(tag)
            break
          case 'body-prepend':
            ;(bodyPrependTags ??= []).push(tag)
            break
          case 'head':
            ;(headTags ??= []).push(tag)
            break
          default:
            ;(headPrependTags ??= []).push(tag)
        }
      }
      headTagInsertCheck([...(headTags || []), ...(headPrependTags || [])], ctx)
      if (headPrependTags) html = injectToHead(html, headPrependTags, true)
      if (headTags) html = injectToHead(html, headTags)
      if (bodyPrependTags) html = injectToBody(html, bodyPrependTags, true)
      if (bodyTags) html = injectToBody(html, bodyTags)
    }
  }

  return html
}
相关推荐
军军君012 小时前
数字孪生监控大屏实战模板:空气污染监控
前端·javascript·vue.js·typescript·前端框架·echarts·数字孪生
米丘2 小时前
vite 插件 @vitejs/plugin-vue
javascript·node.js·vite
冰暮流星2 小时前
javascript之DOM更新操作
开发语言·javascript·ecmascript
吃西瓜的年年2 小时前
react(四)
前端·javascript·react.js
阿凤213 小时前
后端返回数据流的格式
开发语言·前端·javascript·uniapp
kyriewen3 小时前
屎山代码拆不动?微前端来救场:一个应用变“乐高城堡”
前端·javascript·前端框架
@大迁世界3 小时前
3月 React 圈又变天了
前端·javascript·react.js·前端框架·ecmascript
SuperEugene3 小时前
Vue3 配置驱动弹窗:JSON配置弹窗内容/按钮,避免重复开发弹窗|配置驱动开发实战篇
前端·javascript·vue.js·前端框架·json
WayneYang4 小时前
前端 JavaScript 核心知识点 + 高频踩坑 + 大厂面试题全汇总(开发 / 面试必备)
前端·javascript