在上一篇文章,咱們已經實現了React的組件功能,從功能的角度來講已經實現了React的核心功能了。前端
可是咱們的實現方式有很大的問題:每次更新都從新渲染整個應用或者整個組件,DOM操做十分昂貴,這樣性能損耗很是大。node
爲了減小DOM更新,咱們須要找渲染先後真正變化的部分,只更新這一部分DOM。而對比變化,找出須要更新部分的算法咱們稱之爲diff算法。react
在前面兩篇文章後,咱們實現了一個render方法,它能將虛擬DOM渲染成真正的DOM,咱們如今就須要改進它,讓它不要再傻乎乎地從新渲染整個DOM樹,而是找出真正變化的部分。git
這部分不少類React框架實現方式都不太同樣,有的框架會選擇保存上次渲染的虛擬DOM,而後對比虛擬DOM先後的變化,獲得一系列更新的數據,而後再將這些更新應用到真正的DOM上。github
但也有一些框架會選擇直接對比虛擬DOM和真實DOM,這樣就不須要額外保存上一次渲染的虛擬DOM,而且可以一邊對比一邊更新,這也是咱們選擇的方式。算法
不論是DOM仍是虛擬DOM,它們的結構都是一棵樹,徹底對比兩棵樹變化的算法時間複雜度是O(n^3),可是考慮到咱們不多會跨層級移動DOM,因此咱們只須要對比同一層級的變化。chrome
只須要對比同一顏色框內的節點數組
總而言之,咱們的diff算法有兩個原則:架構
咱們須要實現一個diff方法,它的做用是對比真實DOM和虛擬DOM,最後返回更新後的DOMapp
/** * @param {HTMLElement} dom 真實DOM * @param {vnode} vnode 虛擬DOM * @returns {HTMLElement} 更新後的DOM */ function diff( dom, vnode ) { // ... }
接下來就要實現這個方法。
在這以前先來回憶一下咱們虛擬DOM的結構:
虛擬DOM的結構能夠分爲三種,分別表示文本、原生DOM節點以及組件。
// 原生DOM節點的vnode { tag: 'div', attrs: { className: 'container' }, children: [] } // 文本節點的vnode "hello,world" // 組件的vnode { tag: ComponentConstrucotr, attrs: { className: 'container' }, children: [] }
首先考慮最簡單的文本節點,若是當前的DOM就是文本節點,則直接更新內容,不然就新建一個文本節點,並移除掉原來的DOM。
// diff text node if ( typeof vnode === 'string' ) { // 若是當前的DOM就是文本節點,則直接更新內容 if ( dom && dom.nodeType === 3 ) { // nodeType: https://developer.mozilla.org/zh-CN/docs/Web/API/Node/nodeType if ( dom.textContent !== vnode ) { dom.textContent = vnode; } // 若是DOM不是文本節點,則新建一個文本節點DOM,並移除掉原來的 } else { out = document.createTextNode( vnode ); if ( dom && dom.parentNode ) { dom.parentNode.replaceChild( out, dom ); } } return out; }
文本節點十分簡單,它沒有屬性,也沒有子元素,因此這一步結束後就能夠直接返回結果了。
若是vnode表示的是一個非文本的DOM節點,那就要分幾種狀況了:
若是真實DOM和虛擬DOM的類型不一樣,例如當前真實DOM是一個div,而vnode的tag的值是'button',那麼原來的div就沒有利用價值了,直接新建一個button元素,並將div的全部子節點移到button下,而後用replaceChild方法將div替換成button。
if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) { out = document.createElement( vnode.tag ); if ( dom ) { [ ...dom.childNodes ].map( out.appendChild ); // 將原來的子節點移到新節點下 if ( dom.parentNode ) { dom.parentNode.replaceChild( out, dom ); // 移除掉原來的DOM對象 } } }
若是真實DOM和虛擬DOM是同一類型的,那咱們暫時不須要作別的,只須要等待後面對比屬性和對比子節點。
實際上diff算法不只僅是找出節點類型的變化,它還要找出來節點的屬性以及事件監聽的變化。咱們將對比屬性單獨拿出來做爲一個方法:
function diffAttributes( dom, vnode ) { const old = dom.attributes; // 當前DOM的屬性 const attrs = vnode.attrs; // 虛擬DOM的屬性 // 若是原來的屬性不在新的屬性當中,則將其移除掉(屬性值設爲undefined) for ( let name in old ) { if ( !( name in attrs ) ) { setAttribute( dom, name, undefined ); } } // 更新新的屬性值 for ( let name in attrs ) { if ( old[ name ] !== attrs[ name ] ) { setAttribute( dom, name, attrs[ name ] ); } } }
setAttribute方法的實現參見第一篇文章
節點自己對比完成了,接下來就是對比它的子節點。
這裏會面臨一個問題,前面咱們實現的不一樣diff方法,都是明確知道哪個真實DOM和虛擬DOM對比,可是子節點是一個數組,它們可能改變了順序,或者數量有所變化,咱們很難肯定要和虛擬DOM對比的是哪個。
爲了簡化邏輯,咱們可讓用戶提供一些線索:給節點設一個key值,從新渲染時對比key值相同的節點。
// diff方法 if ( vnode.children && vnode.children.length > 0 || ( out.childNodes && out.childNodes.length > 0 ) ) { diffChildren( out, vnode.children ); }
function diffChildren( dom, vchildren ) { const domChildren = dom.childNodes; const children = []; const keyed = {}; // 將有key的節點和沒有key的節點分開 if ( domChildren.length > 0 ) { for ( let i = 0; i < domChildren.length; i++ ) { const child = domChildren[ i ]; const key = child.key; if ( key ) { keyedLen++; keyed[ key ] = child; } else { children.push( child ); } } } if ( vchildren && vchildren.length > 0 ) { let min = 0; let childrenLen = children.length; for ( let i = 0; i < vchildren.length; i++ ) { const vchild = vchildren[ i ]; const key = vchild.key; let child; // 若是有key,找到對應key值的節點 if ( key ) { if ( keyed[ key ] ) { child = keyed[ key ]; keyed[ key ] = undefined; } // 若是沒有key,則優先找類型相同的節點 } else if ( min < childrenLen ) { for ( let j = min; j < childrenLen; j++ ) { let c = children[ j ]; if ( c && isSameNodeType( c, vchild ) ) { child = c; children[ j ] = undefined; if ( j === childrenLen - 1 ) childrenLen--; if ( j === min ) min++; break; } } } // 對比 child = diff( child, vchild ); // 更新DOM const f = domChildren[ i ]; if ( child && child !== dom && child !== f ) { if ( !f ) { dom.appendChild(child); } else if ( child === f.nextSibling ) { removeNode( f ); } else { dom.insertBefore( child, f ); } } } } }
若是vnode是一個組件,咱們也單獨拿出來做爲一個方法:
function diffComponent( dom, vnode ) { let c = dom && dom._component; let oldDom = dom; // 若是組件類型沒有變化,則從新set props if ( c && c.constructor === vnode.tag ) { setComponentProps( c, vnode.attrs ); dom = c.base; // 若是組件類型變化,則移除掉原來組件,並渲染新的組件 } else { if ( c ) { unmountComponent( c ); oldDom = null; } c = createComponent( vnode.tag, vnode.attrs ); setComponentProps( c, vnode.attrs ); dom = c.base; if ( oldDom && dom !== oldDom ) { oldDom._component = null; removeNode( oldDom ); } } return dom; }
下面是相關的工具方法的實現,和上一篇文章的實現相比,只須要修改renderComponent方法其中的一行。
function renderComponent( component ) { // ... // base = base = _render( renderer ); // 將_render改爲diff base = diff( component.base, renderer ); // ... }
完整diff實現看這個文件
如今咱們實現了diff方法,咱們嘗試渲染上一篇文章中定義的Counter組件,來感覺一下有無diff方法的不一樣。
class Counter extends React.Component { constructor( props ) { super( props ); this.state = { num: 1 } } onClick() { this.setState( { num: this.state.num + 1 } ); } render() { return ( <div> <h1>count: { this.state.num }</h1> <button onClick={ () => this.onClick()}>add</button> </div> ); } }
使用上一篇文章的實現,從chrome的調試工具中能夠看到,閃爍的部分是每次更新的部分,每次點擊按鈕,都會從新渲染整個組件。
而實現了diff方法後,每次點擊按鈕,都只會從新渲染變化的部分。
在這篇文章中咱們實現了diff算法,經過它作到了每次只更新須要更新的部分,極大地減小了DOM操做。React實現遠比這個要複雜,特別是在React 16以後還引入了Fiber架構,可是主要的思想是一致的。
實現diff算法能夠說性能有了很大的提高,可是在別的地方仍而後不少改進的空間:每次調用setState後會當即調用renderComponent從新渲染組件,但現實狀況是,咱們可能會在極短的時間內屢次調用setState。
假設咱們在上文的Counter組件中寫出了這種代碼
onClick() { for ( let i = 0; i < 100; i++ ) { this.setState( { num: this.state.num + 1 } ); } }
那以目前的實現,每次點擊都會渲染100次組件,對性能確定有很大的影響。
下一篇文章咱們就要來改進setState方法
這篇文章的代碼:https://github.com/hujiulong/...
React是前端最受歡迎的框架之一,解讀其源碼的文章很是多,可是我想從另外一個角度去解讀React:從零開始實現一個React,從API層面實現React的大部分功能,在這個過程當中去探索爲何有虛擬DOM、diff、爲何setState這樣設計等問題。
整個系列大概會有四篇,我每週會更新一到兩篇,我會第一時間在github上更新,有問題須要探討也請在github上回復我~
博客地址: https://github.com/hujiulong/...
關注點star,訂閱點watch