循序漸進DIY一個react(三)

鯊叔發表於2019-02-28

在正式進入實現之前,我們先來了解一下幾個概念。首先,“對映”這個概念已經在“第一篇文章裡”裡面介紹過了,這裡就不在贅述了。我們來講講這裡所說的“整樹”和“協調”到底指的是什麼?

熟悉react的讀者都知道,完整的react應用是可以用一顆元件樹來表示的。而元件樹背後對應的歸根到底還是virtual DOM物件樹。react官方推薦僅呼叫一次ReactDOM.render()來將這顆virtual DOM物件樹掛載在真實的文件中去。所以,這裡,我們就將呼叫render方法時傳入的第一引數稱之為“整樹”(整一顆virtual DOM物件樹):

const rootNode = document.getElementById('root')
const app = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文字節點1
        <a href="https://www.baidu.com">百度一下</a>
    </div>
)

render(app,rootNode)
複製程式碼

在上面的示例程式碼中,app變數所指向的virtual DOM物件樹就是我們所說的“整樹”。

那“協調”又是啥意思呢?協調,原概念來自於英文單詞“Reconciliation”,你也可以翻譯為“調和”,“和解”或者什麼的。在react中,“Reconciliation”是個什麼樣的定義呢?官方文件好像也沒有給出,官方文件只是給出了一段稍微相關的解釋而已:

React provides a declarative API so that you don’t have to worry about exactly what changes on every update. This makes writing applications a lot easier, but it might not be obvious how this is implemented within React. This article explains the choices we made in React’s “diffing” algorithm so that component updates are predictable while being fast enough for high-performance apps.

同時,官方提醒我們,reconciliation演算法的實現經常處於變動當中。我們可以把這個提醒這理解為官方也難以給reconciliation演算法下一個準確的定義。但是reconciliation演算法的目標是明確的,那就是“在更新介面的過程中儘可能少地進行DOM操作”。所以,我們可以把react.js中“協調”這個概念簡單地理解為:

“協調”,是指在儘量少地操作DOM的前提下,將virtual DOM 對映為真實文件樹的過程。

綜上所述,我們這一篇章要做的事就是:“在將整顆virtual DOM物件樹對映為真實文件過程中,如何實現儘量少地操作DOM”。為什麼我們總在強調要儘量少地操作DOM呢?這是因為,javascript是足夠快的,慢的是DOM操作。在更新介面的過程,越是少地操作DOM,UI的渲染效能越好。

在上一個篇章裡面,我們實現了重頭戲函式-render。如果將render函式的第一次呼叫,稱作為“整樹的初始掛載”,那麼往後的呼叫就是“整樹的更新”了。拿我們已經實現的render函式來說,如果我要更新介面,我只能傳入一個新的element,重複呼叫render:

const root = document.getElementById('root')

const initDivElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文字節點
    </div>
)

const newDivElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是更新後的文字節點
    </div>
)
// 初始掛載
render(initDivElement,root)

// 更新
render(newDivElement,root)
複製程式碼

程式碼一執行,你發現執行結果明顯不是我們想要的。因為,目前render函式只會往容器節點裡面追加子元素,而不是替代原有的子元素。所以我們得把最後一塊程式碼的邏輯改一改:

 function render(element, domContainer) {
    // ......
    if(domContainer.hasChildNodes()){
        domContainer.replaceChild(domNode,domContainer.lastChild)
    }else {
        domContainer.appendChild(domNode)
    }
 }
複製程式碼

以最基本的要求看上面的實現,它是沒問題的。但是如果使用者只更新整樹的根節點上的一個property,又或者更新一顆深度很深,廣度很廣的整樹呢?在這些情況下,再這麼粗暴地直接替換一整顆現有的DOM樹顯得太沒有技術追求了。我們要時刻記住,DOM操作是相對消耗效能的。我們的目標是:儘量少地操作DOM。所以,我們還得繼續優化。

鑑於virtual DOM字面量物件所帶來的宣告正規化,我們可以把一個react element看做是螢幕上的一幀。渲染是一幀一幀地進行的,所以我們能想到的做法就是通過對比上一幀和現在要渲染的這一幀,找出兩者之間的不同點,針對這些不同點來執行相應的DOM更新。

那麼問題來啦。程式在執行時,我們該如何訪問先前的virtual DOM物件呢?我們該如何複用已經建立過的原生DOM物件呢?想了很久,我又想到字面量物件了。是的,我們需要建立一個字面量物件,讓它儲存著先前virtual DOM物件的引用和已建立原生DOM物件的引用。我們還需要儲存一個指向各個子react element的引用,以便使用遞迴法則對他們進行“協調”。綜合考慮一下,我們口中的這個“字面量物件”的資料結構如下:

// 虛擬碼
const 字面量物件 = {
    dom: element對應的原生DOM物件,
    element:上一幀所對應的element,
    children:[子virtual DOM物件所對應的字面量物件]
}
複製程式碼

熟悉react概念的讀者肯定會知道,我們口中的這種“字面量物件”,就是react.js原始碼中的instance的概念。注意,這個instance的概念跟物件導向程式設計中instance(例項)的概念是不同的。它是一個相對的概念。如果講react element(virutal DOM物件)是“虛”的,那麼使用這個react element來建立的原生DOM物件(在render函式的實現中有相關程式碼)就是“實”的。我們把這個“實”的東西掛載在一個字面量物件上,並稱這個字面量物件為instance,稱這個過程為“例項化”,好像也說得過去。加入instance概念之後,值得強調的一點是:“一旦一個react element建立過對應的原生DOM物件,我們就說這個element被例項化過了”。

現在,我們來看看react element,原生DOM物件和instance三者之間的關係吧:

循序漸進DIY一個react(三)

是的,它們之間是一一對應的關係。 instance概念的引入非常重要,它是我們實現Reconciliation演算法的基石。所以,在這,我們有必要重新整理一下它的資料結構:

const instance = {
    dom: DOMObject,
    element:reactElement,
    childInstances:[childInstance1,childInstance2]
}
複製程式碼

梳理完畢,我們就開始重構我們的程式碼。 萬事從頭起,對待樹狀結構的資料更是如此。是的,我們需要一個root instance,並且它應該是一個全域性的“單例”。同時,我們以instance這個概念為關注點分離的啟發點,將原有的render函式根據各自的職責將它分離為三個函式:(新的)render函式,reconcile函式和instantiate函式。各自的函式簽名和職責如下:

// 主要對root element呼叫reconcile函式,維護一份新的root instance
render:(element,domContainer) => void

// 主要負責對根節點執行一些增刪改的DOM操作,並且通過呼叫instantiate函式,
// 返回當前element所對應的新的instance
reconcile:(instance,element,domContainer) => instance

// 負責具體的“例項化”工作。
// “例項化”工作大概包含兩部分:
// 1)建立相應的DOM物件 2)為建立的DOM物件設定相應的屬性
instantiate:(element) => instance
複製程式碼

下面看看具體的實現程式碼:

let rootInstance = null

function render(element,domContainer){
    const prevRootInstance = rootInstance
    const newRootInstance = reconcile(prevRootInstance,element,domContainer)
    rootInstance = newRootInstance
}

function reconcile(instance,element,domContainer){
    let newInstance
    // 對應於整樹的初始掛載
    if(instance === null){
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else { // 對應於整樹的更新
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}

//大部分複用原render函式的實現
function instantiate(element){
     const { type, props } = element
    
    // 建立對應的DOM節點
    const isTextElement = type === 'TEXT_ELEMENT'
    const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
    
    // 給DOM節點的屬性分類:事件屬性,普通屬性和children
    const keys = Object.keys(props)
    const isEventProp = prop => /^on[A-Z]/.test(prop)
    const eventProps = keys.filter(isEventProp) // 事件屬性
    const normalProps = keys.filter((key) => !isEventProp(key) && key !== 'children') // 普通屬性
    const children = props.children // children
    
    // 對事件屬性,新增對應的事件監聽器
    eventProps.forEach(name => {
        const eventType = name.toLowerCase().slice(2)
        const eventHandler = props[name]
        domNode.addEventListener(eventType,eventHandler)
    })
    
    // 對普通屬性,直接設定
     normalProps.forEach(name => {
        domNode[name] = props[name]
    })
    
    // 對children element遞迴呼叫instantiate函式
    const childInstances = []
     if(children && children.length){
        childInstances = children.map(childElement => instantiate(childElement))
        const childDoms = childInstances.map(childInstance => childInstance.dom)
        childDoms.forEach(childDom => domNode.appendChild(childDom)
    }
    
    const instance = {
        dom:domNode,
        element,
        childInstances
    }
    
    return instance
}

複製程式碼

從上面的實現可以看出,reconcile函式主要負責對root element進行對映來完成整樹的初始掛載或更新。在條件分支語句中,第一個分支實現的是初始掛載,第二個分支實現的是更新,對應的是“增”和“改”。那“刪除”去去哪啦?好吧,我們補上這個分支:

function reconcile(instance,element,domContainer){
    let newInstance
    
    if(instance === null){// 整樹的初始掛載
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else if(element === null){ // 整樹的刪除
        newInstance = null
        domContainer.removeChild(instance.dom)
    }else { // 整樹的更新
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}
複製程式碼

還記得我們的上面提到的目標嗎?所以,我們在仔細審視一下自己的程式碼,看看還能有優化的空間不?果不其然,對待“更新”,我們直接一個“replaceChild”操作,未免也顯得太簡單粗暴了吧?細想一下,root element的對映過程中的更新,也可以分為兩種情況,第一種是root element的type屬性值變了,另外一個種是type屬性值不變,變的是另外兩個屬性-props和children。在補上另外一個分支之前,我們不妨把對DOM節點屬性的操作的實現邏輯從instantiate函式中抽出來,封裝一下,使之能夠同時應付屬性的設定和更新。我們給它命名為“updateDomProperties”,函式簽名為:

updateDomProperties:(domNode,prevProps,currProps) => void
複製程式碼

下面,我們來實現它:

function updateDomProperties(domNode,prevProps,currProps){

    // 給DOM節點的屬性分類:事件屬性,普通屬性
    const isEventProp = prop => /^on[A-Z]/.test(prop)
    const isNormalProp = prop => { return !isEventProp(prop) && prop !== 'children'}

    // 如果先前的props是有key-value值的話,則先做一些清除工作。否則容易導致記憶體溢位
    if(Object.keys(prevProps).length){
        // 清除domNode的事件處理器
        Object.keys(prevProps).filter(isEventProp).forEach(name => {
            const eventType = name.toLowerCase().slice(2)
            const eventHandler = props[name]
            domNode.removeEventListener(eventType,eventHandler
        })
        
        // 清除domNode上的舊屬性
        Object.keys(prevProps).filter(isNormalProp).forEach(name => {
            domNode[name] = null
        })
    }
    
    // current props
    const keys = Object.keys(currProps)
    const eventProps = keys.filter(isEventProp) // 事件屬性
    const normalProps = keys.filter(isNormalProp) // 普通屬性
    
    // 掛載新的事件處理器
    eventProps.forEach(name => {
        const eventType = name.toLowerCase().slice(2)
        const eventHandler = props[name]
        domNode.addEventListener(eventType,eventHandler)
    })
    
    // 設定新屬性
    normalProps.forEach(name => {
        domNode[name] = currProps[name]
    })
}
複製程式碼

同時,我也更新一下instantiate的實現:

function instantiate(element){
     const { type, props } = element
    
    // 建立對應的DOM節點
    const isTextElement = type === 'TEXT_ELEMENT'
    const domNode = isTextElement ? document.createTextNode('') : document.createElement(type)
    
    // 設定屬性
    updateDomProperties(domNode,{},props)
    
    // 對children element遞迴呼叫instantiate函式
    const children = props.children 
    let childInstances = []
     if(children && children.length){
        childInstances = children.map(childElement => instantiate(childElement))
        const childDoms = childInstances.map(childInstance => childInstance.dom)
        childDoms.forEach(childDom => domNode.appendChild(childDom))
    }
    
    const instance = {
        dom:domNode,
        element,
        childInstances
    }
    
    return instance
}

複製程式碼

updateDomProperties函式實現完畢,最後我們去reconcile函式那裡把前面提到的那個條件分支補上。注意,這個分支對應的實現的成本很大一部分是花在對children的遞迴協調上:

function reconcile(instance,element,domContainer){
    let newInstance = {} 
    // 整樹的初始掛載
    if(instance === null){
        newInstance = instantiate(element)
        domContainer.appendChild(newInstance.dom)
    }else if(element === null){ // 整樹的刪除
        newInstance = null
        domContainer.removeChild(instance.dom)
    }else if(element.type === instance.element.type){ // 整樹的更新
        newInstance.dom = instance.dom
        newInstance.element = element
        
        // 更新屬性
        updateDomProperties(instance.dom,instance.element.props,element.props)
        
        // 遞迴呼叫reconcile來更新children
        newInstance.childInstances = (() => {
            const parentNode = instance.dom
            const prevChildInstances = instance.childInstances
            const currChildElement = element.props.children || []
            const nextChildInstances = []
            const count = Math.max(prevChildInstances.length,element.props.children.length)
            for(let i=0 ; i<count ; i++){
                const childInstance = prevChildInstances[i]
                const childElement = currChildElement[i]
                
                // 增加子元素
                if(childInstance === undefined){
                    childInstance = null
                }
                // 刪除子元素
                if(childElement === undefined){
                    childElement = null
                }
                const nextChildInstance = reconcile(childInstance,childElement,parentNode)
                 
                 //過濾為null的例項
                if(nextChildInstance !== null){
                    nextChildInstances.push(nextChildInstance)
                }
            }
            
            return nextChildInstances
        })()
    }else { // 整樹的替換
        newInstance = instantiate(element)
        domContainer.replaceChild(newInstance.dom,instance.dom)
    }
    return newInstance
}
複製程式碼

我們用四個函式就實現了整一個virtual DOM對映過程中的協調了。我們可以這麼說:

協調的物件是virtual DOM物件樹和real DOM物件樹;協調的媒介是instance;協調的路徑是始於根節點,終於末端節點。

到目前為止,如果我們想要更新介面,我們只能對virtual DOM物件樹的根節點呼叫render函式,協調便會在整顆樹上發生。如果這顆樹深度很深,廣度很廣的話,即使有了協調,渲染效能也不會太可觀。下一篇章,我們一起來運用react分而治之的理念,引入“component”概念,實現更細粒度的協調。

下一篇:循序漸進DIY一個react(四)

相關文章