vue2源码学习开胃菜——snabbdom源码学习(二)

前言

在上一章咱们学习了,modules,vnode,h,htmldomapi,is等模块,在这一篇咱们将会学习到
snabbdom的核心功能——patchVnode和updateChildren功能。javascript

继续咱们的snabbdom源码之旅

最终章 snabbdom!

首先咱们先从简单的部分开始,好比一些工具函数,我将逐个来说解他们的用处html

sameNode

这个函数主要用于比较oldvnode与vnode同层次节点的比较,若是同层次节点的key和sel都相同
咱们就能够保留这个节点,不然直接替换节点java

function sameVnode(vnode1, vnode2) {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}

createKeyToOldIdx

这个函数的功能十分简单,就是将oldvnode数组中位置对oldvnode.key的映射转换为oldvnode.key
对位置的映射node

function createKeyToOldIdx(children, beginIdx, endIdx) {
  var i, map = {}, key;
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key;
    if (isDef(key)) map[key] = i;
  }
  return map;
}

hook

snabbdom在全局下有6种类型的钩子,触发这些钩子时,会调用对应的函数对节点的状态进行更改
首先咱们来看看有哪些钩子:算法

Name Triggered when Arguments to callback
pre the patch process begins (patch开始时触发) none
init a vnode has been added (vnode被建立时触发) vnode
create a DOM element has been created based on a vnode (vnode转换为真实DOM节点时触发 emptyVnode, vnode
insert an element has been inserted into the DOM (插入到DOM树时触发) vnode
prepatch an element is about to be patched (元素准备patch前触发) oldVnode, vnode
update an element is being updated (元素更新时触发) oldVnode, vnode
postpatch an element has been patched (元素patch完触发) oldVnode, vnode
destroy an element is directly or indirectly being removed (元素被删除时触发) vnode
remove an element is directly being removed from the DOM (元素从父节点删除时触发,和destory略有不一样,remove只影响到被移除节点中最顶层的节点) vnode, removeCallback
post the patch process is done (patch完成后触发) none

而后,下面列出钩子对应的状态更新函数:api

  • create => style,class,dataset,eventlistener,props,hero数组

  • update => style,class,dataset,eventlistener,props,heroapp

  • remove => styledom

  • destory => eventlistener,style,hero函数

  • pre => hero

  • post => hero

好了,简单的都看完了,接下来咱们开始打大boss了,第一关就是init函数了

init

init函数有两个参数modules和api,其中modules是init依赖的模块,如attribute、props
、eventlistener这些模块,api则是对封装真实DOM操做的工具函数库,若是咱们没有传入,则默认
使用snabbdom提供的htmldomapi。init还包含了许多vnode和真实DOM之间的操做和注册全局钩子,
还有patchVnode和updateChildren这两个重要功能,而后返回一个patch函数

注册全局钩子

//注册钩子的回调,在发生状态变动时,触发对应属性变动
      for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = [];
        for (j = 0; j < modules.length; ++j) {
          if (modules[j][hooks[i]] !== undefined) cbs[hooks[i]].push(modules[j][hooks[i]]);
        }
      }

emptyNodeAt

这个函数主要的功能是将一个真实DOM节点转化成vnode形式,
<div id='a' class='b c'></div>将转换为{sel:'div#a.b.c',data:{},children:[],text:undefined,elm:<div id='a' class='b c'>}

function emptyNodeAt(elm) {
        var id = elm.id ? '#' + elm.id : '';
        var c = elm.className ? '.' + elm.className.split(' ').join('.') : '';
        return VNode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm);
      }

createRmCb

咱们知道当咱们须要remove一个vnode时,会触发remove钩子做拦截器,只有在全部remove钩子
回调函数都触发完才会将节点从父节点删除,而这个函数提供的就是对remove钩子回调操做的计数功能

function createRmCb(childElm, listeners) {
    return function() {
      if (--listeners === 0) {
        var parent = api.parentNode(childElm);
        api.removeChild(parent, childElm);
      }
    };
  }

invokeDestoryHook

这个函数用于手动触发destory钩子回调,主要步骤以下:

  • 先调用vnode上的destory

  • 再调用全局下的destory

  • 递归调用子vnode的destory

    function invokeDestroyHook(vnode) {
      var i, j, data = vnode.data;
      if (isDef(data)) {
    //先触发该节点上的destory回调
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
    //在触发全局下的destory回调
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
    //递归触发子节点的destory回调
    if (isDef(i = vnode.children)) {
      for (j = 0; j < vnode.children.length; ++j) {
        invokeDestroyHook(vnode.children[j]);
      }
    }
      }
    }

removeVnodes

这个函数主要功能是批量删除DOM节点,须要配合invokeDestoryHook和createRmCb服用,效果更佳
主要步骤以下:

  • 调用invokeDestoryHook以触发destory回调

  • 调用createRmCb来开始对remove回调进行计数

  • 删除DOM节点

    /**
       *
       * @param parentElm 父节点
       * @param vnodes  删除节点数组
       * @param startIdx  删除起始坐标
       * @param endIdx  删除结束坐标
       */
    function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
      for (; startIdx <= endIdx; ++startIdx) {
    var i, listeners, rm, ch = vnodes[startIdx];
    if (isDef(ch)) {
      if (isDef(ch.sel)) {
        //调用destroy钩子
        invokeDestroyHook(ch);
        //对全局remove钩子进行计数
        listeners = cbs.remove.length + 1;
        rm = createRmCb(ch.elm, listeners);
        //调用全局remove回调函数,并每次减小一个remove钩子计数
        for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
        //调用内部vnode.data.hook中的remove钩子(只有一个)
        if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
          i(ch, rm);
        } else {
          //若是没有内部remove钩子,须要调用rm,确保可以remove节点
          rm();
        }
      } else { // Text node
        api.removeChild(parentElm, ch.elm);
      }
    }
      }
    }

createElm

就如太极有阴就有阳同样,既然咱们有remove操做,确定也有createelm的操做,这个函数主要功能
以下:

  • 初始化vnode,调用init钩子

  • 建立对应tagname的DOM element节点,并将vnode.sel中的id名和class名挂载上去

  • 若是有子vnode,递归建立DOM element节点,并添加到父vnode对应的element节点上去,

    不然若是有text属性,则建立text节点,并添加到父vnode对应的element节点上去
  • vnode转换成dom节点操做完成后,调用create钩子

  • 若是vnode上有insert钩子,那么就将这个vnode放入insertedVnodeQueue中做记录,到时

    再在全局批量调用insert钩子回调
    function createElm(vnode, insertedVnodeQueue) {
       var i, data = vnode.data;
       if (isDef(data)) {
     //当节点上存在hook并且hook中有init钩子时,先调用init回调,对刚建立的vnode进行处理
     if (isDef(i = data.hook) && isDef(i = i.init)) {
       i(vnode);
       //获取init钩子修改后的数据
       data = vnode.data;
     }
       }
       var elm, children = vnode.children, sel = vnode.sel;
       if (isDef(sel)) {
     // Parse selector
     var hashIdx = sel.indexOf('#');
     //先id后class
     var dotIdx = sel.indexOf('.', hashIdx);
     var hash = hashIdx > 0 ? hashIdx : sel.length;
     var dot = dotIdx > 0 ? dotIdx : sel.length;
     var tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
     //建立一个DOM节点引用,并对其属性实例化
     elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag): api.createElement(tag);
      //获取id名 #a --> a
     if (hash < dot) elm.id = sel.slice(hash + 1, dot);
     //获取类名,并格式化  .a.b --> a b
     if (dotIdx > 0) elm.className = sel.slice(dot + 1).replace(/\./g, ' ');
     //若是存在子元素Vnode节点,则递归将子元素节点插入到当前Vnode节点中,并将已插入的子元素节点在insertedVnodeQueue中做记录
     if (is.array(children)) {
       for (i = 0; i < children.length; ++i) {
         api.appendChild(elm, createElm(children[i], insertedVnodeQueue));
       }
       //若是存在子文本节点,则直接将其插入到当前Vnode节点
     } else if (is.primitive(vnode.text)) {
       api.appendChild(elm, api.createTextNode(vnode.text));
     }
     //当建立完毕后,触发全局create钩子回调
     for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
     i = vnode.data.hook; // Reuse variable
     if (isDef(i)) {
       if (i.create) i.create(emptyNode, vnode);
       //若是有insert钩子,则推动insertedVnodeQueue中做记录,从而实现批量插入触发insert回调
       if (i.insert) insertedVnodeQueue.push(vnode);
     }
       }
       //若是没声明选择器,则说明这个是一个text节点
       else {
     elm = vnode.elm = api.createTextNode(vnode.text);
       }
       return vnode.elm;
     }

addVnodes

这个函数十分简单,就是将vnode转换后的dom节点插入到dom树的指定位置中去

function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) {
    for (; startIdx <= endIdx; ++startIdx) {
      api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before);
    }
  }

说完上面的节点工具函数以后,咱们就开始看如何进行patch操做了,首先咱们从patch,也就是init
返回的函数开始

patch

首先咱们须要明确的一个是,若是按照传统的diff算法,那么为了找到最小变化,须要逐层逐层的去
搜索比较,这样时间复杂度将会达到 O(n^3)的级别,代价十分高,考虑到节点变化不多是跨层次的,
vdom采起的是一种简化的思路,只比较同层节点,若是不一样,那么即便该节点的子节点没变化,咱们
也不复用,直接将从父节点开始的子树所有删除,而后再从新建立节点添加到新的位置。若是父节点
没变化,咱们就比较全部同层的子节点,对这些子节点进行删除、建立、移位操做。有了这个思想,
理解patch也十分简单了。patch只须要对两个vnode进行判断是否类似,若是类似,则对他们进行
patchVnode操做,不然直接用vnode替换oldvnode。

return function(oldVnode, vnode) {
    var i, elm, parent;
    //记录被插入的vnode队列,用于批触发insert
    var insertedVnodeQueue = [];
    //调用全局pre钩子
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
    //若是oldvnode是dom节点,转化为oldvnode
    if (isUndef(oldVnode.sel)) {
      oldVnode = emptyNodeAt(oldVnode);
    }
    //若是oldvnode与vnode类似,进行更新
    if (sameVnode(oldVnode, vnode)) {
      patchVnode(oldVnode, vnode, insertedVnodeQueue);
    } else {
      //不然,将vnode插入,并将oldvnode从其父节点上直接删除
      elm = oldVnode.elm;
      parent = api.parentNode(elm);

      createElm(vnode, insertedVnodeQueue);

      if (parent !== null) {
        api.insertBefore(parent, vnode.elm, api.nextSibling(elm));
        removeVnodes(parent, [oldVnode], 0, 0);
      }
    }
    //插入完后,调用被插入的vnode的insert钩子
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
      insertedVnodeQueue[i].data.hook.insert(insertedVnodeQueue[i]);
    }
    //而后调用全局下的post钩子
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
    //返回vnode用做下次patch的oldvnode
    return vnode;
  };

patchVnode

真正对vnode内部patch的仍是得靠patchVnode。让咱们看看他到底作了什么?

function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
    var i, hook;
    //在patch以前,先调用vnode.data的prepatch钩子
    if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
      i(oldVnode, vnode);
    }
    var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children;
    //若是oldvnode和vnode的引用相同,说明没发生任何变化直接返回,避免性能浪费
    if (oldVnode === vnode) return;
    //若是oldvnode和vnode不一样,说明vnode有更新
    //若是vnode和oldvnode不类似则直接用vnode引用的DOM节点去替代oldvnode引用的旧节点
    if (!sameVnode(oldVnode, vnode)) {
      var parentElm = api.parentNode(oldVnode.elm);
      elm = createElm(vnode, insertedVnodeQueue);
      api.insertBefore(parentElm, elm, oldVnode.elm);
      removeVnodes(parentElm, [oldVnode], 0, 0);
      return;
    }
    //若是vnode和oldvnode类似,那么咱们要对oldvnode自己进行更新
    if (isDef(vnode.data)) {
      //首先调用全局的update钩子,对vnode.elm自己属性进行更新
      for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
      //而后调用vnode.data里面的update钩子,再次对vnode.elm更新
      i = vnode.data.hook;
      if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
    }
    //若是vnode不是text节点
    if (isUndef(vnode.text)) {
      //若是vnode和oldVnode都有子节点
      if (isDef(oldCh) && isDef(ch)) {
        //当Vnode和oldvnode的子节点不一样时,调用updatechilren函数,diff子节点
        if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
      }
      //若是vnode有子节点,oldvnode没子节点
      else if (isDef(ch)) {
        //oldvnode是text节点,则将elm的text清除
        if (isDef(oldVnode.text)) api.setTextContent(elm, '');
        //并添加vnode的children
        addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
      }
      //若是oldvnode有children,而vnode没children,则移除elm的children
      else if (isDef(oldCh)) {
        removeVnodes(elm, oldCh, 0, oldCh.length - 1);
      }
      //若是vnode和oldvnode都没chidlren,且vnode没text,则删除oldvnode的text
      else if (isDef(oldVnode.text)) {
        api.setTextContent(elm, '');
      }
    }

    //若是oldvnode的text和vnode的text不一样,则更新为vnode的text
    else if (oldVnode.text !== vnode.text) {
      api.setTextContent(elm, vnode.text);
    }
    //patch完,触发postpatch钩子
    if (isDef(hook) && isDef(i = hook.postpatch)) {
      i(oldVnode, vnode);
    }
  }

updateChildren

对于同层的子节点,snabbdom主要有删除、建立的操做,同时经过移位的方法,达到最大复用存在
节点的目的,其中须要维护四个索引,分别是:

  • oldStartIdx => 旧头索引

  • oldEndIdx => 旧尾索引

  • newStartIdx => 新头索引

  • newEndIdx => 新尾索引

而后开始将旧子节点组和新子节点组进行逐一比对,直到遍历完任一子节点组,比对策略有5种:

  • oldStartVnode和newStartVnode进行比对,若是类似,则进行patch,而后新旧头索引都后移

  • oldEndVnode和newEndVnode进行比对,若是类似,则进行patch,而后新旧尾索引前移

  • oldStartVnode和newEndVnode进行比对,若是类似,则进行patch,将旧节点移位到最后

    而后旧头索引后移,尾索引前移,为何要这样作呢?咱们思考一种状况,如旧节点为【5,1,2,3,4】
    ,新节点为【1,2,3,4,5】,若是缺少这种判断,意味着须要先将5->1,1->2,2->3,3->4,4->5五
    次删除插入操做,即便是有了key-index来复用,也会出现也会出现【5,1,2,3,4】->
    【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】共4次操做,若是
    有了这种判断,咱们只须要将5插入到旧尾索引后面便可,从而实现右移
  • oldEndVnode和newStartVnode进行比对,处理和上面相似,只不过改成左移

  • 若是以上状况都失败了,咱们就只能复用key相同的节点了。首先咱们要经过createKeyToOldIdx

    建立key-index的映射,若是新节点在旧节点中不存在,咱们将它插入到旧头索引节点前,
    而后新头索引向后;若是新节点在就旧节点组中存在,先找到对应的旧节点,而后patch,并将
    旧节点组中对应节点设置为undefined,表明已经遍历过了,再也不遍历,不然可能存在重复
    插入的问题,最后将节点移位到旧头索引节点以前,新头索引向后

遍历完以后,将剩余的新Vnode添加到最后一个新节点的位置后或者删除多余的旧节点

/**
   *
     * @param parentElm 父节点
     * @param oldCh 旧节点数组
     * @param newCh 新节点数组
     * @param insertedVnodeQueue
     */
  function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue) {

    var oldStartIdx = 0, 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];
    var oldKeyToIdx, idxInOld, elmToMove, before;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx]; // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx];
      }
      //若是旧头索引节点和新头索引节点相同,
      else if (sameVnode(oldStartVnode, newStartVnode)) {
        //对旧头索引节点和新头索引节点进行diff更新, 从而达到复用节点效果
        patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
        //旧头索引向后
        oldStartVnode = oldCh[++oldStartIdx];
        //新头索引向后
        newStartVnode = newCh[++newStartIdx];
      }
      //若是旧尾索引节点和新尾索引节点类似,能够复用
      else if (sameVnode(oldEndVnode, newEndVnode)) {
        //旧尾索引节点和新尾索引节点进行更新
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
        //旧尾索引向前
        oldEndVnode = oldCh[--oldEndIdx];
        //新尾索引向前
        newEndVnode = newCh[--newEndIdx];
      }
        //若是旧头索引节点和新头索引节点类似,能够经过移动来复用
        //如旧节点为【5,1,2,3,4】,新节点为【1,2,3,4,5】,若是缺少这种判断,意味着
        //那样须要先将5->1,1->2,2->3,3->4,4->5五次删除插入操做,即便是有了key-index来复用,
        // 也会出现【5,1,2,3,4】->【1,5,2,3,4】->【1,2,5,3,4】->【1,2,3,5,4】->【1,2,3,4,5】
        // 共4次操做,若是有了这种判断,咱们只须要将5插入到最后一次操做便可
      else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldStartVnode.elm, api.nextSibling(oldEndVnode.elm));
        oldStartVnode = oldCh[++oldStartIdx];
        newEndVnode = newCh[--newEndIdx];
      }
      //原理与上面相同
      else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
        api.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm);
        oldEndVnode = oldCh[--oldEndIdx];
        newStartVnode = newCh[++newStartIdx];
      }
      //若是上面的判断都不经过,咱们就须要key-index表来达到最大程度复用了
      else {
        //若是不存在旧节点的key-index表,则建立
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
        //找到新节点在旧节点组中对应节点的位置
        idxInOld = oldKeyToIdx[newStartVnode.key];
        //若是新节点在旧节点中不存在,咱们将它插入到旧头索引节点前,而后新头索引向后
        if (isUndef(idxInOld)) { // New element
          api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm);
          newStartVnode = newCh[++newStartIdx];
        } else {
          //若是新节点在就旧节点组中存在,先找到对应的旧节点
          elmToMove = oldCh[idxInOld];
          //先将新节点和对应旧节点做更新
          patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
          //而后将旧节点组中对应节点设置为undefined,表明已经遍历过了,不在遍历,不然可能存在重复插入的问题

          oldCh[idxInOld] = undefined;
          //插入到旧头索引节点以前
          api.insertBefore(parentElm, elmToMove.elm, oldStartVnode.elm);
          //新头索引向后
          newStartVnode = newCh[++newStartIdx];
        }
      }
    }
    //当旧头索引大于旧尾索引时,表明旧节点组已经遍历完,将剩余的新Vnode添加到最后一个新节点的位置后
    if (oldStartIdx > oldEndIdx) {
      before = isUndef(newCh[newEndIdx+1]) ? null : newCh[newEndIdx+1].elm;
      addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
    }
    //若是新节点组先遍历完,那么表明旧节点组中剩余节点都不须要,因此直接删除
    else if (newStartIdx > newEndIdx) {
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
    }
  }

至此,snabbdom的主要功能就分析完了

相关文章
相关标签/搜索