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的主要功能就分析完了

相關文章
相關標籤/搜索