likes
comments
collection
share

前端框架中的虚拟DOM和渲染器

作者站长头像
站长
· 阅读数 25

前端框架中的虚拟DOM和渲染器

目前前端的流行框架Vue和React都引入了虚拟DOM,它的出现了推动了前端框架具备更强大的能力,提升了开发效率,实现了更多的可能性。

虚拟DOM是啥

虚拟DOM,virtual document object model,虚拟文档对象模型,是用存粹的JS对象来描述DOM结点,我们在框架的源码或相关的文章中,也经常用VNode指代虚拟DOM。 snabbdom是一个著名的虚拟DOM库,vue也从中借鉴了相关的思想,下面是一个VNode的定义。

interface VNode {
  // 选择器
  sel: string | undefined;
  // 节点数据:属性/样式/事件等
  data: VNodeData | undefined;
  children: Array<VNode | string> | undefined;
  // 记录 vnode 对应的真实 DOM
  elm: Node | undefined;
  text: string | undefined;
  key: Key | undefined;
}
interface VNodeData {
  props?: Props;
  attrs?: Attrs;
  class?: Classes;
  style?: VNodeStyle;
  dataset?: Dataset;
  on?: On;
  attachData?: AttachData;
  hook?: Hooks;
  key?: Key;
  ns?: string; // for SVGs
  fn?: () => VNode; // for thunks
  args?: any[]; // for thunks
  is?: string; // for custom elements v1
  [key: string]: any; // for any other 3rd party module
}

VNodeData 描述结点相关的属性信息。

一个 html 标签有它的名字、属性、事件、样式、子节点等诸多信息,这些内容都需要在 VNode 中体现,我们可以用如下对象来描述一个红色背景的正方形 div 元素:

const elementVNode = {
  tag: 'div',
  data: {
    style: {
      width: '100px',
      height: '100px',
      backgroundColor: 'red'
    }
  }
}

我们使用 tag 属性来存储标签的名字,用 data 属性来存储该标签的附加信息,比如 style、class、事件等,通常我们把一个 VNode 对象的 data 属性称为 VNodeData。

为了描述子节点,我们需要给 VNode 对象添加 children 属性,如下 VNode 对象用来描述一个有子节点的 div 元素

const elementVNode = {
  tag: 'div',
  data: null,
  children: [
    {
      tag: 'h1',
      data: null
    },
    {
      tag: 'p',
      data: null
    }
  ]
}

为啥要引入虚拟DOM

如果没有虚拟DOM,我们之前经常用jQuery这种直接操作DOM的框架,开发业务系统。一般通过数据状态的变更,直接操纵DOM结点来更新视图。

前端框架中的虚拟DOM和渲染器

后来有了如handlebars、jade这类的模板引擎类的框架,也只能便于初始化数据状态,生成的是一堆HTML字符串,扔给浏览器渲染。无法追踪后续的视图状态。

前端框架中的虚拟DOM和渲染器

这样处理的的缺点有

  • 维护成本高,当视图上的数据状态多的时候,程序员的心智负担非常重,随着业务逻辑的增加,导致应用难以维护
  • 性能不高(相对),频繁的操纵DOM,对性能的损耗较大,DOM是浏览器经过一系列的计算、渲染、绘制消耗的资源量非常的。

前端框架中的虚拟DOM和渲染器

视图上一个普通的空DIV结点,就有上百个属性,可见操作DOM是件成本比较高的任务。基于上面的缺陷,业界引入了VNode,虚拟DOM的概念,使用原生的JavaScript对象来描述真实的DOM结点。 将业务的数据状态处理成 一颗虚拟DOM树,根据和真实DOM树的对比,寻找差异,只更新变化的部分,通过最小化更改,来降低视图更新的成本。

前端框架中的虚拟DOM和渲染器

引入了虚拟DOM层后,我们业务开发人员,就可以不用直接操作DOM,通过上层的库(vue、react)直接维护数据状态即可。当数据发生改变后,交由框架来自动处理,减轻了维护负担。

组件和虚拟DOM

在前端开发中,我们经常打交道的是组件,无论是vue中的模板组件或者是react中的JSX组件,它们是如何关联到虚拟DOM上的呢?

Vue中的处理过程是这样的

前端框架中的虚拟DOM和渲染器

最终生成的组件实例如下图所示

前端框架中的虚拟DOM和渲染器

组件实例,关联着VNode

前端框架中的虚拟DOM和渲染器

我们通过const app = new Vue(option);app.mount('#root');生成的组件实例,就关联到了虚拟DOM上,在程序运行期间一直存在,就像一块内存中的缓存,联系着浏览器中的真实DOM结点和程序的数据状态。

React中的处理过程是这样的,一般都是用JSX写react组件

前端框架中的虚拟DOM和渲染器

在早期版本的react中,会转换成createElement的形式,在react v17后,生成_jsxs的形式,其实内涵都是一样的。 解析JSX中的 render 函数(对应类组件) 或 返回的字符串(对应函数组件),生成 虚拟DOM

可以看出,无论在react或vue中,我们在业务中定义的各种组件,都转换成了VNode的形式。形成了一颗虚拟DOM树,关联着真实的DOM结点树

前端框架中的虚拟DOM和渲染器

当然,react中不仅有VNode,还有fiber结点的概念,我们这不展开来说了。

渲染器的作用

有了虚拟DOM结点树后,是如何映射到真实DOM树的呢,当数据状态改变的时候,如何及时更新DOM结点呢?这里我们就得引出渲染器的概念了。 渲染器,会接收传递来的VNode,对比新老结点,俗称打补丁(即:patch)的过程。

我们以Vue框架为例,在调用 createApp方法的时候,就会生成渲染器的实例。

let renderer;
function ensureRenderer() {
    return (renderer ||
        (renderer = createRenderer({
            createElement,
            createText,
            setText,
            setElementText,
            patchProp,
            insert,
            remove,
        })));
}
const createApp = (...args) => {
    console.log('createApp: ', args);
    return ensureRenderer().createApp(...args);
};

在渲染器的内部,有个 patch方法,来对比新旧的VNode结点

function patch(n1, n2, container = null, anchor = null, parentComponent = null) {
    const { type, shapeFlag } = n2;
    switch (type) {
        case Text:
            processText(n1, n2, container);
            break;
        case Fragment:
            processFragment(n1, n2, container);
            break;
        default:
            if (shapeFlag & 1) {
                console.log("处理 element");
                processElement(n1, n2, container, anchor, parentComponent);
            }
            else if (shapeFlag & 4) {
                console.log("处理 component");
                processComponent(n1, n2, container, parentComponent);
            }
    }
}

我们引入snabbdom库,来说明整个过程。

<div id="app"></div>
<script type="module">
  // import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
  import { h, init } from "./snabbdom@3.5.1.js";

  // 1. hello world
  // 参数:数组,模块
  // 返回值:patch函数,作用对比两个vnode的差异更新到真实DOM
  let patch = init([])
  // 第一个参数:标签+选择器
  // 第二个参数:如果是字符串的话就是标签中的内容
  let vnode = h('div#container', { }, 'hello VNode , ---old')

  let app = document.querySelector('#app')
  // 第一个参数:可以是DOM元素,内部会把DOM元素转换成VNode
  // 第二个参数:VNode
  // 返回值:VNde
  let oldVNode = patch(app, vnode); // 首次挂载
  console.log('oldVNode : ', oldVNode);

  setTimeout(() => {
    // 定义一个新的VNode
    vnode = h('div', 'Hello Virtual DOM  , --- new')

    // 在 3 秒后更新挂载到视图中
    const newVNode = patch(oldVNode, vnode)
    console.log('newVNode : ', newVNode);
  }, 3000);
</script>

可以看到渲染器主要有两个作用

  • 对比新旧的结点,寻找差异,打补丁
  • 将差异化的部分,跟新到真实的视图上

具体patch的实现细节,就是结点Diff,和增、删、改元素结点的内容了,大致的代码逻辑如下:

// 相同的结点复用
if (sameVnode(oldVnode, vnode)) {
  patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不同的结点,增加或删除
  elm = oldVnode.elm!;
  parent = api.parentNode(elm) as Node;

  createElm(vnode, insertedVnodeQueue);

  if (parent !== null) {
    api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
    removeVnodes(parent, [oldVnode], 0, 0);
  }
}

自定义渲染器

渲染器在更新Web视图的时候,会调用浏览器的DOM API,将数据的变化反映到界面上。

interface DOMAPI {
  createElement: (
    tagName: any,
    options?: ElementCreationOptions
  ) => HTMLElement;
  /**
   * @experimental
   * @todo Make it required when the fragment is considered stable.
   */
  createDocumentFragment?: () => SnabbdomFragment;
  createTextNode: (text: string) => Text;
  createComment: (text: string) => Comment;
  insertBefore: (
    parentNode: Node,
    newNode: Node,
    referenceNode: Node | null
  ) => void;
  removeChild: (node: Node, child: Node) => void;
  appendChild: (node: Node, child: Node) => void;
  parentNode: (node: Node) => Node | null;
  nextSibling: (node: Node) => Node | null;
  tagName: (elm: Element) => string;
  setTextContent: (node: Node, text: string | null) => void;
  getTextContent: (node: Node) => string | null;
  isElement: (node: Node) => node is Element;
  isText: (node: Node) => node is Text;
  isComment: (node: Node) => node is Comment;
  /**
   * @experimental
   * @todo Make it required when the fragment is considered stable.
   */
  isDocumentFragment?: (node: Node) => node is DocumentFragment;
}

因为有了 VNode 这一个中间的抽象层,我们可以实现自定义的渲染器。通过改造内部的API调用,比如调用原生Native API,来操纵原生的视图。 ReactNative框架就是这样的原理。

function createElement(
  tagName: any,
  options?: ElementCreationOptions
): HTMLElement {

  if (tagName === 'view') {
    const boxEle = document.createElement('div');
    boxEle.style.backgroundColor = 'gray';
    boxEle.style.borderRadius = '20px';
    boxEle.style.width = '200px';
    boxEle.style.width = '100px';
    return boxEle;
  }
  return document.createElement(tagName, options);
}

调用的时候,使用自定义的标签 view

// import { h, init } from 'https://cdn.jsdelivr.net/npm/snabbdom@3.5.1/+esm'
import { h, init } from "./snabbdom@3.5.1.js";

let patch = init([])
let vnode = h('view', { }, '自定义的元素')

let app = document.getElementById('app')
let oldVNode = patch(app, vnode); // 首次挂载
console.log('oldVNode : ', oldVNode);

在移动端,如果能够通过 hybrid的方式,调用原生的视图组件,就能够生成媲美原生体验的UI。

虚拟DOM的延展

了解了虚拟DOM,知道了渲染器后,其实这只是很小的一部分。渲染器要处理的工作很多,包括:

  • 控制部分组件生命周期钩子hooks(beforeCreatecreated等)的调用 在整个渲染周期中包含了大量的 DOM 操作、组件的挂载、卸载,控制着组件的生命周期钩子调用的时机。

  • 多端渲染的桥梁 渲染器也是多端渲染的桥梁,自定义渲染器的本质就是把特定平台操作“DOM”的方法从核心算法中抽离,并提供一套API规范,以便各个平台实现。

  • 与异步渲染有直接关系 Vue3 的异步渲染是基于调度器的实现,若要实现异步渲染,组件的挂载就不能同步进行,DOM的变更就要在合适的时机,一些需要在真实DOM存在之后才能执行的操作(如 ref)也应该在合适的时机进行。对于时机的控制是由调度器来完成的,但类似于组件的挂载与卸载以及操作 DOM 等行为的入队还是由渲染器来完成的,这也是为什么 Vue2 无法轻易实现异步渲染的原因。

  • 包含最核心的 Diff 算法 Diff 算法是渲染器的核心特性之一,可以说正是 Diff 算法的存在才使得 Virtual DOM 如此成功。

参考链接

github.com/snabbdom/sn…