從零手寫 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
元素:
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAjM2EzLcd3LcJzLcJzdllmVldWYtl2Pn5GcuQzM4kTY5QGMwgDNmNjYwImNlRWM1cjN5MWN2QzM5EWZvwlM3kTN3MjMtUGall3LcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.png)
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
,除了提高性能,還有一個好處就是可以更好的支援擴平台。