天天看點

Vue2剝絲抽繭-虛拟 dom 簡介虛拟 dom 定義跨平台整體流程測試完善 render總

從零手寫 Vue之響應式系統 中我們通過響應式系統實作了視圖的自動更新,但遺留了一個問題是當資料變化的時候我們是将原來的

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

儲存到了

vnode

elm

屬性中,接下來隻需要将生成的

dom

插入到相應的節點中即可。

VNode

除了

tag

children

屬性外,還有很多其他屬性,如下所示:

//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 (支援

iOS

Android

開發),我們就不能直接使用

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;
};
           

複制

然後我們需要擷取

options

el

占位

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

挂到虛拟

dom

el

屬性中。

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);
  }
}
           

複制

拿到

children

tag

,然後通過平台無關的

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

設定

#root

render

方法傳回虛拟

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

元素:

Vue2剝絲抽繭-虛拟 dom 簡介虛拟 dom 定義跨平台整體流程測試完善 render總

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

,除了提高性能,還有一個好處就是可以更好的支援擴平台。