咱们常说的虚拟DOM是经过JS对象模拟出来的DOM
节点,domDiff是经过特定算法计算出来一次操做所带来的DOM
变化。
react和vue中都使用了虚拟DOM,vue我只停留在使用层面就很少说了,react了解多一些,就借着react聊聊虚拟DOM。
react中涉及到虚拟DOM的代码主要分为如下三部分,其中核心是第二步的domDiff算法:css
干前端的都知道DOM
操做是性能杀手,由于操做DOM
会引发页面的回流或者重绘。相比起来,经过多一些预先计算来减小DOM
的操做要划算的多。
可是,“使用虚拟DOM会更快”这句话并不必定适用于全部场景。例如:一个页面就有一个按钮,点击一下,数字加一,那确定是直接操做DOM
更快。使用虚拟DOM无非白白增长了计算量和代码量。即便是复杂状况,浏览器也会对咱们的DOM
操做进行优化,大部分浏览器会根据咱们操做的时间和次数进行批量处理,因此直接操做DOM
也未必很慢。
那么为何如今的框架都使用虚拟DOM呢?由于使用虚拟DOM能够提升代码的性能下限,并极大的优化大量操做DOM时产生的性能损耗。同时这些框架也保证了,即便在少数虚拟DOM不太给力的场景下,性能也在咱们接受的范围内。
并且,咱们之因此喜欢react、vue等使用了虚拟DOM框架,不光是由于他们快,还有不少其余更重要的缘由。例如react对函数式编程的友好,vue优秀的开发体验等,目前社区也有好多比较这两个框架并打口水战的,我觉着仍是在两个都懂的状况下多探究一下原理更有意义一些。前端
实现domDiff分为如下四步:vue
设计师的老本行不能忘,看我画张图:react
解释一下这张图:
首先看第一个红色色块,这里说的是把真实DOM
映射为虚拟DOM
,其实在react中没有这个过程,咱们直接写的就是虚拟DOM(JSX),只是这个虚拟DOM
表明着真实DOM
。
当虚拟DOM变化时,例如上图,它的第三个p
和第二个p
中的son2
被删除了。这个时候咱们会根据先后的变化计算出一个差别对象patches
。
这个差别对象的key值就是老的DOM
节点遍历时的索引,用这个索引咱们能够找到那个节点。属性值是记录的变化,这里是remove
,表明删除。
最后,根据patches
中每一项的索引去对应的位置修改老的DOM
节点。es6
下面这段代码是入口文件,咱们模拟了一个虚拟DOM叫oldEle
,咱们这里是写死的。而在react中,是经过babel解析JSX语法获得一个抽象语法树(AST),进而生成虚拟DOM。若是对babel转换感兴趣,能够看看另外一篇文章入门babel--实现一个es6的class转换器。算法
import { createElement } from './createElement' let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style:'color:red' }, ['son1']), createElement('h2', { style:'color:blue' }, ['son2']), createElement('h3', { style:'color:red' }, ['son3']) ]) document.body.appendChild(oldEle.render()) 复制代码
下面这个文件导出了createElement
方法。它其实就是new
了一个Element
类,调用这个类的render
方法能够把虚拟DOM
转换为真实DOM
。编程
class Element { constructor(tagName, attrs, childs) { this.tagName = tagName this.attrs = attrs this.childs = childs } render() { let element = document.createElement(this.tagName) let attrs = this.attrs let childs = this.childs //设置属性 for (let attr in attrs) { setAttr(element, attr, attrs[attr]) } //先序深度优先遍历子建立并插入子节点 for (let i = 0; i < childs.length; i++) { let child = childs[i] console.log(111, child instanceof Element) let childElement = child instanceof Element ? child.render() : document.createTextNode(child) element.appendChild(childElement) } return element } } function setAttr(ele, attr, value) { switch (attr) { case 'style': ele.style.cssText = value break; case 'value': let tageName = ele.tagName.toLowerCase() if (tagName == 'input' || tagName == 'textarea') { ele.value = value } else { ele.setAttribute(attr, value) } break; default: ele.setAttribute(attr, value) break; } } function createElement(tagName, props, child) { return new Element(tagName, props, child) } module.exports = { createElement } 复制代码
如今这段代码已经能够跑起来了,执行之后的结果以下图:浏览器
//keyIndex记录遍历顺序 let keyIndex = 0 function diff(oldEle, newEle) { let patches = {} keyIndex = 0 walk(patches, 0, oldEle, newEle) return patches } //分析变化 function walk(patches, index, oldEle, newEle) { let currentPatches = [] //这里应该有不少的判断类型,这里只处理了删除的状况... if (!newEle) { currentPatches.push({ type: 'remove' }) } else if (oldEle.tagName == newEle.tagName) { //比较儿子们 walkChild(patches, currentPatches, oldEle.childs, newEle.childs) } //判断当前节点是否有改变,有的话把补丁放入补丁集合中 if (currentPatches.length) { patches[index] = currentPatches } } function walkChild(patches, currentPatches, oldChilds, newChilds) { if (oldChilds) { for (let i = 0; i < oldChilds.length; i++) { let oldChild = oldChilds[i] let newChild = newChilds[i] walk(patches, ++keyIndex, oldChild, newChild) } } } module.exports = { diff } 复制代码
上面这段代码就是domDiff算法的超级简化版本:bash
其实walk中应该有大量的逻辑,我只处理了一种状况,就是元素被删除。其实还应该有添加、替换等各类状况,同时涉及到大量的边界检查。真正的domDiff算法很复杂,它的复杂度应该是O(n3),react为了把复杂度下降到线性而作了一系列的妥协。
我这里只是选取一种状况作了演示,有兴趣的能够看看源码或者搜索一些相关的文章。这篇文章毕竟叫“浅入浅出”,很是浅……babel
好,那咱们执行这个算法看看效果:
import { createElement } from './createElement' import { diff } from './diff' let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, ['son2']), createElement('h3', { style: 'color:red' }, ['son3']) ]) let newEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, []) ]) console.log(diff(oldEle, newEle)) 复制代码
我在入口文件中新建立了一个元素,用来表明被更改以后的虚拟DOM,它有两个元素被删除了,一个h3
、一个文本节点son2
,理论上应该有两条记录,执行代码咱们看下:
咱们看到,输出的patches
对象里有两个属性,属性名是这个元素的遍历序号、属性值是记录的信息,咱们就是经过序号去遍历找到老的DOM
节点,经过属性值里的信息来作相应的更新。
下面咱们看如何经过获得的patches
对象更新视图:
let index = 0; let allPatches; function patch(root, patches) { allPatches = patches walk(root) } function walk(root) { let currentPatches = allPatches[index] index++ (root.childNodes || []) && root.childNodes.forEach(child => { walk(child) }) if (currentPatches) { doPatch(root, currentPatches) } } function doPatch(ele, currentPatches) { currentPatches.forEach(currentPatch => { if (currentPatch.type == 'remove') { ele.parentNode.removeChild(ele) } }) } module.exports = { patch } 复制代码
文件导出的patch
方法有两个参数,root
是真实的DOM
节点,patches
是补丁对象,咱们用和遍历虚拟DOM
一样的手段(先序深度优先)去遍历真实的节点,这很重要,由于咱们是经过patches
对象的key
属性记录哪一个节点发生了变化,相同的遍历手段能够保证咱们的对应关系是正确的。
doPatch
方法很简单,判断若是type
是“remove”,直接删掉该DOM
节点。其实这个方法也不该该这么简单,它也应该处理不少事情,好比说删除、互换等,其实还应该判断属性的变化并作相应的处理。
浅入浅出嘛,因此这些都没处理,我固然不会说我根本写不出来……
如今咱们应用一下这个patch
方法:
import { createElement } from './createElement' import { diff } from './diff' import { patch } from './patch' let oldEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:blue' }, ['son2']), createElement('h3', { style: 'color:green' }, ['son3']) ]) let newEle = createElement('div', { class: 'father' }, [ createElement('h1', { style: 'color:red' }, ['son1']), createElement('h2', { style: 'color:green' }, []) ]) //这里应用了patch方法,给原始的root节点打了补丁,更新成了新的节点 let root = oldEle.render() let patches = diff(oldEle, newEle) patch(root, patches) document.body.appendChild(root) 复制代码
好,咱们执行代码,看一下视图的变化:
咱们看到,h3标签不见了,h2标签还在,可是里面的文本节点son2不见了,这跟咱们的预期是同样的。
到这里,这个算法就已经写完了,上面贴出来的代码都是按模块贴出来的,而且是完整能够运行的。
这个算法还有不少没有处理的问题,例如:
上面的代码只是把react中的核心思路简单实现了一下,只是供你们了解一下domDiff算法的思路,如我个人描述让你对domDiff产生了一点兴趣或者对你有一点帮助,我很高兴。