从零手写 Vue之响应式系统 (opens new window) 中我们通过响应式系统实现了视图的自动更新,但遗留了一个问题是当数据变化的时候我们是将原来的 dom 全部删除,然后重新生成所有新 dom ,而 dom 的生成和渲染是一个相对比较耗时的工作,如果当前组件很复杂的话页面的性能会受到很大的影响。

虚拟 dom 就是为了解决这个问题,映射为真实 dom 前,我们会先生成虚拟 dom ,当数据变化的时候生成新的虚拟 dom ,然后将新旧虚拟 dom 进行对比,尽可能的复用原有的 dom,从而提高页面的性能。

这边文章主要介绍虚拟 dom 的定义和将虚拟 dom 渲染为真实 dom 的过程。

# 虚拟 dom 定义

虚拟 dom 用途就是生成真实 dom ,我们只需要定义一个对象结构,能通过这个对象来生成真实 dom 就够了。

最简单的 dom 节点比如一个 div 标签。

<div>windliang</div>

我们只需要描述 dom 的名字和 dom 中的元素,children 数组中的每一个元素也都是一个 vnode

const vnode = {
  tag: 'div',
  children: [
    {
      text: 'windliang'
    }
  ]
}

然后我们可以通过 dom API 去生成真正的 dom

const children = vnode.children;
const tag = vnode.tag;
vnode.elm = document.createElement(tag);

childVNode = children[0];
const childEle = document.createTextNode(childVNode.text);

vnode.elm.appendChild(childEle);

如上所示,把生成的 dom 保存到了 vnodeelm 属性中,接下来只需要将生成的 dom 插入到相应的节点中即可。

VNode 除了 tagchildren 属性外,还有很多其他属性,如下所示:

//vnode.js
export default class VNode {
    tag;
    data;
    children;
    text;
    elm;
    ns;
    context; // rendered in this component's scope
    key;
    componentOptions;
    componentInstance; // component instance
    parent; // component placeholder node

    // strictly internal
    raw; // contains raw HTML? (server only)
    isStatic; // hoisted static node
    isRootInsert; // necessary for enter transition check
    isComment; // empty comment placeholder?
    isCloned; // is a cloned node?
    isOnce; // is a v-once node?
    asyncFactory; // async component factory function
    asyncMeta;
    isAsyncPlaceholder;
    ssrContext;
    fnContext; // real context vm for functional nodes
    fnOptions; // for SSR caching
    devtoolsMeta; // used to store functional render context for devtools
    fnScopeId; // functional scope id support

    constructor(
        tag,
        data,
        children,
        text,
        elm,
        context,
        componentOptions,
        asyncFactory
    ) {
        this.tag = tag;
        this.data = data;
        this.children = children;
        this.text = text;
        this.elm = elm;
        this.ns = undefined;
        this.context = context;
        this.fnContext = undefined;
        this.fnOptions = undefined;
        this.fnScopeId = undefined;
        this.key = data && data.key;
        this.componentOptions = componentOptions;
        this.componentInstance = undefined;
        this.parent = undefined;
        this.raw = false;
        this.isStatic = false;
        this.isRootInsert = true;
        this.isComment = false;
        this.isCloned = false;
        this.isOnce = false;
        this.asyncFactory = asyncFactory;
        this.asyncMeta = undefined;
        this.isAsyncPlaceholder = false;
    }

    // DEPRECATED: alias for componentInstance for backwards compat.
    /* istanbul ignore next */
    get child() {
        return this.componentInstance;
    }
}

export const createEmptyVNode = (text) => {
    const node = new VNode();
    node.text = text;
    node.isComment = true;
    return node;
};

export function createTextVNode(val) {
    return new VNode(undefined, undefined, undefined, String(val));
}

// optimized shallow clone
// used for static nodes and slot nodes because they may be reused across
// multiple renders, cloning them avoids errors when DOM manipulations rely
// on their elm reference.
export function cloneVNode(vnode) {
    const cloned = new VNode(
        vnode.tag,
        vnode.data,
        // #7975
        // clone children array to avoid mutating original in case of cloning
        // a child.
        vnode.children && vnode.children.slice(),
        vnode.text,
        vnode.elm,
        vnode.context,
        vnode.componentOptions,
        vnode.asyncFactory
    );
    cloned.ns = vnode.ns;
    cloned.isStatic = vnode.isStatic;
    cloned.key = vnode.key;
    cloned.isComment = vnode.isComment;
    cloned.fnContext = vnode.fnContext;
    cloned.fnOptions = vnode.fnOptions;
    cloned.fnScopeId = vnode.fnScopeId;
    cloned.asyncMeta = vnode.asyncMeta;
    cloned.isCloned = true;
    return cloned;
}

未来的示例中可能会用到上边的一些其他属性,这里就不细说了。

# 跨平台

上边代码我们假设了创建元素是在浏览器中,直接使用了 document.xxx 方法。但如果我们想支持更多的平台,比如 weex (opens new window) (支持 iOSAndroid 开发),我们就不能直接使用 document.xxx 的形式了,需要使用 weex 自己所提供的语法来创建节点。

因此,我们可以提供每个平台各自的创建节点、更新节点、删除节点的一套方法,实现框架的跨平台。

对于浏览器的话,就是下边的方法:

// node-ops
export function createElement(tagName) {
    const elm = document.createElement(tagName);
    return elm;
}

export function createTextNode(text) {
    return document.createTextNode(text);
}

export function insertBefore(parentNode, newNode, referenceNode) {
    parentNode.insertBefore(newNode, referenceNode);
}

export function removeChild(node, child) {
    node.removeChild(child);
}

export function appendChild(node, child) {
    node.appendChild(child);
}

export function parentNode(node) {
    return node.parentNode;
}

export function nextSibling(node) {
    return node.nextSibling;
}

export function tagName(node) {
    return node.tagName;
}

export function setTextContent(node, text) {
    node.textContent = text;
}

这样创建节点的时候,我们根据不同平台去引不同的方法簇即可。

import * as nodeOps from "./node-ops"; // 引入操作节点的方法簇
const vnode = { tag: "div", children: [{ text: "windliang" }] };
const children = vnode.children;
const tag = vnode.tag;
vnode.elm = nodeOps.createElement(tag);

const childVNode = children[0];
const childEle = nodeOps.createTextNode(childVNode.text);

nodeOps.appendChild(vnode.elm, childEle);

节点的操作我们都调用 nodeOps 提供的方法。

这样如果想跨平台的话,我们只需要更改 import * as nodeOps from "./node-ops"; 这里的引入路径即可,其他代码就无需改动了。

# 整体流程

我们提供一个 options 对象,里边包含一个 render 方法返回 vnode 对象。

const options = {
    el: "#root",
    data: {
        text: "hello,liang",
        text2: "2",
    },
    render() {
        return {
            tag: "div",
            children: [
                {
                    text: this.text,
                },
                {
                    tag: "div",
                    children: [
                        {
                            text: this.text2,
                        },
                    ],
                },
            ],
        };
    },
};

render 函数中为了使用 data 属性,我们可以通过 call 函数改变一下 this 指向。

const _render = function () {
    const vnode = options.render.call(options.data);
    return vnode;
};

然后我们需要获取 optionsel 占位 dom ,未来将该 dom 替换为由虚拟 dom 生成的 dom

const $el = document.querySelector(options.el);

最后我们提供一个 _update 方法,传入 _render 方法返回的虚拟 dom,完成虚拟 dom 的渲染。

function _update(vnode) {
    __patch__($el, vnode);
}

patch 方法其实相当于一个渲染器,将虚拟 dom 变为真正的 dom

我们可以提供 createPatchFunction 函数返回 patch 方法。

createPatchFunction 函数内部我们可以通过闭包,将之前写的 nodeOps 引入,再封装一些 patch 所需要的方法。

// patch.js
import VNode from "./vnode";
import { isDef } from "./util";
/***
// isDef 就是判断当前变量是不是有值
export function isDef(v) {
    return v !== undefined && v !== null;
}
***/
export const emptyNode = new VNode("", {}, []);

export function createPatchFunction(backend) {
    const { nodeOps } = backend;
    ...
    return function patch(oldVnode, vnode) {
        const isRealElement = isDef(oldVnode.nodeType);
        if (isRealElement) {
            // either not server-rendered, or hydration failed.
            // create an empty node and replace it
            oldVnode = emptyNodeAt(oldVnode);
        }

        // replacing existing element
        const oldElm = oldVnode.elm;
        const parentElm = nodeOps.parentNode(oldElm);

        // create new node
        createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));

        removeVnodes([oldVnode], 0, 0);
        return vnode.elm;
    };
}

patch 函数接受两个 vnode 对象,但第一次渲染的时候我们只有占位 dom 元素 $el ,还没有 oldVnode

因此先通过是否有 nodeType 属性来判断当前是 dom 还是虚拟 dom,如果是 dom 就通过 emptyNodeAt 方法创建一个虚拟 node,并且将该 dom 挂到虚拟 domel 属性中。

const isRealElement = isDef(oldVnode.nodeType);
if (isRealElement) {
  // either not server-rendered, or hydration failed.
  // create an empty node and replace it
  oldVnode = emptyNodeAt(oldVnode);
}

看一下 emptyNodeAt 方法,就是创建一个 Vnode 对象返回即可。

function emptyNodeAt(elm) {
  return new VNode(
    nodeOps.tagName(elm).toLowerCase(),
    {},
    [],
    undefined,
    elm
  );
}

接下来我们拿到旧的 dom 和旧 dom 的父 dom ,调用 createElm 方法。

// replacing existing element
const oldElm = oldVnode.elm;
const parentElm = nodeOps.parentNode(oldElm);

// create new node
createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));

createElm 接受三个参数,第一个参数是要渲染的 vnode ,第二个参数是要加入位置的父节点,第三个参数是定位节点,未来插入 dom 会在该节点的前边插入。

可以理解成下边的过程:

<parentElm>
  <oldElm />
  <nodeOps.nextSibling(oldElm) />
<parentElm />

调用 createElm 方法后就变成了下边的样子:

<parentElm>
  <oldElm />
  <newElm /> // 虚拟 dom 生成的 dom
  <nodeOps.nextSibling(oldElm) />
<parentElm />

如果第三个参数不传的话, createElm 函数会直接将生成的节点加到 parent 节点的最后。

<parentElm>
  <oldElm />
  <nodeOps.nextSibling(oldElm) />
  <newElm /> // 虚拟 dom 生成的 dom
<parentElm />

让我们详细看一下 createElm 函数:

function createElm(vnode, parentElm, refElm) {
  const children = vnode.children;
  const tag = vnode.tag;
  if (isDef(tag)) {
    vnode.elm = nodeOps.createElement(tag);
    createChildren(vnode, children);
    insert(parentElm, vnode.elm, refElm);
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text);
    insert(parentElm, vnode.elm, refElm);
  }
}

拿到 childrentag ,然后通过平台无关的 nodeOps 去创建当前 dom ,并保存到 elm 属性中。

接下来调用 createChildren 方法,来创建子节点。

function createChildren(vnode, children) {
  if (Array.isArray(children)) {
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], vnode.elm);
    }
  }
}

我们只需要遍历当前数组,然后同样调用 createElm 函数。

再看一眼上边的 createElm 函数,当节点创建好以后,会调用 insert 方法,把生成的节点加入到 parentElm 中。

function insert(parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref);
      }
    } else {
      nodeOps.appendChild(parent, elm);
    }
  }
}

insert 方法就会用到前边介绍的第三个参数,如果有第三个参数会调用 insertBefore 方法,不然的话就是直接调用 appendChild 方法插入。

这样 createElm 就介绍完了:

function patch(oldVnode, vnode) {
  const isRealElement = isDef(oldVnode.nodeType);
  if (isRealElement) {
    // either not server-rendered, or hydration failed.
    // create an empty node and replace it
    oldVnode = emptyNodeAt(oldVnode);
  }

  // replacing existing element
  const oldElm = oldVnode.elm;
  const parentElm = nodeOps.parentNode(oldElm);

  // create new node
  createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));

  removeVnodes([oldVnode], 0, 0);
  return vnode.elm;
};

当前在 parentElm 中通过 vnode 插入了一个新节点:

<parentElm>
  <oldElm />
  <newElm />
  <nodeOps.nextSibling(oldElm) />
<parentElm />

因此我们最后还需要调用 removeVnodes 函数把旧的 dom 元素删除。

function removeVnodes(vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx];
    if (isDef(ch)) {
      if (isDef(ch.tag)) {
        removeAndInvokeRemoveHook(ch);
      } else {
        // Text node
        removeNode(ch.elm);
      }
    }
  }
}
function removeAndInvokeRemoveHook(vnode, rm) {
  removeNode(vnode.elm);
}
function removeNode(el) {
  const parent = nodeOps.parentNode(el);
  // element may have already been removed due to v-html / v-text
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el);
  }
}

最终通过 nodeOps.removeChild 删除旧节点即可。

<parentElm>
  <newElm /> // 虚拟 dom 生成的节点
  <nodeOps.nextSibling(oldElm) />
<parentElm />

全部完成后的 dom 如上所示,我们把占位节点变为了 vnode 生成的 dom 节点。

看一下 patch 完整代码:

import VNode from "./vnode";
import { isDef } from "./util";

export const emptyNode = new VNode("", {}, []);

export function createPatchFunction(backend) {
    const { nodeOps } = backend;

    function emptyNodeAt(elm) {
        return new VNode(
            nodeOps.tagName(elm).toLowerCase(),
            {},
            [],
            undefined,
            elm
        );
    }

    function removeNode(el) {
        const parent = nodeOps.parentNode(el);
        // element may have already been removed due to v-html / v-text
        if (isDef(parent)) {
            nodeOps.removeChild(parent, el);
        }
    }
    function createElm(vnode, parentElm, refElm) {
        const children = vnode.children;
        const tag = vnode.tag;
        if (isDef(tag)) {
            vnode.elm = nodeOps.createElement(tag);
            createChildren(vnode, children);
            insert(parentElm, vnode.elm, refElm);
        } else {
            vnode.elm = nodeOps.createTextNode(vnode.text);
            insert(parentElm, vnode.elm, refElm);
        }
    }
    function insert(parent, elm, ref) {
        if (isDef(parent)) {
            if (isDef(ref)) {
                if (nodeOps.parentNode(ref) === parent) {
                    nodeOps.insertBefore(parent, elm, ref);
                }
            } else {
                nodeOps.appendChild(parent, elm);
            }
        }
    }

    function createChildren(vnode, children) {
        if (Array.isArray(children)) {
            for (let i = 0; i < children.length; ++i) {
                createElm(children[i], vnode.elm);
            }
        }
    }

    function removeVnodes(vnodes, startIdx, endIdx) {
        for (; startIdx <= endIdx; ++startIdx) {
            const ch = vnodes[startIdx];
            if (isDef(ch)) {
                if (isDef(ch.tag)) {
                    removeAndInvokeRemoveHook(ch);
                } else {
                    // Text node
                    removeNode(ch.elm);
                }
            }
        }
    }
    function removeAndInvokeRemoveHook(vnode, rm) {
        removeNode(vnode.elm);
    }

    return function patch(oldVnode, vnode) {
        const isRealElement = isDef(oldVnode.nodeType);
        if (isRealElement) {
            // either not server-rendered, or hydration failed.
            // create an empty node and replace it
            oldVnode = emptyNodeAt(oldVnode);
        }

        // replacing existing element
        const oldElm = oldVnode.elm;
        const parentElm = nodeOps.parentNode(oldElm);

        // create new node
        createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));

        removeVnodes([oldVnode], 0, 0);
        return vnode.elm;
    };
}

# 测试

首先页面提供一个 dom 节点用来占位:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        <div id="root"></div>
        <script src="bundle.js"></script>
    </body>
</html>

然后 el 设置 #rootrender 方法返回虚拟 dom

import * as nodeOps from "./node-ops";
import { createPatchFunction } from "./patch";
const options = {
    el: "#root",
    data: {
        text: "hello,liang",
        text2: "2",
    },
    render() {
        return {
            tag: "div",
            children: [
                {
                    text: this.text,
                },
                {
                    tag: "div",
                    children: [
                        {
                            text: this.text2,
                        },
                    ],
                },
            ],
        };
    },
};

const _render = function () {
    const vnode = options.render.call(options.data, createElement);
    return vnode;
};

const $el = document.querySelector(options.el); // 占位节点

const __patch__ = createPatchFunction({ nodeOps }); // 返回 patch 方法

function _update(vnode) {
    __patch__($el, vnode);
}

_update(_render());

最终页面就正常渲染了两个 dom 元素:

image-20220603143433935

并且原来的 <div id="root"></div> 也进行了删除。

# 完善 render

上边 render 函数中我们直接返回了一个对象,

render() {
  return {
    tag: "div",
    children: [
      {
        text: this.text,
      },
      {
        tag: "div",
        children: [
          {
            text: this.text2,
          },
        ],
      },
    ],
  };
},

严格来说它只是一个像 vnode 的对象,但并不是真正的 vode 对象,文章最开头我们也看到了 Vnode 对象有好多好多参数,很多参数也有默认值,因此 render 函数会提供一个 createElement 来帮助我们生成真正的 Vnode

我们只需要改成下边的样子:

render(createElement) {
  const test = createElement("div", [
    this.text,
    createElement("div", this.text2),
  ]);
  return test;
},

下边我们来实现一版简易的 createElement 函数。

import VNode, { createEmptyVNode } from "./vnode";
import { normalizeChildren } from "./normalize-children";
export function createElement(tag, children) {
    return _createElement(tag, children);
}

export function _createElement(tag, children) {
    if (!tag) {
        // in case of component :is set to falsy value
        return createEmptyVNode();
    }
    children = normalizeChildren(children);
    let vnode = new VNode(tag, null, children);
    return vnode;
}

因为 children 传进来的可能不是 vnode 对象,比如可能只是一个字符传,我们需要调用 normalizeChildren 把它标准化。

下边看一下 normalizeChildren 函数:

import { createTextVNode } from "./vnode";
import { isUndef, isPrimitive } from "./util";

export function normalizeChildren(children) {
  return isPrimitive(children)
    ? [createTextVNode(children)]
  : Array.isArray(children)
    ? normalizeArrayChildren(children)
  : undefined;
}

function normalizeArrayChildren(children) {
  const res = [];
  let i, c;
  for (i = 0; i < children.length; i++) {
    c = children[i];
    if (isUndef(c) || typeof c === "boolean") continue;
    if (isPrimitive(c)) {
      if (c !== "")
        // convert primitive to vnode
        res.push(createTextVNode(c));
      // 省略了很多 if else
    } else {
      // 走到这里说明当前 c 已经是一个 vnode 节点了
      res.push(c);
    }
  }
  return res;
}

上边我们只处理当前 child 是字符串的时候,我们就创建一个 createTextVNode 节点,真正源码中会处理很多很多情况。

然后我们的测试函数,在 _render 函数中,将 createElement 传入即可:

const _render = function () {
    const vnode = options.render.call(options.data, createElement);
    return vnode;
};

整个的测试代码就变成了下边的样子:

import * as nodeOps from "./node-ops";
import { createPatchFunction } from "./patch";
import { createElement } from "./create-element";

const options = {
    el: "#root",
    data: {
        text: "hello,liang",
        text2: "2",
    },
    render(createElement) {
        const test = createElement("div", [
            this.text,
            createElement("div", this.text2),
        ]);
        return test;
    },
};

const _render = options.render.bind(options.data, createElement);

const $el = document.querySelector(options.el);

const __patch__ = createPatchFunction({ nodeOps });

function _update(vnode) {
    __patch__($el, vnode);
}

_update(_render());

最终生成的页面和之前是完全一致的。

#

这边文章了解了什么是虚拟 dom 和如何将虚拟 dom 渲染为真实 dom ,了解了 Vue 中生成 dom 的全过程。

通过抽象出虚拟 dom ,除了提高性能,还有一个好处就是可以更好的支持扩平台。

Last Updated: 6/6/2022, 1:53:00 PM