React diff算法的實現(xiàn)示例
前言
在上一篇文章,我們已經(jīng)實現(xiàn)了React的組件功能,從功能的角度來說已經(jīng)實現(xiàn)了React的核心功能了。
但是我們的實現(xiàn)方式有很大的問題:每次更新都重新渲染整個應(yīng)用或者整個組件,DOM操作十分昂貴,這樣性能損耗非常大。
為了減少DOM更新,我們需要找渲染前后真正變化的部分,只更新這一部分DOM。而對比變化,找出需要更新部分的算法我們稱之為diff算法。
對比策略
在前面兩篇文章后,我們實現(xiàn)了一個render方法,它能將虛擬DOM渲染成真正的DOM,我們現(xiàn)在就需要改進(jìn)它,讓它不要再傻乎乎地重新渲染整個DOM樹,而是找出真正變化的部分。
這部分很多類React框架實現(xiàn)方式都不太一樣,有的框架會選擇保存上次渲染的虛擬DOM,然后對比虛擬DOM前后的變化,得到一系列更新的數(shù)據(jù),然后再將這些更新應(yīng)用到真正的DOM上。
但也有一些框架會選擇直接對比虛擬DOM和真實DOM,這樣就不需要額外保存上一次渲染的虛擬DOM,并且能夠一邊對比一邊更新,這也是我們選擇的方式。
不管是DOM還是虛擬DOM,它們的結(jié)構(gòu)都是一棵樹,完全對比兩棵樹變化的算法時間復(fù)雜度是O(n^3),但是考慮到我們很少會跨層級移動DOM,所以我們只需要對比同一層級的變化。

只需要對比同一顏色框內(nèi)的節(jié)點
總而言之,我們的diff算法有兩個原則:
- 對比當(dāng)前真實的DOM和虛擬DOM,在對比過程中直接更新真實DOM
- 只對比同一層級的變化實現(xiàn)
我們需要實現(xiàn)一個diff方法,它的作用是對比真實DOM和虛擬DOM,最后返回更新后的DOM
/**
* @param {HTMLElement} dom 真實DOM
* @param {vnode} vnode 虛擬DOM
* @returns {HTMLElement} 更新后的DOM
*/
function diff( dom, vnode ) {
// ...
}
接下來就要實現(xiàn)這個方法。
在這之前先來回憶一下我們虛擬DOM的結(jié)構(gòu):
虛擬DOM的結(jié)構(gòu)可以分為三種,分別表示文本、原生DOM節(jié)點以及組件。
// 原生DOM節(jié)點的vnode
{
tag: 'div',
attrs: {
className: 'container'
},
children: []
}
// 文本節(jié)點的vnode
"hello,world"
// 組件的vnode
{
tag: ComponentConstrucotr,
attrs: {
className: 'container'
},
children: []
}
對比文本節(jié)點
首先考慮最簡單的文本節(jié)點,如果當(dāng)前的DOM就是文本節(jié)點,則直接更新內(nèi)容,否則就新建一個文本節(jié)點,并移除掉原來的DOM。
// diff text node
if ( typeof vnode === 'string' ) {
// 如果當(dāng)前的DOM就是文本節(jié)點,則直接更新內(nèi)容
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不是文本節(jié)點,則新建一個文本節(jié)點DOM,并移除掉原來的
} else {
out = document.createTextNode( vnode );
if ( dom && dom.parentNode ) {
dom.parentNode.replaceChild( out, dom );
}
}
return out;
}
文本節(jié)點十分簡單,它沒有屬性,也沒有子元素,所以這一步結(jié)束后就可以直接返回結(jié)果了。
對比非文本DOM節(jié)點
如果vnode表示的是一個非文本的DOM節(jié)點,那就要分幾種情況了:
如果真實DOM和虛擬DOM的類型不同,例如當(dāng)前真實DOM是一個div,而vnode的tag的值是'button',那么原來的div就沒有利用價值了,直接新建一個button元素,并將div的所有子節(jié)點移到button下,然后用replaceChild方法將div替換成button。
if ( !dom || dom.nodeName.toLowerCase() !== vnode.tag.toLowerCase() ) {
out = document.createElement( vnode.tag );
if ( dom ) {
[ ...dom.childNodes ].map( out.appendChild ); // 將原來的子節(jié)點移到新節(jié)點下
if ( dom.parentNode ) {
dom.parentNode.replaceChild( out, dom ); // 移除掉原來的DOM對象
}
}
}
如果真實DOM和虛擬DOM是同一類型的,那我們暫時不需要做別的,只需要等待后面對比屬性和對比子節(jié)點。
對比屬性
實際上diff算法不僅僅是找出節(jié)點類型的變化,它還要找出來節(jié)點的屬性以及事件監(jiān)聽的變化。我們將對比屬性單獨拿出來作為一個方法:
function diffAttributes( dom, vnode ) {
const old = dom.attributes; // 當(dāng)前DOM的屬性
const attrs = vnode.attrs; // 虛擬DOM的屬性
// 如果原來的屬性不在新的屬性當(dāng)中,則將其移除掉(屬性值設(shè)為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方法的實現(xiàn)參見第一篇文章
對比子節(jié)點
節(jié)點本身對比完成了,接下來就是對比它的子節(jié)點。
這里會面臨一個問題,前面我們實現(xiàn)的不同diff方法,都是明確知道哪一個真實DOM和虛擬DOM對比,但是子節(jié)點是一個數(shù)組,它們可能改變了順序,或者數(shù)量有所變化,我們很難確定要和虛擬DOM對比的是哪一個。
為了簡化邏輯,我們可以讓用戶提供一些線索:給節(jié)點設(shè)一個key值,重新渲染時對比key值相同的節(jié)點。
// 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的節(jié)點和沒有key的節(jié)點分開
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,找到對應(yīng)key值的節(jié)點
if ( key ) {
if ( keyed[ key ] ) {
child = keyed[ key ];
keyed[ key ] = undefined;
}
// 如果沒有key,則優(yōu)先找類型相同的節(jié)點
} 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;
}
下面是相關(guān)的工具方法的實現(xiàn),和上一篇文章的實現(xiàn)相比,只需要修改renderComponent方法其中的一行。
function renderComponent( component ) {
// ...
// base = base = _render( renderer ); // 將_render改成diff
base = diff( component.base, renderer );
// ...
}
完整diff實現(xiàn)看這個文件
渲染
現(xiàn)在我們實現(xiàn)了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>
);
}
}
不使用diff
使用上一篇文章的實現(xiàn),從chrome的調(diào)試工具中可以看到,閃爍的部分是每次更新的部分,每次點擊按鈕,都會重新渲染整個組件。

使用diff
而實現(xiàn)了diff方法后,每次點擊按鈕,都只會重新渲染變化的部分。

后話
在這篇文章中我們實現(xiàn)了diff算法,通過它做到了每次只更新需要更新的部分,極大地減少了DOM操作。React實現(xiàn)遠(yuǎn)比這個要復(fù)雜,特別是在React 16之后還引入了Fiber架構(gòu),但是主要的思想是一致的。
實現(xiàn)diff算法可以說性能有了很大的提升,但是在別的地方仍然后很多改進(jìn)的空間:每次調(diào)用setState后會立即調(diào)用renderComponent重新渲染組件,但現(xiàn)實情況是,我們可能會在極短的時間內(nèi)多次調(diào)用setState。
假設(shè)我們在上文的Counter組件中寫出了這種代碼
onClick() {
for ( let i = 0; i < 100; i++ ) {
this.setState( { num: this.state.num + 1 } );
}
}
那以目前的實現(xiàn),每次點擊都會渲染100次組件,對性能肯定有很大的影響。
下一篇文章我們就要來改進(jìn)setState方法
這篇文章的代碼:https://github.com/hujiulong/simple-react/tree/chapter-3
以上就是本文的全部內(nèi)容,希望對大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
react.js 獲取真實的DOM節(jié)點實例(必看)
下面小編就為大家?guī)硪黄猺eact.js 獲取真實的DOM節(jié)點實例(必看)。小編覺得挺不錯的,現(xiàn)在就分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2017-04-04
解決React報錯The?tag?is?unrecognized?in?this?browser
這篇文章主要為大家介紹了解決React報錯The?tag?is?unrecognized?in?this?browser示例詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2022-12-12
詳解React如何使用??useReducer???高階鉤子來管理狀態(tài)
useReducer是React中的一個鉤子,用于替代?useState來管理復(fù)雜的狀態(tài)邏輯,本文將詳細(xì)介紹如何在React中使用?useReducer高階鉤子來管理狀態(tài),感興趣的可以了解下2025-02-02

