vue源码分析【7】-diff算法

356 阅读6分钟

以下代码和分析过程需要结合vue.js源码查看,通过打断点逐一比对。

模板代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <script src="./../../oldVue.js"></script>
</head>

<body>
    <div id="app">
        <div>
            <div>
                <div @click='updateNode'>更新</div>
                <span v-if='oldVal'>更换这里</Span>
            </div>
            <span v-if='newVal'>更换这里</span>
        </div>
    </div>

    <script>

        var app = new Vue({
            el: '#app',
            beforeCreate() { },
            created() { },
            beforeMount() { },
            mounted: () => { },
            beforeUpdate() { },
            updated() { },
            beforeDestroy() { },
            destroyed() { },
            data: function () {
                return {
                    oldVal: true,
                    newVal: false,
                }
            },
            methods: {
                updateNode() {
                    this.oldVal = false
                    this.newVal = true
                }
            }
        })

    </script>

</body>

</html>

前言

本文的结构依据点,线,面来展开。

  • 点即函数的作用
  • 线即函数的执行流程
  • 面即源码的详细解读

十分不建议直接看源码,很多函数非常长,并且链路很长,在没有对函数有大概的了解情况,大概率下,你读了一遍源码后会发现,wc 我刚看了源码了吗?可是咋记不清它们做了啥操作。因此,先看作用,再看流程,再展开看源码。


1. withMacroTask

作用:

当我们点击更新去更新节点时,就会走到这里。

源码:

function withMacroTask(fn) {
        //宏任务
        return fn._withTask || (fn._withTask = function () {
            useMacroTask = true;
            var res = fn.apply(null, arguments);
            useMacroTask = false;
            return res
        })
}

function flushCallbacks() {
        pending = false;
        //.slice(0) 浅拷贝
        var copies = callbacks.slice(0);
        callbacks.length = 0;
        for (var i = 0; i < copies.length; i++) {
            //执行回调函数
            copies[i]();
        }
    }

2. mountComponent

作用:

接着就会走到这里。

源码:

function mountComponent(
        vm,  //Vue 实例
        el,  //真实dom
        hydrating //新的虚拟dom vonde
    ) {
        vm.$el = el;
        if (!vm.$options.render) {
            vm.$options.render = createEmptyVNode;
            ...
            //如果参数中的template模板第一个不为# 号则会 警告,
            // 因为Runtime only版本的代码是这种格式:el: '#app'

            //无法装载组件:未定义template或render函数
        }
        callHook(vm, 'beforeMount');
        var updateComponent;
        if ("development" !== 'production' && config.performance && mark) {
            updateComponent = function () {
                var name = vm._name;
                var id = vm._uid;
                var startTag = "vue-perf-start:" + id;
                var endTag = "vue-perf-end:" + id;
                mark(startTag); //插入一个名称 并且记录插入名称的时间
                var vnode = vm._render();
                mark(endTag);
                measure(("vue " + name + " render"), startTag, endTag);
                mark(startTag); //浏览器 性能时间戳监听
                //更新组件
                vm._update(vnode, hydrating);
                mark(endTag);
                measure(("vue " + name + " patch"), startTag, endTag);
            };
        } else {
            // 会走到这里----------
            updateComponent = function () {
                //直接更新view试图
                vm._update(
                    vm._render(), 
                    hydrating
                );
            };
        }
}

3. lifecycleMixin

作用:

接着就会走到这里,最终走到vm.$el = vm.__patch__(prevVnode, vnode);

源码:

function lifecycleMixin(Vue) {
            // 更新数据函数,负责更新页面,页面首次渲染和后续更新的入口位置,也是 patch 的入口位置 
        Vue.prototype._update = function (vnode, hydrating) {
            debugger
            var vm = this;
            // 是否 触发过 钩子Mounted
            // Todo vm._isMounte表示什么
            if (vm._isMounted) {
                //触发更新数据 触发生命周期函数
                callHook(vm, 'beforeUpdate');
            }
            // 标志上一个 el 节点
            var prevEl = vm.$el;
            // 标志上一个 vonde
            var prevVnode = vm._vnode;
            // 活动实例
            var prevActiveInstance = activeInstance;
            activeInstance = vm;
            //标志上一个 vonde
            vm._vnode = vnode;
            // 【逻辑 1】 执行初始化
            //如果不存在表示上一次没有创建过vnode,即当前是初始化,第一次进来
            if (!prevVnode) {
                // 这里通过patch函数,是创建真实dom
                // 注意,patch在vue中非常核心,我们将在后面用单独文章来解析
                debugger
                vm.$el = vm.__patch__(
                    vm.$el, //真正的dom
                    vnode, //vnode
                    hydrating, // 空
                    false /* removeOnly */,
                    vm.$options._parentElm, //父节点 空
                    vm.$options._refElm //当前节点 空
                );
                //  初始补丁之后不需要ref节点,这可以防止在内存中保留分离的DOM树
                vm.$options._parentElm = vm.$options._refElm = null;
            }
            // 【逻辑 2】 执行数据更新
            else {
                // 如果这个prevVnode存在,表示vno的已经创建过,只是更新数据而已
                // 比较新旧节点,生成新的dom
                //-------重点就是这里了
                debugger
                vm.$el = vm.__patch__(prevVnode, vnode);
            }
            // 更新全局的activeInstance原来的旧值
            activeInstance = prevActiveInstance;
            //  更新vue参考
            if (prevEl) {
                prevEl.__vue__ = null;
            }
            if (vm.$el) { // 更新真实dom上对虚拟dom的指向
                vm.$el.__vue__ = vm;
            }
            // if parent is an HOC, update its $el as well
            if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
                vm.$parent.$el = vm.$el;
            }
            // updated hook is called by the scheduler to ensure that children are
            // updated in a parent's updated hook.
        };
        ... // 省略其它代码
}

4. createPatchFunction

作用:

接着就会走到这里,最终走到vm.$el = vm.__patch__(prevVnode, vnode);

执行流程:

  • vnode和oldVnod有一个不存在,则销毁老节点或者创建新节点
  • 剩下的逻辑为vnode和oldVnode都存在
  • 如果oldVnode不是真实dom,且2个节点的基本属性相同,那么就进入了2个节点的【diff过程】,用patchVnode函数进行后续比对工作
  • 如果oldVnode是真实dom,并且服务端渲染元素或者合并到真实DOM失败,则创建一个空的Vnode节点去替换它
    • 如果老节点是真实DOM,创建对应的vnode节点
    • 为新的Vnode创建元素/组件实例,若parentElm存在,则插入到父元素上
    • 如果组件根节点被替换,遍历更新父节点elm
    • 然后移除老节点
  • 返回vnode.elm 源码:
function createPatchFunction(backend) {
    ...//省略多行代码

    // 最终走到这里
        return function patch(
            oldVnode, //旧的vonde或者是真实的dom. 或者是没有
            vnode, //新的vode
            hydrating,
            removeOnly, //是否要全部删除标志
            parentElm, //父节点 真实的dom
            refElm//当前节点 真实的dom
        ) {
            debugger
            ;
            // vnode和oldVnod有一个不存在--------------------
            /**
             * 如果vnode不存在,但是oldVnode存在,说明意图是要【销毁老节点】,
             * 那么就调用invokeDestroyHook(oldVnode)来进行销毁
            */
            if (isUndef(vnode)) {
                if (isDef(oldVnode)) {
                    invokeDestroyHook(oldVnode);
                }
                return
            }
            var isInitialPatch = false;
            /**
             vonde队列 如果vnode上有insert钩子,那么就将这个vnode放入
             insertedVnodeQueue中作记录,到时再在全局批量调用insert钩子回调
            */
            var insertedVnodeQueue = [];

            //如果没有定义旧的vonde, 则当前操作为【创建新的节点】
            if (isUndef(oldVnode)) { 
                isInitialPatch = true;
                createElm( //创建节点
                    vnode, //虚拟dom
                    insertedVnodeQueue, //vonde队列空数组
                    parentElm, //真实的 父节点
                    refElm  //当前节点
                );
            } 
            // 剩余情况为vnode和oldVnode都存在--------------------
            else {
                var isRealElement = isDef(oldVnode.nodeType); //判断是否为真是dom
                /**
                如果oldVnode不是真实dom,且2个节点的基本属性相同,
                那么就进入了2个节点的【diff过程】,用patchVnode函数进行后续比对工作
                */
                if (!isRealElement &&  
                    sameVnode(oldVnode, vnode) 
                ) {
                    patchVnode(
                        oldVnode,
                        vnode,
                        insertedVnodeQueue, //vonde队列
                        removeOnly  //是否要全部删除标志
                    );
                } else {
                    //oldVnode是真实dom
                    if (isRealElement) {
                      if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
                        // 当旧的Vnode是服务端渲染元素,hydrating记为true
                        oldVnode.removeAttribute(SSR_ATTR)
                        hydrating = true
                      }
                      // 需要用hydrate函数将虚拟DOM和真实DOM进行映射
                      if (isTrue(hydrating)) {
                        // 需要合并到真实DOM上
                        if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
                          // 调用insert钩子
                          invokeInsertHook(vnode, insertedVnodeQueue, true)
                          return oldVnode
                        } else if (process.env.NODE_ENV !== 'production') {
                       ...
                       //客户端呈现的虚拟DOM树不匹配服务器呈现的内容,可能由于标签缺失
                        }
                      }
                      // 如果不是服务端渲染元素或者合并到真实DOM失败,则创建一个空的Vnode节点去替换它
                      oldVnode = emptyNodeAt(oldVnode)
                    }

                    // 获取oldVnode父节点
                    var oldElm = oldVnode.elm;
                    var parentElm$1 = nodeOps.parentNode(oldElm);
                    // 根据vnode创建一个真实DOM节点并挂载至oldVnode的父节点下
                    createElm(
                        vnode,
                        insertedVnodeQueue,
                        oldElm._leaveCb ? null : parentElm$1,
                        nodeOps.nextSibling(oldElm)
                    );
                    
                    // 如果组件根节点被替换,遍历更新父节点Element
                    if (isDef(vnode.parent)) {
                        var ancestor = vnode.parent;
                        var patchable = isPatchable(vnode);
                        while (ancestor) {
                            for (var i = 0; i < cbs.destroy.length; ++i) {
                                cbs.destroy[i](ancestor);
                            }
                            ancestor.elm = vnode.elm;
                            if (patchable) {
                                for (var i$1 = 0; i$1 < cbs.create.length; ++i$1) {
                                    cbs.create[i$1](emptyNode, ancestor);
                                }

                                var insert = ancestor.data.hook.insert;
                                if (insert.merged) {
                                    for (var i$2 = 1; i$2 < insert.fns.length; i$2++) {
                                        insert.fns[i$2]();
                                    }
                                }
                            } else {
                                registerRef(ancestor);
                            }
                            ancestor = ancestor.parent;
                        }
                    }
                       // 销毁旧节点
                    if (isDef(parentElm)) {
                      // 移除老节点
                      removeVnodes(parentElm, [oldVnode], 0, 0)
                    } else if (isDef(oldVnode.tag)) {
                      // 调用destroy钩子
                      invokeDestroyHook(oldVnode)
                    }
                }
            }

            invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch);
            return vnode.elm
        }
}

5.sameNode

源码:

function sameVnode (a, b) {  // 是否是相同的VNode节点
  return (
    a.key === b.key && (  // 如平时v-for内写的key
      (
        a.tag === b.tag &&   // tag相同
        a.isComment === b.isComment &&  // 注释节点
        isDef(a.data) === isDef(b.data) &&  // 都有data属性
        sameInputType(a, b)  // 相同的input类型
      ) || (
        isTrue(a.isAsyncPlaceholder) &&  // 是异步占位符节点
        a.asyncFactory === b.asyncFactory &&  // 异步工厂方法
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

6. updateChildren

执行流程:

如何 循环遍历?

1、使用 while

2、新旧节点数组都配置首尾两个索引

新节点的两个索引:newStartIdx , newEndIdx

旧节点的两个索引:oldStartIdx,oldEndIdx

以两边向中间包围的形式 来进行遍历

头部的子节点比较完毕,startIdx 就加1

尾部的子节点比较完毕,endIdex 就减1

只要其中一个数组遍历完(startIdx<endIdx),则结束遍历

源码处理的流程分为两个

1、比较新旧子节点

2、比较完毕,处理剩下的节点

源码:

function updateChildren(parentElm, oldCh, newCh) {
    var oldStartIdx = 0;    
    var oldEndIdx = oldCh.length - 1;    
    var oldStartVnode = oldCh[0];    
    var oldEndVnode = oldCh[oldEndIdx];    
    var newStartIdx = 0;    
    var newEndIdx = newCh.length - 1;    
    var newStartVnode = newCh[0];    
    var newEndVnode = newCh[newEndIdx];    
    var oldKeyToIdx, idxInOld, vnodeToMove, refElm;

    // 不断地更新 OldIndex 和 OldVnode ,newIndex 和 newVnode
    while (
        oldStartIdx <= oldEndIdx && 
        newStartIdx <= newEndIdx
    ) {        
        if (!oldStartVnode) {
            oldStartVnode = oldCh[++oldStartIdx];
        }     
        else if (!oldEndVnode) {
            oldEndVnode = oldCh[--oldEndIdx];
        }   

        //  旧头 和新头 比较
        else if (sameVnode(oldStartVnode, newStartVnode)) {
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        }    

        //  旧尾 和新尾 比较
        else if (sameVnode(oldEndVnode, newEndVnode)) {
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        }                
               

        // 旧头 和 新尾 比较
        else if (sameVnode(oldStartVnode, newEndVnode)) {
            patchVnode(oldStartVnode, newEndVnode);            
            // oldStartVnode 放到 oldEndVnode 后面,还要找到 oldEndValue 后面的节点
            parentElm.insertBefore(
                oldStartVnode.elm, 
                oldEndVnode.elm.nextSibling
            );
            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        }   

        //  旧尾 和新头 比较
        else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode);            
            // oldEndVnode 放到 oldStartVnode 前面
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        }        


        // 单个新子节点 在 旧子节点数组中 查找位置
        else {    
            // oldKeyToIdx 是一个 把 Vnode 的 key 和 index 转换的 map
            if (!oldKeyToIdx) {
                oldKeyToIdx = createKeyToOldIdx(
                    oldCh, oldStartIdx, oldEndIdx
                );
            }     

            // 使用 newStartVnode 去 OldMap 中寻找 相同节点,默认key存在
            idxInOld = oldKeyToIdx[newStartVnode.key]        

            //  新孩子中,存在一个新节点,老节点中没有,需要新建 
            if (!idxInOld) {  
                //  把  newStartVnode 插入 oldStartVnode 的前面
                createElm(
                    newStartVnode, 
                    parentElm, 
                    oldStartVnode.elm
                );

            }            

            else {                
                //  找到 oldCh 中 和 newStartVnode 一样的节点
                vnodeToMove = oldCh[idxInOld];     
                if (sameVnode(vnodeToMove, newStartVnode)) {
                    patchVnode(vnodeToMove, newStartVnode);  
                
                    // 删除这个 index
                    oldCh[idxInOld] = undefined;                    
                    // 把 vnodeToMove 移动到  oldStartVnode 前面
                    parentElm.insertBefore(
                        vnodeToMove.elm, 
                        oldStartVnode.elm
                    );
                }                

                // 只能创建一个新节点插入到 parentElm 的子节点中
                else {                    
                    // same key but different element. treat as new element
                    createElm(
                        newStartVnode, 
                        parentElm, 
                        oldStartVnode.elm
                    );

                }
            }            
            // 这个新子节点更新完毕,更新 newStartIdx,开始比较下一个
            newStartVnode = newCh[++newStartIdx];
        }
    }    


    // 处理剩下的节点
    if (oldStartIdx > oldEndIdx) {  
        var newEnd = newCh[newEndIdx + 1]
        refElm = newEnd ? newEnd.elm :null;        
        for (; newStartIdx <= newEndIdx; ++newStartIdx) {
            createElm(
               newCh[newStartIdx], parentElm, refElm
            );
        }
    }    

    // 说明新节点比对完了,老节点可能还有,需要删除剩余的老节点
    else if (newStartIdx > newEndIdx) {       
        for (; oldStartIdx<=oldEndIdx; ++oldStartIdx) {
            oldCh[oldStartIdx].parentNode.removeChild(el);
        }
    }
}

源码提问

1. diff算法的时间复杂度

两个树完全diff算法的时间复杂度为O(n3),Vue进行了优化,只考虑同级不考虑跨级,将时间复杂度降为O(n)
前端当中,很少会跨层级的移动Dom元素,所以Virtual Dom只会对同一个层级的元素进行对比

2. 简述vue中diff算法原理

  1. 先同级比较,再比较儿子节点
  2. 先判断一方有儿子一方没儿子的情况
  3. 比较都有儿子的情况
  4. 递归比较子节点

vue3中做了优化,只比较动态节点,略过静态节点,极大的提高了效率
双指针去确定位置

diff算法做的事情是比较VNodeoldVNode,再以VNode为标准的情况下在oldVNode上做小的改动,完成VNode对应的Dom渲染。

回到之前_update方法的实现,这个时候就会走到else的逻辑了:

Vue.prototype._update = function(vnode) {
  const vm = this
  const prevVnode = vm._vnode  // 缓存为之前vnode
  
  vm._vnode = vnode //挂载新的vnode
  
  if(!prevVnode) {  // 首次渲染(没有旧节点),只要传入新的vnode
    vm.$el = vm.__patch__(vm.$el, vnode)
  } else {  // 重新渲染,需要传入旧的vnode和新vnode,因为它需要进行对比
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
}

既然是在现有的VNode上修修补补来达到重新渲染的目的,所以无非是做三件事情:

创建新增节点,删除废弃节点,更新已有节点

2-1. 创建新增节点

新增节点两种情况下会遇到:

  • VNode中有的节点而oldVNode`没有

    • VNode中有的节点而oldVNode中没有,最明显的场景就是首次渲染了,这个时候是没有oldVNode的,所以将整个VNode渲染为真实Dom插入到根节点之内即可。
  • VNodeoldVNode完全不同

    • VNodeoldVNode不是同一个节点时,直接会将VNode创建为真实Dom,插入到旧节点的后面,这个时候旧节点就变成了废弃节点,移除以完成替换过程。

判断两个节点是否为同一个节点,内部是这样定义的:

function sameVnode (a, b) {  // 是否是相同的VNode节点
  return (
    a.key === b.key && (  // 如平时v-for内写的key
      (
        a.tag === b.tag &&   // tag相同
        a.isComment === b.isComment &&  // 注释节点
        isDef(a.data) === isDef(b.data) &&  // 都有data属性
        sameInputType(a, b)  // 相同的input类型
      ) || (
        isTrue(a.isAsyncPlaceholder) &&  // 是异步占位符节点
        a.asyncFactory === b.asyncFactory &&  // 异步工厂方法
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

2-2. 删除废弃节点

上面创建新增节点的第二种情况以略有提及,比较vnodeoldVnode,如果根节点不相同就将Vnode整颗渲染为真实Dom,插入到旧节点的后面,最后删除掉已经废弃的旧节点即可:

image.png

patch方法内将创建好的Dom插入到废弃节点后面之后:

if (isDef(parentElm)) {  // 在它们的父节点内删除旧节点
  removeVnodes(parentElm, [oldVnode], 0, 0)
}

-------------------------------------------------------------

function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      removeNode(ch.elm)
    }
  }
}  // 移除从startIdx到endIdx之间的内容

------------------------------------------------------------

function removeNode(el) {  // 单个节点移除
  const parent = nodeOps.parentNode(el)
  if(isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}

2-3. 更新已有节点

这个才是diff算法的重点,当两个节点是相同的节点时,这个时候就需要找出它们的不同之处,比较它们主要是使用patchVnode方法,这个方法里面主要也是处理几种分支情况:

2-3-1. 都是静态节点

function patchVnode(oldVnode, vnode) {
  
  if (oldVnode === vnode) {  // 完全一样
    return
  }

  const elm = vnode.elm = oldVnode.elm
  if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {  
    vnode.componentInstance = oldVnode.componentInstance
    return  // 都是静态节点,跳过
  }
  ...
}

什么是静态节点了?这是编译阶段做的事情,它会找出模板中的静态节点并做上标记(isStatictrue),例如:

<template>
  <div>
    <h2>{{title}}</h2>
    <p>新鲜食材</p>
  </div>
</template>

这里的h2标签就不是静态节点,因为是根据插值变化的,而p标签就是静态节点,因为不会改变。如果都是静态节点就跳过这次比较,这也是编译阶段为diff比对做的优化。

2-3-2. vnode节点没文本属性

function patchVnode(oldVnode, vnode) {

  const elm = vnode.elm = oldVnode.elm
  const oldCh = oldVnode.children
  const ch = vnode.children

  if (isUndef(vnode.text)) {  // vnode没有text属性
    
    if (isDef(oldCh) && isDef(ch)) {  // // 都有children
      if (oldCh !== ch) {  // 且children不同
        updateChildren(elm, oldCh, ch)  // 更新子节点
      }
    } 
    
    else if (isDef(ch)) {  // 只有vnode有children
      if (isDef(oldVnode.text)) {  // oldVnode有文本节点
        nodeOps.setTextContent(elm, '')  // 设置oldVnode文本为空
      }
      addVnodes(elm, null, ch, 0, ch.length - 1)
      // 往oldVnode空的标签内插入vnode的children的真实dom
    } 
    
    else if (isDef(oldCh)) {  // 只有oldVnode有children
      removeVnodes(elm, oldCh, 0, oldCh.length - 1)  // 全部移除
    } 
    
    else if (isDef(oldVnode.text)) {  // oldVnode有文本节点
      nodeOps.setTextContent(elm, '')  // 设置为空
    }
  } 
  
  else {  vnode有text属性
    ...
  }
  
  ...
  

如果vnode没有文本节点,又会有接下来的四个分支:

1. 都有children且不相同

  • 使用updateChildren方法更详细的比对它们的children,如果说更新已有节点是patch的核心,那这里的更新children就是核心中的核心,这个之后使用流程图的方式仔仔细细说明。

2. 只有vnodechildren

  • 那这里的oldVnode要么是一个空标签或者是文本节点,如果是文本节点就清空文本节点,然后将vnodechildren创建为真实Dom后插入到空标签内。

3. 只有oldVnodechildren

  • 因为是以vnode为标准的,所以vnode没有的东西,oldVnode内就是废弃节点,需要删除掉。

4. 只有oldVnode有文本

  • 只要是oldVnode有而vnode没有的,清空或移除即可。

2-3-3. vnode节点有文本属性

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {

  const elm = vnode.elm = oldVnode.elm
  const oldCh = oldVnode.children
  const ch = vnode.children

  if (isUndef(vnode.text)) {  // vnode没有text属性
    ...
  } else if(oldVnode.text !== vnode.text) {  // vnode有text属性且不同
    nodeOps.setTextContent(elm, vnode.text)  // 设置文本
  }
  
  ...
  

还是那句话,以vnode为标准,所以vnode有文本节点的话,无论oldVnode是什么类型节点,直接设置为vnode内的文本即可。至此,整个diff比对的大致过程就算是说明完毕了,我们还是以一张流程图来理清思路:

image.png

2-3-4. 更新已有节点之更新子节点 (重点中的重点)

更新子节点示例:
<template>
  <ul>
    <li v-for='item in list' :key='item.id'>{{item.name}}</li>
  </ul>
</template>

export default {
  data() {
    return {
      list: [{
        id: 'a1',name: 'A'}, {
        id: 'b2',name: 'B'}, {
        id: 'c3',name: 'C'}, {
        id: 'd4',name: 'D'}
      ]
    }
  },
  mounted() {
    setTimeout(() => {
      this.list.sort(() => Math.random() - .5)
        .unshift({id: 'e5', name: 'E'})
    }, 1000)
  }
}

上述代码中首先渲染一个列表,然后将其随机打乱顺序后并添加一项到列表最前面,这个时候就会触发该组件更新子节点的逻辑,之前也会有一些其他的逻辑,这里只用关注更新子节点相关,来看下它怎么更新Dom的:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0  // 旧第一个下标
  let oldStartVnode = oldCh[0]  // 旧第一个节点
  let oldEndIdx = oldCh.length - 1  // 旧最后下标
  let oldEndVnode = oldCh[oldEndIdx]  // 旧最后节点
  
  let newStartIdx = 0  // 新第一个下标
  let newStartVnode = newCh[0]  // 新第一个节点
  let newEndIdx = newCh.length - 1  // 新最后下标
  let newEndVnode = newCh[newEndIdx]  // 新最后节点
  
  let oldKeyToIdx  // 旧节点key和下标的对象集合
  let idxInOld  // 新节点key在旧节点key集合里的下标
  let vnodeToMove  // idxInOld对应的旧节点
  let refElm  // 参考节点
  
  checkDuplicateKeys(newCh) // 检测newVnode的key是否有重复
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  // 开始遍历children
  
    if (isUndef(oldStartVnode)) {  // 跳过因位移留下的undefined
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {  // 跳过因位移留下的undefine
      oldEndVnode = oldCh[--oldEndIdx]  
    } 
    
    else if(sameVnode(oldStartVnode, newStartVnode)) {  // 比对新第一和旧第一节点
      patchVnode(oldStartVnode, newStartVnode)  // 递归调用                        
      oldStartVnode = oldCh[++oldStartIdx]  // 旧第一节点和下表重新标记后移        
      newStartVnode = newCh[++newStartIdx]  // 新第一节点和下表重新标记后移        
    }
    
    else if (sameVnode(oldEndVnode, newEndVnode)) {  // 比对旧最后和新最后节点     
      patchVnode(oldEndVnode, newEndVnode)  // 递归调用                            
      oldEndVnode = oldCh[--oldEndIdx]  // 旧最后节点和下表重新标记前移            
      newEndVnode = newCh[--newEndIdx]  // 新最后节点和下表重新标记前移            
    }
    
    else if (sameVnode(oldStartVnode, newEndVnode)) { // 比对旧第一和新最后节点
      patchVnode(oldStartVnode, newEndVnode)  // 递归调用
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))  
      // 将旧第一节点右移到最后,视图立刻呈现
      oldStartVnode = oldCh[++oldStartIdx]  // 旧开始节点被处理,旧开始节点为第二个
      newEndVnode = newCh[--newEndIdx]  // 新最后节点被处理,新最后节点为倒数第二个
    }
    
    else if (sameVnode(oldEndVnode, newStartVnode)) { // 比对旧最后和新第一节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  // 递归调用
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // 将旧最后节点左移到最前面,视图立刻呈现
      oldEndVnode = oldCh[--oldEndIdx]  // 旧最后节点被处理,旧最后节点为倒数第二个
      newStartVnode = newCh[++newStartIdx]  // 新第一节点被处理,新第一节点为第二个
    }
    
    else {  // 不包括以上四种快捷比对方式
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) 
        // 获取旧开始到结束节点的key和下表集合
      }
      
      idxInOld = isDef(newStartVnode.key)  // 获取新节点key在旧节点key集合里的下标
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
      
      if (isUndef(idxInOld)) { // 找不到对应的下标,表示新节点是新增的,需要创建新dom
        createElm(
          newStartVnode, 
          insertedVnodeQueue, 
          parentElm, 
          oldStartVnode.elm, 
          false, 
          newCh, 
          newStartIdx
        )
      }
      
      else {  // 能找到对应的下标,表示是已有的节点,移动位置即可
        vnodeToMove = oldCh[idxInOld]  // 获取对应已有的旧节点
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
        oldCh[idxInOld] = undefined
        nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      }
      
      newStartVnode = newCh[++newStartIdx]  // 新开始下标和节点更新为第二个节点
      
    }
  }
  
  ...
  
}

函数内首先会定义一堆let定义的变量,这些变量是随着while循环体而改变当前值的,循环的退出条件为只要新旧节点列表有一个处理完就退出,看着循环体代码挺复杂,其实它只是做了三件事,明白了哪三件事再看循环体,会发现其实并不复杂:

2-3-4-1. 跳过undefined

为什么会有undefined,之后的流程图会说明清楚。这里只要记住,如果旧开始节点为undefined,就后移一位;如果旧结束节点为undefined,就前移一位。

2-3-4-2. 快捷查找

首先会尝试四种快速查找的方式,如果不匹配,再做进一步处理:

  • 1 新开始和旧开始节点比对

如果匹配,表示它们位置都是对的,Dom不用改,就将新旧节点开始的下标往后移一位即可。

  • 2 旧结束和新结束节点比对

如果匹配,也表示它们位置是对的,Dom不用改,就将新旧节点结束的下标前移一位即可。

  • 3 旧开始和新结束节点比对

如果匹配,位置不对需要更新Dom视图,将旧开始节点对应的真实Dom插入到最后一位,旧开始节点下标后移一位,新结束节点下标前移一位。

  • 4 旧结束和新开始节点比对

如果匹配,位置不对需要更新Dom视图,将旧结束节点对应的真实Dom插入到旧开始节点对应真实Dom的前面,旧结束节点下标前移一位,新开始节点下标后移一位。

2-3-4-3. key值查找
    1. 如果和已有key值匹配

那就说明是已有的节点,只是位置不对,那就移动节点位置即可。

    1. 如果和已有key值不匹配

在已有的key值集合内找不到,那就说明是新的节点,那就创建一个对应的真实Dom节点,插入到旧开始节点对应的真实Dom前面即可。

这么说并不太好理解,结合之前的示例,根据以下的流程图将会明白很多:

image.png

↑ 示例的初始状态就是这样了,之前定义的下标以及对应的节点就是startend标记。

image.png

↑ 首先进行之前说明两两四次的快捷比对,找不到后通过旧节点的key值列表查找,并没有找到说明E是新增的节点,创建对应的真实Dom,插入到旧节点里start对应真实Dom的前面,也就是A的前面,已经处理完了一个,新start位置后移一位。

image.png

↑ 接着开始处理第二个,还是首先进行快捷查找,没有后进行key值列表查找。发现是已有的节点,只是位置不对,那么进行插入操作,参考节点还是A节点,将原来旧节点C设置为undefined,这里之后会跳过它。又处理完了一个节点,新start后移一位。

image.png

↑ 再处理第三个节点,通过快捷查找找到了,是新开始节点对应旧开始节点,Dom位置是对的,新start和旧start都后移一位。

image.png

↑ 接着处理的第四个节点,通过快捷查找,这个时候先满足了旧开始节点和新结束节点的匹配,Dom位置是不对的,插入节点到最后位置,最后将新end前移一位,旧start后移一位。

image.png

↑ 处理最后一个节点,首先会执行跳过undefined的逻辑,然后再开始快捷比对,匹配到的是新开始节点和旧开始节点,它们各自start后移一位,这个时候就会跳出循环了。接着看下最后的收尾代码:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  ...
  
  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    ...
  }
  
  if (oldStartIdx > oldEndIdx) {  // 如果旧节点列表先处理完,处理剩余新节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)  // 添加
  } 
  
  else if (newStartIdx > newEndIdx) {  // 如果新节点列表先处理完,处理剩余旧节点
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)  // 删除废弃节点
  }
}

我们之前的示例刚好是新旧节点列表同时处理完退出的循环,这里是退出循环后为还有没有处理完的节点,做不同的处理:

image.png

以新节点列表为标准,如果是新节点列表处理完,旧列表还有没被处理的废弃节点,删除即可;如果是旧节点先处理完,新列表里还有没被使用的节点,创建真实Dom并插入到视图即可。

具体链接

3. v-for中为什么要用key

diff比对内部做更新子节点时,会根据oldVnode内没有处理的节点得到一个key值和下标对应的对象集合,为的就是当处理vnode每一个节点时,能快速查找该节点是否是已有的节点,从而提高整个diff比对的性能。如果是一个动态列表,key值最好能保持唯一性,但像轮播图那种不会变更的列表,使用index也是没问题的。

4. Vue中常见性能优化

1、编码优化

  1. 不要将所有的数据都放到data中,data中的数据都会增加getter、setter,会收集对应的watcher
  2. vue在v-for时给每项元素绑定事件需要用事件代理
  3. SPA页面采用keep-alive缓存组件
  4. 拆分组件(提高复用性、增加代码的可维护性,减少不必要的渲染)
  5. v-if当值为false时,内部指令不会执行,具有阻断功能。很多情况下使用v-if替换v-show
  6. key保证唯一性(默认vue会采用就地复用策略)
  7. Object.freeze冻结数据
  8. 合理使用路由懒加载、异步组件
  9. 数据持久化的问题,防抖、节流

2、Vue加载性能优化

3、用户体验

  • app-skeleton骨架屏
  • app shell app壳
  • pwa

4、SEO优化

  • 预渲染插件prerender-spa-plugin
  • 服务端渲染ssr

5、打包优化

  • 使用cdn的方式加载第三方模块
  • 多线程打包happypack
  • splitChunks抽离公共文件
  • sourceMap生成

6、缓存压缩

  • 客户端缓存、服务端缓存
  • 服务端gzip压缩