Ray-D-Song's Blog

vite-plugin-vue 实现分析

2024-12-13 10min

最近在编写vue-jit项目,直接在浏览器中编译 Vue 组件并运行,因此需要了解在 Vite 中,Vue 插件是如何工作的。

插件架构

graph TD
    subgraph vite-plugin-vue
        A[plugin-vue]
        B[plugin-vue-jsx]
    end

    subgraph plugin-vue核心功能
        A --> C[Vue SFC处理]
        A --> D[HMR热更新]
        A --> E[SSR支持]

        C --> C1[template编译]
        C --> C2[script处理]
        C --> C3[style处理]
        C --> C4[custom blocks]

        D --> D1[文件变更监听]
        D --> D2[组件重载]

        E --> E1[SSR模块注册]
        E --> E2[SSR上下文]
    end

    subgraph plugin-vue-jsx核心功能
        B --> F[JSX/TSX转换]
        B --> G[HMR支持]
        B --> H[SSR支持]

        F --> F1[Babel转换]
        F --> F2[TypeScript支持]

        G --> G1[组件热重载]

        H --> H1[SSR模块注册]
    end

    subgraph 公共功能
        I[源码映射]
        J[自定义元素支持]
        K[开发工具集成]
    end

    A --> I
    A --> J
    A --> K

    B --> I
    B --> K

只提取 SFC 部分的架构:

graph TD
    A[plugin-vue]
    A --> C[Vue SFC处理]
    C --> C1[template编译]
    C --> C2[script处理]
    C --> C3[style处理]
    C --> C4[custom blocks]

Vue SFC 处理

入口位于/packages/plugin-vue/src/main.ts

流程如下

  1. Vite 通过 plugin-vue 插件拦截 .vue 文件
  2. 将 SFC 解析为 descriptor (描述符)
  3. 分别处理 template/script/style 块
  4. 生成最终的 JavaScript 代码
  5. 处理 HMR、Sourcemap 等

接下来试试编译后的结果长什么样,以下面这个 .vue 文件为例

<template>
  <div class="container">
    <span>{{ name }}</span>
    <span>{{ age }}</span>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  name: {
    type: String,
    default: 'Ray',
  },
})

const age = ref(18)
</script>

<style scoped>
.container {
  color: red;
}
</style>

编译后:

// 导入 VDOM API
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "container" }

import { ref } from 'vue'

// 主模块
const _sfc_main = {
  __name: 'test',
  props: {
  name: {
    type: String,
    default: 'Ray',
  },
},
  setup(__props) {

const props = __props

const age = ref(18)

// 返回一个函数,用于渲染组件
return (_ctx, _cache) => {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _createElementVNode("span", null, _toDisplayString(__props.name), 1 /* TEXT */),
    _createElementVNode("span", null, _toDisplayString(age.value), 1 /* TEXT */)
  ]))
}
}

}


// 样式模块
import "test.vue?vue&type=style&index=0&scoped=5e63b55d&lang.css"

import _export_sfc from '�plugin-vue:export-helper'
export default /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-5e63b55d"]])

最后的 import 和 export default 是插件自动生成的,用于转换主模块后导出。 转化相关的代码在 helper.ts

export const EXPORT_HELPER_ID = '\0plugin-vue:export-helper'

export const helperCode = `
export default (sfc, props) => {
  const target = sfc.__vccOpts || sfc;
  for (const [key, val] of props) {
    target[key] = val;
  }
  return target;
}
`

上面的样例代码,通过 _export_sfc 函数为组件添加了 __scopeId 属性,值为 “data-v-5e63b55d”。这个 ID 用于 scoped CSS 的实现。 这样确保样式只影响当前组件的元素。 比如:

.container {
  color: red;
}

会被编译为:

.container[data-v-5e63b55d] {
  color: red;
}

具体分析每一步的实现

解析 SFC

为了防止对同一个.vue文件重复解析,Vite 插件包含缓存机制,在没有缓存的情况下,会调用 createDescriptor 函数解析 SFC。 createDescriptor 函数内部调用 compiler.parse(@vue/compiler-sfc) 函数,解析 SFC 文件。

解析后的 descriptor 的主要数据结构如下:

interface SFCDescriptor {
  template: SFCTemplateBlock | null;
  script: SFCScriptBlock | null;
  // <script setup>
  scriptSetup: SFCScriptBlock | null;
  styles: SFCStyleBlock[];
}

descriptor 会作为后续步骤的参数。

脚本处理

脚本处理通过 main.ts 中的genScriptCode 函数完成,主要流程如下:

初始化

let scriptCode = `const ${scriptIdentifier} = {}`
let map: RawSourceMap | undefined

解析脚本

const script = resolveScript(descriptor, options, ssr, customElement)

在 resolveScript 内部,调用了 compiler.compileScript 方法(@vue/compiler-sfc),将脚本编译为 JavaScript 代码后返回。

解析脚本后,有两种情况:

一些主要的判断逻辑:

  1. 是否可以内联(canInlineMain)
if (canInlineMain(descriptor, options)) {
  // 内联处理
} else {
  // 外部处理
}
  1. 编译器版本
if (!options.compiler.version) {
  // 旧版本编译器处理
  scriptCode = options.compiler.rewriteDefault(...)
} else {
  // 新版本编译器处理
  scriptCode = script.content
}
  1. TypeScript 支持
const defaultPlugins =
  script.lang === 'ts'
    ? userPlugins.includes('decorators')
      ? (['typescript'] as const)
      : (['typescript', 'decorators-legacy'] as const)
    : []

最终,scriptCode 会传入output数组,作为主模块的一部分。

模板编译

main.ts 通过 genTemplateCode 函数处理模板编译。

({ code: templateCode, map: templateMap } = await genTemplateCode(
  descriptor,
  options,
  pluginContext,
  ssr,
  customElement,
))

在 genTemplateCode 内部,调用了位于packages/plugin-vue/src/template.ts的两个方法。

这两个方法本质都是调用 compiler.compileTemplate 方法(@vue/compiler-sfc),将模板编译为 JavaScript 代码。

最终编译完成的 templateCode 会传入output数组,作为主模块的一部分。

样式处理

样式处理通过genStyleCode 函数完成,主要内容是遍历所有的样式块,根据不同的情况进行处理。

最终生成的是一堆 import 语句,如果使用了 CSS Modules,还会生成一个 cssModulesMap 对象。

// 普通样式
import "style.css?vue&type=style&index=0&scoped=xxx"
// CSS Modules
import style0 from "style.css?vue&type=style&index=0&module"
const cssModules = {
  "className": style0["className"]
}
// 包含作用域
import "style.css?vue&type=style&index=0&scoped=data-v-xxx"

这些包含查询条件的导入会交由 Vite 处理。

外部样式流程:

CSS Modules 流程:

最终,stylesCode 会传入output数组,作为主模块的一部分。

output 数组

output 数组是插件处理后的结果,包含以下几个部分:

const output: string[] = [
  // 脚本
  scriptCode,
  // 模板
  templateCode,
  // 样式
  stylesCode,
  // 自定义块 (在本文中被忽略)
  customBlocksCode,
]

在 output 数组构建完成后,会进一步处理:

if (hasScoped) {
  attachedProps.push([`__scopeId`, JSON.stringify(`data-v-${descriptor.id}`)])
}
if (devToolsEnabled || (devServer && !isProduction)) {
  attachedProps.push([
    `__file`,
    JSON.stringify(isProduction ? path.basename(filename) : filename),
  ])
}
// 如果没有任何附加属性,则直接导出主模块
if (!attachedProps.length) {
  output.push(`export default _sfc_main`)
} else {
  // 否则,导出带有附加属性的主模块
  output.push(
    `import _export_sfc from '${EXPORT_HELPER_ID}'`,
    `export default /*#__PURE__*/_export_sfc(_sfc_main, [${attachedProps
      .map(([key, val]) => `['${key}',${val}]`)
      .join(',')}])`,
  )
}

将 output 数组拼接后,就得到开头的编译结果:

// 导入 VDOM API
import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "container" }

import { ref } from 'vue'

// 主模块
const _sfc_main = {
  __name: 'test',
  props: {
  name: {
    type: String,
    default: 'Ray',
  },
},
  setup(__props) {

const props = __props

const age = ref(18)

// 返回一个函数,用于渲染组件
return (_ctx, _cache) => {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _createElementVNode("span", null, _toDisplayString(__props.name), 1 /* TEXT */),
    _createElementVNode("span", null, _toDisplayString(age.value), 1 /* TEXT */)
  ]))
}
}

}


// 样式模块
import "test.vue?vue&type=style&index=0&scoped=5e63b55d&lang.css"

import _export_sfc from '�plugin-vue:export-helper'
export default /*#__PURE__*/_export_sfc(_sfc_main, [['__scopeId',"data-v-5e63b55d"]])

一些额外内容

生成实际的 css 文件

先前提到,插件会为组件生成类似下面的样式导入:

import "style.css?vue&type=style&index=0"

但这个文件实际上并不存在,当 Vite 遇到这些带查询参数的导入时,会: