承接上文,假如我給你一個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語法也“發明”了。那麼下一步,我們就來談談整樹對映過程中協調的實現。