react diff 算法實現思路及原理解析
前面幾節我們學習瞭解瞭 react
的渲染機制和生命周期,本節我們正式進入基本面試必考的核心地帶 — diff
算法,瞭解如何優化和復用 dom
操作的,還有我們常見的 key
的作用。
diff
算法使用在子都是數組的情況下,這點和vue
是一樣的。如果元素是其他類型的話直接替換就好。
事例分析
按照之前的 diff
寫法,如果元素不同我們是直接刪瞭 a
再插入的:
按照上面圖的結構,我們需要知道那個元素變化瞭,其實右邊相對左邊隻是把 A
做瞭移動,沒有 dom
元素的刪除和新增。
diff
特點
- 同級對比
On
- 類型不一樣銷毀老的,創建新的
- 通過
key
標識
key
這裡需要標識,主要是為瞭列表中有刪除新增時有優化效果,如果純靜態列表,隻是展示作用,key
意義不大。
diff 思路
- 使用
map
存儲節點狀態,格式如下:
let map = { keyA: ADOM, keyB: BDOM }
- 定義 lastPlacedIndex 記錄上一個不需要移動的老節點
默認 lastPlacedIndex = 0
,上一個不需要移動的節點,在循環新的子虛擬 dom
時,如果老節點的掛載索引小於當前值,則改變 lastPlacedIndex
。這裡有點類似 vue
的最長遞增子序列,最大的保證不變的 dom
元素,隻是判斷方式不同。
- 循環新數組
- 先出
A
,map
中如果有A
,表示可以復用- 判斷
A
的老掛載索引和lastPlacedIndex
對比,如果索引值大,A
節點不需要移動,更新lastPlacedIndex
的值;否則循環到B
,掛載索引小,需要移動B
;循環到G
,map
中沒有值,需要新增;新的數組節點循環完,未用到的老節點全部刪除。
- 判斷
實現 diff 算法
修改入口文件
// src/index.js class Counter extends React.Component { constructor(props) { super(props) this.state = {list: ['A','B', 'C', 'D', 'E', 'F']} } handleClick = () => { this.setState({ list: ['A', 'C', 'E', 'B', 'G'] }) } render() { // 使用空標簽 return <React.Fragment> <ul> {this.state.list.map(item => { // 這裡使用 key 標識 return <li key={item}>{item}</li> })} </ul> <button onClick={this.handleClick}>add 1</button> </React.Fragment> } }
實現 React.Fragment
Fragment
就是代碼片段,不占用dom
結構。簡寫<></>
,對應dom
操作為createDocumentFragment
。
- 是用原生庫打印,看結構
可以發現就是一個簡單的 Symbol
,所以需要定義新的類型:
為什麼一個簡單的
Symbol
可以被渲染成片段呢?依賴於babel
解析。
// src/constants.js export const REACT_FRAGMENT = Symbol("react.fragment") // React.Fragment 標簽 // 備用,diff 時做 patch 的 type 定義 // 新的插入 export const PLACEMENT = 'PLACEMENT' // 復用的移動 export const MOVE = 'MOVE'
在創建元素的時候進行類型判斷,記得 react.js
中導出
// src/react-dom.js // createDOM 方法 else if (type === REACT_FRAGMENT) { // fragment 片段 dom = document.createDocumentFragment() } // updateElement 方法 else if (oldVdom.type === REACT_FRAGMENT) { // fragment 不需要對比,直接對比 子 就可以瞭 const currentDOM = newVdom.dom = findDOM(oldVdom) updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children) }
我們需要修改 children 對比
之前邏輯:
// src/react-dom.js // diff 沒有做復用,直接做的替換 function updateChildren(parentDOM, oldVChildren, newVChildren) { // 拿到最長的 let maxLength = Math.max(oldVChildren.length, newVChildren.length); for (let i = 0; i < maxLength; i++) { // 不能直接 appendChild 進父,需要找到當前操作的節點的下一個節點。在其前面插入 const nextVdom = oldVChildren.find((item, index) => index > i && item && findDOM(item)) compareTwoVdom(parentDOM, oldVChildren[i], newVChildren[i], findDOM(nextVdom)); } }
新的邏輯(參考上面的流程):
// diff function updateChildren(parentDOM, oldVChildren, newVChildren) { oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : [oldVChildren]; newVChildren = Array.isArray(newVChildren) ? newVChildren : [newVChildren]; // 1.循環老結構, 構建map存儲 key: dom const keydOldMap = {} let lastPlacedIndex = 0 oldVChildren.forEach((oldVChild, index) => { let oldKey = oldVChild?.key || index // 寫key 瞭就用key,沒寫默認 index keydOldMap[oldKey] = oldVChild }) // 2. 創建 dom 補丁包,收集 dom 操作 const patch = [] newVChildren.forEach((newVChild, index) => { newVChild.mountIndex = index // 為新元素每個添加索引標識 const newKey = newVChild?.key || index const oldVChild = keydOldMap[newKey] // 看有沒有存 if(oldVChild) { // 如果有老的,就去更新老節點 這裡直接可以復用 updateElement(findDOM(oldVChild).parentNode, oldVChild, newVChild) if(oldVChild.mountIndex < lastPlacedIndex) { patch.push({ type: MOVE, oldVChild, newVChild, mountIndex: index // 舊的移動到新的的位置 }) } // 復用過瞭 刪除掉 delete keydOldMap[newKey] lastPlacedIndex = Math.max(lastPlacedIndex, oldVChild.mountIndex)// 取最大 } else { // 新的 patch.push({ type: PLACEMENT, newVChild, mountIndex: index }) } }) // 找到需要移動的老節點 const moveVChildren = patch.filter(action => action.type === MOVE).map(action => action.oldVChild) // 把要刪除的節點 和 要移動的節點先全刪除 (頁面裡沒有瞭,但是內存中還存在 patch 中有存) Object.values(keydOldMap).concat(moveVChildren).forEach(oldVdom => { let currentDOM = findDOM(oldVdom) currentDOM.remove() }) patch.forEach(action => { const {type, oldVChild, newVChild, mountIndex} = action // 老的真實子節點 const childNodes = parentDOM.childNodes // 新的插入 if (type === PLACEMENT) { let newDOM = createDOM(newVChild) let childNode = childNodes[mountIndex] // 老真實節點 if (childNode) { // 往 老的父對應位置插入 parentDOM.insertBefore(newDOM, childNode) } else { parentDOM.appendChild(newDOM) } } else if (type === MOVE) { // 移動不用創建 新 dom,復用 let oldDOM = findDOM(oldVChild) let childNode = childNodes[mountIndex] // 老真實節點 if (childNode) { // 往 老的父對應位置插入 parentDOM.insertBefore(oldDOM, childNode) } else { parentDOM.appendChild(oldDOM) } } }) }
實現如下跟原生一致,可以看到,三個節點實現瞭復用,即 A, C, E
如果沒有寫 key
,我們在看效果:
可以看到隻有第一個節點實現瞭復用,因為默認索引都使用的 0
。所以這也是為什麼不建議我們使用索引當 key
的原因。動態列表 key
意義不大。
本節代碼不是很多,主要是 diff
算法的思路和實現原理。如果瞭解瞭 vue
的 diff
算法,相信理解起來更好,也能更好的對比。下一小節我們學習下 react
新的生命周期。
到此這篇關於react diff 算法實現思路及原理解析的文章就介紹到這瞭,更多相關react diff 算法內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!