一文看懂 Vue runtime-core 基本概念&架构

1,333 阅读6分钟

文章首发于: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:节点的属性,常见的比如idclassclick等等,动态绑定也写在这里统一处理。
  • 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操作。

在掌握这些之后,应该就可以快乐地阅读源码了😆。