今天來讀點snabbdom吧~?
為什麼要讀snabbdom?
- snabbdom是一個VirtualDOM實現。代碼行數比較少,邏輯清晰,讀起來更輕鬆。
- VUE的虛擬DOM是在SNABBDOM基礎上改的,吹逼有談資。(大概……會有吧?)
- 反正其他的也看不懂,只能看看這個裝作讀過VDOM的源碼的樣子……
- 閑的,沒事幹。
讀了這篇文章大概會獲得什麼?
- 浪費半到一個小時時間。
- 又得到了一些知道了也沒什麼鳥用的知識。
snabbdom大致的思路是?
官方示例:
var snabbdom = require(snabbdom);/*init後獲得一個patch函數,用於對比新舊vdom樹、並更新dom此處傳入的數組,是一組snabbdom模塊。snabbdom模塊中包含許多生命周期鉤子,用於在snabbdom運行期間做一些額外的處理比如,解析class、style表達式,修改props,綁定事件等。snabbdom本身只負責管理vdom和dom的結構,比如新增刪除節點、修改文字之類的。其他操作基本上是由模塊完成的。*/var patch = snabbdom.init([ // Init patch function with chosen modules require(snabbdom/modules/class).default, // makes it easy to toggle classes require(snabbdom/modules/props).default, // for setting properties on DOM elements require(snabbdom/modules/style).default, // handles styling on elements with support for animations require(snabbdom/modules/eventlisteners).default, // attaches event listeners]);// 用於創建虛擬DOM(vnode對象)的函數。類似於React.createElementvar h = require(snabbdom/h).default; // helper function for creating vnodesvar container = document.getElementById(container);var vnode = h(div#container.two.classes, {on: {click: someFn}}, [ h(span, {style: {fontWeight: bold}}, This is bold), and this is just normal text, h(a, {props: {href: /foo}}, Ill take you places!)]);// Patch into empty DOM element – this modifies the DOM as a side effect/* 這個操作snabbdom稱之為「patch」,也就是打補丁。實際上就是比對新舊vdom,然後在此同時對真正的DOM做操作。第一次調用時,傳入一個空的HTMLElement作為oldVnode即可。*/patch(container, vnode);var newVnode = h(div#container.two.classes, {on: {click: anotherEventHandler}}, [ h(span, {style: {fontWeight: normal, fontStyle: italic}}, This is now italic type), and this is still just normal text, h(a, {props: {href: /bar}}, Ill take you places!)]);// Second `patch` invocation// 第二次調用時,先前的vnode已經patch過,和真正的HTMLElement連接在一起了。patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
思路相當簡潔明了,你給我新的vnode對象,我給你更新。於是,關鍵點就在於比對和更新的過程。
vnode.ts
這個文件很短,存儲了對vnode對象的描述
export interface VNode { sel: string | undefined; // selector的縮寫 data: VNodeData | undefined; // 下面VNodeData介面的內容 children: Array<VNode | string> | undefined; // 子節點 elm: Node | undefined; // element的縮寫,存儲了真實的HTMLElement text: string | undefined; // 如果是文本節點,則存儲text key: Key | undefined; // 節點的key,在做列表時很有用}export interface VNodeData { props?: Props; attrs?: Attrs; class?: Classes; style?: VNodeStyle; dataset?: Dataset; on?: On; hero?: Hero; attachData?: AttachData; hook?: Hooks; key?: Key; ns?: string; // for SVGs fn?: () => VNode; // for thunks args?: Array<any>; // for thunks [key: string]: any; // for any other 3rd party module}
h.ts
生成vnode的函數。
不熟悉typescript的朋友可以不要介意那堆函數聲明,只看最後一個 export function h(sel: any, b?: any, c?: any): VNode 就可以了。前面的那堆聲明是ts的偽重載的寫法,基本不影響編譯出來的js的。
/** * 生成vnode * @param {string} sel * @returns {VNode} */export function h(sel: string): VNode;export function h(sel: string, data: VNodeData): VNode;export function h(sel: string, children: VNodeChildren): VNode;export function h(sel: string, data: VNodeData, children: VNodeChildren): VNode;export function h(sel: any, b?: any, c?: any): VNode { var data: VNodeData = {}, children: any, text: any, i: number; // 判斷參數的個數 if (c !== undefined) { // 如果是3參數 data = b; // c如果是數組,則當做children處理 if (is.array(c)) { children = c; } // 如果是字元串或數字,則當做文本處理 else if (is.primitive(c)) { text = c; } // 如果c是vnode else if (c && c.sel) { children = [c]; } } else if (b !== undefined) { // 如果是2參數 if (is.array(b)) { children = b; } else if (is.primitive(b)) { text = b; } else if (b && b.sel) { children = [b]; } else { // b是data data = b; } } if (is.array(children)) { // 如果children是數組的話,將所有字元串轉化成vnode節點 for (i = 0; i < children.length; ++i) { if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined); } } if ( // 對svg進行處理,添加namespace sel[0] === s && sel[1] === v && sel[2] === g && (sel.length === 3 || sel[3] === . || sel[3] === #) ) { addNS(data, children, sel); // addNS就是一個為svg增加ns的函數,不必介意 } return vnode(sel, data, children, text, undefined);};
snabbdom.ts
知道vnode是什麼、vnode怎麼生成之後,就可以關注新舊vnode之間怎麼做比較、然後怎麼更新DOM了。
不過在看snabbdom.ts之前,我們先來看看htmldomapi.ts。
export interface DOMAPI { createElement: (tagName: any) => HTMLElement; createElementNS: (namespaceURI: string, qualifiedName: string) => Element; createTextNode: (text: string) => Text; createComment: (text: string) => Comment; insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void; removeChild: (node: Node, child: Node) => void; appendChild: (node: Node, child: Node) => void; parentNode: (node: Node) => Node; nextSibling: (node: Node) => Node; tagName: (elm: Element) => string; setTextContent: (node: Node, text: string | null) => void; getTextContent: (node: Node) => string | null; isElement: (node: Node) => node is Element; isText: (node: Node) => node is Text; isComment: (node: Node) => node is Comment;}
只截取了ts介面聲明,看函數名就知道這是做什麼的了吧?基本上就是DOMAPI直接拿過來而已。這裡面涉及了snabbdom需要的大部分DOM操作。
snabbdom專門寫了個文件包裹一下,大概是為了跨平台渲染?
接下來,看snabbdom.ts裡面的幾個輔助函數。
sameVnode:
function sameVnode(vnode1: VNode, vnode2: VNode): boolean { return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;}
之前說過patch函數是為了拿新舊vdom作對比,然後根據變動對DOM做操作的sameVnode函數的作用就是,判斷新舊兩個vnode邏輯上,是不是同一個對象。
根據vnode的key值和vnode的selector來做判斷。key值這東西很有趣,當你渲染一個數組時,數組的項目如果有增刪,只看selector或者index是判斷不出來如何一一對應的。
比如,原數組: [0,1,2,3,4],刪除一項變成[0,1,3,4],這個時候,可能明明3,4沒變,但是沒有key的話,框架不知道,如果依次比對的話,就只好拿舊數組的2和新數組裡的3去比對,舊數組的3和新數組的4做比對,然後對第3、4項進行更新,刪除第5項,這就造成了性能的浪費。
所以sameVnode就是用來判斷哪兩個vnode在邏輯上是同一個vnode、方便重複利用的。
所以,某些拿index做key的同學,你們是在自欺欺人哦(逃
createKeyToOldIdx
function createKeyToOldIdx(children: Array<VNode>, beginIdx: number, endIdx: number): KeyToIndexMap { let i: number, map: KeyToIndexMap = {}, key: Key | undefined, ch; for (i = beginIdx; i <= endIdx; ++i) { ch = children[i]; if (ch != null) { key = ch.key; if (key !== undefined) map[key] = i; } } return map;}
把key和index用map形式保存起來。
snabbdom.ts的DOM操作,基本上就是創建刪除插入改文本之類的。
創建:
/** * 創建一個HTML Element * 傳入vNode,返回HTML節點,會處理子節點,會觸發init 和 create鉤子。 * 模塊的create鉤子也會在創建出HTML Element後觸發。 */ function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { let i: any, data = vnode.data; // vNode的init鉤子 if (data !== undefined) { if (isDef(i = data.hook) && isDef(i = i.init)) { i(vnode); data = vnode.data; } } let children = vnode.children, sel = vnode.sel; // 對於注釋節點的處理 if (sel === !) { if (isUndef(vnode.text)) { vnode.text = ; } vnode.elm = api.createComment(vnode.text as string); } // 如果存在選擇器 else if (sel !== undefined) { // 解析selector const hashIdx = sel.indexOf(#); const dotIdx = sel.indexOf(., hashIdx); const hash = hashIdx > 0 ? hashIdx : sel.length; const dot = dotIdx > 0 ? dotIdx : sel.length; // 解析出標籤名 const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel; // 創建一個Html element const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag) : api.createElement(tag); // 為elm設置id和class if (hash < dot) elm.setAttribute(id, sel.slice(hash + 1, dot)); if (dotIdx > 0) elm.setAttribute(class, sel.slice(dot + 1).replace(/./g, )); // 模塊的Create鉤子 for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode); // 如果存在children則掛上children if (is.array(children)) { for (i = 0; i < children.length; ++i) { const ch = children[i]; if (ch != null) { api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue)); } } } // 如果是文本節點,則掛入文本節點 else if (is.primitive(vnode.text)) { api.appendChild(elm, api.createTextNode(vnode.text)); } i = (vnode.data as VNodeData).hook; // Reuse variable if (isDef(i)) { // vNode的create鉤子 if (i.create) i.create(emptyNode, vnode); // 把insert鉤子加入隊列(在patch的最後階段執行) if (i.insert) insertedVnodeQueue.push(vnode); } } // 不存在選擇器則是文本節點 else { vnode.elm = api.createTextNode(vnode.text as string); } return vnode.elm; }
刪除:
/** * 移除節點,會執行模塊的remove鉤子,然後執行vNode自身的remove鉤子 */ function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void { for (; startIdx <= endIdx; ++startIdx) { let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx]; if (ch != null) { if (isDef(ch.sel)) { invokeDestroyHook(ch); listeners = cbs.remove.length + 1; // cbs就是模塊的各種鉤子,cbs.remove就是各個模塊給remove這個周期事件註冊的鉤子 // rmcb是 remove callback的意思,調用就刪除這個node,當然,要求linsteners歸零之後才能刪 // 於是listeners=cbs.remove.length + 1就是為了調用全部模塊註冊的鉤子。 rm = createRmCb(ch.elm as Node, listeners); for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm); if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) { i(ch, rm); } else { rm(); } } else { // Text node api.removeChild(parentElm, ch.elm as Node); } } } }
其中createRmCb就是remove callback的意思。代碼如下:
function createRmCb(childElm: Node, listeners: number) { return function rmCb() { if (--listeners === 0) { const parent = api.parentNode(childElm); api.removeChild(parent, childElm); } };}
大概就是這樣,然後就可以看看patch的代碼了:
之前提到過,snabbdom的所有模塊的callback,都在cbs這個對象上。
cbs.pre就是各個模塊註冊在pre這個生命周期的鉤子。
api就是HTML DOM API的封裝。
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node; const insertedVnodeQueue: VNodeQueue = []; for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); if (!isVnode(oldVnode)) { oldVnode = emptyNodeAt(oldVnode); } if (sameVnode(oldVnode, vnode)) { // 如果邏輯上是同一個vnode則作比對並更新 patchVnode(oldVnode, vnode, insertedVnodeQueue); } else { // 否則就要新建、刪除了 elm = oldVnode.elm as Node; parent = api.parentNode(elm); createElm(vnode, insertedVnodeQueue); if (parent !== null) { api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm)); removeVnodes(parent, [oldVnode], 0, 0); } } // insert周期事件的處理,如果整個文件從頭到尾去看的話,這個insertedVnodeQueue的存在是有點迷的,很長時間不知道是做什麼的。。 for (i = 0; i < insertedVnodeQueue.length; ++i) { (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]); } for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); return vnode; };
到這一步,我們就知道了,snabbdom的核心,大概就是patchVnode這個函數了。
patchVnode函數負責比對和更新dom。
如下:
注意,isDef是isDefined的縮寫,反過來就是isUndefined,反正就是看它是不是undefined(逃
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { let i: any, hook: any; if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) { i(oldVnode, vnode); } const elm = vnode.elm = (oldVnode.elm as Node); let oldCh = oldVnode.children; let ch = vnode.children; if (oldVnode === vnode) return; // 完全相同時就沒必要做patch了。大概。 // 鉤子時間,先是模塊的Update鉤子,然後是vNode自身的update鉤子 if (vnode.data !== undefined) { for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); i = vnode.data.hook; if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode); } // 如果不是文本節點 if (isUndef(vnode.text)) { if (isDef(oldCh) && isDef(ch)) { // 對比新舊的children if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue); } else if (isDef(ch)) { // 如果有了新的children if (isDef(oldVnode.text)) api.setTextContent(elm, ); // 如果舊的vnode是文本節點 addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) { // 如果只有舊的children(說明新vnode裡面,children被刪除了) removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1); } else if (isDef(oldVnode.text)) { // 如果是文本節點 api.setTextContent(elm, ); } } // 如果是文本節點 else if (oldVnode.text !== vnode.text) { api.setTextContent(elm, vnode.text as string); } // 模塊的postpatch鉤子 if (isDef(hook) && isDef(i = hook.postpatch)) { i(oldVnode, vnode); } }
很好,接下來未解之謎就只剩下一個了,updateChildren。
用於遞歸比對/更新新舊兩個節點的子節點。
於是,在這裡先給一個思考題:
有數組A和B,存儲著這種結構的數據:
[{key:1,name:張三},....,{key:6,name:"田七"}]
如何確認,A和B數組中,key相同的項目有哪些,它們的name是否有改變?
B數組中,比A數組多了哪些key?
B數組中,比A數組少了哪些key?
如果有數組C存在,C=A.map(x=>x)
在AB兩個數組的比對過程中,如何用splice函數把C數組修整成B數組的形狀?
嗯,答得出上面的題,那麼下面的代碼基本上應該也就能看懂了。=w=
function updateChildren(parentElm: Node, oldCh: Array<VNode>, newCh: Array<VNode>, insertedVnodeQueue: VNodeQueue) { let oldStartIdx = 0, newStartIdx = 0; let oldEndIdx = oldCh.length - 1; let oldStartVnode = oldCh[0]; let oldEndVnode = oldCh[oldEndIdx]; let newEndIdx = newCh.length - 1; let newStartVnode = newCh[0]; let newEndVnode = newCh[newEndIdx]; let oldKeyToIdx: any; let idxInOld: number; let elmToMove: VNode; let before: any; // 第一階段:新舊兩個children數組,至少有一個被全部比對過 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 游標左右移動、過濾掉null if (oldStartVnode == null) { oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left } else if (oldEndVnode == null) { oldEndVnode = oldCh[--oldEndIdx]; } else if (newStartVnode == null) { newStartVnode = newCh[++newStartIdx]; } else if (newEndVnode == null) { newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newStartVnode)) { // 新舊start node 相同時(key和選擇器相同) patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue); oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) { // 新舊end node相同時 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue); oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) { // 舊start node和新end node相同時,插到舊的start node之後 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) { // 舊end node和新start node相同時,插到舊的end node之前 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue); api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } else { // 查index表 if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } idxInOld = oldKeyToIdx[newStartVnode.key as string]; if (isUndef(idxInOld)) { // 未命中則新建 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node); newStartVnode = newCh[++newStartIdx]; } else { // 命中,但是selector不同也新建,否則patch elmToMove = oldCh[idxInOld]; if (elmToMove.sel !== newStartVnode.sel) { api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node); } else { patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); oldCh[idxInOld] = undefined as any; api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node); } newStartVnode = newCh[++newStartIdx]; } } } // 第二階段:對比結束後還會剩下一些,進行批量更新 if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) { if (oldStartIdx > oldEndIdx) { before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm; addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else { removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } } }
這一段讀起來比較麻煩。
其實就是新舊兩組children進行比對和更新。
分成兩個階段。
第一階段是比對,目的是新舊兩個children數組至少有一個數組,全部都被比對過。
第二階段是批量更新,如果新的數組全部都被比對過,而舊數組還剩下什麼東西,那麼說明它們應該被刪除了,反之,就是需要被新增。
結束
這麼零零散散的拷貝代碼過來,可能並不方便閱讀,推薦還是先大致上掃一遍,然後去看看snabbdom的源代碼,然後對照著讀,或許能方便些?
然後。
emmmm。
題圖是《莉蒂和蘇爾的工作室:不可思議之畫的鍊金術上》的同人圖,超級好玩……大概很好玩。至少對我來說大概很好玩。索菲真是太棒了。標題其實是在neta莉蒂的口頭禪「今天來調和點什麼呢?」。(逃
推薦閱讀:
※為什麼說現在 React Native 涼了?
※react許可證的問題是否意味著要轉技術棧了?
※angular的$watch是如何實現的?
※漸進增強的 CSS 布局:從浮動到 Flexbox 到 Grid
※CSS Grid 系列(下)-使用Grid布局構建網站首頁