【算法剖析】Virtual DOM
Virtual DOM 的优势
- 可以缓存dom操作,按批执行,减少dom操作。
- 可以知道DOM的具体更新位置,做到局部刷新。
Virtual dom相当于框架API与运行环境之间的中间层,将框架渲染与DOM API进行解耦,增加了跨平台能力。(可以将virtual dom映射为DOM的步骤,改为映射到其他的执行环境,比如安卓、IOS)
Virtual DOM 的步骤
设计js数据结构来表示DOM节点:
function VNode(tagName, props, children, key) { this.tagName = tagName this.props = props this.children = children this.key = key }
实现一个方法,能够从VNode生成真正的DOM tree(VNode对应根节点):
function render({tag, props, children, key}) { // 通过 tag 创建节点 let el = document.createElement(tag) // 设置节点属性 for (const key in props) { if (props.hasOwnProperty(key)) { const value = props[key] el.setAttribute(key, value) } } if (key) { el.setAttribute('key', key) } // 递归创建子节点 if (children) { children.forEach(element => { let child if (element instanceof VNode) { child = this._createElement( element.tag, element.props, element.children, element.key ) } else { child = document.createTextNode(element) } el.appendChild(child) }) } return el }
- 找到一个方法,根据data model生成vDOM tree:
这个映射是由用户来定义的(一般通过template),所以这个方法一般是通过编译template来得到。
- 实现vdom的diff算法,计算两棵树的差异。
- 实现patch算法,将差异应用到真正的DOM tree上,使view(DOM)与新 vdom(data model)保持一致。
第4步和第5步的2个函数可以合并。找出新vdom和DOM tree之间的差异,同时更新DOM tree来消除差异。
Virtual DOM 管理视图更新的流程
首次渲染:
状态更新:根据model渲染新的vdom tree,与旧的vdom tree对比,计算出需要的更新。
如果选择合并diff和patch算法,渲染出新vdom以后,将新的vdom tree与真实的DOM tree对比,同时更新DOM tree,使view(DOM)与新 vdom(data model)保持一致。
diff算法的实现
diff的算法有很多种实现(见下面的参考资料),目的都是计算出需要的更新步骤,以便应用到真实的DOM上。
各种vdom tree diff算法之间的主要差异在于diff子节点列表的算法(也就是下面的listDiff)。把握各种listDiff算法的关键在于,
- 在diff之前,旧vdom tree和新vdom tree的根节点作为参数传入。
开始对2棵树同时进行深度优先遍历。这是特殊的深度优先遍历,每次同时访问2个节点用于对比:旧vdom的节点(以下称为oldNode)和新vdom的对应节点(以下称为newNode)。
如果newNode.tag === oldNode.tag && newNode.key === oldNode.key(key可以都为undefined),将它们视为同一个元素在不同时刻的状态。要分别diff它们的属性和子节点。
- diffProps(oldNode, newNode),检测节点上的属性是否发生了增加、删除、修改,这些修改应该记录为patch(后面讨论patch)。
diffChildren(oldNode.children, newNode.children),检测子节点(数组)是否发生了变化,这些修改应该记录为patch。此外,diffChildren将会递归调用diffTree,来检测子树的变化。
要检测子节点数组的变化,即需要一个算法来找出:oldNode.children数组如何通过 增加、删除、移动 节点,变成newNode.children。我们把这种算法称为listDiff:
检测删除的节点:遍历oldNode.children,对于每个child,查找newNode.children中是否有相同key的节点(用map数据结构,查找的时间为log(n))。如果不存在,说明这个是被删除的节点,要输出删除操作,并记录它在中间状态数组中的下标。
- 中间状态数组:在listDiff算法开始的时候,中间状态数组就是oldNode.children。每检测出一个增加、删除、移动操作,都要对中间状态数组进行这个操作,中间状态数组跃迁到下一个状态。最后,中间状态数组跃迁变成了newNode.children。
- 我们每次检测出的操作都是要作用在中间状态数组上的。因此,在输出删除操作的时候,记录的下标是被删节点在中间状态数组中的下标。输出其他类型的操作也同理。
- 基于中间状态数组输出操作的目的是:我们能将这些操作相继执行,从oldNode.children得到newNode.children。这也是listDiff算法需要保证的语义。
- 逆序遍历的技巧:如果你遍历oldNode.children的时候是按照下标顺序遍历的,你会发现,直接输出被删除节点在oldNode.children中的下标,是不符合上面所说的语义的。但是,如果你遍历oldNode.children的时候是按照下标逆序遍历的,直接输出被删除节点在oldNode.children中的下标就恰好符合语义。这是因为先删除数组后面的节点,不会影响数组前面的节点的下标。
检测增加和移动的节点:遍历newNode.children,对于每个child,查找oldNode.children中是否存在相同key的节点。
- 如果不存在,说明这个是被增加的节点,要输出这个节点,以及它被插入后在中间状态数组中的位置;
如果存在,但是在newNode.children中的下标不等于在中间状态数组中的下标,说明这个是被移动的节点,要输出这个节点移动前和移动后的中间状态数组中的位置。
- 遍历newNode.children的时候按照下标顺序遍历。直观上,中间状态数组从左往右被扫描和修正,被扫描过的节点一一匹配于newNode.children中的节点,就像一个分开的拉链被从左往右缓缓拉上一样。仔细思考一下,有这样的结论:节点被插入后在中间状态数组中的位置==节点在newNode.children中的位置(因为插入完成以后,中间状态数组的这个位置就不会再修改了),节点移动后在中间状态数组中的位置==节点在newNode.children中的位置(因为移动完成以后,中间状态数组的这个位置就不会再修改了)。
- listDiff完成以后,得到一个由增加、删除、移动组成的操作序列,能将oldNode.children变成newNode.children,要将这些修改记录为patch。
找到2个对应的子节点(一个在oldNode.children中,一个在newNode.children中,两个节点是同一个元素在不同时刻的状态)来调用diffTree:
遍历oldNode.children,对于每一个child,找出在newNode.children中有相同key的节点,这两个节点就是相互对应的节点。以这两个节点为参数调用diffTree。
- 递归调用时,只对那些相互对应的2个节点递归调用diffTree,如果节点在另一个vdom没有对应,则不会被递归遍历到。
- 我们只需要对同时存在于旧vdom和新vdom中的节点递归调用diffTree。假设一个节点不存在于旧vdom但在新vdom中,那么【它所在的整个子树】会在【某个祖先节点增加子节点的时候】被创建,这个新创建的子树肯定是不需要更新的。不在旧vdom中也同理,详见patch的讨论。
- 如果newNode.tag !== oldNode.tag || newNode.key !== oldNode.key,说明newNode和oldNode根本不是同一个节点,因此直接替换(删除oldNode,增加newNode)。在遍历两个树的根节点的时候可能会出现这种情况,从这以后,如果2个节点不是同一个节点,那根本就不会对它们调用diffTree。
diffTree的基本代码如下(先不考虑patch):
function diffTree(oldTreeRootNode, newTreeRootNode) { diffProps(oldTreeRootNode, newTreeRootNode); // 旧树和新树中对应的节点结对返回 const pairs = listDiff(oldTreeRootNode.children, newTreeRootNode.children); for (const [oldChild, newChild] of pairs) { diffTree(oldChild, newChild); } }
上面的讨论没有考虑没有key的节点。可以将【旧list的无key节点】与【新list的无key节点】按出现顺序一一对应,视为同一个节点。这个算法的实现可以参考参考资料1。这个实现仅用于理解中间状态diff算法的思想。
这个算法并不是vue所使用的(见参考资料2)。这个算法仅用于理解diff的思想。
patches
patch 含义
patch的意思是“如何修改旧的vdom,将它变为新的vdom”。它是diff vdom最重要的输出,毕竟我们diff vdom的目的就是要知道如何修改DOM。
patch的操作包括:
- 增加、删除、修改某个节点的属性。
增加、删除、移动某个节点的子节点。
- 在实现patch的操作的时候要注意,“增加某个节点的子节点”的patch操作,意味着增加整个子树。删除、移动同理。
可见,任何patch的操作都和某个节点相关,并且这个节点必定在旧vdom和新vdom中都存在的。
反证法:假设某个patch的操作(设为patchA)作用的节点不存在于旧vdom中,说明这个节点或它的某个祖先节点是新增的节点,也就是说,必定有一个“增加某个节点的子节点”的patch操作(设为patchB)作用于一个祖先节点。既然patchB意味着增加整个子树,那么patchA根本就没有存在的必要,因为它所在的整个子树在patchB的时候就被已经正确创建了。
存储patches
因为每一个patch操作都关联于一个已存在节点,所以我们存储patch的方式是:为每个旧vdom中的节点分配一个数组,这个数组包括了所有和这个节点有关的patch操作。因此最终的patches是一个二维数组。在第一个维度上,节点按照深度优先遍历的顺序排列,也就是第1行是根节点的patch操作,第2行是左子节点的patch操作,第3行是【左子节点的左子节点】的patch操作,以此类推。
应用patches
得到了patches以后,通过将patches中的操作应用于对应的DOM节点,我们就可以更新DOM树,使得DOM树等价于新的vdom树。
更新的方法就是,对dom进行深度优先遍历,当前元素深度优先遍历的序号是n,那么patches[n]就是这个元素关联的patch操作,然后将这些操作依次应用于这个元素。
如果有一个子节点是被增加的,那么这个子节点下面的子树就可以被跳过了,因为这是按照新vdom创建的子树,不需要更新。
对patch的处理上,参考资料5实现得比较清晰。
参考资料
- 中间状态 的diff算法: 与Angular的diff算法有一些类似,不过Angular的实现要高效很多(使用链表来加速插入删除),并且考虑得更加完备(多个节点有同一个key)。
- 双端对比 的diff算法: snabbdom使用了这个算法,Vue使用了snabbdom。
- 最长递增子序列(LIS) 的diff算法: inferno使用这个算法。
- 最长公共子序列(LCS) 的diff算法: petit-dom实现了这个算法。
- Virtual DOM 算法实现框架: 实现了完整的Virtual DOM工作流程,不过list-diff有点太简单了,以至于会忽略很多节点移动,而用插入/删除代替。建议用其它的diff算法来代替它的list-diff。
- 各种框架的状态-UI同步机制
相关推荐
Vue和React是数据驱动视图,如何有效控制DOM操作?能不能把计算,更多的转移为js计算?因为js执行速度很快。patch函数-->patch,对比tag,对比tag与key,对比children