在上一章咱們學習了,modules,vnode,h,htmldomapi,is等模塊,在這一篇咱們將會學習到
snabbdom的核心功能——patchVnode和updateChildren功能。javascript
首先咱們先從簡單的部分開始,好比一些工具函數,我將逐個來說解他們的用處html
這個函數主要用於比較oldvnode與vnode同層次節點的比較,若是同層次節點的key和sel都相同
咱們就能夠保留這個節點,不然直接替換節點java
function sameVnode(vnode1, vnode2) { return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel; }
這個函數的功能十分簡單,就是將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; }
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函數有兩個參數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]]); } }
這個函數主要的功能是將一個真實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); }
咱們知道當咱們須要remove一個vnode時,會觸發remove鉤子做攔截器,只有在全部remove鉤子
回調函數都觸發完纔會將節點從父節點刪除,而這個函數提供的就是對remove鉤子回調操做的計數功能
function createRmCb(childElm, listeners) { return function() { if (--listeners === 0) { var parent = api.parentNode(childElm); api.removeChild(parent, childElm); } }; }
這個函數用於手動觸發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]); } } } }
這個函數主要功能是批量刪除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); } } } }
就如太極有陰就有陽同樣,既然咱們有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; }
這個函數十分簡單,就是將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
返回的函數開始
首先咱們須要明確的一個是,若是按照傳統的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; };
真正對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); } }
對於同層的子節點,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的主要功能就分析完了