首先歡迎你們關注、點贊、收藏個人掘金帳號和Github博客,也算是對個人一點鼓勵,畢竟寫東西無法得到變現,能堅持下去也是靠的是本身的熱情和你們的鼓勵。以前的文章咱們介紹了MV*框架的歷史以及React引入Virtual DOM所帶來的新的解決思路,俗話說,百聞不如一見,百見不如一干。這篇文章咱們將嘗試使用去實現一個Virtual DOM的最小化實現方案,由於最近剛學了TypeScript,正好拿來練手。源碼地址將在文章最後附錄。
javascript
不管是MVC模式仍是後來改進的MVP模式以及目前更爲常見的MVVM模式,其目的都是爲了解決Model層和View如何鏈接,經過採用各類中間層(Controller、Presenter、View of Model)協調View與Model的關係。可是React所倡導的Virtual DOM方案卻劍走偏鋒,即每次Model層的變化都會從新渲染View層,那麼做爲開發者而言,只須要處理好數據和視圖映射,從而將咱們的關注重點集中於數據和數據流的變化,從而極大的下降開發關注度。css
實際上咱們都知道瀏覽器對DOM的操做所帶來的渲染重繪相比於JavaScript計算速度確定是慢上好幾個數量級的。假設僅僅只是頁面中一個數據的變化就重繪整個頁面,那確定是咱們所不能接受的。借鑑計算機學科中最經常使用的Cache思想,咱們在低速的DOM操做和高速的JavaScript執行之間引入了Virtual DOM,經過對比兩個Virtual DOM節點的變化,找出其中的變化,從而精準地修改DOM節點,在實現思路的同時儘量地下降操做代價,達到良好的性能體驗。html
衆所周知,把大象裝到冰箱須要三步,那麼實現一個Virtual DOM庫須要幾步呢?前端
上圖就是咱們要實現Virtual DOM的基本流程:java
上面的四個步驟也就基本對應着咱們所要實現Virtual DOM的四個函數:node
createElement
render
diff
applyDiff
乍一看想要實現Virtual DOM庫可能感受很有難度,可是通過仔細的分析,其實將問題轉化成實現四個特定功能的函數。其實這種思惟方式在咱們軟件開發中仍是很是的實用的,當目標過大而無從下手時,要學會將目標合理拆分。React所倡導的前端組件化其實就包含這個思想,組件化最重要的兩個特色就是:複用和分治,咱們每每過於強調複用的特性。其實相比複用,分治纔是組件化的精髓,咱們經過劃分組件,每每使得特定組件僅具備相對較爲簡單的職責功能,而後經過組合簡單的組件成爲複雜的功能。相比而言,維護功能職責簡單的組件更爲容易,也不容易出錯。接下來咱們要作的就是一步步實現各個函數功能,最終實現一個簡單的Virtural DOM庫。react
在此以前,咱們首先簡要介紹JSX的做用,由React發揚光大的JSX語法使得咱們更爲方便的在JavaScript中建立HTML,描述UI界面。JSX語法並非某個庫所獨有的,而是一種JavaScript函數調用的語法糖,JSX其實至關於JavaScript + HTML(也被稱爲hyperscript,即hyper + script,hyper是HyperText超文本的簡寫,而script是JavaScript的簡寫)。在React中,JSX語法都會轉化成React.createElement
調用,而在Preact中,JSX語法則會被轉成preact.h
函數調用。git
例如在React中:github
<ul> <li>列表1</li> <li>列表2</li> <li>列表3</li> </ul>
則會被轉化爲:算法
React.createElement( 'ul', null, React.createElement('li', null, '列表1'), React.createElement('li', null, '列表2'), React.createElement('li', null, '列表3') );
其中createElement
的參數依次是元素類型、屬性、以及子元素。類型元素能夠分爲三種,依次是:字符串、函數、類,依次對應於HTML固有元素、無狀態函數組件 (SFC)、類組件。本篇文章重點只在於闡釋Virtual DOM基本原理,所以簡單起見,咱們僅支持HTML固有元素,暫不支持無狀態函數組件 (SFC)和類組件。
JSX能夠根據使用的框架編譯成不一樣的函數調用,例如React的React.createElement
或者Preact的h
,咱們能夠經過在JSX上添加編譯註釋(Pragma)來局部改變,例如:
/** @jsx h */ let dom = <div id="foo">Hello!</div>;
經過爲JSX添加註釋@jsx(這也被成爲Pragma,即編譯註釋),可使得Babel在轉化JSX代碼時,將其裝換成函數h的調用。固然,也能夠在工程全局進行配置,好比咱們能夠在Babel6中的.babelrc文件中設置:
{ "plugins": [ ["transform-react-jsx", { "pragma":"h" }] ] }
這樣工程中全部用到JSX的地方都是被Babel轉化成使用h函數的調用。在TypeScript中咱們能夠經過更改配置文件tsconfig.json
中的jsxFactory
來控制JSX的編譯,具體可參照TypeScript中關於JSX的文檔,再也不此處贅述。
根據Virtual DOM節點的特色,咱們給出Virtual DOM節點類描述:
// 類型別名 type TagNameType = string; type KeyType = string | number | null; interface PropsType { key?: string | number; [prop: string]: any; } // 類 class VNode { // 節點類型 public tagName: TagNameType; // 屬性 public props: PropsType; // key public key? : KeyType; // 子元素 public children: (VNode | string)[]; public constructor(tagName: TagNameType) { this.tagName = tagName; this.key = null; this.children = []; this.props = {}; } }
其中tagName
爲元素類型,例如:div
、p
。由於咱們暫時僅支持HTML固有元素,所以TagNameType
是字符串類型。props
爲元素屬性,在接口PropsType
咱們規定屬性中的key
值必須爲number
或者string
或者null
(null
不傳key
屬性),若是對key
有不明白的同窗,歡迎你們閱讀我以前的文章:React技術內幕:key帶來了什麼。
接下來讓咱們看一下createElement
函數的定義:
function createElement(tagName: TagNameType, props: PropsType, ...children: any[]) { let key: KeyType = null; if (isNotNull(props)) { if (isKey(props.key)) { key = props.key!; delete props.key; } if (isNotNull(props.children)) { children.push(props.children); delete props.children; } } const node = new VNode(tagName); node.children = flatten(children); node.key = key; node.props = isNotNull(props) ? props : {}; return node; }
若是props
中含有key
屬性,則會將其從props中刪除,單獨賦值給VNode的key屬性,而處理props中的children
屬性主要目的是爲了處理如下狀況經過props
中的children
屬性直接傳遞子元素。而對children
調用flatten
主要是爲了處理:
const dom = ( <ul> { Array.from({length: 3}).map((val, index)=>{ return (<li key={index}>列表</li>) }) } </ul> );
在這種狀況下createElement
中的chilren[0]
是子元素數組,所以咱們使用flatten
函數將其處理普通的子元素數組。
經過createElement
函數咱們就能夠將JSX轉化成Virtual DOM節點,寫個單元測試驗證一下是否正確:
describe('createElement', () => { test('多個子元素-數組形式', () => { const dom = ( <ul> { Array.from({ length: 2 }).map((val, index) => { return <li key={index}></li>; }) } </ul> ); const ul = new VNode('ul'); ul.children = Array.from({ length: 2 }).map((val, index) => { const li = new VNode('li'); li.key = index; return li; }); expect(dom).toEqual(ul); }); });
運行一下,Bingo,測試經過。
將Virtual DOM樹渲染成真實DOM函數也並不複雜:
const renderDOM = function (vnode: VNodeChildType) { if (isVNode(vnode)) { let node = document.createElement(vnode.tagName); // 設置元素屬性 for (const prop of Object.keys(vnode.props)) { let value = vnode.props[prop]; if (prop === 'style') { value = transformStyleToString(value); } node.setAttribute(prop, value); } for (const child of vnode.children) { node.appendChild(renderDOM(child)); } return node; } if (typeof vnode === 'number') { return document.createTextNode(String(vnode)); } return document.createTextNode(vnode); }; const render = function (vnode: VNode, root: HTMLElement) { const dom = renderDOM(vnode); root.appendChild(dom); return dom; }
其中邏輯並不複雜,只須要特殊說起一點,元素中style
屬性較爲特殊,style
屬性用來經過CSS爲元素指定樣式。在經過getAttribute()
訪問時,返回的style
特性值中包含的是CSS文本,而經過屬性來訪問它則會返回一個對象。所以在這裏咱們經過setAttribute
函數設置元素樣式前,經過transformStyleToString
函數將樣式從對象改變爲字符串類型,而且將駝峯式的樣式屬性轉化爲普通的CSS樣式屬性,具體可見函數定義:
const transformStyleToString = function (style) { // 如果文本類型則直接返回 if (isString(style)) { return style; } // 如果對象類型則轉爲字符串 if (isObject(style)) { return Object.keys(style).reduce((acc, key) => { let cssKey = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); return acc + `${cssKey}: ${style[key]};`; }, ''); } return ''; };
Diff算法多是Virtual DOM中相對較爲複雜的部分,固然咱們只是爲了實現一個簡易的Virtual DOM系統,並不須要過於複雜的實現,下面只是我本身的一種實現策略,並不具備廣泛性。Diff算法目的是爲了比較兩棵Virtual DOM樹的差別,傳統diff算法的複雜度爲 O(n^3),實際上前端DOM樹結構具備其自身的特定,所以衍生了各類各樣的啓發式算法,並將Diff算法的時間複雜度下降到O(n)。
所謂的啓發式算法是指:在解決問題時所採起的一種根據經驗規則進行發現的方法。其特色是在解決問題時,利用過去的經驗,選擇已經行之有效的方法,而不是系統地、以肯定的步驟去尋求答案。而在Diff中啓發式算法主要是依賴於下列條件:
同級比較是指,咱們僅會對比同一層次的節點,而忽略跨層級的DOM移動操做。對於同一層次節點的增長和刪除,咱們則不會進一步比較其子節點,這樣只須要對樹遍歷一遍便可。
以上圖爲例,父節點從ul
變爲p
節點,即便ul
大部分節點也能夠重用,但咱們並不會跨層級比較,所以咱們會從新渲染div
及其子節點。
同元素比較是指,當遇到元素類型變化時,不會比較兩個組件的不一樣,直接建立新的元素。
以上圖爲例,父節點從ul
變爲ol
節點,即便ul
子節點並未發生改變,但咱們認爲元素類型從ul
改變爲ol
,雖然子節點未發生改變,咱們並不會比較子節點,直接建立新的節點。
子元素比較是指,當節點處於同一層級時,咱們認爲存在如下的節點操做:
INSERT_MARKUP
)REMOVE_NODE
)MOVE_EXISTING
)以上圖爲例,假設以前的Virtual DOM樹爲Old Tree。
當比較第一個子元素div
時,由於New Tree中的div
與同位置Old Tree中的div
節點類型一致,則咱們認爲先後變化中對應位置的節點還是同一個,則咱們會繼續比較節點屬性及其及其子節點。
當比較第二個子元素時,由於p
節點含有key
屬性,且key = 2
的節點也存在於Old Tree,而且先後兩個key = 2
的節點類型是一致的,所以咱們認爲New Tree中key = 2
的p
元素是由Old Tree中第三個子元素移動(MOVE_EXISTING
)而來。
當比較第三個子元素時,由於p
節點含有key = 3
且Old Tree中並不含有key = 3
的同類型節點,則咱們認爲改節點屬於插入節點(INSERT_MARKUP
)。
當咱們比較a
元素的子節點時,由於New Tree中已經不存在該位置的節點,所以咱們認爲改節點屬於刪除節點(REMOVE_NODE
)。
當比較兩棵Virtual DOM樹時,咱們須要記錄兩棵Virtual DOM樹的區別,咱們將其稱爲Patch
,由於咱們須要記錄的是樹節點的差別,所以咱們也能夠將Patch
同類化一個樹結構。根據Patch類特色,咱們給Patch類的定義:
class Patch { // 節點變化類型 public types: OPERATOR_TYPE[]; // 子元素Patch集合 public children: Patch[]; // 存儲帶渲染的新節點 public node: VNode | string | null; // 存儲屬性改變 public modifyProps: ModifyProps[]; // 文本改變 public modifyString: string; // 節點移動,搭配`MOVE_EXISTING`使用 public removeIndex: number; public constructor(types?: (OPERATOR_TYPE | OPERATOR_TYPE[])) { this.types = []; this.children = []; this.node = null; this.modifyProps = []; this.modifyString = ''; this.removeIndex = 0; if (types) { types instanceof Array ? this.types.push(...types) : this.types.push(types); } } // 省略類方法實現 // addType // addModifyProps // addChildPatch // setNode // setModifyString // setRemoveIndex }
其中types
屬性用於存儲變化類型,注意同一個Patch可能存在多種變化類型,所以咱們使用數組存儲。Patch
存在如下幾種類型:
export const enum OPERATOR_TYPE { INSERT_MARKUP, MOVE_EXISTING, REMOVE_NODE, PROPS_CHANG, TEXT_CHANGE }
其中INSERT_MARKUP
、MOVE_EXISTING
、REMOVE_NODE
咱們都已經介紹過,而PROPS_CHANG
表示節點屬性發生改變,例如id
屬性變化。而TEXT_CHANGE
適用於文本節點,表示文本節點內容發生改變。例如文本節點內容從Hello!
改變爲Hello World
。
node
屬性用於存儲待渲染的節點類型類型,搭配INSERT_MARKUP
使用。removeIndex
屬性表示當前節點是從同層序號節點位置移動而來,搭配MOVE_EXISTING
使用。modifyString
表示文本節點變化後的內容,搭配TEXT_CHANGE
使用。modifyProps
表示屬性改變的數組,搭配PROPS_CHANGE
使用。其中ModifyProps
接口描述爲:
const enum PROP_TYPE { ADD, // 新增屬性 DELETE, // 刪除屬性 MODIFY // 屬性改變 } interface ModifyProps { type: PROP_TYPE; key?: string; value?: any; }
完事具有,讓咱們開始實現diff
函數
// 用於返回子元素數組NodeList中key-node集合(Map) function getChildrenKeyMap(children: VNodeChildType[]) { let map = new Map(); each(children, child => { if (isVNode(child) && isKey(child.key)) { map.set(child.key, child); } }); return map; } // 返回給定key值對應節點所在位置 function getChildIndexByKey(children: VNodeChildType[], key) { return findIndex(children, child => (isVNode(child) && child.key === key)); } function diffProps(preProps: PropsType, nextProps: PropsType) { // return [...addPropResult, ...deletePropResult, ...modifyPropResult]; // 返回Props比較數組結果,若是不存在Props變化則返回空數組。 }
function diff(preNode: VNode | null, nextNode: VNode) { const patch = new Patch(); // 若節點類型不一致或者以前的元素爲空,則直接從新建立該節點及其子節點 if (preNode === null || preNode.tagName !== nextNode.tagName) { return patch.addType(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextNode); } // 先後兩個虛擬節點類型一致,則須要比較屬性是否一致 const propsCompareResult = diffProps(preNode.props, nextNode.props); if (isNotEmptyArray(propsCompareResult)) { patch.addType(OPERATOR_TYPE.PROPS_CHANGE).addModifyProps(propsCompareResult); } // 若是上一個子元素不爲空,且下一個子元素全爲空,則須要清除全部子元素 if (isEmptyArray(nextNode.children) && isNotEmptyArray(preNode.children)) { return patch.addChildPatch(preNode.children.map(() => new Patch(OPERATOR_TYPE.REMOVE_NODE))); } const preChildrenKeyMap = getChildrenKeyMap(preNode.children); // 遍歷處理子元素 each(nextNode.children, (child, index) => { const nextChild = child; const preChild = isNotNull(preNode.children[index]) ? preNode.children[index] : null; // 若是當前子節點是字符串類型 if (isString(nextChild)) { // 以前對應節點也是字符串 if (isString(preChild)) { if (nextChild === preChild) { return patch.addChildPatch(new Patch()); } else { return patch.addChildPatch((new Patch(OPERATOR_TYPE.TEXT_CHANGE).setModifyString(nextChild))); } } else { // 以前對應節點不是字符串,則須要建立新的節點 return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild)); } } // 若當前的子節點中存在key屬性 if (isVNode(nextChild) && isKey(nextChild.key)) { // 若是上一個同層虛擬DOM節點中存在相同key且元素類型相同的節點 if (preChildrenKeyMap.has(nextChild.key) && preChildrenKeyMap.get(nextChild.key).tagName === nextChild.tagName) { // 若是先後兩個元素的key值和元素類型相等 const preSameKeyChild = preChildrenKeyMap.get(nextChild.key); const sameKeyIndex = getChildIndexByKey(preNode.children, nextChild.key); const childPatch = diff(preSameKeyChild, nextChild); if (sameKeyIndex !== index) { childPatch.addType(OPERATOR_TYPE.MOVE_EXISTING).setRemoveIndex(sameKeyIndex); } return patch.addChildPatch(childPatch); } else { // 直接建立新的元素 return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextChild))); } } // 子節點中不存在key屬性 // 若先後相同位置的節點是 非VNode(字符串) 或者 存在key值( nextChild不含有key) 或者是 節點類型不一樣,則直接建立新節點 if (!isVNode(preChild) || isKey(preChild.key) || preChild.tagName !== nextChild.tagName) { return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild)); } return patch.addChildPatch(diff(preChild, nextChild)); }); // 若是存在nextChildren個數少於preChildren,則須要補充刪除節點 if (preNode.children.length > nextNode.children.length) { patch.addChildPatch(Array.from({ length: preNode.children.length - nextNode.children.length }, () => new Patch(OPERATOR_TYPE.REMOVE_NODE))); } return patch; }
咱們簡單舉例下圖場景,分別給new Tree
和old Tree
調用diff
算法,則會生成圖中所示的Patch Tree
:
applyDiff
函數的實現則相對要簡單的多,咱們只要對照Patch
Tree,對以前渲染的DOM樹進行修改便可。
function applyChildDiff(actualDOM: HTMLElement, patch: Patch) { // 由於removeIndex是基於old Tree中的序號位置,所以咱們須要提早備份節點節點順序關係 const childrenDOM = map(actualDOM.childNodes, child => child); const childrenPatch = patch.children; for (let index = 0; index < actualDOM.childNodes.length; index++) { const childPatch = childrenPatch[index]; let childDOM = childrenDOM[index]; if (contains(childPatch.types, OPERATOR_TYPE.MOVE_EXISTING)) { const insertDOM = childrenDOM[childPatch.removeIndex]; actualDOM.insertBefore(insertDOM, childDOM); childDOM = insertDOM; } innerApplyDiff(childDOM, childPatch, actualDOM); } } function innerApplyDiff(actualDOM: HTMLElement | Text, patch: Patch, parentDOM: HTMLElement) { // 處理INSERT_MARKUP,直接建立新節點替換以前節點 if (contains(patch.types, OPERATOR_TYPE.INSERT_MARKUP)) { const replaceDOM = renderDOM(patch.node!); parentDOM.replaceChild(replaceDOM, actualDOM); return replaceDOM; } // 處理REMOVE_NODE,直接刪除當前節點 if (contains(patch.types, OPERATOR_TYPE.REMOVE_NODE)) { parentDOM.removeChild(actualDOM); return null; } // 處理TEXT_CHANGE if (contains(patch.types, OPERATOR_TYPE.TEXT_CHANGE)) { actualDOM.nodeValue = patch.modifyString; return actualDOM; } // 處理PROPS_CHANGE if (contains(patch.types, OPERATOR_TYPE.PROPS_CHANGE)) { each(patch.modifyProps, function (modifyProp) { let key = modifyProp.key; let value = modifyProp.value; switch (modifyProp.type) { case PROP_TYPE.ADD: case PROP_TYPE.MODIFY: if (key === 'style') { value = transformStyleToString(value); } actualDOM.setAttribute(key, value); break; case PROP_TYPE.DELETE: actualDOM.removeAttribute(key); break; } }); } if (isHTMLElement(actualDOM)) { applyChildDiff(actualDOM, patch); } return actualDOM; } function applyDiff (actualDOM: HTMLElement | Text, patch: Patch) { if (!(actualDOM.parentNode instanceof HTMLElement)) { throw Error('DOM元素未渲染'); } return applyDiff(actualDOM, patch, actualDOM.parentNode); }
如今咱們的Virtual DOM庫已經基本完成,咱們起個名字就叫Vom,讓咱們嘗試使用一下:
import Vom from '../index'; function getRandomArray(length) { return Array.from(new Array(length).keys()).sort(() => Math.random() - 0.5); } function getRandomColor() { const colors = ['blue', 'red', 'green']; return colors[(new Date().getSeconds()) % colors.length]; } function getJSX() { return ( <div> <p>這是一個由Vom渲染的界面</p> <p> <span style={{ color: getRandomColor() }}>如今時間: { Date().toString() }</span> </p> <p>下面是一個順序動態變化的有序列表:</p> <ul> { getRandomArray(10).map((key) => { return <li key={key}>列表序號: {key} </li>; }) } </ul> </div> ); } let preNode = getJSX(); let actualDom = Vom.render(preNode, document.body); setInterval(() => { const nextNode = getJSX(); const patch = Vom.diff(preNode, nextNode); actualDom = Vom.applyDiff(actualDom, patch)!; preNode = nextNode; }, 1000);
到目前爲止咱們已經實現一個Virtual DOM的基本功能,本篇文章重點仍是在講述Virtual DOM基本原理,實現方面相對比較簡陋,若有不正確之處,望各位見諒。代碼已經上傳Github:Vom。寫做不易,願你們能多多支持,關注個人Github博客,關注、點贊、收藏 素質三連哦!但願這篇文章能對你有些許幫助,願共同窗習!