@vitejs/plugin-vue 原理分析

781 阅读10分钟

前言

我们每天都在写vue单文件组件(SFC),然后分别在<template>, <script><style>块里写对应的代码。或许我们有时会有点好奇,vue文件的内容是如何被处理并最终渲染到浏览器上的?

带着这个疑问,我们来探索一下它背后的原理。

注意: 对于插件 @vitejs/plugin-vue,此次分析的版本为 5.2.0

目录结构

这里使用了之前开发的小工具 treei (感兴趣的话欢迎✨star✨) 来生成该仓库的目录树形结构:

├──📁packages
|   ├──📁plugin-vue
|   |   ├──📁dist
|   |   |   ├──📄index.cjs
|   |   |   ├──📄index.d.ts
|   |   |   └──📄index.mjs
|   |   ├──📁src
|   |   |   ├──📁utils
|   |   |   |   ├──📄descriptorCache.ts
|   |   |   |   ├──📄error.ts
|   |   |   |   └──📄query.ts
|   |   |   ├──📄compiler.ts
|   |   |   ├──📄handleHotUpdate.ts
|   |   |   ├──📄helper.ts
|   |   |   ├──📄index.ts
|   |   |   ├──📄main.ts
|   |   |   ├──📄script.ts
|   |   |   ├──📄style.ts
|   |   |   └──📄template.ts
|   |   ├──📄build.config.ts
|   |   ├──📄CHANGELOG.md
|   |   ├──📄LICENSE
|   |   ├──📄package.json
|   |   ├──📄README.md
|   |   └──📄tsconfig.json
|   ├──📁plugin-vue-jsx // vue 的 jsx 插件
├──📁playground // 各种 examples
├──📁scripts
|   ├──📄patchCJS.ts
|   ├──📄publishCI.ts
|   ├──📄release.ts
|   ├──📄releaseUtils.ts
|   └──📄tsconfig.json
├──📄.editorconfig
├──📄.eslintcache
├──📄.git-blame-ignore-revs
├──📄.gitattributes
├──📄.gitignore
├──📄.npmrc
├──📄.prettierignore
├──📄.prettierrc.json
├──📄CODE_OF_CONDUCT.md
├──📄eslint.config.js
├──📄LICENSE
├──📄package.json
├──📄pnpm-lock.yaml
├──📄pnpm-workspace.yaml
├──📄README.md
├──📄vitest.config.e2e.ts
└──📄vitest.config.ts

该仓库使用的是基于pnpmworkspace搭建的monorepo多包项目,包含两个插件:

PackageDescription
@vitejs/plugin-vue用于解析和转换.vue文件的vite插件
@vitejs/plugin-vue-jsx提供了 jsx / tsx 的支持

不过这里我们仅关注@vitejs/plugin-vue,即packages/plugin-vue目录。

插件的基本使用

当使用 create-vite 脚手架,即使用pnpm create vite命令完成vue项目的初始化后,得到vite.config.ts的配置如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue()],
})

只需要将插件@vitejs/plugin-vue传入到viteplugins中,该项目就自动拥有了解析vue文件的能力。

插件主函数结构

首先,我们知道 @vitejs/plugin-vue 作为一个 vite 插件,它需要符合 vite 插件的约定,下面是插件的主函数:

关于如何开发一个 vite 插件和插件的 API 含义这里不做赘述,详情看官方文档 Vite Plugin API

// packages/plugin-vue/index.ts

export default function vuePlugin(rawOptions: Options = {}): Plugin<Api> {
    // ...忽略部分代码
    
    const options = shallowRef<ResolvedOptions>({
        isProduction: process.env.NODE_ENV === 'production',
        compiler: null as any, // to be set in buildStart
        include: /\.vue$/,
        customElement: /\.ce\.vue$/,
        ...rawOptions,
        root: process.cwd(),
        sourceMap: true,
        cssDevSourcemap: false,
    })
    
    return {
        name: 'vite:vue',
        api: {/*...*/},
        handleHotUpdate(ctx) {/*...*/},
        configResolved(config) {/*...*/},
        configureServer(server) {/*...*/},
        buildStart() {/*...*/},
        async resolveId(id) {/*...*/},
        load(id, opt) {/*...*/},
        async transform(code, id, opt) {/*...*/},
    }
}

插件的函数主体大概就是这样,这里我们主要关注 resolveIdloadtransform 三个 hooks

记得当时看这里代码的时候,发现尤大写了个错别字,还顺带水了一个 PR

vue 文件分割为多个子模块

总的来说,@vitejs/plugin-vue 会配合使用 @vue/compiler-sfc.vue 文件处理成多个子模块,包括 scripttemplatestyleCustomBlock,然后对每一个子模块应用不同的 transform 去处理。

@vue/compiler-sfc 文档中这样一段话:

The general idea is to generate a facade module that imports the individual blocks of the component. The trick is the module imports itself with different query strings so that the build system can handle each request as "virtual" modules:


                                      +--------------------+
                                      |                    |
                                      |  script transform  |
                               +----->+                    |
                               |      +--------------------+
                               |
    +--------------------+     |      +--------------------+
    |                    |     |      |                    |
    |  facade transform  +----------->+ template transform |
    |                    |     |      |                    |
    +--------------------+     |      +--------------------+
                               |
                               |      +--------------------+
                               +----->+                    |
                                      |  style transform   |
                                      |                    |
                                      +--------------------+

Where the facade module looks like this:

// main script
import script from '/project/foo.vue?vue&type=script'
// template compiled to render function
import { render } from '/project/foo.vue?vue&type=template&id=xxxxxx'
// css
import '/project/foo.vue?vue&type=style&index=0&id=xxxxxx'

// attach render function to script
script.render = render

// attach additional metadata
// some of these should be dev only
script.__file = 'example.vue'
script.__scopeId = 'xxxxxx'

// additional tooling-specific HMR handling code
// using __VUE_HMR_API__ global

export default script

翻译过来的意思大概就是: 生成一个门面模块,导入组件的各个独立block。技巧就是在模块导入自身的时候,加上不同的query字符串,这样构建系统就能把每个请求处理为"虚拟"模块。

工作流程:

  1. facade transform 中,会使用 @vue/compiler-sfcparse API 将 .vue 文件的源代码解析成一个 descriptor 描述符,并且会基于该描述符生成上面的门面模块代码。
  2. script transform 中,会使用 @vue/compiler-sfccompileScript API 来处理该 script。这可以处理诸如像 <script setup> 和 CSS 变量这样的功能。或者,也可以直接在 facade 模块中完成(使用内联代码而不是导入代码)。
  3. template transform 中,使用 compileTemplate 将原始模板编译为渲染函数代码
  4. style transform 中,使用 compileStyle 编译原始CSS来处理 <style scoped><style module> 和 CSS变量注入。

这里我们使用一个 Demo 来演示下效果。如下所示,通过 src imports 方式导入各个 block:

注意:大部分时候我们一般使用 <script><script setup> 这种方式来写 vue 组件,而不是 src imports 这种方式,但是背后原理还是类似的,仅仅转换规则会有所不同。这里为了方便演示模块的转换(模块导入自身的时候,加上不同的query字符串),我们在这里使用 src imports 的方式。

index.vue:

<template src="./template.html"></template>
<style src="./style.css"></style>
<script src="./script.js"></script>

transform hook 代码如下:

// plugin-vue/src/index.ts

async function transform(code, id, opt) {
  const { filename, query } = parseVueRequest(id)
  if (!filter.value(filename) && !query.vue) {
    return
  }
  if (!query.vue) {
    // main request
    return transformMain(...)
  } else {
    // ... sub block request
  }
}

在第一次执行 transform hook 解析 vue 文件时,显然此时是不带 vue 参数标识的,因此会调用transformMain 函数将对 vue 文件的请求转换为多个子模块请求的形式。

transformMain 函数的代码如下:

// plugin-vue/src/main.ts

async function transformMain(
  code: string,
  filename: string,
  options: ResolvedOptions,
  // ... other params
) {
  // 创建 .vue 文件的描述符,包含了 script, template, style 等信息
  const { descriptor } = createDescriptor(filename, code, options)
  // 生成 script 代码
  const { code: scriptCode } = await genScriptCode(...)
  // 生成 template 代码
  const { code: templateCode } = await genTemplateCode(...)
  // 生成 style 代码
  const stylesCode = await genStyleCode(...)

  const output: string[] = [
    scriptCode,
    templateCode,
    stylesCode,
  ]

  // ... ignore other code

  let resolvedCode = output.join('\n')

  return {
    code: resolvedCode,
    // ...
  }
}

// 创建 vue 组件的描述符
function createDescriptor(
  filename: string,
  source: string,
) {
  // 调用 @vue/compiler-sfc 的 parse 函数来解析 .vue 文件的代码
  const { descriptor, errors } = compiler.parse(source, {
    filename,
    // other options
  })
  return { descriptor }
}

而上面的 descriptor 描述符就是一个对象,下面是打印的 json 格式的数据:

{
  "filename": "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue",
  "source": "<template src=\"./template.html\"></template>\n<style src=\"./style.css\"></style>\n<script src=\"./script.js\"></script>\n",
  "template": {
    "type": "template",
    "content": "",
    "attrs": {
      "src": "./template.html"
    },
    "src": "./template.html"
  },
  "script": {
    "type": "script",
    "content": "",
    "attrs": {
      "src": "./script.js"
    },
    "src": "./script.js"
  },
  "scriptSetup": null,
  "styles": [
    {
      "type": "style",
      "content": "",
      "attrs": {
        "src": "./style.css"
      },
      "src": "./style.css"
    }
  ],
  "customBlocks": [],
  "id": "b2ef2ffb"
  // 省略了部分属性
}

这里,重点关注下 script, templatestyles 属性,可以看到它们都有一个 src 属性用于引入外部文件。

而最终,这里的 transformMain 函数得到的 resolvedCode 如下:

// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
export * from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'

// ... ignore HMR code

import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
  ['render', _sfc_render],
  [
    '__file',
    'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
  ],
])

也就是说,当我们应用上面的index.vue组件:

import IndexComponent from './index.vue'

在经过 transformMain() 函数处理后,会被转换成下面这种形式的代码:

// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'

// ...

也就得到了在前面所说的,将 .vue 组件分成多个子模块的效果 —— 导入自身的时候,加上不同的query字符串,这样构建系统就能把每个请求处理为"虚拟"模块

解析子模块

首先来看下 rollup hooks 的执行流程图:

hooks-execution-workflow-diagram.png

可以看到,在经过 transform hook 转换后,下一步就是执行 moduleParsed hook,它会解析所有的静态 import 语句,因此上文的 resolvedCode 会继续被解析,然后再依次执行 resolveId, loadtransform 等钩子,如此反复,直到所有的静态 import 语句都被解析完毕。

resolveId hook 代码如下:

// plugin-vue/src/index.ts

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

async function resolveId(id) {
  // component export helper
  if (id === EXPORT_HELPER_ID) {
    return id
  }
  // serve sub-part requests (*?vue) as virtual modules
  if (parseVueRequest(id).query.vue) {
    return id
  }
}

可以看到,当解析到:

  1. import _export_sfc from 'plugin-vue:export-helper'
  2. import xxx from *?vue

这种类似请求时,会被转换为 虚拟模块

再来看看 load hook 的代码:

// plugin-vue/src/index.ts

function load(id, opt) {
  const ssr = opt?.ssr === true
  if (id === EXPORT_HELPER_ID) {
    return helperCode
  }
  // 解析文件名和查询参数
  const { filename, query } = parseVueRequest(id)
  // 为 sub-part 虚拟模块选择相应的块 (script, template, style, customBlock)
  if (query.vue) {
    if (query.src) {
      // 如果是 src imports 的形式,则返回文件内容
      // case 1: "*.js?vue&type=script&src=true&lang.js"
      // case 2: "*.html?vue&type=template&src=true&lang.js"
      // case 3: "*.css?vue&type=style&index=0&src=true&lang.css"
      return fs.readFileSync(filename, 'utf-8')
    }
    // ...ignore other code
  }
}

可以看到,当 id === EXPORT_HELPER_ID,会直接返回 helperCode,即:

// plugin-vue/src/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;
}
`

再结合前面出现在 resolveCode 中的代码:

import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
  ['render', _sfc_render],
  [
    '__file',
    'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
  ],
])

等价于:

const _export_sfc = (sfc, props) => {
  const target = sfc.__vccOpts || sfc
  for (const [key, val] of props) {
    target[key] = val
  }
  return target
}
// 将渲染函数添加到组件上
_sfc_main.render = _sfc_render
_sfc_main.__file =
  'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue'

export default _sfc_main

再来看看 transform hook 代码:

async function transform(code, id, opt) {
  const ssr = opt?.ssr === true
  const { filename, query } = parseVueRequest(id)

  if (query.raw || query.url) {
    return
  }
  if (!filter.value(filename) && !query.vue) {
    return
  }
  if (!query.vue) {
    // main request
    return transformMain(...)
  } else {
    // sub block request
    const descriptor = query.src
      ? getSrcDescriptor(filename, query) ||
        getTempSrcDescriptor(filename, query)
      : getDescriptor(filename, options.value)!

    if (query.type === 'template') {
      // case: "*.html?vue&type=template&src=true&lang.js"
      return transformTemplateAsModule(
        code,
        descriptor,
        options.value,
        this,
        ssr,
        customElementFilter.value(filename),
      )
    } else if (query.type === 'style') {
      // case: "*.css?vue&type=style&index=0&src=true&lang.css"
      return await transformStyle(
        code,
        descriptor,
        Number(query.index || 0),
        options.value,
        this,
        filename,
      )
    }
  }
}

可以看到,transform hook 中,会对子模块的请求进行处理:

  • 如果 query.type === 'template',则会调用 transformTemplateAsModule 函数,该函数其实是调用了 @vue/compiler-sfccompileTemplate 函数——将模板字符串编译成渲染函数字符串。

  • 如果 query.type === 'style',则会调用 transformStyle 函数,该函数其实是调用了 @vue/compiler-sfccompileStyleAsync 函数——对 css 进行处理 (应用 css 预处理器,postcss 等转换成原生 css 格式)。

  • 如果 query.type === 'script',因为已经在 load hook 中被处理了(直接返回文件内容)。

对于 template:

import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'

转换后得到:

import {
  toDisplayString as _toDisplayString,
  openBlock as _openBlock,
  createElementBlock as _createElementBlock,
} from 'vue'

const _hoisted_1 = { class: 'test' }

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (
    _openBlock(),
    _createElementBlock(
      'div',
      _hoisted_1,
      _toDisplayString(_ctx.msg),
      1 /* TEXT */,
    )
  )
}

对于 style:

import './style.css?vue&type=style&index=0&src=true&lang.css'

转换后得到:

因为这里用的是原生 css ,所以没有做任何转换处理,如果使用的是 less, scss, stylus 等预处理器语言,则会被转换成原生的 css。

.test {
  color: orange;
}

显然,对于上面的 css 字符串,浏览器是不认识的,因此还需要进一步处理将其写入到 html 中。

那它是如何被处理的呢?这就需要靠 vite 内置的插件来处理了,它依次被 vite:css, 和 vite:css-post 插件处理,最终得到的结果如下:

import {
  updateStyle as __vite__updateStyle,
  removeStyle as __vite__removeStyle,
} from '/@vite/client'
const __vite__id =
  'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css'
const __vite__css = '.test {\n  color: orange;\n}\n'
__vite__updateStyle(__vite__id, __vite__css)

// ...ignore HMR code

即:

const __vite__updateStyle = updateStyle
const __vite__id =
  'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css'
const __vite__css = '.test {\n  color: orange;\n}\n'
__vite__updateStyle(__vite__id, __vite__css)

// `vite\packages\vite\src\client\client.ts#updateStyle`
export function updateStyle(id: string, content: string): void {
  let style = sheetsMap.get(id)
  if (!style) {
    style = document.createElement('style')
    style.setAttribute('type', 'text/css')
    style.setAttribute('data-vite-dev-id', id)
    style.textContent = content
    // insert into html
    document.head.appendChild(style)
  } else {
    style.textContent = content
  }
  sheetsMap.set(id, style)
}

对于 script, transform hook 并没有对它做任何处理,而是在 load hook 中:

function load(id, opt) {
  // ...
  const { filename, query } = parseVueRequest(id)
  if (query.vue) {
    if (query.src) {
      // 如果是 src imports 的形式,则返回文件内容
      // case: "*.js?vue&type=script&src=true&lang.js"
      return fs.readFileSync(filename, 'utf-8')
    }
  }
  // ...
}

可以看到,script 的内容直接从文件中读取并返回了。

所以,对于 index.vue:

<template src="./template.html"></template>
<style src="./style.css"></style>
<script src="./script.js"></script>

在第一次 transform hook中,会被 transformMain 函数处理,得到的 resolvedCode 如下:

// script
import _sfc_main from './script.js?vue&type=script&src=true&lang.js'
export * from './script.js?vue&type=script&src=true&lang.js'
// template
import { render as _sfc_render } from './template.html?vue&type=template&src=true&lang.js'
// style
import './style.css?vue&type=style&index=0&src=true&lang.css'

// ... ignore HMR code

import _export_sfc from 'plugin-vue:export-helper'
export default /*#__PURE__*/ _export_sfc(_sfc_main, [
  ['render', _sfc_render],
  [
    '__file',
    'D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue',
  ],
])

然后,后续的每个子模块请求都会被 transform hook 处理,最终得到大致如下的代码:

// script
const _sfc_main = {
  name: 'Test',
  setup() {
    return {
      msg: 'Hello App',
    }
  },
}

// template
import { toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

const _hoisted_1 = { class: "test" }

function _sfc_render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, _toDisplayString(_ctx.msg), 1 /* TEXT */))
}

// style
const __vite__id = "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/style.css"
const __vite__css = ".test {\n  color: orange;\n}\n"
__vite__updateStyle(__vite__id, __vite__css)

function __vite__updateStyle(id: string, content: string): void {
  let style = sheetsMap.get(id)
  if (!style) {
    style = document.createElement('style')
    style.setAttribute('type', 'text/css')
    style.setAttribute('data-vite-dev-id', id)
    style.textContent = content
    // insert into html
    document.head.appendChild(style)
  } else {
    style.textContent = content
  }
  sheetsMap.set(id, style)
}

// ... ignore HMR code

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

_sfc_main.render = _sfc_render
_sfc_main.__file = "D:/www/github/vite-plugin-vue/playground/vue-demo/src/components/srcImports/index.vue"

export default _sfc_main

可以看到,一个 vue 组件最终会被处理成上面这样类似的 js 代码,而这些代码也是可以被支持 ES6+ 的现代浏览器直接执行的。

image.png

总结

简单总结下,我从中收获到了哪些东西:

  • 学会了如何去调试和开发一个 vite 插件。比如以前在写 vue2 项目的时候用过一个将 vue template 的行内样式 px 转换为 rem 单位的 webpack 插件,然后就突然想到是不是可以写一个类似的 vite 插件 vite-plugin-pxtorem
  • 理解了 <style sceopd> 实现样式隔离的原理
  • 理解了一个 vue 组件是如何被转换并最终被处理成 js 的过程的原理

参考