likes
comments
collection
share

vue3 模板编译

作者站长头像
站长
· 阅读数 41

一、背景

项目需求中需要根据不同场景展示不同文案和 UI。其中 UI 总类繁多,并且文案内容均需后端组装拼接,UI 模板中还需要支持链接跳转。同时需求还有二期,可能会继续增加很多种新的场景和 UI,因此考虑到扩展性,思索是否可以通过动态渲染 UI 模板的方式来做,由后端下发具体的模版内容,前端仅仅负责展示。

二、目标

  • 可动态渲染 UI 模板,具备快速支持新的 UI场景,无需重复开发

三、方案调研

支持动态渲染模板主要有三种方案:

3.1、方案一:v-html

v-html:想到动态渲染模板,首先就会想到通过 v-html 方式,直接嵌入原生 html 内容。

demo

<template>
	<div v-html="template" @click="onClick"></div>
	<p>{{ msg }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue';
  
const template = `
	<span class="label" style="color:red">原因</span>
	<a class="link" href="#/xxx" target="_blank">点击跳转</a>
`;
  
const msg = ref('');

const onClick = (event) => {
  if (event.target.nodeName === 'SPAN' && event.target.className === 'label') {
    msg.value = `${event.target.nodeName} is clicked!`;
  }
}
</script>

优点:

  1. 简单,原生支持,能支持动态 html 标签和样式。
  2. 实现成本低,快捷。

缺点:

  1. 只能使用原生 html 标签,无法使用项目中组件库。
  2. 绑定事件只能通过顶层标签代理来实现,较为繁琐。
  3. 存在 XSS 安全问题。

3.2、方案二:vue.compile

通过 compile 函数,动态编译模板,并返回一个渲染函数,能够在运行时生成动态组件。

demo

<!-- compile.vue -->
<script lang="ts">
import { compile, defineComponent } from 'vue';
export default defineComponent({
  props: {
    template: {
      type: String,
      default: '',
    }
  },
  data() {
    return {
      text: '这是data数据',
    }
  },
  methods: {
    onClick() {
      alert("clicked");
    }
  }, 
  setup(props) {
    return compile(props.template);
  }
})
</script>

App.vue

<!-- App.vue -->
<template>
   <compileComp :template="template"></compileComp>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import compileComp from './components/compile.vue';

const template = `<div>
  <el-button @click="onClick" type="danger">{{ text }}</el-button>
  <el-link type="primary" href="https://element.eleme.io" target="_blank">主要链接</el-link>
  <el-popover
    placement="top-start"
    title="标题"
    :width="200"
    trigger="hover"
    content="这是一段内容,这是一段内容,这是一段内容,这是一段内容。">
    <template #reference>
      <el-button>hover 激活</el-button>
    </template>
  </el-popover>
  </div>`
</script>

vite.config.ts

// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
  ...
  resolve: {
    alias: {
      // 仅完整版 vue 才支持实时动态编译,默认的 runtime 版本不包含此功能,需要显式引用
      vue: "vue/dist/vue.esm-bundler.js",
    },
  },
})

优点:

  1. 可以引用项目中全局导入的组件库。
  2. 绑定组件中数据和事件比较方便。

缺点:

  1. vue runtime 版本不支持动态 compile,需导入带编译的完整版才可以,这样会导致 vue 包体积变大,影响性能。

  2. 存在 XSS 安全问题。

3.3、方案三:渲染函数 h & jsx

渲染函数 & jsx 可以处理复杂的业务逻辑,用于实现动态组件。

demo

<script lang="ts">
import { h, compile } from "vue";
import { ElButton } from "element-plus";

export default {
  props: {
    template: {
      type: String,
      default: '',
    }
  },
  render() {
    return h(
      ElButton, { type: 'danger' }, 'hihihi'
    );
  },
};
</script>

vite.config.js

// vite.config.js
import vueJsx from '@vitejs/plugin-vue-jsx'

export default {
  plugins: [
    // 若要支持 jsx 需要引入 @vitejs/plugin-vue-jsx 插件编译构建
    vueJsx({
      // options are passed on to @vue/babel-plugin-jsx
    }),
  ],
}

优点:

  1. 非常的灵活,可以处理复杂业务逻辑,动态渲染组件。

缺点:

  1. 必须要实现每种 UI 场景,后续若有新增场景仍需要开发,无法达到减少前端开发成本的目标。

3.4、方案确认

当前需求中,仅仅只需展示一些文本 & 超链接,没有其他复杂的 UI 功能,以及没有任何点点击或悬浮事件,因此最终决定选择方案一。通过配置平台存放不同场景的UI模版,后端读取模板配置&填充模版数据,再将完整模版&数据下发给前端,最后页面通过 v-html 展示模板 UI。

至此这个需求其实就结束了,由于上面的方案中使用到了 vue.compile,因此就好奇 vue 是如何去编译模板,以及 vue SFC 文件的编译与实时编译的模板字符串过程有何不同,下面就为大家浅析 vue 模板编译的过程。

四、模板编译

4.1、vue 不同构建版本

前面方案中提到若要支持在运行时编译模板,必须要手动引入带编译版本的 vue 才行,打包构建时默认引用的是 runtime 版本,不包含编译功能。下面梳理了 vue 的所有构建版本,以及各个版本使用场景。

// cjs 服务端渲染,两个版本都是完整版,包含编译器。 通过 `require()` 在 Node.js 服务器端渲染使用。
vue.cjs.js
vue.cjs.prod.js

// bundler 两个版本没有打包所有的代码,只会打包使用的代码,需要配合打包工具来使用,会让 Vue 体积更小。
vue.esm-bundler.js 【完整版,包含编译器,体积大点】
vue.runtime.esm-bundler.js【默认】

// global 四个版本都可以在浏览器中直接通过 `<script src="...">` 标签导入,导入之后会增加一个全局的Vue对象。
vue.global.js
vue.global.prod.js
vue.runtime.global.js
vue.runtime.global.prod.js

// browser 四个版本都包含 esm,浏览器的原生 ES 模块化方式,可以直接通过<script type="module" />的方式来导入模块
vue.esm-browser.js
vue.esm-browser.prod.js
vue.runtime.esm-browser.js
vue.runtime.esm-browser.prod.js

// 项目中通过下面方式导入的是 vue.runtime.esm-bundler.js 版本
import { createApp } from 'vue'

官方文档上对于这部分解释在这里,对于不同版本的使用是这样区分的:

  • 通过 CDN 导入,使用 vue(.runtime).global(.prod).js 版本

  • 通过 ES6 模块导入,使用 vue(.runtime).esm-browser(.prod).js 版本

  • 通过构建工具进行模块化开发使用 vue(.runtime).esm-bundler.js 版本

  • 用于 Node.js 的服务器端渲染使用 vue.cjs(.prod).js 版本

完整版和运行时版本的区别

  • 运行时版本(runtime):名字中带有 runtime 的是运行时版本,不包含编译器,所有模板都要预先编译。通过构建工具使用的 Vue 版本是 vue.runtime.esm-bundler.js 也是默认版本。
  • 完整版:名字中不带 runtime 的是完整版本,多导出 compileToFunction 函数,即 compile 方法。能在打包编译后,还能识别模板代码,拥有编译模板的能力,支持在运行时编译模板,体积会更大些查看包体积 (517B vs 2.01KB)

4.2、vue 编译包

vue3 模板编译 vue 的模板编译核心是通过下面三个包来处理的:

  • @vue/compiler-core:是 Vue.js 编译器的核心包,与平台无关,提供了编译器的核心逻辑和 AST 抽象语法树的数据结构。

  • @vue/compiler-dom:该包提供了特定于浏览器的编译器,将 AST 抽象语法树转换为浏览器可执行的代码,以生成 DOM 元素。相对于 @vue/compiler-core,它包含浏览器特定的逻辑和属性处理,例如事件处理和样式绑定等。

  • @vue/compiler-sfc:该包是用于处理单文件组件(SFC)的编译器。它依赖于 @vue/compiler-core 和 @vue/compiler-dom,可以将 SFC 中的模板、脚本和样式分别编译成渲染函数、模块和 CSS 代码。此外,它还提供了自定义块处理和 HMR(热重载)支持等功能。

4.3、单文件组件编译

vue3 模板编译

从 vue 官方文档 中知道,Vue 组件挂载时会发生如下几件事:

  1. 编译:Vue 模板被编译为渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
  2. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
  3. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

这里我们要讲的则是第一步,编译:将 vue 模板编译为渲染函数

在 vue 项目中,通过 vite 构建时会使用 @vitejs/plugin-vue 插件去编译 SFC 模板文件,而这个插件实际是引用 @vue/compiler-sfc 库去编译 vue sfc 文件的。

@vitejs/plugin-vue 解析过程如下:

vue3 模板编译

// compiler-sfc 解析过程
function transformVueSFC(source, filename) {
    // 解析 sfc 文件,构建为 descriptor 对象
  const { descriptor, errors } = compiler.parse(source, {filename});

  // 编译 script, 返回的 script.content 即为 script 模块代码
  const script = compiler.compileScript(descriptor, {id, templateOptions, sourceMap:true});

  // 编译 template, 返回的template.code 即为 render 渲染函数
    // 此方法调用的实际是 @vue/compiler-dom 的 compile 方法,与 vue.compile 效果一致 
  const template = compiler.compileTemplate({...templateOptions, sourceMap: true});

  // 编译 style,返回的 style.code 即为 css 代码
    cont style = compiler.compileStyle({
    source: descriptor.styles[0].content,
    scoped: true,
    id: 'data-v-123'
  });

举个例子:

<template>
  <div>{{ name }}</div>
  <input type="text" v-model="name">
</template>
<script lang='ts' setup>
    import { ref } from 'vue';
    const name = ref('jack');
</script>
<style lang="scss">
input {
  color: #333;
}
</style>

parse

这一步做的是解析,并没有对代码进行编译,把一个 Vue 文件,拆分成不同的块,然后分别对每个部分进行编译,其中最最终的 3 个部分如下:

  • template 块
  • script 和 scriptSetup 块
  • 多个 style 块
// 用来表示 .vue 文件中每个代码块的对象
export interface SFCDescriptor {
  // 模板
  template: SFCTemplateBlock | null;

  // 脚本
  script: SFCScriptBlock | null

  // 使用<script setup> 定义的脚本
  scriptSetup: SFCScriptBlock | null

  // 样式
  styles: SFCStyleBlock[]
}

下图即为调用 parse 后的结果,即 SFCDescriptior 结构:

vue3 模板编译

compileTemplate

目的是将 template 转换为 render 函数,实际上 compiler-sfc 就是调用 compiler-dom 的 compiler 方法来编译的。上面 demo 通过 compileTemplate 编译后内容如下:

    import { toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, vModelText as _vModelText, withDirectives as _withDirectives, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"\n\n
    export function render(_ctx, _cache) {\n  
    return (_openBlock(), _createElementBlock(_Fragment, null, [\n    _createElementVNode("div", null, _toDisplayString(_ctx.name), 1 /* TEXT */),\n    _withDirectives(_createElementVNode("input", {\n      type: "text",\n      "onUpdate:modelValue": _cache[0] || (_cache[0] = $event => ((_ctx.name) = $event))\n    }, null, 512 /* NEED_PATCH */), [\n      [_vModelText, _ctx.name]\n    ])\n  ], 64 /* STABLE_FRAGMENT */))\n}

compileScript

这个步骤实际上就是把 script 代码编译成可以让 vue runtime 执行的 js 代码。其中 script setup  代码不能直接运行,需要进行转换,最后通过 babel 来解析的。上面 demo 通过 compileScript 编译后得到的 content 内容如下:

    import { defineComponent as _defineComponent } from 'vue'
    \n
    import { ref } from 'vue';
    \n\n
    export default /*#__PURE__*/_defineComponent({\n  
    	__name: 'demo1',\n  
    	setup(__props, { expose: __expose }) {\n  
        __expose();\n\n
        const name = ref('jack');\n\n
        const __returned__ = { name }\n
        Object.defineProperty(__returned__, '__isScriptSetup', { 
            enumerable: false, value: true 
        })\n
        return __returned__
        \n
      }
    	\n\n
    })

compileStyle

编译 style,编译后产物还是style,不是 js,目的是编译 vue 的特殊能力,如 style scope, v-bind, :deep等。上面 demo 通过 compileStyle 编译后得到的 code 内容:

    \n
    input[data-v-123] {\n  color: #333;\n}\n

组合

最后 @vitejs/plugin-vue 将 sfc 编译后的 template code、script code 和 style code ,合并成新的文件内容输出。

// vite-plugin-vue/packages/plugin-vue/src/main.ts 
function transformMain() {
  // 利用 compiler-sfc parse 方法将sfc 文件解析为 SFCDesciptor
  const { descriptor, errors } = createDescriptor(filename, code, options)

  // 利用 compileScript 编译 script 代码,生成code
  const { code: scriptCode, map: scriptMap } = await genScriptCode(
    descriptor,
    options,
  )

    // 利用 compileTemplate 编译 script 代码,生成 code
    const  { code: templateCode, map: templateMap } = await genTemplateCode(
      descriptor,
      options,
    ))

    // 利用 compileStyle 编译 script 代码,生成 code
  const stylesCode = await genStyleCode(
    descriptor,
    pluginContext,
  )

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

   let resolvedCode = output.join('\n')
   return {
    code: resolvedCode,
  }
}

4.4 、模板编译原理

上面讲述了整个单文件组件编译的大致流程,了解到 compiler-sfc 其实是依赖于 @vue/compiler-dom 去处理文件中的 template 模块,接下来主要介绍 compile template 的详细过程,compile script 和 style 具体细节不在本次分享范围内。

vue3 模板编译

Vue 的编译分为三个阶段,分别是:parse、transform、generate。

parse 阶段将模板字符串解析为 AST。transform 阶段则是对 AST 进行了一些转换处理。generate 阶段根据 AST 生成对应的 render 函数字符串。精简源码如下:

// compiler-dom 入口
function compile(template) {
    // 将 template 解析为 AST
  const ast = parse(template, options);

  // 对 AST 做一些处理
    transform(ast, options);

  // 通过 AST 生成代码字符串,并最终生成 render 函数字符串
  const { ast, code } = generate(ast, options);
  return { ast, code };
}

parse 解析器

解析器主要就是将 模板字符串 转换成 AST

模板字符串

    <div name="test">
      文本节点
      <p>{{ msg }}</p>
    </div>

AST

AST是抽象语法树 和 Vnode 类似,都是使用JavaScript对象来描述节点的树状表现形式。

// AST
export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 类型
  ns: Namespace // 命名空间 默认为 HTML,即 0
  tag: string // 标签名
  tagType: ElementTypes // 元素类型,ELEMENT, COMPONENT, SLOT, TEMPLATE
  isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
  children: TemplateChildNode[] // 子节点
}

截取规则

vue3 模板编译

如何将模板字符串解析为 AST 节点的呢?

通过对字符串截取匹配一步步生成标签,文本,或属性节点的。在解析模板字符串时,截取规则可分为两种情况:以 < 开头的字符串和不以 < 开头的字符串。大致伪代码如下:

while(s.length) {
  if (startsWith(s, '{{')) {
      // 如果以 '{{' 开头
      node = parseInterpolation(context, mode)
  } else if (s[0] === '<') {
      // 以 < 标签开头
      if (s[1] === '!') {
          if (startsWith(s, '<!--')) {
              // 注释
              node = parseComment(context)
          } else if (startsWith(s, '<!DOCTYPE')) {
              // 文档声明,当成注释处理
              node = parseBogusComment(context)
          }
      } else if (s[1] === '/') {
          // 结束标签
          parseTag(context, TagType.End, parent)
      } else if (/[a-z]/i.test(s[1])) {
          // 开始标签
          node = parseElement(context, ancestors)
      }
  } else {
      // 普通文本节点
      node = parseText(context, mode)
  }
}

每解析完一个标签、文本、注释等节点时,就会生成对应的 AST 节点,并且把已经解析完的字符串截断。对字符串进行截断使用的是 advanceBy(context, numberOfCharacters) 函数,context 是字符串的上下文对象,numberOfCharacters 是要截断的字符长度。

举个例子,来模拟每个阶段操作:

<div name="test">
  文本节点
  <p>{{ msg }}</p>
</div>

首先解析 <div , 然后执行 advanceBy(context, 4) 进行截断(内部执行的是 s = s.slice(4) ),源字符串就会变成:

 name="test">
  文本节点
  <p>{{ msg }}</p>
</div>

接下来,会解析属性 parseAttribute ,属性的匹配是通过 /^[^\t\r\n\f />][^\t\r\n\f />=]*/ 空格,换行或分割符来匹配长度的,匹配完并截断。注意这里的属性可以是普通 html 属性,也可以是 vue 的指令 v-xxx ,不过他们的解析过程一致,唯一不同的是,返回的 AST结构中的节点类型不同。解析完属性,字符串就变成:

    文本节点
    <p>{{ msg }}</p>
</div>

注释文本和普通文本节点解析规则都很简单,直接截断,生成节点。注释文本调用parseComment() 函数处理,文本节点调用 parseText() 处理。文本节点截断位置判断是先找到下一个 < 字符字符位置,若没有就找 }} 字符位置,若都没有则认为直到字符串的结束都是文本节点。变成:

    <p>{{ msg }}</p>
    </div>

同理继续截断:

    >{{ msg }}</p>
    </div>

此时<p>标签没有属性,直接就是标签结束,并且不是自闭合标签,此时直接执行截断操作  advanceBy(context, 1),变成:

{{ msg }}</p>
</div>

此时遇到双花括号 {{ msg }},会调用 parseInterpolation 解析插值,具体步骤如下:

  1. 先将括号内的内容提取出来,即  msg ,再对它进行 trim() 处理,去除前后空格。
  2. 然后会生成两个节点,一个节点是 INTERPOLATION,表示是双花插值。
  3. 第二个节点是第一个节点的内容,即 msg会生成一个SIMPLE_EXPRESSION 节点,表示是一个表达式。

最后,上面的内容变成:

    </p>
    </div>

...

    <!-- 所有字符串已经解析完 -->

每一个标签,元素,属性解析完后,都会生成一个对应的 AST 结构,AST的节点类型值如下:

// AST 节点类型
export const enum NodeTypes {
    ROOT, // 根节点 0
  ELEMENT, // 元素节点 1
  TEXT, // 文本节点 2
  COMMENT, // 注释节点 3
  SIMPLE_EXPRESSION, // 表达式 4
  INTERPOLATION, // 插值 {{ }} 5
  ATTRIBUTE, // 属性 6
  DIRECTIVE, // 指令 7
}
// AST
export interface BaseElementNode extends Node {
  type: NodeTypes.ELEMENT // 节点类型
  ns: Namespace // 命名空间 默认为 HTML,即 0
  tag: string // 标签名
  tagType: ElementTypes // 元素类型,ELEMENT 0, COMPONENT 1, SLOT 2, TEMPLATE 3 用来表示是哪种节点
  isSelfClosing: boolean // 是否是自闭合标签 例如 <br/> <hr/>
  props: Array<AttributeNode | DirectiveNode> // props 属性,包含 HTML 属性和指令
  children: TemplateChildNode[] // 子节点
}

上面 demo 中的完整 AST 结构如下:

{"type":0,"children":[{"type":1,"ns":0,"tag":"div","tagType":0,"props":[{"type":6,"name":"name","value":{"type":2,"content":"test","loc":{"start":{"column":11,"line":2,"offset":11},"end":{"column":17,"line":2,"offset":17},"source":""test""}},"loc":{"start":{"column":6,"line":2,"offset":6},"end":{"column":17,"line":2,"offset":17},"source":"name="test""}}],"isSelfClosing":false,"children":[{"type":2,"content":" 文本节点 ","loc":{"start":{"column":18,"line":2,"offset":18},"end":{"column":3,"line":4,"offset":28},"source":"\n  文本节点\n  "}},{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":5,"content":{"type":4,"isStatic":false,"constType":0,"content":"msg","loc":{"start":{"column":9,"line":4,"offset":34},"end":{"column":12,"line":4,"offset":37},"source":"msg"}},"loc":{"start":{"column":6,"line":4,"offset":31},"end":{"column":15,"line":4,"offset":40},"source":"{{ msg }}"}}],"loc":{"start":{"column":3,"line":4,"offset":28},"end":{"column":19,"line":4,"offset":44},"source":"<p>{{ msg }}</p>"}}],"loc":{"start":{"column":1,"line":2,"offset":1},"end":{"column":7,"line":5,"offset":51},"source":"<div name="test">\n  文本节点\n  <p>{{ msg }}</p>\n</div>"}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":1,"line":6,"offset":52},"source":"\n<div name="test">\n  文本节点\n  <p>{{ msg }}</p>\n</div>\n"}}

transform

// transform
export function transform(root: RootNode, options: TransformOptions) {
  const context = createTransformContext(root, options)

  // 遍历 AST 节点
  traverseNode(root, context);

  if (options.hoistStatic) {
    // 静态节点提升
    hoistStatic(root, context)
  }

  // 生成 codegenNode
  createRootCodegen(root, context)

}

在 transform 阶段,核心的逻辑就是识别一个个的 Vue 的语法,并且进行编译器的优化,我们经常提到的静态标记就是这一步完成的,最后生成一个带 codegenNode 字段的 AST。

  • codegenNode:是生成代码阶段需要用到的数据,也是一个节点。只不过节点 type 类型与 parse 阶段的不同,此时的类型对应着生成代码时需要调用的 vue 方法,如 openBlock 或 createElementBlock 等,具体结构如下:
// codegenNode
export interface VNodeCall extends Node {
  type: NodeTypes.VNODE_CALL; // 创建该节点时需要调用的 vue 方法,如 OPEN_BLOCK
  tag: string; // 标签名称
  props: PropsExpression | undefined; // 属性
  children // 子节点
  patchFlag: string | undefined
  dynamicProps: string | SimpleExpressionNode | undefined; // 动态属性
  directives: DirectiveArguments | undefined; // 指令
  isBlock: boolean; // 是否为外层块元素,需要
  isComponent: boolean; // 是否为组件
}
  • hoistStatic: hoistStatic 是一个标识符,表示要不要开启静态节点提升。如果值为 true,静态节点将被提升到 render()  函数外面生成,并被命名为 _hoisted_x 变量,这些静态节点都存储在 hoists 中,无论静态节点多深,都会被提升到 hoists 中。

<!-- hoistStatic=true-->
<div>
  <div>foo</div> <!-- hoisted -->
</div>

import { createElementVNode as _createElementVNode, createCommentVNode as _createCommentVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

// 静态节点被提升到外部,变成一个变量
const _hoisted_1 = /*#__PURE__*/_createElementVNode("div", null, "foo", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _hoisted_1,
    _createCommentVNode(" hoisted ")
  ]))
}
<!-- hoistStatic=false-->
<div>
  <div>foo</div>
</div>

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", null, [
    _createElementVNode("div", null, "foo"),
    _createCommentVNode(" hoisted ")
  ]))
}
  • helpers:存储的是创建 VNode 的函数名称(其实是 Symbol)。

// runtimeHelpers.ts
export const helperNameMap: Record<symbol, string> = {
  [FRAGMENT]: `Fragment`,
  [TELEPORT]: `Teleport`,
  [SUSPENSE]: `Suspense`,
  [KEEP_ALIVE]: `KeepAlive`,
  [BASE_TRANSITION]: `BaseTransition`,
  [OPEN_BLOCK]: `openBlock`,
  [CREATE_BLOCK]: `createBlock`,
  [CREATE_ELEMENT_BLOCK]: `createElementBlock`,
  [CREATE_VNODE]: `createVNode`,
  [CREATE_ELEMENT_VNODE]: `createElementVNode`,
  [CREATE_COMMENT]: `createCommentVNode`,
  [CREATE_TEXT]: `createTextVNode`,
  [CREATE_STATIC]: `createStaticVNode`,
  [RESOLVE_COMPONENT]: `resolveComponent`,
}
  • PatchFlags:transform 在对 AST 节点进行转换时,会打上 patchflag 参数,这个参数主要用于 diff 比较过程。当 DOM 节点有这个标志并且大于 0,就代表要更新,没有就跳过。这是 vue3 与 vue2 模版编译不同的地方。 patchFlags 类型如下:
// packages/shared/src/patchFlags.ts
export const enum PatchFlags {
    // 动态文本节点
  TEXT = 1,

    // 动态 class
  CLASS = 1 << 1, // 2

    // 动态 style
  STYLE = 1 << 2, // 4

    // 动态属性,但不包含类名和样式
  // 如果是组件,则可以包含类名和样式
  PROPS = 1 << 3, // 8

  ...

     // 静态节点
  HOISTED = -1,

  // 指示在 diff 过程应该要退出优化模式
  BAIL = -2
}

举个例子,来看看经过 tranform 后的结果与 parse 后有何不同:

<p>test</p>

parse 后的 ast 结构:

{"type":0,"children":[{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}}],"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}

transform 后的 ast 结构:

{"type":0,"children":[{"type":1,"ns":0,"tag":"p","tagType":0,"props":[],"isSelfClosing":false,"children":[{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}}],"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"},"codegenNode":{"type":13,"tag":""p"","children":{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}},"isBlock":true,"disableTracking":false,"isComponent":false,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}}}],"helpers":{},"components":[],"directives":[],"hoists":[],"imports":[],"cached":0,"temps":0,"codegenNode":{"type":13,"tag":""p"","children":{"type":2,"content":"test","loc":{"start":{"column":4,"line":1,"offset":3},"end":{"column":8,"line":1,"offset":7},"source":"test"}},"isBlock":true,"disableTracking":false,"isComponent":false,"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"}},"loc":{"start":{"column":1,"line":1,"offset":0},"end":{"column":12,"line":1,"offset":11},"source":"<p>test</p>"},"filters":[]}

vue3 模板编译

generate 代码生成器

代码生成器的作用是通过 AST 语法树生成代码字符串,代码字符串被包装进渲染函数,执行渲染函数后,可以得到一份 vnode。

// generate 伪代码
export function generate(
  ast: RootNode,
  options: CodegenOptions & {
    onContextCreated?: (context: CodegenContext) => void
  } = {}
): CodegenResult {

  // 用来存放最终生成的 code 字符串
    const context = { code: '' };

  // 定义 render 函数,以及函数入参
  const functionName = `render`
  const args = ['_ctx', '_cache']
  const signature = args.join(', ')

  // 将内容填充到 context.code 中
  push(`function ${functionName}(${signature}) {`)

  push(`return `)

  // 根据 tranform 阶段生成的 codegenNode 生成对应的 code 代码
  if (ast.codegenNode) {
    genNode(ast.codegenNode, context)
  }

  push(`}`)

  return {
    ast,
    code: context,
  }
}

还是上面的例子:

<p>test</p>

经过 transform 得到的 codegenNode 如下:

{
  "codegenNode": {
      "type": 13,
      "tag": ""p"",
      "children": {
          "type": 2,
          "content": "test",
      },
      "isBlock": true,
      "disableTracking": false,
      "isComponent": false,
  }
}

通过 generate生成的code 如下:

import { openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("p", null, "Test"))
}

generate 拼接代码字符串时,遍历 transform 后的 ast ,根据每个节点 type 找到对应的创建虚拟 DOM 的函数名称,再根据 codegenNode 中的 tag, content 等填充对应函数的入参。

上面 demo 中的 type = 13,同时这里的 isBlock 为 true, isComponent 为 false,所以会同时生成调用 _openBlock()createElementBlock () 代码字符串。

最后在 vue 项目中导出的是 compileToFunction 函数,将 generate 生成的 code 字符串通过 new Function 方式转换为 render 函数:

// packages/vue/src/index.ts
function compileToFunction(
  template: string | HTMLElement,
  options?: CompilerOptions
): RenderFunction {

  // 调用 compile-dom 的 compile 方法,返回 render 函数代码字符串
  const { code } = compile(template, opts)


  // 通过 new Function 将代码字符串转换为真正的 render 函数
  const render = (
    __GLOBAL__ ? new Function(code)() : new Function('Vue', code)(runtimeDom)
  ) as RenderFunction


  return render;
}

五、总结

前面我们大致了解了 vue 模板编译的过程,知道了 ASTtranformparse,你肯定好奇,了解了这些编译原理,到底应该怎么应用到项目中呢?

编译的核心本质就是实现代码之间的转换,譬如 babel 把代码中的 ES6 语法转换浏览器支持的语法,如 vite 插件,在每一个 vue 组件中,我们都需要手动的去 import { ref, computed } from 'vue'; 这个过程是不是很繁琐,这里就可以通过编译的思想来解决,目前已有  auto-import 实现了,具体的原理大家可以查看源码,Eslint, preitter 检测等等。

六、参考资料

转载自:https://juejin.cn/post/7238541527058939959
评论
请登录