在正式進入實現以前,咱們先來了解一下幾個概念。首先,「映射」這個概念已經在「第一篇文章裏」裏面介紹過了,這裏就不在贅述了。咱們來說講這裏所說的「整樹」和「協調」到底指的是什麼?javascript
熟悉react的讀者都知道,完整的react應用是能夠用一顆組件樹來表示的。而組件樹背後對應的歸根到底仍是virtual DOM對象樹。react官方推薦僅調用一次ReactDOM.render()來將這顆virtual DOM對象樹掛載在真實的文檔中去。因此,這裏,咱們就將調用render方法時傳入的第一參數稱之爲「整樹」(整一顆virtual DOM對象樹):html
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對象樹就是咱們所說的「整樹」。java
那「協調」又是啥意思呢?協調,原概念來自於英文單詞「Reconciliation」,你也能夠翻譯爲「調和」,「和解」或者什麼的。在react中,「Reconciliation」是個什麼樣的定義呢?官方文檔好像也沒有給出,官方文檔只是給出了一段稍微相關的解釋而已:react
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的渲染性能越好。app
在上一個篇章裏面,咱們實現了重頭戲函數-render。若是將render函數的第一次調用,稱做爲「整樹的初始掛載」,那麼日後的調用就是「整樹的更新」了。拿咱們已經實現的render函數來講,若是我要更新界面,我只能傳入一個新的element,重複調用render:dom
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函數只會往容器節點裏面追加子元素,而不是替代原有的子元素。因此咱們得把最後一塊代碼的邏輯改一改:ide
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三者之間的關係吧:
是的,它們之間是一一對應的關係。 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」概念,實現更細粒度的協調。