循序漸進DIY一個react(二)

鯊叔發表於2019-02-23

承接上文,假如我給你一個virtual DOM物件,那麼你該如何實現將它渲染到真實的文件中去呢?這個時候就涉及到原生DOM介面的一些增刪改查的知識點了:

// 增:根據標籤名,建立一個元素節點(element node)
let divElement = document.createElement('div')

// 增:根據文字內容,建立一個文字節點(text node)
const textNode = document.createTextNode('我是文字節點')

// 查:通過一個id字串來獲取文件中的元素節點
const bodyElement = document.getElementsByTagName('body')[0]

// 改:設定元素節點的非事件型別的屬性(property)
divElement['id'] = 'test'
divElement['className'] = 'my-class'

// 改:給元素設定事件監聽器
divElement.addEventListener('click',() => { console.log('I been clicked!')})

// 改:改變文件樹結構
divElement.appendChild(textNode)
bodyElement.appendChild(divElement)

// 刪:從文件結構樹中刪除
bodyElement.removeChild(divElement)
複製程式碼

上面有一個注意點,那就是我們設定元素屬性的寫法是設定property而不是設定attibute。在DOM裡面,property和attribute是兩種概念。而設定property意味著只有有效的屬性才會生效。

在react中,“react element”是一個術語,指的就是一個virtual DOM物件。並且在react.js的原始碼中,都是用element來指代的。為了統一,我們也使用elment這個名字來命名virtual DOM物件,如下:

const element = {
  type:'div',
  props:{
    id:'test',
    children:['我是文字節點']
  }
}

複製程式碼

我們暫時不考慮引入“component”這個概念,所以,type的值的型別是隻有字串。因為有些文件標籤是可以沒有屬性的,所以props的值可以是空物件(注意,不是null)。props的children屬性值是陣列型別,陣列中的每一項又都是一個react element。因為有些文件標籤是可以沒有子節點,所以,props的children屬性值也是可以是空陣列。這裡面我們看到了一個巢狀的資料結構,可想而知,具體的現實裡面很可能會出現遞迴。

大家有沒有發現,即使我們不考慮引入“component”這個概念,我們到目前為止,前面所提的都是對應於element node的,我們並沒有提到text node在virtual DOM的世界是如何表示的。咋一想,我們可能會這樣設計:

const element = {
  type:'我是文字節點',
  props:{}
}
複製程式碼

從技術實現方面講,這是可行的。但是仔細思考後,這樣做顯然是混淆了當初定義type欄位的語義的。為了維持各欄位(type,props)語義的統一化,我們不妨這樣設計:

const element = {
  type:'TEXT_ELEMENT',
  props:{
    nodeValue:'我是文字節點'
  }
}
複製程式碼

這樣一來, text node和element node在virtual DOM的世界裡面都有了對應的表示形式了:DOMElement 和 textElement

// 元素節點表示為:
const DOMElement = {
   type:'div',
   props:{
    id:'test',
    children:[
        {
            type:'TEXT_ELEMENT',
            props:{
                nodeValue:'我是文字節點'
       }
    ]
   }
}

// 文字節點表示為:
const textElement = {
   type:'TEXT_ELEMENT',
   props:{
    nodeValue:'我是文字節點'
   }
}

複製程式碼

對react element的資料結構補充完畢後,我們可以考慮具體的實現了。我們就叫這個函式為render(對應ReactDOM.render()方法)吧。根據我們的需求,render函式的簽名大概是這樣的:

render : (element,domContainer) => void
複製程式碼

細想之下,這個函式的實現邏輯的流程圖大概是這樣的:

循序漸進DIY一個react(二)

那好,為了簡便,我們暫時不考慮edge case,並使用ES6的語法來實現這個邏輯:

function render(element,domContainer){
    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,遞迴呼叫render函式
    if(children && children.length){
        children.forEach(child => render(child,domNode))
    }
    
    // 最終追加到容器節點中去
    domContainer.appendChild(domNode)
}
複製程式碼

至此,我們完成了從virtual DOM -> real DOM的對映的實現。現在,我們可以用以下的virtual DOM:

const element = {
    type:'div',
    props:{
    id:'test',
    onClick:() => { alert('I been clicked') },
    children:[
        {
            type:'TEXT_ELEMENT',
            props:{
                nodeValue:'我是文字節點'
            }
        }
    ]
    }
}
複製程式碼

來對映這樣的文件結構:

<div id="test" onClick={() => { alert('I been clicked')}>
    我是文字節點
</div>
複製程式碼

你可以把下面完整的程式碼複製到codepen裡面驗證一下:

 const element = {
            type: 'div',
            props: {
                id: 'test',
                onClick: () => { alert('I been clicked') },
                children: [
                    {
                        type: 'TEXT_ELEMENT',
                        props: {
                            nodeValue: '我是文字節點'
                        }
                    }
                ]
            }
        }

        function render(element, domContainer) {
            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,遞迴呼叫render函式
            if (children && children.length) {
                children.forEach(child => render(child, domNode))
            }

            // 最終追加到容器節點中去
            domContainer.appendChild(domNode)
        }

        window.onload = () => {
            render(element, document.body)
        }
複製程式碼

雖然我們已經完成了基本對映的實現,但是你有沒有想過,假如我們要用virtual DOM物件去描述一顆深度很深,廣度很廣的文件樹的時候,那我們寫javascript物件是不是要寫斷手啦?在這個Node.js賦能前端,語法糖流行的年代,我們有沒有一些即優雅又省力的手段來完成這個工作呢?答案是:“有的,那就是JSX”。 說到這裡,那肯定要提到無所不能的babel編譯器了。現在,我無意講babel基於Node.js+AST的編譯原理和它的基於外掛的擴充套件機制。我們只是假設我們手上有一個叫transform-react-jsx的plugin。它能夠把我們寫的jsx:

const divElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文字節點1
        <a href="https://www.baidu.com">百度一下</a>
    </div>
)
複製程式碼

編譯成對應的javascript函式呼叫:

const divElement = createElement(
    'div',
    {
        id:test,
        onClick:() => { alert('I been clicked') }
    },
    '我是文字節點',
    createElement(
        'a',
        {
            href:'https://www.baidu.com'
        },
        '百度一下'
    )
    )
複製程式碼

而作為配合,我們需要手動實現這個createElement函式。從上面的假設我們可以看出,這個createElement函式的簽名大概是這樣的:

    createElement:(type,props,children1,children2,...) => element
複製程式碼

我們已經約定好了element的資料結構了,現在我們一起來實現一下:

function createElement(type,props,...childrens){
    const newProps = Object.assign({},props)
    const hasChildren = childrens.length > 0 
    newProps.children = hasChildren ? [].concat(...childrens) : []
    return {
        type,
        props:newProps
    }
}
複製程式碼

上面這種實現在正常情況下是沒有問題的,但是卻把children是字串(代表著文字節點)的情況忽略了。除此之外,我們也忽略了children是null,false,undefined等falsy值的情況。好,我們進一步完善一下:

function createElement(type,props,...childrens){
    const newProps = Object.assign({},props)
    const hasChildren = childrens.length > 0 
    const rawChildren = hasChildren ? [].concat(...childrens) : []
    newProps.children = rawChildren.filter(child => !!child).map(child => {
        return child instanceof Object ? child : createTextElement(child)
    })
    return {
        type,
        props:newProps
    }
}

function createTextElement(text){
    return {
        type:'TEXT_ELEMENT',
        props:{
            nodeValue:text
        }
    }
}
複製程式碼

好了,有了babel的jsx編譯外掛,再加上我們實現的createElement函式,我們現在就可以像往常寫HTML標記一樣編寫virtual DOM物件了。

下面,我們來總結一下。我們寫的是:

 <div id="test" onClick={() => { alert('I been clicked')}>
    我是文字節點1
    <a href="https://www.baidu.com">百度一下</a>
</div>
複製程式碼

babel會將我們的jsx轉換為對應的javascript函式呼叫程式碼:

createElement(
    'div',
    {
        id:test,
        onClick:() => { alert('I been clicked') }
    },
    '我是文字節點',
    createElement(
        'a',
        {
            href:'https://www.baidu.com'
        },
        '百度一下'
    )
    )
複製程式碼

而在createElement函式的內部實現裡面,又會針對字串型別的children呼叫createTextElement來獲得對應的textElement。

最後,我們把已實現的函式和jsx語法結合起來,一起看看完整的寫法和程式碼脈絡:

//  jsx的寫法
const divElement = (
    <div id="test" onClick={() => { alert('I been clicked')}>
        我是文字節點1
        <a href="https://www.baidu.com">百度一下</a>
    </div>
)

function render(){/* 內部實現,已給出 */}
function createElement(){/* 內部實現,已給出 */}
function createTextElement(){/* 內部實現,已給出 */}

window.onload = () => {
    render(divElement,document.body)
}

複製程式碼

到這裡,virtual DOM -> real DOM對映的簡單實現也完成了,省時省力的jsx語法也“發明”了。那麼下一步,我們就來談談整樹對映過程中協調的實現。

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

相關文章