VNode與Component

本文同步在我的博客shymean.com上,歡迎關注node

在前面兩篇文章中,咱們研究了VNode的基礎知識,瞭解瞭如何使用VNode描述並渲染視圖,實現了遞歸diff和循環diff兩種方案,並在循環diff中給出了一種簡單的調度器實現方案。本文將緊接上兩篇文章,一步一步思考並實現將VNode封裝成組件。git

本系列文章列表以下github

排在後面文章內會大量採用前面文章中的一些概念和代碼實現,如createVNodediffChildrendoPatch等方法,所以建議逐篇閱讀,避免給讀者形成困惑。本文相關示例代碼均放在github上,若是發現問題,煩請指正。web

一個關於封裝VNode的問題

在以前出現的createRoot等測試方法中,隱約已經出現了這種封裝VNode的設計,咱們用上一篇文章中的例子來解釋一下(雖然我並不想把相同的代碼再粘貼一遍~)app

// 咱們暫且假設這就是一個組件
function createRoot(data) {
    let list = createList(data.list)

    let title = createFiber('h1', {}, [data.title])
    let root = createFiber('div', {}, [title, list])
    return root
}
// 同理,這是一個createRoot組件依賴的list子組件
function createList(list) {
    let listItem = list.map((item) => {
        return createFiber('li', {
            key: item
        }, [item])
    })
    return createFiber('ul', {
        class: 'list-simple',
    }, listItem)
}

// 初始化組件
let root = createRoot({
    title: 'hello fiber',
    list: [1, 2, 3]
})

// 初始化應用
root.$parent = {
    $el: app
}
diff(null, root, (patches) => {
    doPatch(patches)
})

// 更新應用
btn.onclick = function () {
    let root2 = createRoot({
        title: 'title change',
        list: [3, 2]
    })
    diff(root, root2, (patches) => {
        doPatch(patches)
        root = root2
    })
}
複製代碼

在前面的測試代碼中,爲了方便理解,咱們將diffdoPatch等接口都暴露出來了;在實際應用中,咱們應該儘量地減小暴露的接口,避免額外的學習成本,所以咱們將這段代碼封裝一下。框架

首先咱們合併createRootcreateList和初始化root節點等邏輯,將其封裝爲一個叫作App的類,並約定調用new App().render()會返回與前面createRoot相同的root節點dom

class App {
    state = {
        title: 'hello component',
        list: [1, 2, 3]
    }
    // render方法返回的是一個vnode節點,並由Root實例維護渲染和更新該節點須要的數據及方法
    render() {
        // 根據state時初始化數據
        let { list, title } = this.state
        let listItem = list.map((item) => {
            return createFiber('li', {
                key: item
            }, [item])
        })
        let ul = createFiber('ul', {
            class: 'list-simple',
        }, listItem)
        let head = createFiber('h1', {}, [title])

        return createFiber('div', {}, [head, ul])
    }
}
複製代碼

根據慣例,咱們約定將組件的數據放在state中。而後咱們再提供一個統一的初始化方法,封裝初始化應用的相關邏輯,取名爲renderDOM異步

// 將根組件節點渲染到頁面上
function renderDOM(root, dom, cb) {
    if (!root.$parent) {
        root.$parent = {
            $el: dom
        }
    }
    // 初始化時直接使用同步diff
    let patches = diffSync(null, root)
    doPatch(patches)
    cb && cb()
}
複製代碼

正常狀況下,咱們按照下面方式調用,就能夠正常初始化應用了函數

let root = new App().render()
renderDOM(root, document.getElementById("app"), () => {
    console.log('inited')
})
複製代碼

看起來咱們的App類並無提供什麼實質性的幫助,由於如今咱們只是將createRoot方法變成了App.proptotype.render方法而已。學習

接下來看看更新數據的邏輯,咱們但願在App中可以維護數據更新的邏輯,下面的代碼邏輯爲點擊h1標籤時,預期可以修改其標題值,因此咱們在初始化該標籤的時候註冊changeTitle事件

// render
let head = createFiber('h1', {
    onClick: this.changeTitle
}, [title])
複製代碼

遵循UI = f(state)的原則,咱們但願修改state,而後將改動的state同步到視圖上

changeTitle = () => {
    this.state.title = 'change title from click handler'
    // todo
}
複製代碼

在前兩篇文章測試更新的例子中,咱們會經過新的數據從新調用createRoot,獲取新的vnode,而後diff(old, newVNode),而後進行doPatch

changeTitle = () => {
    this.state.title = 'change title from click handler' // 更新數據
    let old = this.vnode
    let root = this.render() // 只不過是將`createRoot`換成了`this.render`
    diff(old, root, (patches) => {
        doPatch(patches) // 確實也可以實現視圖的更新
    })
}
複製代碼

嗨呀好氣,在renderDOM中的封裝都白費了!!

儘管咱們能夠再提供一個相似於updateDOM的方法來處理更新的邏輯,但歸根結底都沒有實現真正的封裝。換言之,咱們不該該在應用代碼中手動調用render。到底該如何封裝這一堆包含邏輯的VNode呢?

組件:擴展VNode的type

我對於組件的理解是: 將一堆VNode節點進行封裝。

初始化組件節點

回頭看一看doPatch的源碼,咱們好像只處理了真實DOM:根據VNode.type建立DOM節點,而後將其插入到父節點上。咱們爲什麼不試一試擴展type呢?

假設咱們按照下面的方法初始化應用

// 如今root.type === App
let root = createFiber(App, {}, [])
renderDOM(root, document.getElementById("app"))
複製代碼

按照這種調用規則,在內部,咱們會執行實例App、初始化應用、將視圖渲染到頁面上。咱們將這種type爲類的節點取名爲組件節點

// 根據type判斷是是否爲自定義組件
function isComponent(type) {
    return typeof type === 'function'
}
複製代碼

在以前的diff方法中,咱們都是提早經過諸如createRoot等方法獲取了整個VNode節點樹,但對於組件節點而言,其子節點列表並非簡單地經過createFiber傳入的children屬性得到,而是須要經過在diff過程當中,經過調用new App().render()方法動態獲取獲得。

基於這個問題,咱們須要在修改以前的diffFiber方法,增長初始化組件實例、調用組件實例的render方法、更新組件節點的子節點列表

function diffFiber(oldNode, newNode, patches) {
     // 組件節點的子節點是在diff過程當中動態生成的
    function renderComponentChildren(node) {
        let instance = node.$instance
        // 咱們約定render函數返回的是單個節點
        let child = instance.render()
        // 爲render函數中的節點更新fiber相關的屬性
        node.children = bindFiber(node, [child])
        // 保存父節點的索引值,插入真實DOM節點
        child.index = node.index
    }

     if (!oldNode) {
        // 當前節點與其子節點都將插入
        if (isComponent(newNode.type)) {
            let component = newNode.type
            let instance = new component()
            instance.$vnode = newNode // 組件實例保存節點
            newNode.$instance = instance // 節點保存組件實例
            renderComponentChildren(newNode)
        }

        patches.push({ type: INSERT, newNode })
    }
    // ... 更新的時候下面再處理
}
複製代碼

咱們根據node.type初始化了組件實例,並將其綁定到組件節點的$instance屬性上,而後在renderComponentChildren方法中,經過調用$instance.render()方法,獲取子節點列表,因爲咱們採用的是循環diff策略,所以還須要在bindFiber方法中爲節點添加fiber相關屬性

function bindFiber(parent, children) {
    let firstChild
    return children.map((child, index) => {
        child.$parent = parent // 每一個子節點保存對父節點的引用
        child.index = index

        if (!firstChild) {
            parent.$child = child // 父節點保存對於第一個子節點的引用
        } else {
            firstChild.$sibling = child // 保存對於下一個兄弟節點的引用
        }
        firstChild = child
        return child
    })
}
複製代碼

OK,回到正題,經過node.children = instance.render()方法,咱們爲組件節點關聯了子節點列表,在後面的performUnitWork的diff任務中,程序將可以遍歷動態插入的子節點,從而收集到相關的patch

須要注意的第一個問題是:咱們在這裏拋棄了組件節點本來的children屬性

// 在renderComponentChildren中,咱們重置了fiber.children屬性,致使 [child1, child2]丟失
createFiber(App, {}, [child1, child2])
複製代碼

在Vue中,這種子節點被稱爲slot;在React中,這種節點爲props.children。這種設計能夠用來實現HOC(Higher Order Component),在後面的文章中會詳細介紹與實現。

須要注意的第二個問題是:此處咱們也建立了一個INSERTpatch,但與元素節點不一樣的是,組件節點的type是一個類,並不能直接經過createDOM的方法進行實例,咱們應該如何處理這種組件節點的patch呢?

我在這裏嘗試過幾種作法

  • 爲組件的子節點建立一個額外的包裹DOM節點,做爲組件節點的$el屬性,這種作法會修改真實的DOM結果,建議不採納
  • 組件節點的$el引用子節點的$el,這要求組件render方法返回的是一個單元素節點;或者組件節點的$el引用父節點的$el,一樣這要求組件的父級節點也是一個元素節點。這兩種方法都必須修改doPatchinsertDOM方法,判斷相關的臨界條件(如子節點插入組件節點等)
  • 組件節點的$elnull,不掛載任何DOM實例;反之,當子節點插入到組件節點時,插入到組件節點的第一個真實DOM節點便可

第三種作法應該是最容易理解與實現的,所以本文采用了這個策略處理組件節點,首先咱們在建立createDOM時直接爲DOM節點返回null

function createDOM(node) {
    let type = node.type
    return isComponent(type) ?
        createComponent(node) :
        isTextNode(type) ?
            document.createTextNode(node.props.nodeValue) :
            document.createElement(type)
}
// 建立組件的DOM節點,在最後的策略中,決定讓組件節點不攜帶任何DOM實例,及vnode.$el = null
function createComponent(node) {
    return null
}
複製代碼

而後在處理INSERT操做時,交給其向上第一個DOM父級元素節點和向下第一個DOM字節元素節點處理,同理REMOVE操做也須要這麼處理

// 從父節點向上找到第一個元素節點
function findLatestParentDOM(node) {
    let parent = node.$parent
    while (parent && isComponent(parent.type)) {
        parent = parent.$parent
    }
    return parent
}
// 從當前節點向下找到第一個元素節點
function findLatestChildDOM(node) {
    let child = node
    while (isComponent(child.type)) {
        child = child.$child
    }
    return child
}
// 插入節點,統一處理元素節點與組件節點的INSERT操做
function insertDOM(newNode) {
    let parent = findLatestParentDOM(newNode)
    let parentDOM = parent && parent.$el
    if (!parentDOM) return

    let child = findLatestChildDOM(newNode)

    let el = child && child.$el
    let after = parentDOM.children[newNode.index]
    after ? parentDOM.insertBefore(el, after) : parentDOM.appendChild(el)
}
// 刪除節點
function removeDOM(oldNode) {
    let parent = findLatestParentDOM(oldNode)
    let child = findLatestChildDOM(oldNode)
    parent.$el.removeChild(child.$el)
}
複製代碼

至此,咱們完成了組件的初始化操做,總結一下

  • diffFiber的方法中,咱們判斷組件節點,並根據node.type獲取組件實例,經過調用實例的render方法獲取組件節點的子節點,最後更新fiber相關屬性,方便在performUnitWork中能夠經過fiber.$child遍歷render方法中動態加入的節點
  • doPatch中,咱們決定不設置組件節點的$el屬性,對於INSERTREMOVE類型的patch,咱們將其交給父DOM節點和子元素節點進行處理

通過上面的處理,在儘量少地改變原有diffdoPatch方法的狀況下,咱們擴展了vnode.type屬性,並增長了組件類型的節點。這是十分有意義的一步,基於這個經驗咱們還能夠實現Function ComponentFragment等多種類型的組件形式。

更新組件節點

解決了初始化的問題後,接下來看看組件更新時的狀況。理想狀況下,當改變數據時,咱們但願可以直接更新視圖,而不是像篇頭那樣手動diff,所以咱們增長一個公共的setState方法,方便全部組件統一處理更新邏輯,爲此咱們將setState方法放在公共的Component基類上。

setState中,咱們將diffdoPatch方法,與初始化不一樣的是,更新時的diff操做是經過調度器管理異步實現的,所以咱們再增長一個回調函數,方便在視圖更新完畢後通知應用。

因爲在diff節點時須要從根節點開始進行對比,所以咱們經過appRoot全局變量保持對於應用根節點的引用。

function renderDOM(root, dom, cb) {
    if (!appRoot) {
        appRoot = root // 保存整個應用根節點的引用
    }
    // ...
}
複製代碼

而後實現setState方法

class Component {
    setState(newState, cb) {
        this.nextState = Object.assign({}, this.state, newState) // 保存更新後的屬性
        // 從根節點開始進行diff,當碰見Component時,須要使用新的props和props更新節點
        diff(appRoot, appRoot, (patches) => {
            doPatch(patches)
            cb && cb()
        })
    }
}
class App extends Component {
    // 封裝diff
    changeTitle = () => {
        this.setState({
            title: 'change title from click handler1'
        }, () => {
            // 組件更新完畢後會調用該回調
            console.log('done1')
        })
    }
    // ... 省略其餘方法如render等
}
複製代碼

能夠看見與以前的diff(root1,root2)方法不一樣的是,咱們在這裏diff的新舊子節點都是appRoot,換言之,節點的變化是在diff過程當中才觸發的。爲了實現這個目的,在setState中,咱們將更新後的數據掛載到this.nextState上,在diff過程當中,咱們須要檢測組件節點是否存在該屬性,若是存在,則應該使用新的state調用render方法,再diff先後state渲染的新舊子節點列表。

咱們來補全diffFiber方法

function performUnitWork(fiber, patches) {
    let oldFiber = fiber.oldFiber
    // 在diffFiber中會更新組件節點的children屬性,所以須要在此處提早保留舊的子節點列表
    let oldChildren = oldFiber && oldFiber.children || [] 

    // 任務一:對比當前新舊節點,收集變化
    diffFiber(oldFiber, fiber, patches)
    // 任務二:爲新節點中children的每一個元素找到須要對比的舊節點,設置oldFiber屬性,方便下個循環繼續執行performUnitWork
    diffChildren(oldChildren, fiber.children, patches)
    //...
}

function diffFiber(oldNode, newNode, patches) {
    if(!oldNode){
        // ... 上面初始化章節的相關邏輯
    }else {
        // 更新時
        if (isComponent(newNode.type)) {
            // 組件節點須要判斷狀態是否發生了變化,若是已變化,則須要對比新舊組件子節點
            let instance = oldNode.$instance
            // 更新時,複用組件實例
            newNode.$instance = oldNode.$instance // 複用組件實例
            let nextState = instance.nextState
            if (nextState) {
                instance.state = nextState  // 在此處更新組件的state
                instance.nextState = null
                renderComponentChildren(newNode) // 從新調用render方法,綁定新的子節點
            } else {
                // 未進行任何修改,則直接使用以前的子節點
                newNode.children = oldNode.children
            }
        } else {
            // ..上一篇fiber與循環diff中元素節點的相關邏輯
        }
    }
}
複製代碼

能夠看見,對於元素節點而言,更新是將改動的屬性應用的DOM節點實例上;對於組件而言,咱們須要更新組件的state,而後從新調用組件的render方法獲取新的children子節點。

至此,咱們就完成了組件state更新的封裝。整理一下流程

  • 在組件中,經過繼承的this.setState(newState)方法將須要更新的狀態掛載到this.nextState
  • diffFiber中,對於組件節點,咱們根據newState更新組件實例的state並從新調用render方法,而後更新組件節點的子節點列表,進入後面的diffChildren流程
  • 最後,仍舊經過doPatch更新變化,因爲組件節點的DOM實例自己就不存在,咱們甚至不須要修改doPatch中的任何代碼

須要注意的是,因爲咱們是在performUnitWork中才更新了組件實例的state,所以能夠將setState也理解爲異步執行,這個緣由致使咱們在調用setState中沒法直接觀察到state的變化。關於fiber調度器相關問題,能夠閱讀上一篇文章:Fiber與循環diff

changeTitle = () => {
    this.setState({
        title: 'change title from click handler1'
    }, () => {
        console.log('done1') // 不會被執行
    })
    console.log(this.state.title) // 原來的hello component
    this.setState({
        title: 'change title from click handler2'
    }, () => {
        // 此時訪問到的纔是更新後的state.title
        // 因爲調度器實現了重複調用`diff`時會重置上一次的diff流程,所以上面的done1永遠沒法執行
        console.log('done2')
    })
    console.log(this.state.title) // 一樣是原來的 hello component
}
複製代碼

減小沒必要要的更新

在上面的組件更新邏輯處理中,咱們只是單純地根據了組件實例是否攜帶newState屬性進行處理,實際上在下面幾種場景下,咱們須要額外考慮是否更新的問題

  • 當數據新舊state相同時,也許並不須要從新調用render方法
  • 開發者但願根據某些數據手動控制組件是否渲染

對於第一個問題,咱們能夠參照元素節點的UPDATE,經過判斷state值的是否改變來決定是否設置this.nextState

// 實現一個淺比較
function shallowCompare(a, b) {
    if (Object.is(a, b)) return true
    const keysA = Object.keys(a)
    const keysB = Object.keys(b)

    if (keysA.length !== keysB.length) return false

    const hasOwn = Object.prototype.hasOwnProperty
    for (let i = 0; i < keysA.length; i++) {
        if (!hasOwn.call(b, keysA[i]) ||
            !Object.is(a[keysA[i]], b[keysA[i]])) {
            return false
        }
    }
    return true
}
class Component {
    setState(newState, cb) {
        // 保存須要更新的狀態
        let nextState = Object.assign({}, this.state, newState)
        // 判斷新舊屬性是否相同
        if (!shallowCompare(nextState, this.state)) {
            this.nextState = nextState
            // 調用diff和doPatch
        }
    }
}
複製代碼

對於第二個問題,咱們能夠爲組件提供一個shouldComponentUpdate的接口,在編寫組件時若是實現了該方法,則在diffFiber時會調用根據其返回值判斷當前組件節點是否須要更新

let shouldUpdate = instance.shouldComponentUpdate ? instance.shouldComponentUpdate() : true
if (nextState &&  shouldUpdate) {
    // ... 更新state,調用render
}
複製代碼

強制更新

render方法中,咱們能夠經過組件的state屬性初始化相關的VNode節點,並經過setState更新數據,同時觸發視圖的更新。但若是某些VNode節點依賴於外部數據源,則setState就無能爲力了,在下面的例子中,點擊countButton並不會觸發視圖的更新

let outerCount = 0

class App extends Component {
    addCount = () => {
        outerCount++
    }
    render(){
        let countButton = createFiber('h1', {
            onClick: this.addCount
        }, [outerCount + ' times click'])
        let children = [countButton]

        let vnode = createFiber('div', {
            class: 'page'
        }, children)
        return vnode
    }
}
複製代碼

爲此,咱們須要實現一個forceUpdate的接口處理這個問題,與上面「減小沒必要要的更新」章節相反的是,在調用forceUpdate時,不管是否存在nextState或者shouldComponentUpdate返回了什麼值,咱們都會強制調用render方法,由於咱們爲組件實例額外實現一個_force屬性。

// 因爲setState和forceUpdate都須要這個方法,咱們將其抽出來
function diffRoot(cb) {
    // 從根節點開始進行diff,當碰見Component時,須要使用新的props和props更新節點
    diff(appRoot, appRoot, (patches) => {
        doPatch(patches)
        cb && cb()
    })
}
class Component {
    // 當render方法中依賴了一些外部變量時,咱們沒法直接經過this.setState()方法來觸發render方法的更新
    // 所以須要提供一個forceUpdate的方法,強制執行render
    forceUpdate() {
        this._isforce = true
        diffRoot(() => {
            this._isforce = false
        })
    }
}
複製代碼

而後修改diffFiber中的更新判斷

if ((nextState && shouldUpdate) || instance._isforce) {
    // ... 調用render
}
複製代碼

這樣,咱們就實現了組件的強制更新,修改前面的測試代碼,點擊按鈕,就能夠看見在修改外部數據時同時更新視圖。

addCount = () => {
    outerCount++
    this.forceUpdate()
}
複製代碼

固然,在封裝組件時,更建議不要使用這種外部數據來源,組件應該保持足夠的獨立性,這樣能夠方便複用與遷移。

當組件依賴外部數據時,咱們能夠經過狀態管理或contextprops等方案進行管理,在下一篇文章中,咱們將介紹組件除了state以外的其餘數據來源與實現方法。

小結

組件是如今web應用中必不可少的一部分,大量的UI框架均是基於組件搭建的,只有瞭解了組件的設計思路,才能更好的使用與開發組件。

本文主要從封裝VNode開始思考如何封裝組件,而後經過擴展vnode.type設計了組件節點,而後根據組件節點的子節點是動態添加和更新的特性,經過改動diffFiberdoPatch方法,完成了組件節點的初始化、掛載與更新。最後給出了減小組件節點更新的兩個方法:shallowCompareshouldComponentUpdate,以及強制更新組件的forceUpdate方法。

較前面兩篇文章相比,本文改動的代碼很少,主要是考慮了關於組件設計的一些思路,並在儘量少地修改原代碼的前提下擴展支持組件節點,在前面文章的基礎之上,思考並給出了本身關於組件的一些實現思路,若是發現問題,煩請指正。

在下一篇文章中,將實現如props等組件特性,研究組件之間通訊、HOC組件設計等問題,思考如何正確地封裝組件。

相關文章
相關標籤/搜索