文章首发于:Vue runtime-core 基本概念&架构 -Aiysosis Blog(aiysosis.ink)
前言
在学习完mini-vue的响应性核心之后,我迫不及待的进入了runtime-core部分的学习。我一开始跟的教程从代码架构开始,直接切入component初始化流程,让对渲染、vnode、patch等等这些概念了解不深的我感到云里雾里😥。于是我又去查找其他资料,发现《Vue.js 设计与实现》这本书(不是广告)的切入点非常合适。本文就将基于这本书的讲解,梳理runtime的基本概念&逻辑架构,这样在深入源码的时候才能够更加地游刃有余。
易混概念
dom & element & node
- dom是文档对象模型,一个页面对应一个文档,也就是一个dom,对于vue构建的单页应用,我们始终在同一个也是唯一的dom上进行操作。
- 一个dom中包含多个节点(node),标签(
<div></div>
),属性(href,src),以及内容(如标签内部的字符串是文本节点)都是节点。
- element(元素)是节点的子集,它仅仅表示标签所代表的节点。
在vue中会有虚拟dom(virtual dom)和虚拟节点(virtual node, vnode)的表达,它们有时候会混用,不太好理解其具体含义。
个人认为用node比较合理,因为vue所创建和管理的所有节点都挂载在一个id为#app
的元素下方。也就是说,我们在vue中描述和操作的始终是真实dom的一个子节点(或者更加局部的一个子节点),而不是整个dom,所以个人认为使用node、virtual node、vnode这样的表述比较好。
因此,在本文中统一使用node,而不会使用dom相关的描述。
renderer & render
这两个概念也比较容易混淆,下面的内容会详细介绍,这里只提及一下。
renderer(渲染器)
renderer(渲染器)是vue runtime-core的最核心功能,它将节点的抽象描述渲染成真实的节点,如图所示:
比如这就是一个最简单渲染器,它接受两个参数,domStr
是一个描述dom的字符串,el
是dom渲染的目标位置。
function renderer(domStr, el) {
el.innerHTML = domStr;
}
如下面的调用:为id是#app
的dom元素添加一个<h1>
节点,它的内容是“hello world”。
renderer("<h1>hello world<h1>", document.getElementById("#app"));
virtual node(虚拟节点)
vue采用虚拟节点来描述真实节点,和真实节点一致地,虚拟节点的数据结构是树形的嵌套结构:
export interface VNode {
type: string;//标签名,但是后面还会进行拓展
props?: {
[key: string]: string;
};
children: string | VNode[];
}
- type:如果节点是一般的标签,那么type就是标签的名称,比如“div”,“h”等等。
- props:节点的属性,常见的比如
id
,class
,click
等等,动态绑定也写在这里统一处理。 - children:可以是文本,也可以是嵌套的vnode。
如节点:<div id="test" click="handleClick" class="hello">hello</div>
,会被解析为:
{
type: "div",
props: {
id: "test",
click: handleClick,
class: "hello"
},
children: "hello"
}
vue中的渲染器接收的参数之一就是vnode对象,并将其渲染成真实节点。
createRenderer函数
createRenderer是一个非常重要的函数,它是vue runtime-core跨平台能力的起点。在上面的renderer中,我们使用了这样的代码:
el.innerHTML = domStr;
innerHTML可以让我们设置某个节点的内部节点/文本,但是这样的设置方式只能在浏览器中使用,而vue需要支持跨平台的渲染,这一点如何做到呢?createRenderer函数就是关键。
要实现跨平台的渲染,最简单的方法就是每个平台都写一个renderer,对于不同的平台,直接调用就好:
function domRenderer(){/*...*/}
function nodejsRenderer(){/*...*/}
但是这样过于冗余了,因为渲染操作本质上是操作节点,节点之间的结构都是一致的,节点的操作也是相通的,只是名称或者调用方式上有些区别而已。
所以要实现跨平台,只需要提取出公共的node操作接口,主体逻辑使用这些公共接口来完成,不同的平台各自实现这些接口即可,如下图所示:
以insert功能为例的代码(实际上,createRenderer接收的是一个对象,里面是平台的公共接口实现,这里为了简化写成了函数):
function createRenderer(hostInsert) {
function render(domStr, container) {
hostInsert(domStr, container);
}
//返回的rendener是一个对象,它有一个render方法
return {
render,
};
}
function domInsert(str, el) {
el.innerHTML = str;
}
const domRenderer = createRenderer(domInsert);
domRenderer.render("hello world", document.getElementById("#app"));
这里的renderer使用对象描述,它有一个方法叫做render,调用它可以将虚拟节点转换为真实节点。但是,后面在组件部分会出现render函数,它和这里的render方法不是同一个东西,很容易混淆,本文中会把renderer的render方法叫做
renderer.render
Component(组件)
从vue使用者的视角来看,当我们试图写一个组件时,会这样来写:
- 在
template
标签内部写节点 - 定义接收的props
- 使用选项式或者组合式api定义响应式变量和处理函数
- 在其他地方引入组件并调用,且调用方式为:组件名称写在标签里
这样看来,组件和vnode有千丝万缕的联系,我们完全可以这样描述:组件是一个有状态的vnode。这里的“状态”包括vnode对象之外的其他各种信息。
vue将组件描述成了一个对象,它长这样:
这里的render函数的作用是获取组件对应的vnode,一定要注意和renderer.render区分。而区分某个vnode是普通节点还是组件,可以通过type
属性:
export interface VNode {
//通过type区分普通节点和组件
type: string | Component;// 仍然不是全部
props?: {
[key: string]: string;
};
children: string | VNode[];
}
type目前展示的类型仍然不是全部,还有Text、Fragment等等其他类型的节点。
patch
patch直译为中文是“打补丁”,它是渲染过程中一个非常核心的函数,主要用于新旧vnode之间的更新操作。其实这个名字还是比较形象的,当响应式数据发生变化的时候,虚拟节点也要进行相应的变化,所以我们需要对照着新节点,去给旧的节点进行“查漏补缺”,这个过程就叫patch。
它接收三个参数:旧的vnode,新的vnode,和挂载元素,通过比对新旧vnode,分情况进行处理。
renderer.render渲染流程
renderer.render将vnode渲染成真实的节点,我们可以用流程图的形式来描述:
这里涉及的具体实现部分就不过多深入了,因为本文的重点还是放在架构和概念的层次上。
总结
vue的runtime-core本质上是跨平台的渲染功能,它使用vnode来描述真实的dom;通过统一功能接口来实现跨平台的渲染能力;通过组件实现了代码逻辑的拆分;通过渲染器将vnode渲染成为真实的dom节点,并且响应性地对vnode进行patch操作。
在掌握这些之后,应该就可以快乐地阅读源码了😆。