做者:戴嘉华javascript
转载请注明出处并保留原文连接( https://github.com/livoras/blog/issues/13 )和做者信息。html
1 前言前端
2 对前端应用状态管理思考java
3 Virtual DOM 算法node
4 算法实现git
4.1 步骤一:用JS对象模拟DOM树github
4.2 步骤二:比较两棵虚拟DOM树的差别算法
4.3 步骤三:把差别应用到真正的DOM树上编程
5 结语segmentfault
6 References
本文会在教你怎么用 300~400 行代码实现一个基本的 Virtual DOM 算法,而且尝试尽可能把 Virtual DOM 的算法思路阐述清楚。但愿在阅读本文后,能让你深刻理解 Virtual DOM 算法,给你现有前端的编程提供一些新的思考。
本文所实现的完整代码存放在 Github。
假如如今你须要写一个像下面同样的表格的应用程序,这个表格能够根据不一样的字段进行升序或者降序的展现。
这个应用程序看起来很简单,你能够想出好几种不一样的方式来写。最容易想到的多是,在你的 JavaScript 代码里面存储这样的数据:
var sortKey = "new" // 排序的字段,新增(new)、取消(cancel)、净关注(gain)、累积(cumulate)人数 var sortType = 1 // 升序仍是逆序 var data = [{...}, {...}, {..}, ..] // 表格数据
用三个字段分别存储当前排序的字段、排序方向、还有表格数据;而后给表格头部加点击事件:当用户点击特定的字段的时候,根据上面几个字段存储的内容来对内容进行排序,而后用 JS 或者 jQuery 操做 DOM,更新页面的排序状态(表头的那几个箭头表示当前排序状态,也须要更新)和表格内容。
这样作会致使的后果就是,随着应用程序愈来愈复杂,须要在JS里面维护的字段也愈来愈多,须要监听事件和在事件回调用更新页面的DOM操做也愈来愈多,应用程序会变得很是难维护。后来人们使用了 MVC、MVP 的架构模式,但愿能从代码组织方式来下降维护这种复杂应用程序的难度。可是 MVC 架构没办法减小你所维护的状态,也没有下降状态更新你须要对页面的更新操做(前端来讲就是DOM操做),你须要操做的DOM仍是须要操做,只是换了个地方。
既然状态改变了要操做相应的DOM元素,为何不作一个东西可让视图和状态进行绑定,状态变动了视图自动变动,就不用手动更新页面了。这就是后来人们想出了 MVVM 模式,只要在模版中声明视图组件是和什么状态进行绑定的,双向绑定引擎就会在状态更新的时候自动更新视图(关于MV*模式的内容,能够看这篇介绍)。
MVVM 能够很好的下降咱们维护状态 -> 视图的复杂程度(大大减小代码中的视图更新逻辑)。可是这不是惟一的办法,还有一个很是直观的方法,能够大大下降视图更新的操做:一旦状态发生了变化,就用模版引擎从新渲染整个视图,而后用新的视图更换掉旧的视图。就像上面的表格,当用户点击的时候,仍是在JS里面更新状态,可是页面更新就不用手动操做 DOM 了,直接把整个表格用模版引擎从新渲染一遍,而后设置一下innerHTML
就完事了。
听到这样的作法,经验丰富的你必定第一时间意识这样的作法会致使不少的问题。最大的问题就是这样作会很慢,由于即便一个小小的状态变动都要从新构造整棵 DOM,性价比过低;并且这样作的话,input
和textarea
的会失去原有的焦点。最后的结论会是:对于局部的小视图的更新,没有问题(Backbone就是这么干的);可是对于大型视图,如全局应用状态变动的时候,须要更新页面较多局部视图的时候,这样的作法不可取。
可是这里要明白和记住这种作法,由于后面你会发现,其实 Virtual DOM 就是这么作的,只是加了一些特别的步骤来避免了整棵 DOM 树变动。
另一点须要注意的就是,上面提供的几种方法,其实都在解决同一个问题:维护状态,更新视图。在通常的应用当中,若是可以很好方案来应对这个问题,那么就几乎下降了大部分复杂性。
DOM是很慢的。若是咱们把一个简单的div
元素的属性都打印出来,你会看到:
而这仅仅是第一层。真正的 DOM 元素很是庞大,这是由于标准就是这么设计的。并且操做它们的时候你要当心翼翼,轻微的触碰可能就会致使页面重排,这但是杀死性能的罪魁祸首。
相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,并且更简单。DOM 树上的结构、属性信息咱们均可以很容易地用 JavaScript 对象表示出来:
var 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树。
以前的章节所说的,状态变动->从新渲染整个视图的方式能够稍微修改一下:用 JavaScript 对象表示 DOM 信息和结构,当状态变动的时候,从新渲染这个 JavaScript 的对象结构。固然这样作其实没什么卵用,由于真正的页面其实没有改变。
可是能够用新渲染的对象树去和旧的树进行对比,记录这两棵树差别。记录下来的不一样就是咱们须要对页面真正的 DOM 操做,而后把它们应用在真正的 DOM 树上,页面就变动了。这样就能够作到:视图的结构确实是整个全新渲染了,可是最后操做DOM的时候确实只变动有不一样的地方。
这就是所谓的 Virtual DOM 算法。包括几个步骤:
用 JavaScript 对象结构表示 DOM 树的结构;而后用这个树构建一个真正的 DOM 树,插到文档当中
当状态变动的时候,从新构造一棵新的对象树。而后用新的树和旧的树进行比较,记录两棵树差别
把2所记录的差别应用到步骤1所构建的真正的DOM树上,视图就更新了
Virtual DOM 本质上就是在 JS 和 DOM 之间作了一个缓存。能够类比 CPU 和硬盘,既然硬盘这么慢,咱们就在它们之间加个缓存:既然 DOM 这么慢,咱们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操做内存(Virtual DOM),最后的时候再把变动写入硬盘(DOM)。
用 JavaScript 来表示一个 DOM 节点是很简单的事情,你只须要记录它的节点类型、属性,还有子节点:
element.js
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 结构就能够简单的表示:
var el = require('./element') var 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
构建真正的<ul>
:
Element.prototype.render = function () { var el = document.createElement(this.tagName) // 根据tagName构建 var props = this.props for (var propName in props) { // 设置节点的DOM属性 var propValue = props[propName] el.setAttribute(propName, propValue) } var children = this.children || [] children.forEach(function (child) { var childEl = (child instanceof Element) ? child.render() // 若是子节点也是虚拟DOM,递归构建DOM节点 : document.createTextNode(child) // 若是字符串,只构建文本节点 el.appendChild(childEl) }) return el }
render
方法会根据tagName
构建一个真正的DOM节点,而后设置这个节点的属性,最后递归地把本身的子节点也构建起来。因此只须要:
var ulRoot = ul.render() document.body.appendChild(ulRoot)
上面的ulRoot
是真正的DOM节点,把它塞入文档中,这样body
里面就有了真正的<ul>
的DOM结构:
<ul id='list'> <li class='item'>Item 1</li> <li class='item'>Item 2</li> <li class='item'>Item 3</li> </ul>
完整代码可见 element.js。
正如你所预料的,比较两棵DOM树的差别是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。两个树的彻底的 diff 算法是一个时间复杂度为 O(n^3) 的问题。可是在前端当中,你不多会跨越层级地移动DOM元素。因此 Virtual DOM 只会对同一个层级的元素进行对比:
上面的div
只会和同一层级的div
对比,第二层级的只会跟第二层级对比。这样算法复杂度就能够达到 O(n)。
在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每一个节点都会有一个惟一的标记:
在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。若是有差别的话就记录到一个对象里面。
// 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 }) }
例如,上面的div
和新的div
有差别,当前的标记是0,那么:
patches[0] = [{difference}, {difference}, ...] // 用数组存储新旧节点的不一样
同理p
是patches[1]
,ul
是patches[3]
,类推。
上面说的节点的差别指的是什么呢?对 DOM 操做可能会:
替换掉原来的节点,例如把上面的div
换成了section
移动、删除、新增子节点,例如上面div
的子节点,把p
和ul
顺序互换
修改了节点的属性
对于文本节点,文本内容可能会改变。例如修改上面的文本节点2内容为Virtual DOM 2
。
因此咱们定义了几种差别类型:
var REPLACE = 0 var REORDER = 1 var PROPS = 2 var TEXT = 3
对于节点替换,很简单。判断新旧节点的tagName
和是否是同样的,若是不同的说明须要替换掉。如div
换成section
,就记录下:
patches[0] = [{ type: REPALCE, node: newNode // el('section', props, children) }]
若是给div
新增了属性id
为container
,就记录下:
patches[0] = [{ type: REPALCE, node: newNode // el('section', props, children) }, { type: PROPS, props: { id: "container" } }]
若是是文本节点,如上面的文本节点2,就记录下:
patches[2] = [{ type: TEXT, content: "Virtual DOM2" }]
那若是把我div
的子节点从新排序呢?例如p, ul, div
的顺序换成了div, p, ul
。这个该怎么对比?若是按照同层级进行顺序对比的话,它们都会被替换掉。如p
和div
的tagName
不一样,p
会被div
所替代。最终,三个节点都会被替换,这样DOM开销就很是大。而其实是不须要替换节点,而只须要通过节点移动就能够达到,咱们只需知道怎么进行移动。
这牵涉到两个列表的对比算法,须要另外起一个小节来讨论。
假设如今能够英文字母惟一地标识每个子节点:
旧的节点顺序:
a b c d e f g h i
如今对节点进行了删除、插入、移动的操做。新增j
节点,删除e
节点,移动h
节点:
新的节点顺序:
a b c h d f g h 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。
由于步骤一所构建的 JavaScript 对象树和render
出来真正的DOM树的信息、结构是同样的。因此咱们能够对那棵DOM树也进行深度优先的遍历,遍历的时候从步骤二生成的patches
对象中找出当前遍历的节点差别,而后进行 DOM 操做。
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(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('Unknown patch type ' + currentPatch.type) } }) }
完整代码可见 patch.js。
Virtual DOM 算法主要是实现上面步骤的三个函数:element,diff,patch。而后就能够实际的进行使用:
// 1. 构建虚拟DOM var tree = el('div', {'id': 'container'}, [ el('h1', {style: 'color: blue'}, ['simple virtal dom']), el('p', ['Hello, virtual-dom']), el('ul', [el('li')]) ]) // 2. 经过虚拟DOM构建真正的DOM var root = tree.render() document.body.appendChild(root) // 3. 生成新的虚拟DOM var newTree = el('div', {'id': 'container'}, [ el('h1', {style: 'color: red'}, ['simple virtal dom']), el('p', ['Hello, virtual-dom']), el('ul', [el('li'), el('li')]) ]) // 4. 比较两棵虚拟DOM树的不一样 var patches = diff(tree, newTree) // 5. 在真正的DOM元素上应用变动 patch(root, patches)
固然这是很是粗糙的实践,实际中还须要处理事件监听等;生成虚拟 DOM 的时候也能够加入 JSX 语法。这些事情都作了的话,就能够构造一个简单的ReactJS了。
本文所实现的完整代码存放在 Github,仅供学习。
https://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js