livoras / blog

Too young, too simple. Sometimes, naive.

Home Page:https://livoras.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

深度剖析:如何实现一个 Virtual DOM 算法

livoras opened this issue · comments

作者:戴嘉华

转载请注明出处并保留原文链接( #13 )和作者信息。

目录:

  • 1 前言
  • 2 对前端应用状态管理思考
  • 3 Virtual DOM 算法
  • 4 算法实现
    • 4.1 步骤一:用JS对象模拟DOM树
    • 4.2 步骤二:比较两棵虚拟DOM树的差异
    • 4.3 步骤三:把差异应用到真正的DOM树上
  • 5 结语
  • 6 References

1 前言

本文会在教你怎么用 300~400 行代码实现一个基本的 Virtual DOM 算法,并且尝试尽量把 Virtual DOM 的算法思路阐述清楚。希望在阅读本文后,能让你深入理解 Virtual DOM 算法,给你现有前端的编程提供一些新的思考。

本文所实现的完整代码存放在 Github

2 对前端应用状态管理的思考

假如现在你需要写一个像下面一样的表格的应用程序,这个表格可以根据不同的字段进行升序或者降序的展示。

sort-table

这个应用程序看起来很简单,你可以想出好几种不同的方式来写。最容易想到的可能是,在你的 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,性价比太低;而且这样做的话,inputtextarea的会失去原有的焦点。最后的结论会是:对于局部的小视图的更新,没有问题(Backbone就是这么干的);但是对于大型视图,如全局应用状态变更的时候,需要更新页面较多局部视图的时候,这样的做法不可取。

但是这里要明白和记住这种做法,因为后面你会发现,其实 Virtual DOM 就是这么做的,只是加了一些特别的步骤来避免了整棵 DOM 树变更

另外一点需要注意的就是,上面提供的几种方法,其实都在解决同一个问题:维护状态,更新视图。在一般的应用当中,如果能够很好方案来应对这个问题,那么就几乎降低了大部分复杂性。

3 Virtual DOM算法

DOM是很慢的。如果我们把一个简单的div元素的属性都打印出来,你会看到:

dom-attr

而这仅仅是第一层。真正的 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 算法。包括几个步骤:

  1. 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中
  2. 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异
  3. 把2所记录的差异应用到步骤1所构建的真正的DOM树上,视图就更新了

Virtual DOM 本质上就是在 JS 和 DOM 之间做了一个缓存。可以类比 CPU 和硬盘,既然硬盘这么慢,我们就在它们之间加个缓存:既然 DOM 这么慢,我们就在它们 JS 和 DOM 之间加个缓存。CPU(JS)只操作内存(Virtual DOM),最后的时候再把变更写入硬盘(DOM)。

4 算法实现

4.1 步骤一:用JS对象模拟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

4.2 步骤二:比较两棵虚拟DOM树的差异

正如你所预料的,比较两棵DOM树的差异是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。两个树的完全的 diff 算法是一个时间复杂度为 O(n^3) 的问题。但是在前端当中,你很少会跨越层级地移动DOM元素。所以 Virtual DOM 只会对同一个层级的元素进行对比:

compare-in-level

上面的div只会和同一层级的div对比,第二层级的只会跟第二层级对比。这样算法复杂度就可以达到 O(n)。

4.2.1 深度优先遍历,记录差异

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

dfs-walk

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

// 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}, ...] // 用数组存储新旧节点的不同

同理ppatches[1]ulpatches[3],类推。

4.2.2 差异类型

上面说的节点的差异指的是什么呢?对 DOM 操作可能会:

  1. 替换掉原来的节点,例如把上面的div换成了section
  2. 移动、删除、新增子节点,例如上面div的子节点,把pul顺序互换
  3. 修改了节点的属性
  4. 对于文本节点,文本内容可能会改变。例如修改上面的文本节点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新增了属性idcontainer,就记录下:

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。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如pdivtagName不同,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树上

因为步骤一所构建的 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

5 结语

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

// 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,仅供学习。

6 References

https://github.com/Matt-Esch/virtual-dom/blob/master/vtree/diff.js

不错

这个确实需要膜拜

commented

👍

👍

commented

不错,学习了。

写得很不错!!!

有没有JSX语法的实现的资料

分析的好透彻。。

这个赞

@binggg 最近在写,到时候也写篇博客整理一下JSX实现相关的。

zan !

赞赞赞...

问下文章里的图是用什么工具画的?

commented

简单的reactjs,不错

commented

DOM是很慢的,待考究,DOM的CURD是很慢的可能更合适。

判断两棵树的某个节点是否为同一个节点是最关键的

@Y-WinDow 如果没有key的情况下,无法判断,所以如果两个节点tagName不一样会整棵子树替换掉;如果有key的话,关键点就是怎么用list-diff使得对比的时候两个节点是同一个节点了

请问一下,simple-virtual-dom 支持IE6+吗?

赞!

好文

赞!关于算法复杂度如何从O(n^3)到O(n),以及从O(MN)到O(max(M, N)),在React的文档里也有介绍,可以参考http://facebook.github.io/react/docs/reconciliation.html

@wzhscript 不错 👍 很好的资料

赞 ,写的很好

commented

妥妥的收藏了

代码分析很到位,可以作为下一个学习的点 👍

commented

en

nice!

最近在学习 mithril.js ,发现这个框架挺适合我的,支持IE6+,虚拟Dom,同构程序。

@cheft 听说过这个库,有空也研究下

@livoras 本来打算在你这个库的基础上开发一个框架,但是项目太紧了,没时间来写,因此打算用其它的库。我本来用 riot.js 开发过几个项目,挺好用的;但是这边公司要支持IE6+,因此发现只有mithril.js呢,但 mithril.js 比 riot.js 坑多一点。

@cheft
我最近也在使用riotjs, 话说还有ie6需要支持,汗。。。太坑了吧

@cheft 刚刚看了看mithril.js发现它的view完全使用js方式来做,用一个m()方法来生成dom元素。感觉对前端开发人员不友好。。。

@lloydzhou mithril 跟 react 的方式是一样的,都是写 jsx 方式,她类似的有个 msx ,在js里面写 html ; 没办法,公司要支持 IE6+ ,所以只能考虑用它。在学习成本上面他要高于 riot.js 的,有些api也不是很容易让人理解。 riot.js 确实很好用,但只支持IE9+。

赞,相当好,谢谢。

// 2. 通过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)

如上,什么场景下会先先新建一个dom加到document上去,虚拟dom做修改后再更新root。
我理解的场景:
已经有一个dom了,先获取该dom,copy其结构,然后做修改,再统一更新root,减少repaint。

所以楼主代码流程里的场景是什么,请指教...

@quenteenfix
传统的服务端开发人员会直接使用后端语言渲染好页面给浏览器。就像你说的dom已经存在了,js只需要更新dom。

但是现在很多的前端页面,还有一些SPA应用,开始的时候页面上面几乎没有什么dom元素,都是生成dom元素之后再添加进去的。

不过,确实像你说的,更新dom元素的场景应该会比较多 😃

@lloydzhou 已经说得很好了。

@quenteenfix

比较适合组件化的前端页面。假如你有一个可复用的组件,那么这个组件的渲染构造、状态维护就是由前端控制的。对于高度组件化的前端页面,可能整个页面都是由组件拼接而成,那么这时候整个DOM的构造几乎都是由前端JS控制的了。

可以举一个例子,例如时间选择的组件,整个组件不能由服务器一步渲染完成,它的构造必然是在JS中进行,这时候就需要在JS中先构造DOM鸟。

ReactJS就是一个很好的例子,把页面完全进行了组件化。

学习

学习

很好的**!

commented

盖楼,膜拜楼主,学习了

commented

赞,学习了

手动点赞

commented

赞,写的不错,学习了

图片不能打开了啊~~

这篇文章我服,尤其是那个list列表对比算法

谢谢分享,有时间来实现一遍。

commented

五体投地

commented

我和作者居然是一届的。。看看现在的自己,惭愧啊,万分惭愧。。

niubility :)

上次看了这篇文章,直到今天才阅读源码,学到很多。

commented

这确实是一个好文,不过有一个问题

MVVM 可以很好的降低我们维护状态 -> 视图的复杂程度(大大减少代码中的视图更新逻辑)。但是这不是唯一的办法,还有一个非常直观的方法,可以大大降低视图更新的操作:一旦状态发生了变化,就用模版引擎重新渲染整个视图,然后用新的视图更换掉旧的视图。就像上面的表格,当用户点击的时候,还是在JS里面更新状态,但是页面更新就不用手动操作 DOM 了,直接把整个表格用模版引擎重新渲染一遍,然后设置一下innerHTML就完事了

这个并不是mvvm的做法,这个有点像是 jquery + template 的实现,mvvm在初始化页面的时候会去分析出一个对应关系,这个步骤也可以叫做依赖收集,也就是说一旦有数据更改,它会去找到对应依赖这个数据的元素,然后进行局部修改。

@monkindey 请注意这里的转折:

但是这不是唯一的办法,还有一个非常直观的方法,可以大大降低视图更新的操作...

commented

@monkindey 难道你认为设置相同的innerHTML内容不会导致浏览器重绘?看起来dom操作好像少了,但是与Virtual DOM的思路是相悖的

commented

@livoras 不好意思,没注意到哈。

commented

@Robin-front 虚拟DOM到最后不也是要用一些元素的DOM api么?比如 innerHTML , 为什么说是相悖的呢?多多指教。

难道你认为设置相同的innerHTML内容不会导致浏览器重绘

这句话不太理解
mvvm 只改我依赖的元素呀,其他不变呢。只是它初始化的时候比较慢而已,它要去生成一个 vmodel

commented

好文章啊,收藏先。

收藏

列表对比算法那里,看了代码感觉不是很理解,能否指点一下?为什么要创建中间的simulateList?

很赞!

有一个地方不太明白额。虚拟DOM最终不也是操作了原生的DOM了吗,这跟直接使用DOM API进行更新的区别在哪儿,同样都操作原生DOM,为什么虚拟DOM比较快?

赞赞

👍

@fengyinchao 没错,同样都是操作了 DOM ,但是你要明白,使用虚拟 DOM 的 DOM 操作步骤是很小的(但并不一定是最小的),而如果你想达到同样小的操作步骤,你用手动操作 DOM 的方法来写就很麻烦了。虚拟 DOM 的目的并不是“更快的 DOM 操作”,而是优化视图更新逻辑。

commented

太赞了!
问个新手问题:“两个树的完全的 diff 算法是一个时间复杂度为 O(n^3) ”这个是怎么来的?有没有形象点的图形化步骤介绍?

学习了,顺便拿来练手自己写写理解一下。

赞!既涨了姿势,又看见了各位**碰撞的火花!

想问代码里面计算virtual element的key的代码

  this.key = props
    ? props.key
    : void 666

这段代码有什么含义?如何计算唯一的key?

commented

zan

真大神也

不错, 思路很清晰

666

真赞

找了好久终于找到你,分析的很透彻,赞赞赞

学习了。赞!

mark

学习,赞

学习了

请问react是如何实现,在一个事件循环中批处理虚拟dom的刷新的

膜拜大神

请问楼主为什么不在操作 virtual dom 时给 element 加个dirty flag?然后渲染时判断 dirty 就重新渲染,而是又要 diff 又要 patch 多两次遍历?

list diff算法应该不是react原版,这个diff结果只有,缺少效率可想而知

实现上有bug,用增 + 删实现的,忘记了,用例:

var key = 'k'
var oldList = [1, 2, 3, 7, 4].map(function(item, i) {return {k: item, v: item}})
var newList = [1, 4, 5, 3, 7, 6].map(function(item, i) {return {k: item, v: item}})
// 结果[1, 4, 5, 3, 7, 6, 4]

list diff策略是更新效率的关键,没有这么简单,需要仔细揣摩react具体实现(几年打磨的东西不是几行就能等价模拟的),最简单的list diff至少要有密切相关,改完看用不用移):

var move = function(list, from, to) {
    var item = list.splice(from, 1);
    if (from > to)
        list.splice(to, 0, item[0]);
    else
        list.splice(to - 1, 0, item[0]);
};
var diff = function(oldList, newList) {
    var changes = [];
    // 镜像,模拟位置
    var _oldList = oldList.slice();
    // 遍历旧的,找出 删
    oldList.forEach(function(item, i) {
        if (newList.indexOf(item) === -1) {
            changes.push({
                type: 'remove',
                index: i
            });
            _oldList.splice(i, 1);
        }
    });

    // 遍历新的,找出 增/移
    newList.forEach(function(item, i) {
        var index = _oldList.indexOf(item);
        if (index === -1) {
            // 增
            changes.push({
                type: 'insert',
                index: i,
                item: item
            });
            _oldList.splice(i, 0, item);
        }
        else {
            // 位置是否一样
            if (index === i) {
                // 检查要不要改
                changes.push({
                    type: 'check',
                    index: i
                });
            }
            else {
                // 移
                var step = {
                    type: 'move',
                    from: index,
                    to: i
                };
                changes.push(step);
                move(_oldList, step.from, step.to);
            }
        }
    });

    return changes;
};

// test
var showSteps = function(changes) {
    changes.forEach(function(change) {
        switch (change.type) {
            case 'insert':
                console.log('insert ' + change.item + ' before ' + oldList[change.index]);
                oldList.splice(change.index, 0, change.item);
                break;
            case 'remove':
                console.log('remove ' + oldList[change.index]);
                oldList.splice(change.index, 1);
                break;
            case 'check':
                console.log('check ' + oldList[change.index] + ' for update');
                break;
            case 'move':
                console.log('move ' + oldList[change.from] + ' to ' + oldList[change.to]);
                move(oldList, change.from, change.to);
                break;
            default:
                cosole.error('not here');
                break;
        }
    })
    if (JSON.stringify(oldList) === JSON.stringify(newList)) console.info(oldList);
    else console.error('diff出错');
}
var oldList = [1, 2, 3, 4, 7];
var newList = [1, 4, 5, 7, 3, 6];
var changes = diff(oldList, newList);
showSteps(changes);

// 结果
remove 2
check 1 for update
move 4 to 3
insert 5 before 3
check 3 for update
check 7 for update
insert 6 before undefined

另外,这里只向后看一位,很弱(错位的时候后面全都是增 + 删,实际上应该是):

// if remove current simulateItem make item in right place
// then just remove it
var nextItemKey = getItemKey(simulateList[j + 1], key)
if (nextItemKey === itemKey) {
  remove(i)
  removeSimulate(j)
  j++ // after removing, current j is right, just jump to next one
} else {
  // else insert item
  insert(i, item)
}

当然,感谢博主提供的整个过程分析

厉害了!

感谢博主分享,收获很大

@channelone
void 666 === undefined
任意数字都行

666

很是形象

commented

学习

@fengyinchao 虚拟dom并不是操作真正的DOM,只是用js对象描述的DOM的结构

如果我们把一个简单的div元素的属性都打印出来,你会看到......而这仅仅是第一层。for...in不是会遍历到原型链中的属性吗?为什么说仅仅是第一层?

commented

这也叫差异算法? 从头比到脚,得出了一个需要多做n步到patch,呵

@DemonCloud 所以你想表达的是?

受益匪浅,多谢博主!!

Mark,感谢大大热心分享~

非常棒!

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
})
}
如果有leftNode时, currentNodeIndex = currentNodeIndex + leftNode.count + 1 ?? 这样算不太对吧,应该currentNodeIndex = currentNodeIndex + leftNode.count吧 ?