虚拟DOM是啥?

Posted 前端大集锦

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟DOM是啥?相关的知识,希望对你有一定的参考价值。

一、浏览器渲染html的步骤

  1. HTML被HTML解析器解析成DOM Tree, CSS则被CSS解析器解析成CSSOM Tree。

  2. DOM Tree和CSSOM Tree解析完成后,被附加到一起,形成渲染树(Render Tree)。

  3. 节点信息计算(重排),这个过程被叫做Layout(Webkit)或者Reflow(Mozilla)。即根据渲染树计算每个节点的几何信息生成布局。

  4. 渲染绘制(重绘),这个过程被叫做(Painting 或者 Repaint)。即根据计算好的信息绘制整个页面。

  5. Display显示到屏幕上

以上5步简述浏览器的一次渲染过程,每一次的dom更改或者css几何属性更改,都会引起一次浏览器的重排/重绘过程。

二、什么是虚拟DOM(三步骤)

虚拟DOM(Virtual DOM)就是用JS来模拟DOM结构。

Virtual DOM 算法:

步骤一:用JS对象生成Virtual DOM树,然后用这个树构建一个真正的 DOM 树,插到文档当中

步骤二:当状态变更的时候,重新构造一棵新的对象树,然后用新的树和旧的树进行比较,记录两棵虚拟DOM树的差异

步骤三:把步骤二所记录的差异应用到步骤一所构建的真正的DOM树上,视图就更新了

三、为什么要使用虚拟DOM?(两个前提)

【1】前提一:直接操作DOM很慢

   
 let div = document.createElement('div') let props = '' for (let key in div) { props = `${props} ${key}` }    console.log(props)

虚拟DOM是啥?

如上图所示,如果我们把一个简单的div元素的属性都打印出来,你会看到真正的 DOM 元素非常庞大,由此可以得知DOM是很慢的。而且操作它们的时候你要小心翼翼,轻微的触碰可能就会导致页面重排。浏览器里一遍又一遍的渲染DOM是非常非常消耗性能的,常常会出现页面卡死的情况,影响用户的体验;所以尽量减少对DOM的操作成为了优化前端性能的必要手段,虚拟DOM就是将DOM的对比放在了js层,通过高效的 diff 算法计算,避免对整棵DOM树进行变更,而是进行针对性的视图变更,从而提高渲染效率。

【2】前提二:JavaScript很快

得益于V8引擎的出现,让javascript可以高效地运行,在性能上有了极大的提高。相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,而且更简单。DOM 树上的结构、属性信息我们都可以很容易地用 JavaScript 对象表示出来,为Virtual DOM的产生提供了大前提。

四、Virtual DOM 算法实现

【4.1】用js来模拟DOM节点

用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,操作内存中的js对象的速度显然要快多了。等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。

那具体怎么实现呢?看一下Element方法的具体实现:

   
 /* tagName: 节点名(如div) props: 节点的属性(如class) children: 子节点(如ul的li)。 除了这三个参数会被保存在对象上外,还保存了key和count。 */ function Element(tagName, props, children) {
// 保证只能通过如下方式调用:new Element() if (!(this instanceof Element)) { return new Element(tagName, props, children); }
// 设置虚拟dom相关属性 this.tagName = tagName; this.props = props || {}; this.children = children || []; this.key = props ? props.key : undefined;
let count = 0; this.children.forEach((child) => { if (child instanceof Element) { count += child.count; } count++; }); this.count = count;    }

有了js对象后,最终还需要将其映射成真实的DOM:根据DOM名调用源生的createElement创建真实DOM,将DOM的属性全都加到这个DOM元素上,如果有子元素继续递归调用创建子元素,并appendChild挂到该DOM元素上。这样就完成了从创建虚拟DOM到将其映射成真实DOM的全部工作。

   
 Element.prototype.render = function () { // 创建标签 const el = document.createElement(this.tagName);
// 设置标签属性 const props = this.props; for (const propName in props) { setAttr(el, propName, props[propName]); }
// 依次创建子节点标签 this.children.forEach((child) => { //如果子节点仍然为element,则递归的创建子节点,否则直接创建文本类型节点 const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child); el.appendChild(childEl); }); return el;    };

对一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。

vdom.render();

通过上面的Element方法,我们可以很简单地用javascript表示DOM结构。例如:

const vdom = Element('div', { id: 'virtual-container' }, [ Element('p', {}, ['Virtual DOM']), Element('ul', {}, [ Element('li', { class: 'item' }, ['Item 1']), Element('li', { class: 'item' }, ['Item 2']), Element('li', { class: 'item' }, ['Item 3']), ]), Element('div', {}, ['Hellow World']),]);
const root = vdom.render(); // 生成真实的DOM树document.getElementById('virtualDom').appendChild(root); // 添加到页面

 上面的vdom是一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。如下:

  
<div id="virtualDom"> <div id="virtual-container"> <p>Virtual DOM</p> <ul> <li class="item">Item 1</li> <li class="item">Item 2</li> <li class="item">Item 3</li> </ul> <div>Hello World</div> </div> </div>

页面展示效果:

【4.2】Diff算法(比较两颗虚拟DOM树的差异)

上面我们已经完成了创建虚拟DOM并将其映射成真实DOM的工作,这样所有的更新都可以先反映到虚拟DOM上,如何反映呢?需要明确一下Diff算法。

4.2.1、如何比较两颗DOM树

计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,这样算法复杂的就可以达到O(n),避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历。

4.2.2 、记录节点之间的差异

在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点都会有一个唯一的标记:


在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个叫patches的对象里面。

// diff 函数,对比两棵树function diff (oldTree, newTree) { var index = 0 // 当前节点的标志 var patches = {} // 用来记录每个节点差异的对象 dfsWalk(oldTree, newTree, index, patches) return patches}
// 对两棵树进行深度优先遍历function dfsWalk (oldNode, newNode, index, patches) { // 对比oldNode和newNode的不同,记录下来 patches[index] = [...]
diffChildren(oldNode.children, newNode.children, index, patches)}
// 遍历子节点function diffChildren (oldChildren, newChildren, index, patches) { var leftNode = null var currentNodeIndex = index oldChildren.forEach(function (child, i) { var newChild = newChildren[i] currentNodeIndex = (leftNode && leftNode.count) // 计算节点的标识 ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1 dfsWalk(child, newChild, currentNodeIndex, patches) // 深度遍历子节点 leftNode = child })}

例如:假设上面的ul有差异,ul的当前标记为3,那么记录为 patches[3] = [ // 用数组存储ul新旧节点的不同 ]

4.2.3、差异类型

由于我们对DOM树采取的是同级比较,因此节点之间的差异可以归结为以下4种类型:

  • 修改节点属性, 用PROPS表示   

  • 修改节点文本内容, 用TEXT表示  

  • 修改节点类型, 用REPLACE表示 

  • 调整子节点,包括移动、增加、删除等,用REORDER表示  

我们新创建一棵树,用于和之前的树进行比较

   
 const newTree = Element('div', { id: 'virtual-container' }, [ Element('h3', {}, ['Virtual DOM']), // REPLACE Element('ul', { class: 'itemWrap' }, [ // PROPS Element('li', { class: 'item' }, ['Item 1']), // Element('li', { class: 'item' }, ['Item 2']), // REORDER remove Element('li', { class: 'item' }, ['Item 3']), ]), Element('div', {}, ['after update']), // TEXT    ]);

  • 修改节点属性, 用PROPS表示   // newTree将ul的class属性改了 

  • 修改节点文本内容, 用TEXT表示  // newTree将div的节点内容Hello World改成了after update

  • 修改节点类型, 用REPLACE表示  // newTree将p替换成h3

  • 调整子节点,包括移动、增加、删除等,用REORDER表示  // newTree将ul下的第二个li子节点删除

1、对于节点替换,判断新旧节点的tagName和是不是一样的,如果不一样的说明需要替换掉。例如:newTree将p替换成h3,就记录如下:

patches[1] = [{ type: REPALCE, node: newNode // el('h3', props, children)}]

2、如果给元素新增了属性,例如:newTree的ul新增了class属性,就记录如下:

patches[3] = [{ type: PROPS, props: { class: "itemWrap" }}]

3、如果是文本节点改变,例如:newTree将div的节点内容Hello World改成了after update,就记录如下:

patches[10] = [{ type: TEXT, content: "after update"}]

 4、如果是删除子节点,例如:newTree将ul下的第二个li子节点删除,就记录如下:

  
 patches[3] = [{ type: PROPS, props: { class: "itemWrap" } }, { type: REORDER, moves: [ { index: 2, type: 0 } ]    }]

5、那如果把我div的子节点重新排序呢?例如p, ul, div的顺序换成了div, p, ul。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如p和div的tagName不同,p会被div所替代。最终,三个节点都会被替换,这样DOM开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。这牵涉到两个列表的对比算法

4.2.3、列表对比算法

假设现在可以英文字母唯一地标识每一个子节点
旧的节点顺序:

a b c d e f g h i

现在对节点进行了删除、插入、移动的操作。新增j节点,删除e节点,移动h节点:

新的节点顺序:

a b c h d f g i j

现在知道了新旧节点的顺序,求最小的插入、删除操作(移动可以看成是删除和插入操作的结合)。这个问题抽象出来其实是字符串的最小编辑距离问题(Edition Distance),最常见的解决算法是 Levenshtein Distance,通过动态规划求解,时间复杂度为 O(M * N)。但是我们并不需要真的达到最小的操作,我们只需要优化一些比较常见的移动情况,牺牲一定DOM操作,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣可以参考代码。

我们能够获取到某个父节点的子节点的操作,就可以记录下来:

patches[0] = [{ type: REORDER, moves: [{remove or insert}, {remove or insert}, ...]}]

但是要注意的是,因为tagName是可重复的,不能用这个来进行对比。所以需要给子节点加上唯一标识key,列表对比的时候,使用key进行对比,这样才能复用老的 DOM 树上的节点。

这样,我们就可以通过深度优先遍历两棵树,每层的节点进行对比,记录下每个节点的差异了。完整 diff 算法代码可见 diff.js。


【4.3】把差异应用到真实的DOM树上

虚拟DOM有了,Diff也有了,现在就可以将Diff应用到真实DOM上了。

深度遍历DOM将Diff的内容更新进去:

function patch (node, patches) { var walker = {index: 0} dfsWalk(node, walker, patches)}
function dfsWalk (node, walker, patches) { var currentPatches = patches[walker.index] // 从patches拿出当前节点的差异
var len = node.childNodes ? node.childNodes.length : 0 for (var i = 0; i < len; i++) { // 深度遍历子节点 var child = node.childNodes[i] walker.index++ dfsWalk(child, walker, patches) }
if (currentPatches) { applyPatches(node, currentPatches) // 对当前节点进行DOM操作 }}

具体更新的代码如下,applyPatches根据不同类型的差异对当前节点进行 DOM 操作:

function applyPatches(node, currentPatches) { currentPatches.forEach((currentPatch) => { switch (currentPatch.type) { case REPLACE: { const newNode = (typeof currentPatch.node === 'string') ? document.createTextNode(currentPatch.node) : currentPatch.node.render(); node.parentNode.replaceChild(newNode, node); break; } case REORDER: reorderChildren(node, currentPatch.moves); break; case PROPS: setProps(node, currentPatch.props); break; case TEXT: if (node.textContent) { node.textContent = currentPatch.content; } else { // ie node.nodeValue = currentPatch.content; } break; default: throw new Error(`Unknown patch type ${currentPatch.type}`); } });}

虚拟DOM的目的是将所有操作累加起来,统计计算出所有的变化后,统一更新一次DOM。

【4.4】总结

Virtual DOM 算法主要是实现上面步骤的三个函数:ELement,diff,patch。然后就可以实际的进行使用

// 1. 构建虚拟DOMvar tree = Element('div', {'id': 'container'}, [ Element('h1', {style: 'color: blue'}, ['simple virtal dom']), Element('p', ['Hello, virtual-dom']), Element('ul', [Element('li')])])
// 2. 通过虚拟DOM构建真正的DOMvar root = tree.render()document.body.appendChild(root)
// 3. 生成新的虚拟DOMvar newTree = Element('div', {'id': 'container'}, [ Element('h1', {style: 'color: red'}, ['simple virtal dom']), Element('p', ['Hello, virtual-dom']), Element('ul', [Element('li'), Element('li')])])
// 4. 比较两棵虚拟DOM树的不同var patches = diff(tree, newTree)
// 5. 在真正的DOM元素上应用变更patch(root, patches)

以上是关于虚拟DOM是啥?的主要内容,如果未能解决你的问题,请参考以下文章

虚拟DOM是啥?

2.ReactJS基础(虚拟DOM,JSX语法)

关于React中的虚拟DOM与Diff算法

此 Canon SDK C++ 代码片段的等效 C# 代码是啥?

有人知道下面的代码片段是啥意思吗?

iphone / Objective c的最佳代码片段网站是啥[重复]