Skip to content

第七章:runtime 运行时 - 运行时核心设计原则

01:前言

从本章开始我们将要进入到 渲染系统 的学习,也就是 运行时 runtime

在之前我们明确过什么是 运行时,看下面的代码(第二章运行时使用过该代码):

html
<body>
  <div id="app"></div>
</body>
<script>
  const { render, h } = Vue
  // 生成 VNode
  const vnode = h('div', {
    class: 'test'
  }, 'hello render')

  // 承载的容器
  const container = document.querySelector('#app')

  // 渲染函数
  render(vnode, container)
</script>

以上代码代表了一个基本的 运行时。即:VNode 渲染到页面中。所以大家可以简单的把运行时理解为 渲染系统

根据以上代码我们可以知道,整个 runtime,包含了两个主要的环节:

  1. h 函数:生成 VNode
  2. render 函数:渲染 VNode

这也是 正式讲解 runtime 的时候主要要去讲解的两大块内容。

但是在正式讲解 runtime 之前,我们还需要了解一些 runtime 的基本概念和设计原则,比如:VNode 是什么?它在干嘛?为什么需要它?里面传递的参数又是干什么的?为什么要传递它们?等等…

而这些就是我们本章所需要讲解的主要内容。

02:HTML DOM 节点树与虚拟 DOM 树

首先我们先来学习两个运行时的基础概念:

  1. HTML DOM 节点树
  2. 虚拟 DOM

我们来看下面这段 HTML

html
<div>
  <h1>hello h1</h1>
  <!-- TODO: comment -->
  hello div
</div>

当浏览器看到这一段 html 时,它会生成一个对应的 DOM 树 来进行表示:

image-20230812213151047

以上我们通过 节点(Node 来描述了以上所有的元素,在 HTML 中所有的元素都是一个节点,注释、文本都属于节点的一部分。

这样的通过节点构成的一个树形结构,我们就把它叫做 HTML DOM 节点树

那么明确好了什么叫做节点树之后,什么是 虚拟 DOM 树呢?

可能有很多同学听说过 虚拟 DOM 的概念,虚拟 DOM 和 虚拟 DOM 是息息相关的。

# 来自 vue 官方文档

虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。这个概念是由 React 率先开拓,随后在许多不同的框架中都有不同的实现,当然也包括 Vue。

虚拟 DOM 是一种理念,比如,我期望通过一个 JavaScript 对象 来描述一个 div 节点它的子节点是一个文本节点 text,则可以这么写:

js
// <div>text</div>

// 通过 虚拟 dom 表示
const vnode = {
	type: 'div',
	children: 'text'
}

在上面这个对象中,我们通过 type 来表示当前为一个 div 节点,通过 children 来表示它的子节点,通过 text 表示子节点是一个 文本节点,内容是 text

这里所设计到的 vnode,是一个 JavaScript 对象,我们通常使用它来表示 一个虚拟节点(或虚拟节点树)。它里面的属性名不是固定的,比如我可以使用 type 表示这是一个 div,也可以使用 tag 进行表示,都是可以的。

vue 的源码中,通过使用它来表示所需要创建元素的所有信息,比如:

html
<div>
  <h1>hello h1</h1>
  <!-- TODO: comment -->
  hello div
</div>

该例子如果使用 vnode 进行表示:

js
const vnode = {
	type: 'div',
	children: [
    {
      type: 'h1',
      children: 'hello h1'
    },
    {
      type: Comment,
      children: 'TODO: comment'
    },
    'hello div'
  ]
}

在运行时 runtime ,渲染器 renderer 会遍历整个虚拟 DOM 树,并据此构建真实的 DOM 树,这个过程我们可以把它叫做 挂载 mount

当这个 VNode 对象发生变化时,那么我们会对比 旧的 VNode新的 VNode 之间的区别,找出它们之间的区别,并应用这其中的变化到真实的 DOM 上。这个过程被称为更新 patch

那么这样的一个 挂载 和 更新的过程,具体是怎么做的呢?

下一小节会对此进行详细的介绍~~~

03:挂载与更新

这一小节,我们将通过一个极简的案例,来了解两个比较重要的概念:

  1. 挂载:mount
  2. 更新:patch

挂载:mount

首先我们先来构建这个案例(该案例在 第二章第七小节 《运行时》进行过大致的讲解):

js
<script>
  const VNode = {
    type: 'div',
    children: 'hello render'
  }

  // 创建 render 渲染函数
  function render(oldVNode, newVNode, container) {
    if (!oldVNode) {
      mount(newVNode, container)
    }
  }

  // 挂载函数
  function mount(vnode, container) {
    // 根据 type 生成 element
    const ele = document.createElement(vnode.type)
    // 把 children 赋值给 ele 的 innerText
    ele.innerText = vnode.children
    // 把 ele 作为子节点插入 body 中
    container.appendChild(ele)
  }

  render(null, VNode, document.querySelector('#app'))
</script>

在当前案例中,我们首先创建了一个 render 渲染函数,该函数接收三个参数:

  1. oldVNode:旧的 VNode
  2. newVNode:新的 VNode
  3. container:容器

oldVNode 不存在时,那么我们就认为这是一个全新的渲染,也就是 挂载

所以以上的 mount 方法,我们就可以把它称为是一个 挂载方法

更新:patch

旧的视图不可能被一直展示,它会在未来某一个时刻被更新为全新的视图。

比如:

js
<script>
  const VNode = {
    type: 'div',
    children: 'hello render'
  }

  const VNode2 = {
    type: 'div',
    children: 'patch render'
  }

  // 创建 render 渲染函数
  function render(oldVNode, newVNode, container) {
    if (!oldVNode) {
      mount(newVNode, container)
    } else {
      patch(oldVNode, newVNode, container)
    }
  }

  // 挂载函数
  function mount(vnode, container) {
    // 根据 type 生成 element
    const ele = document.createElement(vnode.type)
    // 把 children 赋值给 ele 的 innerText
    ele.innerText = vnode.children
    // 把 ele 作为子节点插入 body 中
    container.appendChild(ele)
  }

  // 取消挂载
  function unmount(container) {
    container.innerHTML = ''
  }

  // 更新函数
  function patch(oldVNode, newVNode, container) {
    unmount(container)

    // 根据 type 生成 element
    const ele = document.createElement(newVNode.type)
    // 把 children 赋值给 ele 的 innerText
    ele.innerText = newVNode.children
    // 把 ele 作为子节点插入 body 中
    container.appendChild(ele)
  }

  render(null, VNode, document.querySelector('#app'))

  setTimeout(() => {
    render(VNode, VNode2, document.querySelector('#app'))
  }, 2000);
</script>

我们在原有的代码中去新增了一部分逻辑,新增了 patch 函数。

patch 函数中,我们先 删除了旧的 VNode ,然后创建了一个新的 VNode 这样的一个流程,我们就把它叫做 挂载 patch

总结

本小节我们通过一个简单的例子讲解了 挂载 mount更新 patch 的概念,这两个概念 Vue 3 官方文档 也对此进行了详细的介绍:

  1. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。
  2. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

这两个概念在我们后面去实现 renderer 渲染器的时候还会经常的使用到。

04:h 函数 与 render 函数

不知道大家还记不记得,我们在 第二章 讲解 运行时 的概念时,曾经使用过这样的一个案例:

html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Document</title>
  <script src="https://unpkg.com/vue@3.2.36/dist/vue.global.js"></script>
</head>

<body>
  <div id="app"></div>
</body>

<script>
  const { render, h } = Vue
  // 生成 VNode
  const vnode = h('div', {
    class: 'test'
  }, 'hello render')

  // 承载的容器
  const container = document.querySelector('#app')

  // 渲染函数
  render(vnode, container)
</script>

</html>

当时,我们说:“有些同学可能看不懂当前代码是什么意思,没有关系,这不重要,后面我们会详细去讲”,那么现在就是讲解这个的时候了。

根据前两个小节的介绍其实我们已经知道了,vue 的渲染分为:挂载和更新。两个步骤。

而无论是挂载还是更新,都是借助 VNode 来进行实现的。

在以上代码中,我们知道主要涉及到了两个函数:

  1. h 函数
  2. render 函数

那么下面我们一个一个来去说

h 函数

根据以上代码可知,我们可以通过 h 函数 得到一个 vnode

js
 const vnode = h('div', {
    class: 'test'
  }, 'hello render')

打印当前的 vnode,可以得到一下内容:

json
{
	"__v_isVNode": true,
	"__v_skip": true,
	"type": "div",
	"props": { "class": "test" },
	"key": null,
	"ref": null,
	"scopeId": null,
	"slotScopeIds": null,
	"children": "hello render",
	"component": null,
	"suspense": null,
	"ssContent": null,
	"ssFallback": null,
	"dirs": null,
	"transition": null,
	"el": null,
	"anchor": null,
	"target": null,
	"targetAnchor": null,
	"staticCount": 0,
	"shapeFlag": 9,
	"patchFlag": 0,
	"dynamicProps": null,
	"dynamicChildren": null,
	"appContext": null
}

以上内容,我们剔除掉无用的内容之后,得到一个精简的 json

json
{
  // 是否是一个 VNode 对象
	"__v_isVNode": true,
  // 当前节点类型
	"type": "div",
  // 当前节点的属性
	"props": { "class": "test" }
  // 它的子节点
	"children": "hello render"
}

那么由此可知 h 函数本质上其实就是一个:用来生成 VNode 的函数

h 函数 最多可以接收三个参数:

  1. type: string | Component: 既可以是一个字符串 (用于原生元素) 也可以是一个 Vue 组件定义。
  2. props?: object | null: 要传递的 prop
  3. children?: Children | Slot | Slots:子节点。

官方示例描述了它的详细使用方式,这个就不写在文档中了。

render 函数

那么在了解了 h 函数的作用之后,下面我们来看 render 函数。

js
 render(vnode, container)

从以上代码中我们可知,render 函数主要接收了两个参数:

  1. vnode:虚拟节点树 或者叫做 虚拟 DOM 树,两种叫法皆可
  2. container:承载的容器。真实节点渲染的位置。

通过 render 函数,我们可以:使用编程式地方式,创建虚拟 DOM 树对应的真实 DOM 树,到指定位置。

总结

这小节,我们知道了 h 函数和 render 函数的作用,这两个函数是整个运行时的关键函数,后面我们实现运行时的代码,核心就是实现这两个函数。

05:运行时核心设计原则

那么到这里为止,我们已经了解了在学习 运行时之前的需要掌握的前置知识。

那么在本小节中,我们就需要来看一下 vue 3 运行时的一些设计原则,这样可以帮助我们更好地整体了解 runtime

需要大家在本小节中了解的设计原则分为两个:

  1. runtime-coreruntime-dom 的关系,为什么要这么设计
  2. 渲染时,挂载和更新的逻辑处理

runtime-coreruntime-dom 的关系,为什么要这么设计

vue 源码中,关于运行时的包主要有两个:

  1. packages/runtime-core:运行时的核心代码
  2. packages/runtime-dom:运行时关于浏览器渲染的代码

其中第一个 runtime-core 的概念比较好理解,但是 runtime-dom 它是干什么的呢?为什么要单独分出来这样的一个包呢?

我们来看一下 runtime-dom 的代码,查看 packages/runtime-dom/src/nodeOps.ts 中的代码。

nodeOps.ts 中的代码可知,该处主要为 浏览器的 web api

我们知道 vue 的渲染主要分为两种:

  1. SPA: 单页应用。即:浏览器显然
  2. SSR:服务端渲染

即:Vue 中需要处理两种不同 宿主环境 ,将来还有可能会处理更多,比如 windows 、android、ios应用程序 等等。 在这些不同的宿主环境中,渲染 DOM 的方式是 完全不同 的。

所以 vue 就对运行时进行了处理,把所有的 浏览器 dom 操作,放到了 runtime-dom 中,而把整个运行时的 核心代码 都放入到了 runtime-core 之中。

通过 参数 的形式(详见 packages/runtime-core/src/renderer.ts336 行 ),把 DOM 操作传递给了 renderer 渲染器 ,已达到 不同的宿主环境,可以使用不同的 API 的目的

渲染时,挂载和更新的逻辑处理

packages/runtime-core/src/renderer.ts 中的 baseCreateRenderer 是整个渲染的核心函数,我们可以利用该函数得到一个 渲染器 renderer 对象

renderer 中包含了一个函数 render 叫做 渲染函数,我们在前面使用的 render 函数就是它。

它接收三个参数 vnode, container, isSVG,我们这里不去处理 isSVG 这种 边缘情况。所以就把它当做只有前两个参数即可。

通过代码我们可以发现,当 vnode 虚拟节点存在时,会触发 patch 函数,即 挂载函数

patch 中,根据 type 的类型进行了 switch 操作,这里的 type 代表的就是 节点类型,是 dom 节点 还是 文本节点

根据 switch 的结果,可以触发不同的方法。我们可以随便查看几个方法,可以发现这些方法中的代码处理逻辑都差不多:

js
if (n1 == null) {
    // 挂载
  } else {
    // 更新
  }

所以说由此我们可以得到一个 render 的大致逻辑:

  1. renderer 渲染器对象提供 render 渲染函数
  2. render 渲染函数在 vnode 存在时,触发 patch
  3. patch 中根据 type 的类型,渲染不同的节点
  4. 节点的渲染分为 挂载更新

明确好了以上逻辑之后,那么下面我们去实现 runtime 时,将会更加轻松。

06:总结

在本章节中,我们掌握了开发 runtime 之前的一些必备知识。

通过本章的内容,我们可知整个 runtime 核心的方法有两个:

  1. h 函数
  2. render 函数

那么我们在后面去实现 runtime 时,也会以这两个函数为核心进行实现。

即:先实现 h 函数,再实现 render 函数

以此来完成整个运行时 runtime 的处理。

那么准备好了吗?

Released under the MIT License.