第七章:runtime 运行时 - 运行时核心设计原则
01:前言
从本章开始我们将要进入到 渲染系统 的学习,也就是 运行时 runtime
。
在之前我们明确过什么是 运行时,看下面的代码(第二章运行时使用过该代码):
<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
,包含了两个主要的环节:
h
函数:生成VNode
render
函数:渲染VNode
这也是 正式讲解 runtime
的时候主要要去讲解的两大块内容。
但是在正式讲解 runtime
之前,我们还需要了解一些 runtime
的基本概念和设计原则,比如:VNode
是什么?它在干嘛?为什么需要它?里面传递的参数又是干什么的?为什么要传递它们?等等…
而这些就是我们本章所需要讲解的主要内容。
02:HTML DOM 节点树与虚拟 DOM 树
首先我们先来学习两个运行时的基础概念:
HTML DOM
节点树- 虚拟
DOM
树
我们来看下面这段 HTML
:
<div>
<h1>hello h1</h1>
<!-- TODO: comment -->
hello div
</div>
当浏览器看到这一段 html
时,它会生成一个对应的 DOM 树 来进行表示:
以上我们通过 节点(Node
) 来描述了以上所有的元素,在 HTML
中所有的元素都是一个节点,注释、文本都属于节点的一部分。
这样的通过节点构成的一个树形结构,我们就把它叫做 HTML DOM 节点树
那么明确好了什么叫做节点树之后,什么是 虚拟 DOM
树呢?
可能有很多同学听说过 虚拟 DOM
的概念,虚拟 DOM
树 和 虚拟 DOM
是息息相关的。
# 来自 vue 官方文档
虚拟 DOM (Virtual DOM,简称 VDOM) 是一种编程概念,意为将目标所需的 UI 通过数据结构“虚拟”地表示出来,保存在内存中,然后将真实的 DOM 与之保持同步。这个概念是由 React 率先开拓,随后在许多不同的框架中都有不同的实现,当然也包括 Vue。
虚拟 DOM
是一种理念,比如,我期望通过一个 JavaScript 对象
来描述一个 div 节点它的子节点是一个文本节点 text
,则可以这么写:
// <div>text</div>
// 通过 虚拟 dom 表示
const vnode = {
type: 'div',
children: 'text'
}
在上面这个对象中,我们通过 type
来表示当前为一个 div 节点
,通过 children
来表示它的子节点,通过 text
表示子节点是一个 文本节点,内容是 text
。
这里所设计到的 vnode
,是一个 纯 JavaScript
对象,我们通常使用它来表示 一个虚拟节点(或虚拟节点树)。它里面的属性名不是固定的,比如我可以使用 type
表示这是一个 div
,也可以使用 tag
进行表示,都是可以的。
在 vue
的源码中,通过使用它来表示所需要创建元素的所有信息,比如:
<div>
<h1>hello h1</h1>
<!-- TODO: comment -->
hello div
</div>
该例子如果使用 vnode
进行表示:
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:挂载与更新
这一小节,我们将通过一个极简的案例,来了解两个比较重要的概念:
- 挂载:
mount
- 更新:
patch
挂载:mount
首先我们先来构建这个案例(该案例在 第二章第七小节 《运行时》进行过大致的讲解):
<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
渲染函数,该函数接收三个参数:
oldVNode
:旧的VNode
newVNode
:新的VNode
container
:容器
当 oldVNode
不存在时,那么我们就认为这是一个全新的渲染,也就是 挂载。
所以以上的 mount
方法,我们就可以把它称为是一个 挂载方法。
更新:patch
旧的视图不可能被一直展示,它会在未来某一个时刻被更新为全新的视图。
比如:
<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 官方文档 也对此进行了详细的介绍:
- 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。
- 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。
这两个概念在我们后面去实现 renderer
渲染器的时候还会经常的使用到。
04:h 函数 与 render 函数
不知道大家还记不记得,我们在 第二章 讲解 运行时 的概念时,曾经使用过这样的一个案例:
<!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
来进行实现的。
在以上代码中,我们知道主要涉及到了两个函数:
h
函数render
函数
那么下面我们一个一个来去说
h 函数
根据以上代码可知,我们可以通过 h 函数
得到一个 vnode
:
const vnode = h('div', {
class: 'test'
}, 'hello render')
打印当前的 vnode
,可以得到一下内容:
{
"__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
:
{
// 是否是一个 VNode 对象
"__v_isVNode": true,
// 当前节点类型
"type": "div",
// 当前节点的属性
"props": { "class": "test" }
// 它的子节点
"children": "hello render"
}
那么由此可知 h
函数本质上其实就是一个:用来生成 VNode
的函数。
h 函数 最多可以接收三个参数:
type: string | Component
: 既可以是一个字符串 (用于原生元素) 也可以是一个 Vue 组件定义。props?: object | null
: 要传递的 propchildren?: Children | Slot | Slots
:子节点。
官方示例描述了它的详细使用方式,这个就不写在文档中了。
render 函数
那么在了解了 h
函数的作用之后,下面我们来看 render 函数。
render(vnode, container)
从以上代码中我们可知,render
函数主要接收了两个参数:
vnode
:虚拟节点树 或者叫做 虚拟DOM
树,两种叫法皆可container
:承载的容器。真实节点渲染的位置。
通过 render
函数,我们可以:使用编程式地方式,创建虚拟 DOM 树对应的真实 DOM
树,到指定位置。
总结
这小节,我们知道了 h
函数和 render
函数的作用,这两个函数是整个运行时的关键函数,后面我们实现运行时的代码,核心就是实现这两个函数。
05:运行时核心设计原则
那么到这里为止,我们已经了解了在学习 运行时之前的需要掌握的前置知识。
那么在本小节中,我们就需要来看一下 vue 3
运行时的一些设计原则,这样可以帮助我们更好地整体了解 runtime
。
需要大家在本小节中了解的设计原则分为两个:
runtime-core
与runtime-dom
的关系,为什么要这么设计- 渲染时,挂载和更新的逻辑处理
runtime-core
与 runtime-dom
的关系,为什么要这么设计
在 vue
源码中,关于运行时的包主要有两个:
packages/runtime-core
:运行时的核心代码packages/runtime-dom
:运行时关于浏览器渲染的代码
其中第一个 runtime-core
的概念比较好理解,但是 runtime-dom
它是干什么的呢?为什么要单独分出来这样的一个包呢?
我们来看一下 runtime-dom
的代码,查看 packages/runtime-dom/src/nodeOps.ts
中的代码。
从 nodeOps.ts
中的代码可知,该处主要为 浏览器的 web api
。
我们知道 vue
的渲染主要分为两种:
SPA
: 单页应用。即:浏览器显然SSR
:服务端渲染
即:Vue
中需要处理两种不同 宿主环境 ,将来还有可能会处理更多,比如 windows 、android、ios应用程序
等等。 在这些不同的宿主环境中,渲染 DOM
的方式是 完全不同 的。
所以 vue
就对运行时进行了处理,把所有的 浏览器 dom
操作,放到了 runtime-dom
中,而把整个运行时的 核心代码 都放入到了 runtime-core
之中。
通过 参数 的形式(详见 packages/runtime-core/src/renderer.ts
第 336
行 ),把 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
的结果,可以触发不同的方法。我们可以随便查看几个方法,可以发现这些方法中的代码处理逻辑都差不多:
if (n1 == null) {
// 挂载
} else {
// 更新
}
所以说由此我们可以得到一个 render
的大致逻辑:
renderer
渲染器对象提供render
渲染函数render
渲染函数在vnode
存在时,触发patch
patch
中根据type
的类型,渲染不同的节点- 节点的渲染分为 挂载 和 更新
明确好了以上逻辑之后,那么下面我们去实现 runtime
时,将会更加轻松。
06:总结
在本章节中,我们掌握了开发 runtime
之前的一些必备知识。
通过本章的内容,我们可知整个 runtime
核心的方法有两个:
h
函数render
函数
那么我们在后面去实现 runtime
时,也会以这两个函数为核心进行实现。
即:先实现 h
函数,再实现 render
函数
以此来完成整个运行时 runtime
的处理。
那么准备好了吗?