首页 > Vue源码终笔-VNode更新与diff算法初探

Vue源码终笔-VNode更新与diff算法初探

  写完这个就差不多了,准备干新项目了。

  确实挺不擅长写东西,感觉都是罗列代码写点注释的感觉,这篇就简单阐述一下数据变动时DOM是如何更新的,主要讲解下其中的diff算法。

 

  先来个正常的html模板:

    <body><div id='app'><div v-for="item in items">{ {item}}div><div @click='click'>click me!div>div>body><script src='./vue.js'>script><script>new Vue({el: '#app',data: {items: [1]},methods: {click: function() {this.items.push(2);}}})

  页面上有一个通过v-for渲染的div,还有一个按钮,点击按钮时会让div数量+1。

  

  首先需要提到的是,每一次渲染DOM,都会保存一份当前虚拟DOM的副本挂载到_vnode属性上,如图:

  点击前,整个VNode结构为:根节点及3个子节点,子节点均包含2个div标签和一个空白文本节点,div包含对应的文本节点。

  点击后,由于vue劫持了部分数组方法,所以会进入自定义的push方法中,将弹入的新元素进行广播,过程就不看了。

  完成数组添加后,会生成一个新的render函数与新的VNode,diff算法就是比较新旧VNode的差异,通过最小的变化操作渲染新的DOM。

  讲VNode的diff算法之前,有一个小点先讲一下:如何判断当前VNode可复用?

  销毁一个DOM节点并创建一个新的再插入是消耗非常大的,无论是DOM对象本身的复杂性还是操作引起的重绘重排,所以虚拟DOM的目标是尽可能复用现有DOM进行更新。

  其中涉及的概念就是新的VNode能否在旧的基础上修改并复用呢?有一个函数就是做这个判断的:

    function sameVnode(a, b) {return (// key来源于v-for或者自定的:key属性a.key === b.key &&a.tag === b.tag &&a.isComment === b.isComment &&isDef(a.data) === isDef(b.data) &&sameInputType(a, b))}

  该判断有5重标准:

  (1)key:key属性如果没有设置默认是undefined,当且仅当v-for的列表渲染中会给节点加一个唯一的key,形式如图:,key不一样的节点不进行复用,官方文档也有说明设置key属性可以强制重新生成一个新DOM。

  (2)tag:复用的节点必须保证标签名一致,毕竟没有更改tag名的API

  (3)isComment:注释与普通的DOM不是一个次元,所以需要判断

  (4)isDef(*.data):这个涉及属性的更新,如果一个节点没有任何属性,即data为undefined,与一个有data属性的节点进行更新不如直接渲染一个新的

  (5)sameInputType:这个主要是input标签type属性异同判断,不同的type相当于不同的tag

  如果均满足,可以判定该节点可复用。

 

  前面说了,每一个更改数据源,会生成一个新的VNode,来与旧的VNode进行比较,节点间的比较无非是判断是否可复用,再进行属性置换。

  而diff算法主要是针对子节点的更新,即两个数组之间的异同比较与更新。

  一个数组的变化无非3个状态:增、删、改,但是其中增删会涉及数组索引与对应元素的变动,总体来讲还是比较复杂的。

  源码中有一个函数专门处理子节点比较,整体如下:

    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {// var... 

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {// 旧VNode不存在if (isUndef(oldStartVnode)) {// ...} else if (isUndef(oldEndVnode)) {// ...} else if (sameVnode(oldStartVnode, newStartVnode)) {// ...} else if (sameVnode(oldEndVnode, newEndVnode)) {// ...} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right// ...} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left// ...} else {// ... }}if (oldStartIdx > oldEndIdx) {// ...} else if (newStartIdx > newEndIdx) {// ... }}

  第一次看还是比较懵逼的,主路线while循环中有7重判断,分别对应7种情况。

  分解本例中的情况,不贴代码,尝试画个图:

  

  对比新旧VNode,可以看出新的VNode在索引0的后面插入了一个新的tag

  接下来通过updateChildren函数进行比较,有很多的变量,这里还需要一个图:

  在函数中有8个变量,其中4个旧VNode,4个新VNode,分别是一一对应的,解释一半就行了:

    var oldStartIdx = 0;var newStartIdx = 0;var oldEndIdx = oldCh.length - 1;var oldStartVnode = oldCh[0];var oldEndVnode = oldCh[oldEndIdx];var newEndIdx = newCh.length - 1;var newStartVnode = newCh[0];var newEndVnode = newCh[newEndIdx];

  (1)oldStartIdx => 从前往后的旧VNode数组索引,初始化时为0 => 简称为前索引

  (2)oldStartVnode => 对应索引的旧VNode元素 => 简称为前元素

  (3)oldEndIdx => 从后往前的旧VNode数组索引,初始化为children的数组长度 => 简称为后索引

  (4)oldEndVnode => 对应索引的旧Vnode元素 => 简称为后元素

  后面的阐述全部用简称,不然太难讲了,并且新VNode的数组简称newCh,旧VNode的数组简称oldCh

  另外4个变量只是将old更换为new,并对应新VNode的索引与元素。

  

  接下来是一个大while循环,终止条件是前索引大于后索引(newCh或oldCh):

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {if (isUndef(oldStartVnode)) {// ...} else if (isUndef(oldEndVnode)) {// ...} else if (sameVnode(oldStartVnode, newStartVnode)) {// ...} else if (sameVnode(oldEndVnode, newEndVnode)) {// ...} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right// ...} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left// ...} else {// ...
        }}

   由于有几种情况我模拟不出来,只能大概过一下。

1、isUndef(oldStartVnode)、isUndef(oldEndVnode)

  前两种是oldCh前元素oldCh后元素不存在,我能模拟的情况是当oldCh中没有元素时,会出现这种情况。

  这时只是单纯加前索引加1或者后索引减1,而oldCh长度此时为0,会立即跳出while循环,进入下一步。

2、sameVnode(a,b)

  下面的的4种情况都是判断节点是否可复用,然后进行更新。其中对比的情况有4对:

  oldCh前元素 => newCh前元素

  oldCh后元素 => newCh后元素

  oldCh前元素 => newCh后元素

  oldCh后元素 => newCh前元素 

  取第一种情况来说,如果比较通过,说明oldCh前元素可以被复用,随即调用patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)来对DOM进行更新,由于tag是不变的,可以直接对DOM进行各种API调用,比如说事件更改,只要remove旧事件,add新事件就行,这里只是DOM对象的属性更改,不会影响到DOM的增删。

  当patch完毕后,会将oldCh前索引newCh的前索引加1,并更新对应的元素,然后进入下一轮循环。

  画一轮图解释:

  

  此时第一个子节点已经更新完毕,然后重新开始对比,如果oldCh与newCh的索引1处也可复用,会再次更新并加1,直到前索引大于后索引时,说明所有可能的比较都进行完毕。

  这里的4种比较没有必要重复过一遍,如果是前索引就加1,后索引就减1。

3、else{...}

  最后一种情况是需要强制更新元素时才会有的情况,比如:

    <body><div id='app'><div v-if="!vIfIter" key='o'>old Ele1div><div v-if="vIfIter" key='n'>new Elediv><div @click='click'>click me!div>div>body><script src='./vue.js'>script><script>new Vue({el: '#app',data: {vIfIter: false},methods: {click: function() {this.vIfIter = true;}}})script>

  此时,由于设置了单独的key值,所以div被标记为不可复用,跳过了所有判断进入了else阶段:

    // 这里将旧VNode中剩余的元素key值作为对象输出if (isUndef(oldKeyToIdx)) {oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);}// 判断新VNode中是否存在可复用的元素idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null;// 不存在就创建一个新的插入DOM中if (isUndef(idxInOld)) {// New element
    }// 存在 else {elmToMove = oldCh[idxInOld];if (sameVnode(elmToMove, newStartVnode)) {// 更新VNode
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);// 把旧的VNode置空 此处会触发到while循环的前两个判断oldCh[idxInOld] = undefined;// 移动更新后的VNodecanMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm);newStartVnode = newCh[++newStartIdx];}// 同样的key值不同的tag 创建新DOM插入else {// same key but different element. treat as new element
        }}

  简单来讲还是可复用就复用,不可复用创建新DOM插入。

 

  最后来看看while循环跳出来的语句,其实很简单:

    // VNode数量增加了if (oldStartIdx > oldEndIdx) {// 如果VNode是中间插入就会存在refElm// 否则refElm为null 调用insertBefore会将DOM插入父元素尾部refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm;addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);}// 减少了 else if (newStartIdx > newEndIdx) {// 移除多出来的DOM节点
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);}

 

  至此,所有的分析完了,上面的案例有兴趣可以自己跑跑。

  

  不容易啊,写完了。。。已经入行5个月,由于没有什么好项目练手,只能看源码提升基本功,接下来可能很长时间不写博客了。(反正也没人看,啊哈哈哈哈~)

  (定个小目标,Codewars刷到3kyu,加油!)

转载于:https://www.cnblogs.com/QH-Jimmy/p/7449789.html

更多相关:

  • 栈stack:stack 后入先出(LIFO) q.top()获取栈顶元素(并不删除)q.pop()删除栈顶元素q.push(x)向栈中加入元素q.empty()判断栈是否为空 队列queue:先入先出(FIFO)   q.front()获取队首元素(并不删除)q.pop()删除队首元素q.push(x)向队列中加入元素q....

  • resize(),设置大小(size); reserve(),设置容量(capacity); size()是分配容器的内存大小,而capacity()只是设置容器容量大小,但并没有真正分配内存。 打个比方:正在建造的一辆公交车,车里面可以设置40个座椅(reserve(40);),这是它的容量,但并不是说它里面就有了40个座椅,只能说...

  • v-for="(index,$i) in total" :key="$i":style="{left:`${itemWidth*((index-1)%rowItemCount)}px`,top:`${itemHeight*(Math.ceil(index/rowItemCount)-1)}px`}" //total是显示总数量 //l...

  •   技巧一(推荐指数★★★★★) 采用top、right、bottom、left,可以不在乎父元素的宽度和高度,对GPU损耗低于技巧三,但是对浏览器内存的消耗高于技巧三 .子元素 {/*父元素需要position: relative|absolute;*/position: absolute;margin: auto;to...

  • 设计一个支持 push,pop,top 操作,并能在常数时间内检索到最小元素的栈。 push(x) – 将元素 x 推入栈中。pop() – 删除栈顶的元素。top() – 获取栈顶元素。getMin() – 检索栈中的最小元素。 示例: MinStack minStack = new MinStack(); minStack...

  • 一、视图(Views)与 同义词   1、视图:实际上是对查询结果集的封装,视图本身不存储任何数据,所有的数据都存放在原来的表中;      在逻辑上可以把视图看作是一张表   2、作用: 封装查询语句,简化复杂的查询需求屏蔽表中的细节  3、语法:  create [or replace] view 视图的名称 as 查询语句...

  • explain显示了mysql如何使用索引来处理select语句以及连接表。可以帮助选择更好的索引和写出更优化的查询语句。 虽然这篇文章我写的很长,但看起来真的不会困啊,真的都是干货啊!!!! 先解析一条sql语句,看出现什么内容 EXPLAIN SELECT s.uid,s.username,s.name,f.email,f.mob...

  • 重建索引  如果表中记录频繁地被删除或插入,尽管表中的记录总量保持不变,索引空间的使用量会不断增加。虽然记录从索引中被删除,但是该记录索引项的使用空间不能被重新使用。因此,如果表变化不定,索引空间量会不断增加,不论表中记录数量是否增加,这是因为索引中无效空间会增加。 要回收那些曾被删除记录使用的空间,需要使用Alter index r...

  • 其实SQL能力很差劲,简单查询还成,复杂查询以及优化,基本脑子里没有概念。了解一下概念,然后打算找本理论书好好看看。 先到处找了些优化的sql,整理出来,记录一下。     1、对查询进行优化,应尽量避免全表扫描,首先应考虑在where 及order by 涉及的列上建立索引。     2、应尽量避免在where 子句中对字段进行n...

  • 原文地址:oracle索引的简单总结作者:kindle一、索引的概念:     数据库的索引类似于书籍的索引。在书籍中,索引允许用户不必翻阅完整个书就能迅速地找到所需要的信息。在数据库中,索引也允许数据库程序迅速地找到表中的数据,而不必扫描整个数据库。 二、索引的特点:     1.索引可以加快数据库的检索速度     2.索引降低...