從0實現一個tiny react(二)

ykforerlang發表於2017-09-07

從0實現一個tiny react(二)

ui = f(d)! 這是react考慮ui的方式,開發者可以把重心放到d 資料上面來了。 從開發者的角度來講 d一旦改變,react將會把ui重新渲染,使其再次滿足
ui = f(d), 開發者沒有任何dom操作, 交給react就好!!

怎麼重新渲染呢? (一)文 中我們實現了一種方式, state改變的時候,用新的dom樹替換一下老的dom樹, 這是完全可行的。
考慮一下這個例子 線上演示地址:

class AppWithNoVDOM extends Component {
    constructor(props) {
        super(props)
    }

    testApp3() {
        let result = []
        for(let i = 0; i < 10000 ; i++) {
            result.push(<div style={{
                width: `30px`,
                color: `red`,
                fontSize: `12px`,
                fontWeight: 600,
                height: `20px`,
                textAlign: `center`,
                margin:`5px`,
                padding: `5px`,
                border:`1px solid red`,
                position: `relative`,
                left: `10px`,
                top: `10px`,
            }} title={i} >{i}</div>)
        }
        return result
    }

    render() {
        return (
            <div
                width={100}>
                <a  onClick={e => {
                    this.setState({})
                }}>click me</a>
                {this.testApp3()}
            </div>
        )
    }
}

const startTime = new Date().getTime()
render(<App/>, document.getElementById("root"))
console.log("duration:", new Date().getTime() - startTime)


...
setState(state) {
    setTimeout(() => {
        this.state = state
        const vnode = this.render()
        let olddom = getDOM(this)
        const startTime = new Date().getTime()
        render(vnode, olddom.parentNode, this, olddom)
        console.log("duration:", new Date().getTime() - startTime)
    }, 0)
}
...

我們在 render, setState 設定下時間點。 在10000萬個div的情況下, 第一次render和setState觸發的render 耗時大概在180ms (可能跟機器配置有關)
當點選的時候, 由於呼叫this.setState({}), 頁面將會重新渲染, 再次建立10000萬個div, 但是實際上這裡的DOM一點也沒改。
應用越複雜, 無用功越多,卡頓越明顯

為了解決這個問題, react提出了virtual-dom的概念:vnode(純js物件) `代表` dom, 在渲染之前, 先比較出oldvnode和newvode的 區別。 然後增量的
更新dom。 virtual-dom 使得ui=f(d) 得以在實際專案上使用。
(注意: virtual-dom 並不會加快應用速度, 只是讓應用在不直接操作dom的情況下,通過暴力的比較,增量更新 讓應用沒有那麼慢)

如何增量更新呢?

複用DOM

回想一下, 在 (一) render函式 裡面對於每一個判定為 dom型別的VDOM, 是直接建立一個新的DOM:

...
else if(typeof vnode.nodeName == "string") {
    dom = document.createElement(vnode.nodeName)
    ...
} 
...

一定要建立一個 新的DOM 結構嗎?<br/>
考慮這種情況:假如一個元件, 初次渲染為 renderBefore, 呼叫setState再次渲染為 renderAfter 呼叫setState再再次渲染為 renderAfterAfter。 VNODE如下

const renderBefore = {
    tagName: `div`,
    props: {
        width: `20px`,
        className: `xx`
    },
    children:[vnode1, vnode2, vnode3]
}
const renderAfter = {
    tagName: `div`,
    props: {
        width: `30px`,
        title: `yy`
    },
    children:[vnode1, vnode2]
}
const renderAfterAfter = {
    tagName: `span`,
    props: {
        className: `xx`
    },
    children:[vnode1, vnode2, vnode3]
}

renderBefore 和renderAfter 都是div, 只不過props和children有部分割槽別,那我們是不是可以通過修改DOM屬性, 修改DOM子節點,把 rederBefore 變化為renderAfter呢?, 這樣就避開了DOM建立。 而 renderAfter和renderAfterAfter
屬於不同的DOM型別, 瀏覽器還沒提供修改DOM型別的Api,是無法複用的, 是一定要建立新的DOM的。

原則如下:

  • 不同元素型別是無法複用的, span 是無法變成 div的。
  • 對於相同元素:

    • 更新屬性,
    • 複用子節點。

所以,現在的程式碼可能是這樣的:

...
else if(typeof vnode.nodeName == "string") {
    if(!olddom || olddom.nodeName != vnode.nodeName.toUpperCase()) {
        createNewDom(vnode, parent, comp, olddom)
    } else {
        diffDOM(vnode, parent, comp, olddom) // 包括 更新屬性, 子節點複用
    }
}
...

更新屬性

對於 renderBefore => renderAfter 。 屬性部分需要做3件事情。

  1. renderBefore 和 renderAfter 的屬性交集 如果值不同, 更新值 updateAttr
  2. renderBefore 和 renderAfter 的屬性差集 置空 removeAttr
  3. renderAfter 和 renderBefore 的屬性差集 設定新值 setAttr
const {onlyInLeft, bothIn, onlyInRight} = diffObject(newProps, oldProps)
setAttrs(olddom, onlyInLeft)
removeAttrs(olddom, onlyInRight)
diffAttrs(olddom, bothIn.left, bothIn.right)

function diffObject(leftProps, rightProps) {
    const onlyInLeft = {}
    const bothLeft = {}
    const bothRight = {}
    const onlyInRight = {}

    for(let key in leftProps) {
        if(rightProps[key] === undefined) {
            onlyInLeft[key] = leftProps[key]
        } else {
            bothLeft[key] = leftProps[key]
            bothRight[key] = rightProps[key]
        }
    }

    for(let key in rightProps) {
        if(leftProps[key] === undefined) {
            onlyInRight[key] = rightProps[key]
        }
    }

    return {
        onlyInRight,
        onlyInLeft,
        bothIn: {
            left: bothLeft,
            right: bothRight
        }
    }
}

function setAttrs(dom, props) {
    const allKeys = Object.keys(props)
    allKeys.forEach(k => {
        const v = props[k]

        if(k == "className") {
            dom.setAttribute("class", v)
            return
        }

        if(k == "style") {
            if(typeof v == "string") {
                dom.style.cssText = v //IE
            }

            if(typeof v == "object") {
                for (let i in v) {
                    dom.style[i] =  v[i]
                }
            }
            return

        }

        if(k[0] == "o" && k[1] == "n") {
            const capture = (k.indexOf("Capture") != -1)
            dom.addEventListener(k.substring(2).toLowerCase(), v, capture)
            return
        }

        dom.setAttribute(k, v)
    })
}

function removeAttrs(dom, props) {
    for(let k in props) {
        if(k == "className") {
            dom.removeAttribute("class")
            continue
        }

        if(k == "style") {
            dom.style.cssText = "" //IE
            continue
        }


        if(k[0] == "o" && k[1] == "n") {
            const capture = (k.indexOf("Capture") != -1)
            const v = props[k]
            dom.removeEventListener(k.substring(2).toLowerCase(), v, capture)
            continue
        }

        dom.removeAttribute(k)
    }
}

/**
 *  呼叫者保證newProps 與 oldProps 的keys是相同的
 * @param dom
 * @param newProps
 * @param oldProps
 */
function diffAttrs(dom, newProps, oldProps) {
    for(let k in newProps) {
        let v = newProps[k]
        let ov = oldProps[k]
        if(v === ov) continue

        if(k == "className") {
            dom.setAttribute("class", v)
            continue
        }

        if(k == "style") {
            if(typeof v == "string") {
                dom.style.cssText = v
            } else if( typeof v == "object" && typeof ov == "object") {
                for(let vk in v) {
                    if(v[vk] !== ov[vk]) {
                        dom.style[vk] = v[vk]
                    }
                }

                for(let ovk in ov) {
                    if(v[ovk] === undefined){
                        dom.style[ovk] = ""
                    }
                }
            } else {  //typeof v == "object" && typeof ov == "string"
                dom.style = {}
                for(let vk in v) {
                    dom.style[vk] = v[vk]
                }
            }
            continue
        }

        if(k[0] == "o" && k[1] == "n") {
            const capture = (k.indexOf("Capture") != -1)
            let eventKey = k.substring(2).toLowerCase()
            dom.removeEventListener(eventKey, ov, capture)
            dom.addEventListener(eventKey, v, capture)
            continue
        }

        dom.setAttribute(k, v)
    }
}

`新`的dom結構 屬性和 renderAfter對應了。<br/>
但是 children部分 還是之前的

操作子節點

之前 操作子節點的程式碼:

for(let i = 0; i < vnode.children.length; i++) {
    render(vnode.children[i], dom, null, null)
}

render 的第3個引數comp `誰渲染了我`, 第4個引數olddom `之前的舊dom元素`。現在複用舊的dom, 所以第4個引數可能是有值的 程式碼如下:

let olddomChild = olddom.firstChild
for(let i = 0; i < vnode.children.length; i++) {
    render(vnode.children[i], olddom, null, olddomChild)
    olddomChild = olddomChild && olddomChild.nextSibling
}

//刪除多餘的子節點
while (olddomChild) {
    let next = olddomChild.nextSibling
    olddom.removeChild(olddomChild)
    olddomChild = next
}

綜上所述 完整的diffDOM 如下:

function diffDOM(vnode, parent, comp, olddom) {
    const {onlyInLeft, bothIn, onlyInRight} = diffObject(vnode.props, olddom.__vnode.props)
    setAttrs(olddom, onlyInLeft)
    removeAttrs(olddom, onlyInRight)
    diffAttrs(olddom, bothIn.left, bothIn.right)


    let olddomChild = olddom.firstChild
    for(let i = 0; i < vnode.children.length; i++) {
        render(vnode.children[i], olddom, null, olddomChild)
        olddomChild = olddomChild && olddomChild.nextSibling
    }

    while (olddomChild) { //刪除多餘的子節點
        let next = olddomChild.nextSibling
        olddom.removeChild(olddomChild)
        olddomChild = next
    }
    olddom.__vnode = vnode  
}

由於需要在diffDOM的時候 從olddom獲取 oldVNODE(即 diffObject(vnode.props, olddom.__vnode.props))。 所以:

// 在建立的時候
...
let dom = document.createElement(vnode.nodeName)
dom.__vnode = vnode
...


// diffDOM
...
const {onlyInLeft, bothIn, onlyInRight} = diffObject(vnode.props, olddom.__vnode.props)
...
olddom.__vnode = vnode  // 更新完之後, 需要把__vnode的指向 更新
...

另外 對於 TextNode的複用:

...
if(typeof vnode == "string" || typeof vnode == "number") {
        if(olddom && olddom.splitText) {
            if(olddom.nodeValue !== vnode) {
                olddom.nodeValue = vnode
            }
        } else {
            dom = document.createTextNode(vnode)
            if(olddom) {
                parent.replaceChild(dom, olddom)
            } else {
                parent.appendChild(dom)
            }
        }
    }
...

重新 跑一下開頭 的例子 新的複用DOM演示 setState後渲染時間變成了 20ms 左右。 從 180ms 到20ms 差不多快有一個數量級的差距了。
到底快了多少,取決於前後結構的相似程度, 如果前後結構基本相同,diff是有意義的減少了DOM操作。

複用子節點 – key

初始渲染
...
render() {
    return (
        <div>
            <WeightCompA/>
            <WeightCompB/>
            <WeightCompC/>
        </div>
    )
}
...

setState再次渲染
...
render() {
    return (
        <div>
            <span>hi</span>
            <WeightCompA/>
            <WeightCompB/>
            <WeightCompC/>
        </div>
    )
}
...

我們之前的子節點複用順序就是按照DOM順序, 顯然這裡如果這樣處理的話, 可能導致元件都複用不了。 針對這個問題, React是通過給每一個子元件提供一個 “key”屬性來解決的
對於擁有 同樣key的節點, 認為結構相同。 所以問題變成了:

f([{key: `wca`}, {key: `wcb}, {key: `wcc}]) = [{key:`spanhi`}, {key: `wca`}, {key: `wcb}, {key: `wcc}]

函式f 通過刪除, 插入操作,把olddom的children順序, 改為和 newProps裡面的children一樣 (按照key值一樣)。類似與 字串距離,
對於這個問題, 我將會另開一篇文章

總結

通過 diff 比較渲染前後 DOM的差別來複用實際的, 我們的效能得到了提高。現在 render方法的描述: <br/>
render 方法是根據的vnode, 渲染到實際的dom,如果存在olddom會先嚐試複用的 一個遞迴方法 (由於元件 最終一定會render html的標籤。 所以這個遞迴一定是能夠正常返回的)

  • vnode是字串, 如果存在olddom, 且可以複用, 複用之。否則建立textNode節點
  • 當vnode.nodeName是 字串的時候, 如果存在olddom, 且可以複用, 複用之。否則建立dom節點, 根據props設定節點屬性, 遍歷render children
  • 當vnode.nodeName是 function的時候, 獲取render方法的返回值 vnode`, 執行render(vnode`)

程式碼git地址

相關文章

相關文章