ShawDubie

Vue 系列(九):初识渲染器

Designed by @吃肉的小羊

在第一篇文章中,我们简单提到了渲染器,之后我们介绍了编译器,随后又通过好几篇文章介绍了响应式系统。本篇我们将正式开始介绍 Vue 中的渲染器。

在介绍渲染器之前,我们先来看一个例子,这个例子是我们在介绍编译器的时候所提到的:

:::: code-group ::: code-group-item 模板代码

<template>
  <div>
    <p>Text1</p>
    <p>Text2</p>
  </div>
</template>

::: ::: code-group-item 渲染函数

import { h } from "vue";

function render() {
  return h("div", [h("p", "Text1"), h("p", "Text2")]);
}

::: ::::

我们知道,编译器会把模板代码编译为渲染函数,如上所示的 render,在 Vue 中我们可以通过这个 render 函数编程式地创建组件虚拟 DOM 树,具体可以查阅官方文档。这一步操作的本质还是将模板代码编译为了虚拟 DOM,我们的最终目的是将模板代码转换为真实 DOM,那么这就需要 Vue 内部的渲染器来实现了。

什么是渲染器


通过上图我们知道,渲染器的作用就是将虚拟DOM转换为真实DOM。渲染器把虚拟DOM转换为真实DOM的过程叫做挂载(mount),大家在使用 Vue 时候应该用到过 mounted 钩子,该钩子函数就会在渲染器完成挂载操作后被触发执行。那么渲染器要把真实DOM挂载到何处呢?我们在创建一个 Vue 应用的时候一定使用过 Vue 提供的 app.mount:

import { createApp } from 'vue'
const app = createApp(/* ... */)

app.mount('#app')

上面的代码就是将应用实例挂载在一个容器元素中的例子。们要想让渲染器挂载真实DOM,同样也需要给她指明一个容器,这样渲染器才能知道将真实DOM挂载到何处。

通过上面的分析,我们就可以写出渲染器中渲染函数的伪代码:

const render = (vNode: VNode | null, idSelector: string) => {
  // todo
}
interface VNode
export enum TYPE {
  ELEMENT = 'ELEMENT',
  TEXT = 'TEXT',
  COMMENT = 'COMMENT'
}

export interface VNode {
  type: TYPE,
  tag?: string,
  content?: string,
  children?: Array<VNode>,
  props?: Record<string, string | null>
}

说明


本篇文章只是简述渲染器的原理,不是源码分析,实际上在 Vue 中渲染方法 render 是定义在渲染器 renderer 中的。另外,这里的 render 方法虽然与文章开头提到的渲染函数名字相同,但是她们有本质的区别,文章开头的渲染函数返回的是虚拟DOM,而这里的 render 则是用来将虚拟DOM转换为真实DOM的,可以理解为 Vue 内部的方法。

渲染器和响应式系统有何关系


我们在前面花了大量的文章去介绍了响应式系统,那么响应式系统跟渲染器有什么联系呢?先来看一个例子:

const content = ref(123);

setTimeout(() => {
  effect(() => {
    const vNode = {
      type: TYPE.TEXT,
      content: content.value
    }
    render(vNode, '#demo');
  });
});

setTimeout(() => {
  content.value = 456
}, 1000);

在上面的代码中,我们首先定义一个 ref,然后在 effect 方法中定义了一个虚拟节点 vNode,该虚拟节点为一个文本节点。我们通过 render 方法将该文本节点挂载到页面中。当我们修改 content 的值的时候,副作用函数会重新执行,也就是会重新渲染页面。这样我们就可以实现自动渲染页面,这就是响应式系统与渲染器的关系。

实现一个简单的渲染器


通过前面的介绍,我们基本知道了什么是渲染器。那么我们该如何实现一个简单的渲染器呢?在实现之前,我们先分析一下渲染器是在什么场景下实现什么功能,还是通过上面的例子:

effect(() => render(vNode, '#demo'));

如上面的代码所示,每次 vNode 发生改变的时候都会触发副作用函数的执行,也就是会重新执行渲染函数 render,那么渲染函数执行的时候就会有如下三种情况:

  • 初次渲染:只有新节点,那么此时直接执行挂载(mount) 操作

  • 打补丁:新旧节点都存在,这个时候就需要进行新旧节点的比较,然后再更新变更点。我们一般称这种操作为打补丁(patch)

  • 卸载:当新节点为 null 且旧节点存在时,这个时候需要清空容器,我们称这种操作为卸载(unmount)

根据上图我们可以先写出如下代码:

查看代码
// 定义容器的数据结构
interface Container extends Element {
  oldVNode?: VNode | null
}

const render = (vNode: VNode | null, idSelector: string) => {
  // 获取容器
  const container: Container | null = document.querySelector(idSelector);
  // 容器不存在的时候,直接返回
  if (!container) return;
  if (vNode) {
    // 挂载或者更新
    patch(container.oldVNode, vNode, container);
  } else if (container.oldVNode) {
    // 卸载操作
    unmount(container.oldVNode);
  }
  // 将旧的虚拟DOM存储在container中
  container.oldVNode = vNode;
};

在上面的代码中,我们首先定义了一个数据结构 Container,用来表述容器。其中 oldVNode 则表示当前容器下真实DOM所对应的虚拟DOM,这样我们就能将真实DOM与虚拟DOM相关联。

在 render 方法中,当 vNode 存在时,说明是挂载或者更新,其实 mount 也算是一种特殊的更新操作,所以我们都通过 patch 方法来实现挂载和更新的功能。当 vNode 不存在且 oldVNode 存在时,说明是卸载操作,执行 unmount。最后我们通过 container.oldVNode = vNode 将虚拟DOM与对应的真实DOM进行关联。

有了大致的实现思路,接下来我们就来逐个实现其中的功能方法。

patch


我们将挂载和打补丁统一写在 patch 方法中:

const patch = (oldVNode: VNode | null | undefined, newVNode: VNode, container: Container) => {
  if (!oldVNode) {
    // 旧节点不存在,说明是挂载操作
    mountElement(newVNode, container);
  } else {
    // 新旧节点都存在,需要进行更新操作
    patchElement(oldVNode, newVNode, container);
  }
};

通过上面的代码可以知道,patch 方法接收三个参数,新旧虚拟节点以及容器,当旧节点不存在的时候执行挂载操作,反之则进行打补丁的操作。我们先来看看如何实现 mountElement:

<div>element</div>
<!-- comment -->
text

通过上面的代码我们知道,在 HTML 中一般有三种节点:

  • 标签节点

  • 注释

  • 文本

所以我们只需要实现如何挂载这三种节点即可,先来看看注释节点和文本节点,她们的实现相对简单,只需要将节点挂载到相应的容器中去就可以了:

const mountElement = (node: VNode, container: Container) => {
  // 挂载DOM元素
  if (node.type === TYPE.ELEMENT) {
    // todo
  } else if (node.type === TYPE.COMMENT) {
    // 挂载注释
    const comment = node.content ? node.content : '';
    const commentNode = document.createComment(comment);
    container.appendChild(commentNode);
  } else if (node.type === TYPE.TEXT) {
    // 挂载文本
    const text = node.content ? node.content : '';
    const textNode = document.createTextNode(text);
    container.appendChild(textNode);
  }
};

标签节点相对复杂一些,因为标签节点下面可能会有子节点,所以我们要递归调用 mountElement:

const mountElement = (node: VNode, container: Container) => {
  // 挂载DOM元素
  if (node.type === TYPE.ELEMENT) {
    if (!node.tag) return;
    const element: Container = document.createElement(node.tag);
    // 挂载子节点
    if (node.children) {
      node.children.forEach(child => mountElement(child, element));
    }
    container.appendChild(element);
  }
  // 省略其他代码
};

我们通过递归调用 mountElement 实现了对标签节点的挂载。接下来我们看看如何实现 patchElement,我们知道,patch 操作需要对比新旧节点,然后找出需要更新的节点。这其实就是我们后面要讲的 Diff 算法,这里我们先简化操作,如果是打补丁操作,我们首先将旧节点卸载,然后进行新节点的挂载:

const patchElement = (oldVNode: VNode, newVNode: VNode, container: Container) => {
  // 卸载旧节点
  unmount(oldVNode);
  // 挂载新节点
  mountElement(newVNode, container);
};

unmount


在实现 patchElement 的时候,我们需要先将旧节点卸载,接下来我们就来看看如何实现卸载的功能。卸载其实很简单,只需要找到父节点,然后调用 removeChild 将其移除就行了。要实现这一点,我们需要将虚拟DOM与真实DOM进行关联:

interface VNode {
  type: TYPE,
  tag?: string,
  content?: string,
  children?: Array<VNode>,
  props?: Record<string, string | null>,
  el?: Node,
}

我们给 VNode 增加了一个属性 el,当进行挂载操作的时候,我们将真实DOM赋值给 el,这样就将虚拟节点与真实节点绑定在了一起:

查看代码
const mountElement = (node: VNode, container: Container) => {
  // 挂载DOM元素
  if (node.type === TYPE.ELEMENT) {
    if (!node.tag) return;
    const element: Container = document.createElement(node.tag);
    // 将真实DOM与虚拟DOM相关联
    node.el = element;
    // 挂载子节点
    if (node.children) {
      node.children.forEach(child => mountElement(child, element));
    }
    container.appendChild(element);
  } else if (node.type === TYPE.COMMENT) {
    // 挂载注释
    const comment = node.content ? node.content : '';
    const commentNode = document.createComment(comment);
    node.el = commentNode;
    container.appendChild(commentNode);
  } else if (node.type === TYPE.TEXT) {
    // 挂载文本
    const text = node.content ? node.content : '';
    const textNode = document.createTextNode(text);
    node.el = textNode;
    container.appendChild(textNode);
  }
};

接下来我们就可以实现 卸载(unmount) 操作了:

const unmount = (vNode: VNode) => {
  const el = vNode.el;
  if (!el) return;
  if (vNode.children && vNode.children.length > 0) {
    vNode.children.forEach((child: VNode) => unmount(child));
  }  
  const parent = el.parentNode;
  if (parent) {
    parent.removeChild(el);
  }
};

通过代码可以看到,当有子节点的时候说明是标签节点,我们需要递归调用 unmount 进行节点的卸载,如果是其它情况,直接卸载即可。

处理属性


在进行节点挂载的时候,我们还需要考虑节点中存在属性的情况:

const patchProps = (el: Element, key: string, newValue: string | null) => {
  if (!newValue) {
    el.removeAttribute(key);
  } else if (typeof newValue === 'string') {
    el.setAttribute(key, newValue);
  }
}

可以看到,patchProps 接收三个参数,需要挂载属性的标签节点 el,属性名称 key,属性值 newValue。通过 setAttribute 进行属性的挂载。定义好更新属性的方法之后,我们还需要修改 mountElement:

const mountElement = (node: VNode, container: Container) => {
  // 挂载DOM元素
  if (node.type === TYPE.ELEMENT) {
    if (!node.tag) return;
    const element: Container = document.createElement(node.tag);
    // 将真实DOM与虚拟DOM相关联
    node.el = element;
    if (node.props && node.el) {
      for(const key in node.props) {
        patchProps(node.el as Element, key, node.props[key])
      }
    }
    // 省略其他代码
  }
  // 省略其他代码
};

这样我们就实现了对属性的渲染。这里只是简单说明渲染的原理,其实在 Vue 中,渲染属性远不止这么简单。

最后


本篇我们简单介绍了一下渲染器,渲染器的作用就是将虚拟DOM转换为真实DOM。本文为了叙述清楚渲染器的原理,省去了很多细节,其实在 Vue 内部,渲染器是非常复杂的,大家可以参阅渲染器的源码,本篇文章代码请戳