循序漸進DIY一個react(二)
承接上文,假如我給你一個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 複製程式碼
細想之下,這個函式的實現邏輯的流程圖大概是這樣的:

那好,為了簡便,我們暫時不考慮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(三)