渲染函数也称为**render**函数,作用就是把虚拟 DOM 转换为真实 DOM 的渲染行为。

编译与渲染过程

那么 Vue 是怎么把组件中的<template>一步一步转换为真实 DOM 的呢?
例如下面这段代码:

  1. <template>
  2. <div id="box">
  3. <div class="article-box">
  4. <h1 class="title">{{ title }}</h1>
  5. <p>
  6. <span>{{ author }}</span> - <span>{{ dateTime }}</span>
  7. </p>
  8. <p class="content">{{ content }}</p>
  9. </div>
  10. </div>
  11. </template>

1、引入组件
⬇️ ⬇️ ⬇️
2、提取<template>模版转换为字符串
⬇️ ⬇️ ⬇️
3、编译模版字符串为 AST 树
组件模版中不只有 HTML 能解析的东西也有不认识的东西,例如 v-if、插值表达式等等,浏览器无法解析这些属性,这就需要一种方式把这些浏览器不认识的属性「优化掉」,所以就需要把模版字符串转换为 AST 树。
AST 树是一种自定义的语法树,目的就是把浏览器不认识的东西都解析优化掉,转换为一种对应的 JS 逻辑且从字符串中移除掉,最后剩下一些浏览器能认识的东西。
⬇️ ⬇️ ⬇️
4、把 AST 树转换为虚拟 DOM 树
虚拟 DOM 就是对真实 DOM 的一种描述。
为什么需要虚拟 DOM ?
例如下面这段 DOM 结构:

  1. <span>123</span>

如果没有虚拟 DOM ,我们想要更改<span>的内容一般都使用:

  1. span.innerText = "123";

从逻辑角度来说,这句代码执行后 DOM 肯定会进行更新,但其实这是没有必要的!所以我们可以进行对比判断是否需要继续更新:

  1. span.innerText !== "123" && (span.innerText = "123");

但是这样需要频繁的去操作获取 DOM。
可以通过虚拟 DOM 的去描述这个 DOM :

  1. const vDOM = {
  2. tag: 'span',
  3. attrs: {},
  4. children: "123"
  5. }

当我们需要更改<span>的内容时,我们只需要去和之前的<span>内容进行对比,如果有变化再进行更新。
再比如这段 HTML:

  1. <span>
  2. <span>
  3. 123
  4. <span>234</span>
  5. </span>
  6. </span>

当我们去更改的时候:

  1. span.innerText = `
  2. <span>
  3. 123
  4. <span>345</span>
  5. </span>`;

但其实内容 123 并没有进行更改,如果不使用虚拟 DOM 就会把整个<span>都进行更新。而虚拟 DOM 会逐一的进行对比,只会去更新发生了变化的 DOM。 :::info 所以这就是虚拟 DOM 的好处,虚拟 DOM 是为了尽最大的能力去减少对 DOM 的操作。 ::: ⬇️ ⬇️ ⬇️
5、虚拟 DOM 树转换为真实的 DOM 树
⬇️ ⬇️ ⬇️
6、当数据发生变化时,虚拟 DOM 树会进行对比,然后打一个「补丁 patch」
DOM 会使用 Diff 算法把新旧 DOM 进行对比,然后形成 patch 对象,该 patch 对象记录了对比后差异,然后根据 patch 对象去更新相应的内容。
7、更新真实的 DOM

以上 7 步就是 Vue 组件的编译、渲染过程!!!

h 函数

在 Vue2 的时候想要创建一个 Vue 的应用实例的时候会传递一个render函数,render函数通过 h 函数把根组件进行解析。

  1. new Vue({
  2. render: (h) => h(App)
  3. }).$mount('#app')

文章的开头,我们就说了render函数就是把虚拟 DOM 渲染为真实的 DOM,那么 h 函数到底是干啥的???
**h()**是 hyperscript 的简称,意思是“能生成 HTML (超文本标记语言) 的 JavaScript”。这个名字来源于许多虚拟 DOM 实现默认形成的约定。一个更准确的名称应该是**createVnode()**,意思是创建虚拟节点。
但当你需要多次使用渲染函数时,一个简短的名字h()会更省力。 :::info 所以h()就是用来创建 vNode (Virtual Node) 虚拟节点的,多个虚拟节点组合就会形成虚拟 DOM 树,虚拟 DOM 就是对真实 DOM 的描述。 ::: 所以,当使用h()创建 DOM 的时候,直接省略了把模版字符串转换为 AST 再转换为虚拟 DOM 的过程,因为h()会直接返回一个虚拟 DOM!!!

创建 vNode

Vue 提供了一个h()函数用于创建 vnodes:

  1. import { h } from 'vue'
  2. const vnode = h(
  3. 'div', // type
  4. { id: 'foo', class: 'bar' }, // props
  5. "This is test content."
  6. )

image.png

h()函数的使用也可以更加灵活:

来源于 Vue 文档:https://cn.vuejs.org/guide/extras/render-function.html#creating-vnodes

  1. // 除了类型必填以外,其他的参数都是可选的
  2. h('div')
  3. h('div', { id: 'foo' })
  4. // attribute 和 property 都能在 prop 中书写
  5. // Vue 会自动将它们分配到正确的位置
  6. h('div', { class: 'bar', innerHTML: 'hello' })
  7. // 像 `.prop` 和 `.attr` 这样的的属性修饰符
  8. // 可以分别通过 `.` 和 `^` 前缀来添加
  9. h('div', { '.name': 'some-name', '^width': '100' })
  10. // 类与样式可以像在模板中一样
  11. // 用数组或对象的形式书写
  12. h('div', { class: [foo, { bar }], style: { color: 'red' } })
  13. // 事件监听器应以 onXxx 的形式书写
  14. h('div', { onClick: () => {} })
  15. // children 可以是一个字符串
  16. h('div', { id: 'foo' }, 'hello')
  17. // 没有 props 时可以省略不写
  18. h('div', 'hello')
  19. h('div', [h('span', 'hello')])
  20. // children 数组可以同时包含 vnodes 与字符串
  21. h('div', ['hello', h('span', 'hello')])

:::warning ⚠️ 注意
h()的子节点是一个元素节点的时候,必须再使用h()进行包裹!!! :::

  1. h('div', null, [
  2. h('span', null, 'hello'),
  3. h('span', null, 'world'),
  4. "This is text content."
  5. ])

得到的 vnode 为如下形式:

  1. const vnode = h('div', { id: 'foo' }, [])
  2. vnode.type // 'div'
  3. vnode.props // { id: 'foo' }
  4. vnode.children // []
  5. vnode.key // null

例如我想对开头那段 HTML 用h()进行表达:

  1. <template>
  2. <div id="box">
  3. <div class="article-box">
  4. <h1 class="title">{{ title }}</h1>
  5. <p>
  6. <span>{{ author }}</span> - <span>{{ dateTime }}</span>
  7. </p>
  8. <p class="content">{{ content }}</p>
  9. </div>
  10. </div>
  11. </template>
  1. <script>
  2. import { h } from "vue";
  3. export default{
  4. data() {
  5. return {
  6. title: "This is title.",
  7. author: "Xiechen",
  8. dateTime: "2023/05/23",
  9. content: "This is content."
  10. };
  11. },
  12. // 我们可以使用 render 选项来声明渲染函数
  13. // render() 函数可以访问同一个 this 组件实例
  14. render() {
  15. return h(
  16. "div",
  17. {
  18. id: "box"
  19. },
  20. h(
  21. "div",
  22. {
  23. class: "article-box"
  24. },
  25. [
  26. h(
  27. "h1",
  28. {
  29. class: "title"
  30. },
  31. this.title
  32. ),
  33. h("p", null, [
  34. this.author + "-",
  35. h("span", { class: "date-time" }, this.dateTime)
  36. ]),
  37. h("p", { class: "content" }, this.content)
  38. ]
  39. )
  40. );
  41. }
  42. }
  43. </script>

我们在<template>中写的任何东西都可以使用h()来表达,但是h()不是虚拟节点,而是开发层面对虚拟节点的一种描述,也就是对<template>的描述,h()返回的是一个虚拟节点对象,然后组成虚拟节点书,最后组成虚拟 DOM 树。 :::warning ⚠️ 注意
在单文件 SFC 中,如果 render 选项也同时存在于该组件中,template 将被忽略。
如果应用的根组件不含任何 template 或 render 选项,Vue 将会尝试使用所挂载元素的 innerHTML 来作为模板。 :::

h 函数使用的案例

来源于 Vue 文档 https://cn.vuejs.org/guide/extras/render-function.html#render-function-recipes

1、一般写法

  1. import { h } from "vue";
  2. const vNode = h("h1", null, "This is title");

h()没有 props/attrs,默认第二个参数为 children。

  1. import { h } from "vue";
  2. const vNode = h("h1", "This is title")
  3. const vNode2 = h("h1", [
  4. '"This is title"',
  5. h('span', null, "author")
  6. ])

但是更推荐在没有第二个参数的情况下,使用null或者{}进行占位,这样可以避免一定的混乱!

2、vNode 必须是唯一的
多个 children,不要使用同一个虚拟节点,这样会重复使用 vNode,虽然能渲染,但是更新的时候会存在问题。

  1. function render(){
  2. const vNode = h("li", null, "123");
  3. // 错误的行为!!!
  4. return h('div', null, [
  5. vNode,
  6. vNode
  7. ])
  8. }
  1. function render(){
  2. // 推荐这样操作
  3. return h('ul', null, Array.from({length:6}).map((el, index)=>{
  4. return h('li',null,index)
  5. }))
  6. }

3、当你用h()渲染一个全局组件的时候,但是又拿不到该组件的实例,你需要使用resolveComponent()来把组件名称进行解析。

  1. import { createApp } from "vue";
  2. const app = createApp(App);
  3. // 注册全局组件
  4. app.component("MyTest", {
  5. render: () => h("div", null, "This is MyTest content.")
  6. });
  7. app.mount("#app");

resolveComponent()接收一个组件名称:

  1. import { h, resolveComponent } from "vue";
  2. function render(){
  3. return h(resolveComponent("MyTest"));
  4. }

如果你能导入了 Vue 的组件,你可以直接把组件的实例传递给h(),这意味着使用渲染函数时不再需要注册组件了:

  1. import TestComp from "./components/TestComp.vue";
  2. function render(){
  3. return h(TestComp);
  4. }

4、动态组件
动态组件需要使用resolveDynamicComponent()进行解析。

  1. import { h, defineAsyncComponent, resolveDynamicComponent } from "vue";
  2. const Comp1 = defineAsyncComponent(() => import("./components/Comp1.vue"));
  3. const Comp2 = defineAsyncComponent(() => import("./components/Comp2.vue"));
  4. export default {
  5. data() {
  6. return {
  7. isOpen: true,
  8. currentComponentName: "Comp1"
  9. };
  10. },
  11. computed: {
  12. currentComponent() {
  13. return this.currentComponentName === "Comp1" ? Comp1 : Comp2;
  14. }
  15. },
  16. render(){
  17. const dComponent = resolveDynamicComponent(this.currentComponent);
  18. return h("div", null, [
  19. h(dComponent),
  20. h("button", {
  21. onClick: () => {
  22. this.currentComponentName = this.currentComponentName === "Comp1" ? "Comp2" : "Comp1"
  23. }
  24. }, "Switch Component")
  25. ]);
  26. }
  27. }

5、模拟 v-if 的操作
h()无法使用v-if指令,所以需要使用原生的 JS 去模拟

  1. function render(){
  2. if(true){
  3. return h('h1',null,"This is title.")
  4. }
  5. return h('h2',null,"This is article.")
  6. }

6、模拟 v-show 的操作

  1. function render(){
  2. return h(
  3. "h1",
  4. {
  5. style: {
  6. display: this.isOpen ? "none" : ""
  7. }
  8. },
  9. "This is title."
  10. );
  11. }

7、模拟 v-for 的操作

  1. function render(){
  2. return h(
  3. 'ul',
  4. this.items.map(({ id, text }) => {
  5. return h('li', { key: id }, text)
  6. })
  7. );
  8. }

8、模拟 v-on 和 v-model

  1. import { h } from "vue";
  2. import VModel from "./components/VModel.vue";
  3. function render(){
  4. return h(VModel, {
  5. username: "default username",
  6. password: "default password",
  7. "onUpdate:username": (value) => console.log(value),
  8. "onUpdate:password": (value) => console.log(value),
  9. onSubmit: (value) => console.log(value)
  10. });
  11. }

:::warning ⚠️ 注意
必须使用on开头且小驼峰的形式,来接收子组件emit出来的事件。 :::

  1. <script>
  2. import { h } from "vue";
  3. export default {
  4. name: "VModel",
  5. props: {
  6. username: String,
  7. password: String
  8. },
  9. emits: ["update:username", "update:password", "submit"],
  10. render() {
  11. return h("div", null, [
  12. h("input", {
  13. type: "text",
  14. value: this.username,
  15. placeholder: "Username",
  16. onInput: ($event) => this.$emit("update:username", $event.target.value)
  17. }),
  18. h("input", {
  19. type: "password",
  20. value: this.password,
  21. placeholder: "Password",
  22. onInput: ($event) => this.$emit("update:password", $event.target.value)
  23. }),
  24. h("button", {
  25. onClick: () => this.$emit("submit")
  26. },'Submit')
  27. ]);
  28. }
  29. };
  30. </script>

或者来个简易的版本:

  1. h(
  2. 'button',
  3. {
  4. onClick(event) {
  5. /* ... */
  6. }
  7. },
  8. 'click me'
  9. )

对于.passive.capture.once事件修饰符,可以使用驼峰写法将他们拼接在事件名后面:

  1. h('input', {
  2. onClickCapture() {
  3. /* 捕捉模式中的监听器 */
  4. },
  5. onKeyupOnce() {
  6. /* 只触发一次 */
  7. },
  8. onMouseoverOnceCapture() {
  9. /* 单次 + 捕捉 */
  10. }
  11. })

9、使用插槽

  1. <script>
  2. import { h } from "vue";
  3. export default {
  4. name: "VSlot",
  5. render() {
  6. return h("div", null, [
  7. // 通过 this.$slot().name 来获取插槽的内容
  8. h("h1", { class: "title" }, this.$slots.default()),
  9. h("p", { class: "author" }, this.$slots.author()),
  10. h(
  11. "p",
  12. { class: "content" },
  13. // 给插槽传递数据,也就是作用域插槽
  14. this.$slots.content({
  15. content: "This is content"
  16. })
  17. )
  18. ]);
  19. }
  20. };
  21. </script>
  1. import { h } from "vue";
  2. import VSlot from "./components/VSlot.vue";
  3. function render(){
  4. return h(VSlot, null, {
  5. default: () => "This is title",
  6. author: () => "Xiechen",
  7. content: (props) => h("p", null, props.content)
  8. });
  9. }

10、使用自定义指令
可以使用withDirectives()将自定义指令应用于 vnode:

  1. import { h, withDirectives } from 'vue'
  2. // 自定义指令
  3. const pin = {
  4. mounted() { /* ... */ },
  5. updated() { /* ... */ }
  6. }
  7. // <div v-pin:top.animate="200"></div>
  8. const vnode = withDirectives(
  9. h('div'),
  10. [
  11. [pin, 200, 'top', { animate: true }]
  12. ]
  13. )

当一个指令是以名称注册并且不能被直接导入时,可以使用resolveDirective()函数来解决这个问题。

  1. import { h, withDirectives, resolveDirective } from 'vue'
  2. // <div v-pin:top.animate="200"></div>
  3. const pin = resolveDirective('pin');
  4. const vnode = withDirectives(h('div'), [
  5. [pin, 200, 'top', { animate: true }]
  6. ])

更多用法请看:
渲染函数 & JSX | Vue.js