專一前端與算法的系列乾貨分享,歡迎關注(¬‿¬):
「微信公衆號: 心譚博客」| xin-tan.com | GitHub
隨着 React 的興起,Virtual DOM 的原理和實現也開始出如今各大廠面試和社區的文章中。其實這種作法早在 d3.js
中就有實現,是 react 生態的快速創建讓它正式進入了廣大開發者的視角。javascript
在正式開始前,拋出幾個問題來引導思路,這些問題也會在不一樣的小節中,逐步解決:html
⚠️ 整理後的代碼和效果圖均存放在github.com/dongyuanxin。前端
曾經,前端常作的事情就是根據數據狀態的更新,來更新界面視圖。你們逐漸意識到,對於複雜視圖的界面,頻繁地更新 DOM,會形成迴流或者重繪,引起性能降低,頁面卡頓。java
所以,咱們須要方法避免頻繁地更新 DOM 樹。思路也很簡單,即:對比 DOM 的差距,只更新須要部分節點,而不是更新一棵樹。而實現這個算法的基礎,就須要遍歷 DOM 樹的節點,來進行比較更新。node
爲了處理更快,不使用 DOM 對象,而是用 JS 對象來表示,它就像是 JS 和 DOM 之間的一層緩存。react
藉助 ES6 的 class,表示 VDom 語義化更強。一個基礎的 VDom 須要有標籤名、標籤屬性以及子節點,以下所示:git
class Element { constructor(tagName, props, children) { this.tagName = tagName; this.props = props; this.children = children; } }
爲了更方便調用(不用每次都寫new
),將其封裝返回實例的函數:github
function el(tagName, props, children) { return new Element(tagName, props, children); }
此時,若是想表達下面的 DOM 結構:面試
<div class="test"> <span>span1</span> </div>
用 VDom 就是:算法
// 子節點數組的元素能夠是文本,也能夠是VDom實例 const span = el("span", {}, ["span1"]); const div = el("div", { class: "test" }, [span]);
以後在對比和更新兩棵 VDom 樹的時候,會涉及到將 VDom 渲染成真正的 Dom 節點。所以,爲class Element
增長render
方法:
class Element { constructor(tagName, props, children) { this.tagName = tagName; this.props = props; this.children = children; } render() { const dom = document.createElement(this.tagName); // 設置標籤屬性值 Reflect.ownKeys(this.props).forEach(name => dom.setAttribute(name, this.props[name]) ); // 遞歸更新子節點 this.children.forEach(child => { const childDom = child instanceof Element ? child.render() : document.createTextNode(child); dom.appendChild(childDom); }); return dom; } }
前面已經說明了 VDom 的用法與含義,多個 VDom 就會組成一棵虛擬的 DOM 樹。剩下須要作的就是:根據不一樣的狀況,來進行樹上節點的增刪改的操做。這個過程是分爲diff
和patch
:
目前有兩種思路,一種是先 diff 一遍,記錄全部的差別,再統一進行 patch;另一種是 diff 的同時,進行 patch。相較而言,第二種方法少了一次遞歸查詢,以及不須要構造過多的對象,下面採起的是第二種思路。
將 diff 和 patch 的過程,放入updateEl
方法中,這個方法的定義以下:
/** * * @param {HTMLElement} $parent * @param {Element} newNode * @param {Element} oldNode * @param {Number} index */ function updateEl($parent, newNode, oldNode, index = 0) { // ... }
全部以$
開頭的變量,表明着真實的 DOM。
參數index
表示oldNode
在$parent
的全部子節點構成的數組的下標位置。
若是 oldNode 爲 undefined,說明 newNode 是一個新增的 DOM 節點。直接將其追加到 DOM 節點中便可:
function updateEl($parent, newNode, oldNode, index = 0) { if (!oldNode) { $parent.appendChild(newNode.render()); } }
若是 newNode 爲 undefined,說明新的 VDom 樹中,當前位置沒有節點,所以須要將其從實際的 DOM 中刪除。刪除就調用$parent.removeChild()
,經過index
參數,能夠拿到被刪除元素的引用:
function updateEl($parent, newNode, oldNode, index = 0) { if (!oldNode) { $parent.appendChild(newNode.render()); } else if (!newNode) { $parent.removeChild($parent.childNodes[index]); } }
對比 oldNode 和 newNode,有 3 種狀況,都可視爲改變:
首先,藉助Symbol
更好地語義化聲明這三種變化:
const CHANGE_TYPE_TEXT = Symbol("text"); const CHANGE_TYPE_PROP = Symbol("props"); const CHANGE_TYPE_REPLACE = Symbol("replace");
針對節點屬性發生改變,沒有現成的 api 供咱們批量更新。所以封裝replaceAttribute
,將新 vdom 的屬性直接映射到 dom 結構上:
function replaceAttribute($node, removedAttrs, newAttrs) { if (!$node) { return; } Reflect.ownKeys(removedAttrs).forEach(attr => $node.removeAttribute(attr)); Reflect.ownKeys(newAttrs).forEach(attr => $node.setAttribute(attr, newAttrs[attr]) ); }
編寫checkChangeType
函數判斷變化的類型;若是沒有變化,則返回空:
function checkChangeType(newNode, oldNode) { if ( typeof newNode !== typeof oldNode || newNode.tagName !== oldNode.tagName ) { return CHANGE_TYPE_REPLACE; } if (typeof newNode === "string") { if (newNode !== oldNode) { return CHANGE_TYPE_TEXT; } return; } const propsChanged = Reflect.ownKeys(newNode.props).reduce( (prev, name) => prev || oldNode.props[name] !== newNode.props[name], false ); if (propsChanged) { return CHANGE_TYPE_PROP; } return; }
在updateEl
中,根據checkChangeType
返回的變化類型,作對應的處理。若是類型爲空,則不進行處理。具體邏輯以下:
function updateEl($parent, newNode, oldNode, index = 0) { let changeType = null; if (!oldNode) { $parent.appendChild(newNode.render()); } else if (!newNode) { $parent.removeChild($parent.childNodes[index]); } else if ((changeType = checkChangeType(newNode, oldNode))) { if (changeType === CHANGE_TYPE_TEXT) { $parent.replaceChild( document.createTextNode(newNode), $parent.childNodes[index] ); } else if (changeType === CHANGE_TYPE_REPLACE) { $parent.replaceChild(newNode.render(), $parent.childNodes[index]); } else if (changeType === CHANGE_TYPE_PROP) { replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props); } } }
若是狀況 一、二、3 都沒有命中,那麼說明當前新舊節點自身沒有變化。此時,須要遍歷它們(Virtual Dom)的children
數組(Dom 子節點),遞歸進行處理。
代碼實現很是簡單:
function updateEl($parent, newNode, oldNode, index = 0) { let changeType = null; if (!oldNode) { $parent.appendChild(newNode.render()); } else if (!newNode) { $parent.removeChild($parent.childNodes[index]); } else if ((changeType = checkChangeType(newNode, oldNode))) { if (changeType === CHANGE_TYPE_TEXT) { $parent.replaceChild( document.createTextNode(newNode), $parent.childNodes[index] ); } else if (changeType === CHANGE_TYPE_REPLACE) { $parent.replaceChild(newNode.render(), $parent.childNodes[index]); } else if (changeType === CHANGE_TYPE_PROP) { replaceAttribute($parent.childNodes[index], oldNode.props, newNode.props); } } else if (newNode.tagName) { const newLength = newNode.children.length; const oldLength = oldNode.children.length; for (let i = 0; i < newLength || i < oldLength; ++i) { updateEl( $parent.childNodes[index], newNode.children[i], oldNode.children[i], i ); } } }
將github.com/dongyuanxin/pure-virtual-dom的代碼 clone 到本地,Chrome 打開index.html
。
新增 dom 節點.gif:
更新文本內容.gif:
更改節點屬性.gif:
⚠️ 網速較慢的同窗請移步 github 倉庫
專一前端與算法的系列乾貨分享,歡迎關注(¬‿¬)
![]()