總共寫了四篇文章(都是本身的一些拙見,僅供參考,請多多指教,我這邊也會持續修正加更新)vue
這篇我將以本身的思路去解讀一下源碼(這裏的源碼我爲了兼容IE8有做修改);
node
經過js對象模擬出一個咱們須要渲染到頁面上的dom樹的結構,實現了一個修改js對象便可修改頁面dom的快捷途徑,避免了咱們‘手動’再去一次次操做dom-api的繁瑣,並且其提供了算法可使得用最少的dom操做進行修改。
var snabbdom = SnabbdomModule; var patch = snabbdom.init([ //導入相應的模塊 DatasetModule, ClassModule, AttributesModule, PropsModule, StyleModule, EventlistenerModule ]); var h = HModule.h; var app = document.getElementById('app'); var newVnode = h('div#divId.red', {}, [h('p', {},'已改變')]) var vnode = h('div#divId.red', {}, [h('p',{},'2S後改變')]) vnode = patch(app, vnode); setTimeout(function() { vnode=patch(vnode, newVnode); }, 2000)
從上面的例子不難看出,咱們須要從三個重點函數 init patch h 切入,這三個函數分別的做用是:初始化模塊,對比渲染,構建vnode;算法
而文章開頭我說了實現虛擬dom的第一步就是 經過js對象模擬出一個咱們須要渲染到頁面上的dom樹的結構
,因此'首當其衝'就是須要先了解h函數,如何將js對象封裝成vnode,vnode是咱們定義的虛擬節點,而後就是利用patch函數進行渲染segmentfault
h.js
var HModule = {}; (function(HModule) { var VNode = VNodeModule.VNode; var is = isModule; /** * * @param sel 選擇器 * @param b 數據 * @param childNode 子節點 * @returns {{sel, data, children, text, elm, key}} */ //調用vnode函數將數據封裝成虛擬dom的數據結構並返回,在調用以前會對數據進行一個處理:是否含有數據,是否含有子節點,子節點類型的判斷等 HModule.h = function(sel, b, childNode) { var data = {}, children, text, i; if (childNode !== undefined) { //若是childNode存在,則其爲子節點 //則h的第二項b就是data data = b; if (is.array(childNode)) { //若是子節點是數組,則存在子element節點 children = childNode; } else if (is.primitive(childNode)) { //不然子節點爲text節點 text = childNode; } } else if (b !== undefined) { //若是隻有b存在,childNode不存在,則b有多是子節點也有多是數據 //數組表明子element節點 if (is.array(b)) { children = b; } else if (is.primitive(b)) { //表明子文本節點 text = b; } else { //表明數據 data = b; } } if (is.array(children)) { for (i = 0; i < children.length; ++i) { //若是子節點數組中,存在節點是原始類型,說明該節點是text節點,所以咱們將它渲染爲一個只包含text的VNode if (is.primitive(children[i])) children[i] = VNode(undefined, undefined, undefined, children[i]); } } //返回VNode return VNode(sel, data, children, text, undefined); } })(HModule)
h函數的主要工做就是把傳入的參數封裝爲vnodeapi
接下來看一下,vnode的結構數組
vnode.js
var VNodeModule = {}; (function(VNodeModule) { VNodeModule.VNode = function(sel, data, children, text, elm) { var key = data === undefined ? undefined : data.key; return { sel: sel, data: data, children: children, text: text, elm: elm, key: key }; } })(VNodeModule)
sel 對應的是選擇器,如'div','div#a','div#a.b.c'的形式 data 對應的是vnode綁定的數據,能夠有如下類型:attribute、props、eventlistner、 class、dataset、hook children 子元素數組 text 文本,表明該節點中的文本內容 elm 裏面存儲着對應的真實dom element的引用 key vnode標識符,主要是用在須要循環渲染的dom元素在進行diff運算時的優化算法,例如ul>li,tobody>tr>td等
text和children是不會同時存在的,存在text表明子節點僅爲文本節點
如:h('p',123) ---> <p>123</p>;存在children表明其子節點存在其餘元素節點(也能夠包含文本節點),須要將這些節點放入數組中 如:h('p',[h('h1',123),'222']) ---> <p><h1>123</h1>222</p>數據結構
打印一下例子中調用h函數後的結構:
vnode:
newVnode:app
關於elm這個值後面再說dom
利用vnode生成咱們的虛擬dom樹後,就須要開始進行渲染了;只因此說是對比渲染,是由於它渲染的機制不是直接把咱們的設置好的vnode所有渲染,而是會進行一次新舊vnode的對比,進行差別渲染;函數
snabbdom.js
init函數
function init(modules, api) { ... }
它有兩個參數,第一個是須要加載的模塊數組,第二個是操做dom的api,通常咱們只須要傳入第一個參數便可
1.模塊的初始化
先拿個模塊舉例:
var ClassModule = {}; function updateClass(oldVnode, vnode){} ClassModule.create = updateClass; ClassModule.update = updateClass;
var hooks = ['create', 'update', 'remove', 'destroy', 'pre', 'post']; //全局鉤子:modules自帶的鉤子函數 function init(modules, api) { var i, j, cbs = {}; ... 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]]); } } ... }
上面就是模塊初始化的核心,事先在模塊中定義好鉤子函數(即模塊對於vnode的操做),而後在init函數中依次將這些模塊的鉤子函數加載進來,放在一個對象中保存,等待調用;
ps:init函數裏面還會定義一些功能函數,等用到的時候再說,而後下一個須要分析的就是init被調用後會return一個函數---patch函數(這個函數是本身定義的一個變量名);
2.調用patch函數進行對比渲染
在沒看源碼以前,我一直覺得snabbdom的對比渲染是會把新舊vnode對比結果產生一個差別對象,而後在利用這個差別對象再進行渲染,後面看了後發現snabbdom這邊是在對比的同時就直接利用dom的API在舊的dom上進行修改,而這些操做(渲染)就是定義在咱們前面加載的模塊中。
這裏須要說一下snabbdom的對比策略是針對同層級的節點
進行對比
其實這裏就有一個小知識點,bfs---廣度優先遍歷
廣度優先遍歷從某個頂點出發,首先訪問這個頂點,而後找出這個結點的全部未被訪問的鄰接點,訪問完後再訪問這些結點中第一個鄰接點的全部結點,重複此方法,直到全部結點都被訪問完爲止。網上介紹的文章不少,我這邊就不過多介紹了;
舉個例子
var tree = { val: 'div', ch: [{ val: 'p', ch: [{ val: 'text1' }] }, { val: 'p', ch: [{ val: 'span', ch: [{ val: 'tetx2' }] }] }] } function bfs(tree) { var queue = []; var res = [] if (!tree) return queue.push(tree); while (queue.length) { var node = queue.shift(); if (node.ch) { for (var i = 0; i < node.ch.length; i++) { queue.push(node.ch[i]); } } if (node.val) { res.push(node.val); } } return res; } console.log(bfs(tree)) //["div", "p", "p", "text1", "span", "tetx2"]
思路:先把根節點放入一個數組queue中,而後將其取出來,判斷其是否有子節點,若是有,將其子節點依次放入queue數組中;而後依次再從這個數組中取值,重複上述步驟,直到這個數組queue沒有數據;
這裏snabbdom會比較每個節點它的sel是否類似,若是類似對其子節點再進行比較,不然直接刪除這個節點,添加新節點,其子節點也不會繼續進行比較
patch函數
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節點,則轉化爲一個空vnode,通常這是初始化渲染的時候會用到 if (isUndef(oldVnode.sel)) { oldVnode = emptyNodeAt(oldVnode); } //若是oldvnode與vnode類似,進行更新;類似是比較其key值與sel值 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; };
流程圖:
這基本上就是一個對比的大致過程,值得研究的東西還在後面,涉及到了其核心的diff算法,下篇文章再提。
再介紹一下上面用到的一些功能函數:
isUndef
爲is.js中的函數,用來判斷數據是否爲undefined
emptyNodeAt
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); }
用來將一個真實的無子節點的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'>}
sameVnode
function sameVnode(vnode1, vnode2) { return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel; }
用來比較兩個vnode是否類似。
若是新舊vnode的key和sel都相同,說明兩個vnode類似,咱們就能夠保留舊的vnode節點,再具體去比較其差別性,在舊的vnode上進行'打補丁',不然直接替換節點。這裏須要說的是若是不定義key值,則這個值就爲undefined,undefined===undefined //true,因此平時在用vue的時候,在沒有用v-for渲染的組件的條件下,是不須要定義key值的,不會影響其比較。
createElm
建立vnode對應的真實dom,並將其賦值給vnode.elm,後續對於dom的修改都是在這個值上進行
//將vnode建立爲真實dom function createElm(vnode, insertedVnodeQueue) { var i, data = vnode.data; if (isDef(data)) { //當節點上存在hook並且hook中有beforeCreate鉤子時,先調用beforeCreate回調,對剛建立的vnode進行處理 if (isDef(i = data.hook) && isDef(i = i.beforeCreate)) { i(vnode); //獲取beforeCreate鉤子修改後的數據 data = vnode.data; } } var elm, children = vnode.children, sel = vnode.sel; if (isDef(sel)) { //解析sel參數,例如div#divId.divClass ==>id="divId" class="divClass" 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)); } } else if (is.primitive(vnode.text)) { //若是存在子文本節點,則直接將其插入到當前Vnode節點 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)) { //觸發自身的create鉤子回調 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; }
patchVnode
若是兩個vnode類似,則會對具體的vnode進行‘打補丁’的操做
function patchVnode(oldVnode, vnode, insertedVnodeQueue) { var i, hook; //在patch以前,先調用vnode.data的beforePatch鉤子 if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.beforePatch)) { i(oldVnode, vnode); } var elm = vnode.elm = oldVnode.elm, oldCh = oldVnode.children, ch = vnode.children; //若是oldnode和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); } /* 分狀況討論節點的更新: new表明新Vnode old表明舊Vnode ps:若是自身存在文本節點,則不存在子節點 即:有text則不會存在ch,反之亦然 1 new不爲文本節點 1.1 new不爲文本節點,new還存在子節點 1.1.1 new不爲文本節點,new還存在子節點,old有子節點 1.1.2 new不爲文本節點,new還存在子節點,old沒有子節點 1.1.2.1 new不爲文本節點,new還存在子節點,old沒有子節點,old爲文本節點 1.2 new不爲文本節點,new不存在子節點 1.2.1 new不爲文本節點,new不存在子節點,old存在子節點 1.2.2 new不爲文本節點,new不存在子節點,old爲文本節點 2.new爲文本節點 2.1 new爲文本節點,而且old與new的文本節點不相等 ps:這裏只須要討論這一種狀況,由於若是old存在子節點,那麼文本節點text爲undefined,則與new的text不相等 直接node.textContent便可清楚old存在的子節點。若old存在子節點,且相等則無需修改 */ //1 if (isUndef(vnode.text)) { //1.1.1 if (isDef(oldCh) && isDef(ch)) { //當Vnode和oldvnode的子節點不一樣時,調用updatechilren函數,diff子節點 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); } //1.1.2 else if (isDef(ch)) { //oldvnode是text節點,則將elm的text清除 //1.1.2.1 if (isDef(oldVnode.text)) api.setTextContent(elm, ''); //並添加vnode的children addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); } //若是oldvnode有children,而vnode沒children,則移除elm的children //1.2.1 else if (isDef(oldCh)) { removeVnodes(elm, oldCh, 0, oldCh.length - 1); } //1.2.2 //若是vnode和oldvnode都沒chidlren,且vnode沒text,則刪除oldvnode的text else if (isDef(oldVnode.text)) { api.setTextContent(elm, ''); } } //若是oldvnode的text和vnode的text不一樣,則更新爲vnode的text, //2.1 else if (oldVnode.text !== vnode.text) { api.setTextContent(elm, vnode.text); } //patch完,觸發postpatch鉤子 if (isDef(hook) && isDef(i = hook.postpatch)) { i(oldVnode, vnode); } }
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]; //ch表明子節點 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); } } } }
invokeDestroyHook
/* 這個函數用於手動觸發destory鉤子回調,主要步驟以下: 先調用vnode上的destory 再調用全局下的destory 遞歸調用子vnode的destory */ function invokeDestroyHook(vnode) { var i, j, data = vnode.data; if (isDef(data)) { if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode); //調用自身的destroy鉤子 for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode); //調用全局destroy鉤子 if (isDef(i = vnode.children)) { for (j = 0; j < vnode.children.length; ++j) { invokeDestroyHook(vnode.children[j]); } } } }
addVnodes
//將vnode轉換後的dom節點插入到dom樹的指定位置中去 function addVnodes(parentElm, before, vnodes, startIdx, endIdx, insertedVnodeQueue) { for (; startIdx <= endIdx; ++startIdx) { api.insertBefore(parentElm, createElm(vnodes[startIdx], insertedVnodeQueue), before); } }
createRmCb
/* remove一個vnode時,會觸發remove鉤子做攔截器,只有在全部remove鉤子 回調函數都觸發完纔會將節點從父節點刪除,而這個函數提供的就是對remove鉤子回調操做的計數功能 */ function createRmCb(childElm, listeners) { return function() { if (--listeners === 0) { var parent = api.parentNode(childElm); api.removeChild(parent, childElm); } }; }
還有一個最核心的函數updateChildren
,這個留到下篇文章再說;
咱們這邊簡單的總結一下:對比渲染的流程大致分爲1.經過sameVnode來判斷兩個vnode是否值得進行比較2.若是不值得,直接刪除舊的vnode,渲染新的vnode3.若是值得,調用模塊鉤子函數,對其節點的屬性進行替換,例如style,event等;再判斷節點子節點是否爲文本節點,若是爲文本節點則進行更替,若是還存在其餘子節點則調用updateChildren,對子節點進行更新,更新流程將會回到第一步,重複;