相信大部分前端同窗以前早已無數次聽過或瞭解過 vnode
(虛擬節點),那麼什麼是 vnode
? vnode
應該是什麼樣的?
若是不使用前端框架,咱們可能會寫出這樣的頁面:javascript
<html> <head> <title></title> </head> <body> <div></div> <script></script> </body> </html>
不難發現,整個文檔樹的根節點只有一個 html
,而後嵌套各類子標籤,若是使用某種數據結構來表示這棵樹,那麼它多是這樣。html
{ tagName: 'html', children: [ { tagName: 'head', children: [ { tagName: 'title' } ] }, { tagName: 'body', children: [ { tagName: 'div' }, { tagName: 'script' } ] } ] }
可是實際開發中,整個文檔樹中head
和 script
標籤基本不會有太大的改動。頻繁交互可能改動的應當是 body
裏面的除 script
的部分,因此構建 虛擬節點樹 應當是整個 HTML 文檔樹的一個子樹,而這個子樹應當保持和 HTML 文檔樹一致的數據結構。它多是這樣。前端
<html> <head> <title></title> </head> <body> <div id="root"> <div class="header"></div> <div class="main"></div> <div class="footer"></div> </div> <script></script> </body> </html>
這裏應當構建的 虛擬節點樹 應當是 div#root
這棵子樹:java
{ tagName: 'div', children: [ { tagName: 'div', }, { tagName: 'div', }, { tagName: 'div', }, ] }
到這裏,vnode 的概念應當很清晰了,vnode 是用來表示實際 dom 節點的一種數據結構,其結構大概長這樣。node
{ tagName: 'div', attrs: { class: 'header' }, children: [] }
通常,咱們可能會這樣定義 vnode
。react
// vnode.js export const vnode = function vnode() {}
使用 React
會常常寫 JSX
,那麼如何將 JSX
表示成 vnode
?這裏能夠藉助 @babel/plugin-transform-react-jsx
這個插件來自定義轉換函數,
只須要在 .babelrc
中配置:git
{ "plugins": [ [ "@babel/plugin-transform-react-jsx", { "pragma": "window.h" } ] ] }
而後在 window
對象上掛載一個 h
函數:github
// h.js const flattern = arr => [].concat.apply([], arr) window.h = function h(tagName, attrs, ...children) { const node = new vnode() node.tagName = tagName node.attrs = attrs || {} node.children = flattern(children) return node }
測試一下:算法
如今咱們已經知道了如何構建 vnode
,接下來就是將其渲染成真正的 dom 節點並掛載。前端框架
// 將 vnode 建立爲真正的 dom 節點 export function createElement(vnode) { if (typeof vnode !== 'object') { // 文本節點 return document.createTextNode(vnode) } const el = document.createElement(vnode.tagName) setAttributes(el, vnode.attrs) vnode.children.map(createElement).forEach(el.appendChild.bind(el)) return el } // render.js export default function render(vnode, parent) { parent = typeof parent === 'string' ? document.querySelector(parent) : parent return parent.appendChild(createElement(vnode)) }
這裏的邏輯主要爲:
vnode.tagName
建立元素vnode.attrs
設置元素的 attributes
vnode.children
並將其建立爲真正的元素,而後將真實子元素節點 append 到第 1 步建立的元素第 2 步已經實現了 vnode
到 dom
節點的轉換與掛載,那麼接下來某一個時刻 dom
節點發生了變化,如何更新 dom
樹?顯然不能無腦卸載整棵樹,而後掛載新的樹,最好的辦法仍是找出兩棵樹之間的差別,而後應用這些差別。
在寫 diff
以前,首先要定義好,要 diff
什麼,明確 diff
的返回值。比較上圖兩個 vnode,能夠得出:
li
的內容ul
下建立兩個 li
,這兩個 li 爲 第 4 個和 第 5 個子節點那麼可能得返回值爲:
{ "type": "UPDATE", "children": [ { "type": "UPDATE", "children": [ { "type": "REPLACE", "newVNode": 0 } ], "attrs": [] }, { "type": "UPDATE", "children": [ { "type": "REPLACE", "newVNode": 1 } ], "attrs": [] }, { "type": "UPDATE", "children": [ { "type": "REPLACE", "newVNode": 2 } ], "attrs": [] }, { "type": "CREATE", "newVNode": { "tagName": "li", "attrs": {}, "children": [ 3 ] } }, { "type": "CREATE", "newVNode": { "tagName": "li", "attrs": {}, "children": [ 4 ] } } ], "attrs": [] }
diff
的過程當中,要保證節點的父節點正確,並要保證該節點在父節點 的子節點中的索引正確(保證節點內容正確,位置正確)。diff
的核心流程:
/** * diff 新舊節點差別 * @param {*} oldVNode * @param {*} newVNode */ export default function diff(oldVNode, newVNode) { if (isNull(oldVNode)) { return { type: CREATE, newVNode } } if (isNull(newVNode)) { return { type: REMOVE } } if (isDiffrentVNode(oldVNode, newVNode)) { return { type: REPLACE, newVNode } } if (newVNode.tagName) { return { type: UPDATE, children: diffVNodeChildren(oldVNode, newVNode), attrs: diffVNodeAttrs(oldVNode, newVNode) } } }
知道了兩棵樹以前的差別,接下來如何應用這些更新?在文章開頭部分咱們提到 dom
節點樹應當只有一個根節點,同時 diff
算法是保證了虛擬節點的位置和父節點是與 dom
樹保持一致的,那麼 patch 的入口也就很簡單了,從 虛擬節點的掛載點開始遞歸應用更新便可。
/** * 根據 diff 結果更新 dom 樹 * 這裏爲何從 index = 0 開始? * 由於咱們是使用樹去表示整個 dom 樹的,傳入的 parent 即爲 dom 掛載點 * 從根節點的第一個節點開始應用更新,這是與整個dom樹的結構保持一致的 * @param {*} parent * @param {*} patches * @param {*} index */ export default function patch(parent, patches, index = 0) { if (!patches) { return } parent = typeof parent === 'string' ? document.querySelector(parent) : parent const el = parent.childNodes[index] /* eslint-disable indent */ switch (patches.type) { case CREATE: { const { newVNode } = patches const newEl = createElement(newVNode) parent.appendChild(newEl) break } case REPLACE: { const { newVNode } = patches const newEl = createElement(newVNode) parent.replaceChild(newEl, el) break } case REMOVE: { parent.removeChild(el) break } case UPDATE: { const { attrs, children } = patches patchAttrs(el, attrs) for (let i = 0, len = children.length; i < len; i++) { patch(el, children[i], i) } break } } }
至此,vdom
的核心 diff
與 patch
都已基本實現。在測試 demo 中,不難發現 diff
其實已經很快了,可是 patch
速度會比較慢,因此這裏留下了一個待優化的點就是 patch
。
本文完整代碼均在這個倉庫。