1. 程式人生 > >React node diff演算法學習

React node diff演算法學習

前言

一直都知道node diff演算法很火,最近剛好有時間,有機會出來學習一點點淺薄的知識,這裡做一個簡單的筆記 證明我學過。。。

學習環境搭建

  • 使用create-react-app 初始化一個專案
  • 刪除src目錄下所喲肚餓檔案
  • 自己新建一個index.js檔案

第一步

建立Element物件

  • index.js新增程式碼如如下

    // 這個是虛擬dom要渲染的列表物件
    let vertualDom1 = createElement('ul', {class: 'list'}, [
        createElement
    ('li', {class: 'item'}, ['a']), createElement('li', {class: 'item'}, ['b']), createElement('li', {class: 'item'}, ['c']) ])
  • 再src目錄下新建一個element.js檔案

    • 該檔案提供如下方法:create Element,render,renderDom

    • 首先實現createElement方法:

      // 虛擬物件類
      export class Element {
          constructor (type, props,
      children) { this.type = type this.props = props this.children = children } } // 建立虛擬dom export const createElement = (type, props, children) => (new Element(type, props, children))
  • 現在再index.js匯入建立物件方法,實現 建立虛擬物件

    // 這個是虛擬dom要渲染的列表物件
    let vertualDom1 = createElement
    ('ul', {class: 'list'}, [ createElement('li', {class: 'item'}, ['a']), createElement('li', {class: 'item'}, ['b']), createElement('li', {class: 'item'}, ['c']) ]) console.log(vertualDom) //虛擬dom

第二步虛擬dom轉化為真實dom

  • 在element檔案內實現render方法,以下是element.js內新增的內容
/**
 * 
 * @param {Element dom} node dom元素
 * @param {string} key 
 * @param {*} val 
 */
function setAttr(node, key, value) {
    switch(key) {
        case 'value':
        // node是一個input或者textarea的時候直接設定value
        if (node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA') {
            node.value = value
        } else {
            node.setAttribute(key, value)
        }
        break;
        case 'style':
            node.style.cssText = value
        break;
        default:
            node.setAttribute(key, value)
        break;
    }
}
// render 可以將vnode轉化成真是dom
export const render = (elObj) => {
    let el = document.createElement(elObj.type)
    for (let key in elObj.props) {
        // 設定屬性的方法
        setAttr(el, key, elObj.props[key])
    }
    // 處理子元素,如果是虛擬dom,繼續渲染,如果不是,繼續徐然
    elObj.children.forEach(child => {
        child = (child instanceof Element) ? render(child) : document.createTextNode(child)
        // 新增子元素到當前元素
        el.appendChild(child)
    });
    return el;
}
  • 在index.js頁面匯入render方法,將虛擬dom轉換為真實dom
import { createElement, render } from './element'

let vertualDom1 = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['a']),
    createElement('li', {class: 'item'}, ['b']),
    createElement('li', {class: 'item'}, ['c'])
])

let el = render(vertualDom1) // 虛擬dom轉化為真實dom
console.log(el)

第三部,將真實dom渲染至頁面

  • element.js檔案實現renderDom方法,進行dom渲染

    // 新增如下程式碼
    export const renderDom = (el, target) => {
        target.appendChild(el)
    }
    
  • index.js新增程式碼如下

    import { createElement, render } from './element'
    
    let vertualDom1 = createElement('ul', {class: 'list'}, [
        createElement('li', {class: 'item'}, ['a']),
        createElement('li', {class: 'item'}, ['b']),
        createElement('li', {class: 'item'}, ['c'])
    ])
    
    let el = render(vertualDom1) // 虛擬dom轉化為真實dom
    // console.log(el)
    
    // 渲染頁面
    renderDom(el, window.root)
    

第四部,實現diff演算法

為了方便處理。這裡使用的是深度優先的遍歷演算法,然後可能變更的型別定義為不同型別,如:

  • const ATTRS = 'ATTRS' 屬性發生變化
  • const TEXT = 'TEXT' 文字節點發生變化
  • const REMOVE = 'REMOVE'節點被刪除
  • const REPLACE = 'REPLACE'節點被替換
							a
						 /    \
					    c      d
					  /  \    /  \
					 e   f    g   k
// 遍歷的順序如下:
start => a => c => e => f => d => g => k => end;

深度優先演算法的簡單步驟如下:

1.遍歷a,發現又子節點,然後遍歷a的子節點

2.遍歷子節點c,發現c還有子節點,然後遍歷c的子節點

3.發現c的子節點e沒有子節點,遍歷到了這裡繼續遍歷e的兄弟節點

4.遍歷e兄弟節點f,發現f沒有子節點,也沒有兄弟接節點,然後遍歷c的兄弟節點d

5.遍歷d的子節點g,發現沒有節點了,然後遍歷g兄弟節點k

  • 在src下新建檔案diff, 程式碼如下:

    
    const ATTRS = 'ATTRS'
    const TEXT = 'TEXT'
    const REMOVE = 'REMOVE'
    const REPLACE = 'REPLACE'
    let Index = 0;
    
    function diff (oldTree, newTree) {
        let patches = {}
        let index = 0; // 為了避免index變化,使用唯一的下標
        walk(oldTree, newTree, Index, patches)
        return patches
    }
    
    // 子節點對比
    function diffChildren (oldChildren, newChildren, index, patches) {
        // 比較老的第一個和新的第一個
        oldChildren.forEach((child, idx) => {
            // 索引不應該是idx
            // index 每次呼叫都應該增加, 每次傳遞給walk的時候是遞增的, 所有的人都基於一個index來實現
            // walk(child, newChildren[idx], ++index, patches)
            walk(child, newChildren[idx], ++Index, patches)
        });
    }
    
    // 屬性對比
    function diffAttr (oldAttrs, newAttrs) {
        let patch = {}
        for (let key in oldAttrs) {
            if (oldAttrs[key] !== newAttrs[key]) {
                patch[key] = newAttrs[key] // 有可能是undefined
            }
        }
    
        // 判斷是否有新增屬性, (老街店沒喲新節點的屬性)
        for (let key in newAttrs) {
            if (!oldAttrs.hasOwnProperty(key)) {
                patch[key] = oldAttrs[key] 
            }
        }
        return patch
    }
    function isString (node) {
        return Object.prototype.toString.call(node) === '[object String]'
    }
    
    // 遞迴樹 比較後的結果放入補丁包
    function walk (oldNode, newNode, index, patches) {
        let currentPatch = []
        if (!newNode) {
            currentPatch.push({type: REMOVE, index})
        } else
        // 文字節點是否變化
        if (isString(oldNode) && isString(newNode)) {
            if (oldNode !== newNode) {
                currentPatch.push({type: TEXT, text: newNode})
            }
        } else  if (oldNode.type === newNode.type) {
            // 比較屬性是否有更改
            let attrs = diffAttr(oldNode.props, newNode.props)
            if (Object.keys(attrs).length > 0) {
                currentPatch.push({type: ATTRS, attrs})
            }
    
            // 如果有兒子節點, 遍歷子節點
            diffChildren(oldNode.children, newNode.children, index, patches)
        } else if(newNode) {
            // 說明節點被替換了
            currentPatch.push({type: REPLACE, newNode})
        }
        // 當前補丁包中有改動,才在大的補丁包中儲存不同之處
        if(currentPatch.length) {
            patches[index] = currentPatch
        }
    }
    
    
    
    export default diff
    
  • index.js修改

新增虛擬dom,和老的dom進行匹配,拿到需要更新的補丁包

import { createElement, render, renderDom } from './element'
import diff from './diff'

let vertualDom1 = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['a']),
    createElement('li', {class: 'item'}, ['b']),
    createElement('li', {class: 'item'}, ['c'])
])
// 新增
let vertualDom2 = createElement('ul', {class: 'list-group'}, [
    createElement('li', {class: 'item'}, ['1']),
    createElement('li', {class: 'item'}, ['b']),
    createElement('div', {class: 'item'}, ['3'])
])

// console.log(vertualDom) //虛擬dom
let el = render(vertualDom1) // 虛擬dom轉化為真實dom
// console.log(el)

// 渲染頁面
renderDom(el, window.root)

// DOM Diff是比較兩個虛擬DOM的區別, 比較兩個物件的區別
// dom Diff的作用,根據兩個虛擬物件創建出補丁,描述改變的內容,將這個補丁用來更新dom
let patchs = diff(vertualDom1, vertualDom2)
console.log(patchs)
// 列印內容大概如下
{0: Array(1), 2: Array(1), 5: Array(1)}
0: [{}]
2: [{}]
5: Array(1)
0:
newNode: Element
children: ["3"]
props: {class: "item"}
type: "div"
__proto__: Object
type: "REPLACE"
__proto__: Object
length: 1
__proto__: Array(0)
__proto__: Object

到了這裡,就可以在控制檯拿到需要更新的補丁包,是一個數組,裡邊儲存的是需要改動的型別

第五步,實現更新dom

  • 在src目錄下新建patch.js檔案,在這裡我們進行打補丁

    思路還是和之前一樣,對dom樹行遍歷,

import { render, Element, setAttr } from './element'
let allPatchs;
let index = 0;
const ATTRS = 'ATTRS'
const TEXT = 'TEXT'
const REMOVE = 'REMOVE'
const REPLACE = 'REPLACE'

function patch (node, patchs) {
    // 給某個元素打補丁
    allPatchs = patchs
    let index = 0; // 預設那個需要打補丁
    
    walk(node)
}

/**
 * 補丁包更新
 * @param {object} node 虛擬dom元素
 * @param {Array} patches 補丁包
 */
function doPatch (node, patches) {
    patches.forEach(patch => {
        switch (patch.type) {
            case ATTRS:
            for (let key in patches.attrs) {
                let val = patches.attrs
                if (val) { // 如果有屬性的話重新複製
                    setAttr(node ,key , val)
                } else { // 如果為空的話刪除屬性
                    node.removeAttribute(key)
                }
            }
            break
            case TEXT:
            node.textContent = patch.text
            break
            case REMOVE:
            break
            case REPLACE:
                let newNode = (patch.newNode  instanceof Element) ?
                    render(patch.newNode) :
                    document.createTextNode(patch.newNode)
                node.parentNode.replaceChild(newNode, node)
            break
            default:
            break
        }
    })
}

/**
 * 
 * @param {object} node 需要對比的dom節點
 */
function walk (node) {
    // 拿到補丁
    let currentPatch = allPatchs[index++]
    // 拿到子節點,進行深度遍歷
    let childNodes = node.childNodes
    childNodes.forEach(child => {
        walk(child)
    });
    // 如果有可用的補丁包嗎直接打補丁
    if (currentPatch && currentPatch.length > 0) {
        doPatch(node, currentPatch)
    }
}
export default patch

  • index.js引入path方法,更新dom
import { createElement, render, renderDom } from './element'
import diff from './diff'
import patch from './patch'
let vertualDom1 = createElement('ul', {class: 'list'}, [
    createElement('li', {class: 'item'}, ['a']),
    createElement('li', {class: 'item'}, ['b']),
    createElement('li', {class: 'item'}, ['c'])
])

let vertualDom2 = createElement('ul', {class: 'list-group'}, [
    createElement('li', {class: 'item'}, ['1']),
    createElement('li', {class: 'item'}, ['b']),
    createElement('div', {class: 'item'}, ['3'])
])

// console.log(vertualDom) //虛擬dom
let el = render(vertualDom1) // 虛擬dom轉化為真實dom
// console.log(el)

// 渲染頁面
renderDom(el, window.root)

// DOM Diff是比較兩個虛擬DOM的區別, 比較兩個物件的區別
// dom Diff的作用,根據兩個虛擬物件創建出補丁,描述改變的內容,將這個補丁用來更新dom
let patchs = diff(vertualDom1, vertualDom2)
// console.log(patchs)

// 給元素打補丁,重新更是檢視
patch(el, patchs)

// 如果平級元素有互換 那會導致重新渲染
// 新增節點也不會被更新
// index 實現換位置

最後

還有幾個遺留問題:

1.如果平級元素有互換 那會導致重新渲染

2.新增節點也不會被更新

3.index 實現換位置

雖然還缺這麼多,但是也算對node diff有一個簡單的認識了,這些遺留的問題,以後再說,總有一天都會解決的