本文来自《一文说清VirtualDOM的含义与实现》,若是以为不错,欢迎给Github仓库一个star。javascript
随着 React 的兴起,Virtual DOM 的原理和实现也开始出如今各大厂面试和社区的文章中。其实这种作法早在 d3.js
中就有实现,是 react 生态的快速创建让它正式进入了广大开发者的视角。html
在正式开始前,抛出几个问题来引导思路,这些问题也会在不一样的小节中,逐步解决:前端
⚠️ 整理后的代码和效果图均存放在github.com/dongyuanxin。java
曾经,前端常作的事情就是根据数据状态的更新,来更新界面视图。你们逐渐意识到,对于复杂视图的界面,频繁地更新 DOM,会形成回流或者重绘,引起性能降低,页面卡顿。node
所以,咱们须要方法避免频繁地更新 DOM 树。思路也很简单,即:对比 DOM 的差距,只更新须要部分节点,而不是更新一棵树。而实现这个算法的基础,就须要遍历 DOM 树的节点,来进行比较更新。react
为了处理更快,不使用 DOM 对象,而是用 JS 对象来表示,它就像是 JS 和 DOM 之间的一层缓存。git
借助 ES6 的 class,表示 VDom 语义化更强。一个基础的 VDom 须要有标签名、标签属性以及子节点,以下所示:github
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
}
复制代码
为了更方便调用(不用每次都写new
),将其封装返回实例的函数:面试
function el(tagName, props, children) {
return new Element(tagName, props, children);
}
复制代码
此时,若是想表达下面的 DOM 结构:算法
<div class="test">
<span>span1</span>
</div>
复制代码
用 VDom 就是:
// 子节点数组的元素能够是文本,也能够是VDom实例
const span = el("span", {}, ["span1"]);
const div = el("div", { class: "test" }, [span]);
复制代码
以后在对比和更新两棵 VDom 树的时候,会涉及到将 VDom 渲染成真正的 Dom 节点。所以,为class Element
增长render
方法:
class Element {
constructor(tagName, props, children) {
this.tagName = tagName;
this.props = props;
this.children = children;
}
render() {
const dom = document.createElement(this.tagName);
// 设置标签属性值
Reflect.ownKeys(this.props).forEach(name =>
dom.setAttribute(name, this.props[name])
);
// 递归更新子节点
this.children.forEach(child => {
const childDom =
child instanceof Element
? child.render()
: document.createTextNode(child);
dom.appendChild(childDom);
});
return dom;
}
}
复制代码
前面已经说明了 VDom 的用法与含义,多个 VDom 就会组成一棵虚拟的 DOM 树。剩下须要作的就是:根据不一样的状况,来进行树上节点的增删改的操做。这个过程是分为diff
和patch
:
目前有两种思路,一种是先 diff 一遍,记录全部的差别,再统一进行 patch;另一种是 diff 的同时,进行 patch。相较而言,第二种方法少了一次递归查询,以及不须要构造过多的对象,下面采起的是第二种思路。
将 diff 和 patch 的过程,放入updateEl
方法中,这个方法的定义以下:
/** * * @param {HTMLElement} $parent * @param {Element} newNode * @param {Element} oldNode * @param {Number} index */
function updateEl($parent, newNode, oldNode, index = 0) {
// ...
}
复制代码
全部以$
开头的变量,表明着真实的 DOM。
参数index
表示oldNode
在$parent
的全部子节点构成的数组的下标位置。
若是 oldNode 为 undefined,说明 newNode 是一个新增的 DOM 节点。直接将其追加到 DOM 节点中便可:
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
}
}
复制代码
若是 newNode 为 undefined,说明新的 VDom 树中,当前位置没有节点,所以须要将其从实际的 DOM 中删除。删除就调用$parent.removeChild()
,经过index
参数,能够拿到被删除元素的引用:
function updateEl($parent, newNode, oldNode, index = 0) {
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
}
}
复制代码
对比 oldNode 和 newNode,有 3 种状况,都可视为改变:
首先,借助Symbol
更好地语义化声明这三种变化:
const CHANGE_TYPE_TEXT = Symbol("text");
const CHANGE_TYPE_PROP = Symbol("props");
const CHANGE_TYPE_REPLACE = Symbol("replace");
复制代码
针对节点属性发生改变,没有现成的 api 供咱们批量更新。所以封装replaceAttribute
,将新 vdom 的属性直接映射到 dom 结构上:
function replaceAttribute($node, removedAttrs, newAttrs) {
if (!$node) {
return;
}
Reflect.ownKeys(removedAttrs).forEach(attr => $node.removeAttribute(attr));
Reflect.ownKeys(newAttrs).forEach(attr =>
$node.setAttribute(attr, newAttrs[attr])
);
}
复制代码
编写checkChangeType
函数判断变化的类型;若是没有变化,则返回空:
function checkChangeType(newNode, oldNode) {
if (
typeof newNode !== typeof oldNode ||
newNode.tagName !== oldNode.tagName
) {
return CHANGE_TYPE_REPLACE;
}
if (typeof newNode === "string") {
if (newNode !== oldNode) {
return CHANGE_TYPE_TEXT;
}
return;
}
const propsChanged = Reflect.ownKeys(newNode.props).reduce(
(prev, name) => prev || oldNode.props[name] !== newNode.props[name],
false
);
if (propsChanged) {
return CHANGE_TYPE_PROP;
}
return;
}
复制代码
在updateEl
中,根据checkChangeType
返回的变化类型,作对应的处理。若是类型为空,则不进行处理。具体逻辑以下:
function updateEl($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
if (changeType === CHANGE_TYPE_TEXT) {
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
}
}
复制代码
若是状况 一、二、3 都没有命中,那么说明当前新旧节点自身没有变化。此时,须要遍历它们(Virtual Dom)的children
数组(Dom 子节点),递归进行处理。
代码实现很是简单:
function updateEl($parent, newNode, oldNode, index = 0) {
let changeType = null;
if (!oldNode) {
$parent.appendChild(newNode.render());
} else if (!newNode) {
$parent.removeChild($parent.childNodes[index]);
} else if ((changeType = checkChangeType(newNode, oldNode))) {
if (changeType === CHANGE_TYPE_TEXT) {
$parent.replaceChild(
document.createTextNode(newNode),
$parent.childNodes[index]
);
} else if (changeType === CHANGE_TYPE_REPLACE) {
$parent.replaceChild(newNode.render(), $parent.childNodes[index]);
} else if (changeType === CHANGE_TYPE_PROP) {
replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props);
}
} else if (newNode.tagName) {
const newLength = newNode.children.length;
const oldLength = oldNode.children.length;
for (let i = 0; i < newLength || i < oldLength; ++i) {
updateEl(
$parent.childNodes[index],
newNode.children[i],
oldNode.children[i],
i
);
}
}
}
复制代码
将github.com/dongyuanxin…的代码 clone 到本地,Chrome 打开index.html
。
新增 dom 节点.gif:
更新文本内容.gif:
更改节点属性.gif:
⚠️ 网速较慢的同窗请移步 github 仓库