虚拟DOM(Virtual DOM), 即虚拟DOM节点. 它通过JavaScript的Object对象模拟DOM中的节点, 然后再通过特定的 render方法将其渲染成真实的DOM节点
Virtual DOM算法
DOM操作是很慢的, 如果我们把一个简单的div元素的属性打印出来:
可以看到, 真正的DOM元素非常庞大, 当我们频繁的去做DOM更新, 会产生一定的性能问题. 而VirtualDOM就是用一个原生的JavaScript对象去描述一个DOM节点, 相对于DOM对象, 原生的JavaScript对象处理起来更快, 而且更简单. DOM树上的结构、属性信息我们都可以很容易地用JavaScript对象表示出来
let element = {
tagName: "ul", // 节点标签名
props: { // DOM的属性, 用一个对象存储键值对
id: "list"
},
children: [ // 该节点的子节点
{tagName: "li", props: {class: "item"}, children: ["Item 1"]},
{tagName: "li", props: {class: "item"}, children: ["Item 2"]},
{tagName: "li", props: {class: "item"}, children: ["Item 3"]},
]
}
// 对应的HTML写法是
<ul id="list">
<li class="item">Item 1</li>
<li class="item">Item 2</li>
<li class="item">Item 3</li>
</ul>
原来DOM树的信息可以用JavaScript对象来表示, 反之, 可以根据这个用JavaScript对象表示的树结构来创建一颗真正的DOM树
具体来说, 所谓的Virtual DOM算法用JavaScript对象标识DOM信息和结构, 当状态变更的时候, 重新渲染这个JavaScript的对象结构, 然后用新渲染的对象树去和旧的树进行对比, 记录这两颗树的差异. 记录下来的不同就是我们需要对页面真正的DOM操作, 然后把它们应用再真正的DOM树上, 页面就变更了. 这样就可以做到: 视图的结构确实是整个全新渲染了, 但是最后操作的DOM的时候确实只变更了不同的地方 具体步骤:
- 用JavaScript对象结构标识DOM树的结构, 然后用这个树构建一个真正的DOM树, 插到文档中
- 当状态变更的时候, 重新构造一棵新的对象树, 然后用新的树和旧的树进行比较, 记录两棵树的差异
- 把步骤2所记录的差异应用到步骤1所构建的真正DOM树上, 更新视图
虚拟DOM本质上就是JavaScript和DOM之间做了一个缓存. 可以类比CPU和硬盘, 既然硬盘这么慢, 我们就在它们之间加个缓存: 既然DOM这么慢, 我们就在JavaScript和DOM之间加个缓存. CPU(JavaScript)只操作内存(虚拟DOM), 最后的时候再把变更写入硬盘(DOM)
算法实现
-
用JavaScript对象模拟DOM树
用JavaScript来表示一个DOM节点是很简单的事情, 只需要记录它的节点类型, 属性和子节点
function Element (tagName, props, children) {
this._tagName = tagName;
this._props = props;
this._children = children;
}
module.exports = function (tagName, props, children) {
return new Element(tagName, props, children);
}
// 上面的DOM结构可以表示为
let el = require("./element");
let ul = el("ul", {id: "list"}, [
el("li", {class: "item"}, ["Item 1"]),
el("li", {class: "item"}, ["Item 2"]),
el("li", {class: "item"}, ["Item 3"])
]);
现在ul只是一个JavaScript对象表示的DOM结构, 页面上并没有这个结构. 我们可以根据这个ul构建真正的
Element.prototype.render = () => {
let el = document.createElement(this.tagName); // 根据tagName构建
let props = this._props;
for (let propName in props) { // 设置节点的DOM属性
let propValue = props[propName];
el.setAttribute(propName, propValue);
}
let children = this.children || [];
children.forEach( () => {
let childEl = (child instanceof Element)
? child.render() // 如果子节点也是虚拟DOM, 递归构建DOM节点
: document.createTextNode(child); // 如果是字符串, 则只构建文本节点
el.appendChild(childEl);
})
}
render方法会根据tagName构建一个真正的DOM节点, 然后设置这个节点的属性, 最后递归地把自己的子节点也构建起来
let ulROot = ul.render();
document.body.appendChild(ulRoot);
UlRoot是真正的DOM节点, 把它塞入文档中, 这样body里面就有了真正的的DOM结构
2.比较两颗虚拟DOM树的差异 Vitrual DOM的diff算法. 两个树的完全的diff算法是一个时间复杂度为O(n^3)的问题. 但是在前端中, 很少会跨越层级地移动DOM元素, 所以Virtual DOM只会对同一个层级的元素进行对比
在实际的代码中, 会对新旧两棵树进行一个深度优先的遍历, 这样每一个节点都会有一个唯一的标记. 在深度优先遍历的时候, 每遍历到一个节点就把该节点和新的树进行对比. 如果有差异的话就记录到一个对象里面
// diff函数, 对比两棵树
function diff (oldTree, newTree) {
let index = 0;
let patche = {};
dfsWalk(oldTree, newTree, index, patches)
return patches;
}
// 对两棵树进行深度优先遍历
function dfsWalk (oldNode, newNode, patches) {
// 对比oldNode和newNode的不同, 记录下来
patches[index] = [...];
diffChildren(old.children, newNode.children, index, patches)
}
// 遍历子节点
function diffChildren (oldChildren, newChildren, index, patches) {
let leftNode = null;
let currentNodeIndex = index;
oldChildren.forEach(function (child, i) {
let newChild = newChildren[i];
currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识
? currentNodeIndex + leftNode.count + 1
: currentNodeIndex + 1;
dfsWalk(child, newChild, currentNodeIndex, patches); // 深度遍历子节点
leftNode= child;
})
}
3.把差异应用到真正的DOM树上
因为步骤一所构建的JavaScript对象树和render出来真正的DOM树的信息、结构是一样的. 所以我们可以对那棵DOM树也进行深度优先的遍历, 遍历的时候从步骤二生成的patches对象中找出前遍历的节点差异, 然后进行DOM操作
function patch (node, patches) {
let walker = {index: 0};
dfsWalk(node, walker, patches);
}
function dfsWalk (node, walker, patches) {
let currentPatches = patches[walker.index]; // patches拿出当前节点的差异
let len = node.childNodes;
? node.childNodes.length
: 0;
for (let i = 0; i < len; i++) {
let child = node.childNodes[i];
walker.index++;
dfsWalk(child, walker, patches);
}
if (currentPatches) {
applyPatches(node, currentPatches); // 对当前节点进行DOM操作
}
}
applyPatches, 根据不同类型的差异对当前节点进行DOM操作
function applyPatches (node, currentPatches) {
currentPatches.forEach(function (currentPatch) {
switch (currentPatch.type) {
case REPLACE:
node.parentNode.replaceChild(currentPatch.node.render(), node);
break;
case REORDER:
reorderChildren(node, currentPatch.moves);
break;
case PROPS:
setProps(node, currentPatch.props);
break;
case TEXT:
node.textContent = currentPatch.content;
break;
default:
throw new Error("Unknow patch type " + currentPatch.type)
}
})
}
Virtual DOM主要是实现步骤的三个函数: element, diff, patch. 然后就可以进行实际使用
// 构建虚拟DOM
let tree = el("div", {"id": "container"}, [
el("h1", {style: "color: blue"}, ["simple virtal dom"]),
el("p", ["Hello, virtual-dom"]),
el("ul", [el("li")])
]);
// 通过虚拟DOM构建真正的DOM
let root = tree.render();
document.body.appendChild(root);
// 生成新的虚拟DOM
let newTree = el("div", {"id": "container"}, [
el("h1", {style: "color: red"}, ["simple virtual dom"]),
el("p", ["Hello, virtual-dom"]),
el("ul", [el("li"), el("li")])
]);
// 比较两颗虚拟DOM树的不同
let patches = diff(tree, newTree);
// 在真正的DOM元素上应用变更
patch(root, patches);