這一部分講述的是堆棧調解器的實現
React的API可以被分為三部分,核心,渲染器,調解器,如果你對程式碼程式庫可能有點不瞭解的話,可以看我的部落格
其中堆棧調解器是React產品中最重要的部分,被React DOM和React Native渲染器共同使用,它的代碼地址src/renderers/shared/stack/reconciler。 1.從零開始構建React的曆史
Paul O'Shannessy給予React開發一個非常大的靈感,它對最終的程式碼程式庫的文檔和解說可以得到更好的理解,所以有時間可以去看看。 2.概要
調解器自身並不是一個公用API,而渲染器則作為一個介面,通過使用者寫的React組件來被React DOM和React Native兩個渲染器有效更新。 3.綁定就是遞迴
下面是一個綁定組件的執行個體
ReactDOM.render(<App />, rootEl);
<App/>是一個記錄了該如何去渲染的React對象元素,React DOM將<App />作為對象傳遞給調解器,你可以認為是如下這樣的對象
console.log(<App />);// { type: App, props: {} }
如果App是一個組件類或者函數類,調解器就會檢查他們。
如果App是一個函數,調解器就會調用App(props)去渲染元素。
如果App是一個類,調解器就會通過new App(props)去執行個體化一個App對象,然後調用componentWillMount()生命週期方法,接著是調用render()方法去渲染元素。
無論是哪一種方法,調解器都能夠知道如何去渲染App元素。
這個過程是遞迴的,App可能渲染出<Greeting/>,Greeting可能會渲染出<Button />,接著不斷處理,調解器通過知道一個組件如何渲染的來往深度處理使用者自訂的組件。
你可以認為這個過程是如下的虛擬碼:
function isClass(type) { // React.Component子類都會有這些標記 return ( Boolean(type.prototype) && Boolean(type.prototype.isReactComponent) );}// 這個函數會處理React元素// 返回一個代表需要被綁定的樹的DOM或者Native節點function mount(element) { var type = element.type; var props = element.props; // 我們決定要被渲染的元素 // 它可能是一個函數啟動並執行結果 // 也可能是一個類執行個體調用render運行後的結果 var renderedElement; if (isClass(type)) { // 類組件 var publicInstance = new type(props); // 設定props publicInstance.props = props; // 如果有必要調用生命週期方法 if (publicInstance.componentWillMount) { publicInstance.componentWillMount(); } // 調用render得到要被渲染的元素 renderedElement = publicInstance.render(); } else { // 函數式組件 renderedElement = type(props); } // 由於組件可能會返回另外一個組件 // 所以這個過程是一個遞迴的過程 return mount(renderedElement); // 注意:這個實現也是不完整的,因為遞迴依舊沒有被停止 // 它只能處理自訂群組合組件,比如<App />或<Button /> // 而不能處理host組件,比如<div />或<p />}var rootEl = document.getElementById('root');var node = mount(<App />);rootEl.appendChild(node);
注意
上述的真是一段虛擬碼,和真實的實現相差太遠,直接一看都知道問題,遞迴無法截止,所以上述只是一個簡單的思路,代碼需要完善。
我們先總結一下上述代碼的幾點關鍵點:
React元素表現為一個對象的話,可以用組件的type和props來表示,這就是React.createElement的任務了
自訂的組件(比如App)可以是類或者是函數,他們都會返回需要渲染的元素
綁定是一個建立DOM或者Native節點樹的遞迴過程 4.綁定Host元素
如果我們不在視圖中顯示什麼的話,那麼上述的那些代碼都是沒有用的,顯示出結果是我們的目的。
除了使用者自訂的組合組件,React元素還可能是Host組件,比如Button在render方法中可能會返回<div/>。
如果元素的type是一個字串,我們就需要按照host元素來處理:
console.log(<div />);// { type: 'div', props: {} }
當調解器檢測到是一個host元素後,它會讓渲染器去關心如何綁定它,例如,React DOM渲染器可能就建立一個DOM節點,而其它的渲染器又會建立其它,而這些都不是調解器關心的,這裡大家可以留個心思,真是的渲染過程永遠都是渲染器的事,跟調解器半毛錢的關係都沒有。
如果一個host元素有孩子,調解器就會用同樣的方法遞迴去綁定他們(記住是綁定而不是渲染),孩子是host就host處理,孩子是組合就按照組合的方式處理。
DOM節點被孩子組件建立出來加入父親DOM節點中,不斷遞迴的處理,最後完整的DOM結構就組裝而成了。
注意
調解器自己並不會依賴DOM,綁定的最終結果取決於渲染器,如果是DOM節點就是React DOM渲染器處理的,如果是字串則是React DOM Server渲染器處理的,如果是一個代表著Native視圖的數字則是React Native渲染器處理的,這些都不是調解器需要關心的。
如果你擴充之前講的代碼處理host元素,那麼虛擬碼就變為了如下形式:
function isClass(type) { // React.Component子類都會有這些標記 return ( Boolean(type.prototype) && Boolean(type.prototype.isReactComponent) );}// 這個函數只是用來處理組合組件的// 比如 <App />和<Button />, 不能處理<div />.function mountComposite(element) { var type = element.type; var props = element.props; var renderedElement; if (isClass(type)) { // 類組件執行個體化 var publicInstance = new type(props); // 設定props publicInstance.props = props; //調用生命週期函數 if (publicInstance.componentWillMount) { publicInstance.componentWillMount(); } renderedElement = publicInstance.render(); } else if (typeof type === 'function') { // 函數式組件 renderedElement = type(props); } // 當我們遇到的元素是個Host組件而不是一個組合組件時 // 這個遞迴過程就會停止 return mount(renderedElement);}// 這個函數只是用來處理host組件的// 比如 <div />和<p />, 不能處理<App />.function mountHost(element) { var type = element.type; var props = element.props; var children = props.children || []; if (!Array.isArray(children)) { children = [children]; } children = children.filter(Boolean); // 這一部分代碼不應該存在調解器中 // 因為不同的渲染器初始化節點方式是可能不同的 // 比如說,React Native會建立IOS或者Android視圖 var node = document.createElement(type); Object.keys(props).forEach(propName => { if (propName !== 'children') { node.setAttribute(propName, props[propName]); } }); // 綁定子級 children.forEach(childElement => { // 孩子可能是host或者組合組件 // 然後就是遞迴處理他們 var childNode = mount(childElement); // 這部分代碼也是特殊的 // 形式方法的不同取決於不同的渲染器 node.appendChild(childNode); }); // 返回DOM節點作為綁定的結果 // 遞迴過程從此處返回 return node;}function mount(element) { var type = element.type; if (typeof type === 'function') { // 使用者自訂群組件 return mountComposite(element); } else if (typeof type === 'string') { // host組件 return mountHost(element); }}var rootEl = document.getElementById('root');var node = mount(<App />);rootEl.appendChild(node);
這一部分代碼裡真正的調解器相差依舊是非常遙遠的,它連更新功能都沒有實現。 5.內部執行個體
React的一個非常關鍵的特點就是你可以重複渲染任何東西,這個重複渲染的過程並不會重新建立DOM或者是重設定state,這是非常有意思的,而是不渲染,有趣。
ReactDOM.render(<App />, rootEl);// 下面的代碼並不會有什麼效能損失,因為下面的代碼相當於沒有執行,有趣ReactDOM.render(<App />, rootEl);
然而,我們之前的代碼真是一個最簡單的構造初始綁定樹的方式了,因為我們在前面的代碼沒有進行更新階段的資料儲備,所以根本無法實行更新的操作,比如說publicInstances,或者哪個組件對應著哪個DOM節點,這些資料在初始化綁定後統統不知道,這就非常尷尬了。
這個堆棧調解器程式碼程式庫就通過一個在類中的mount()函數方法來解決它,當然這個方法有一定缺陷,我們會嘗試重寫調解器,不過,它是怎麼工作的呢。
我們不再使用mountHost和mountComposite函數,我們用兩個類來取代他們:DOMComponent和CompositeComponent,因為對象就可以儲存資料,函數一般都是純函數不影響資料儲備。
這兩個類都有一個接受element為參數的建構函式和一個mount方法去返回被綁定的節點,而全域的mount()函數被如下的代碼替換掉了:
function instantiateComponent(element) { var type = element.type; if (typeof type === 'function') { // 使用者自訂群組件 return new CompositeComponent(element); } else if (typeof type === 'string') { // host組件 return new DOMComponent(element); } }
首先我們先編寫CompositeComponent類的實現:
class CompositeComponent { constructor(element) { this.currentElement = element; this.renderedComponent = null; this.publicInstance = null; } getPublicInstance() { // For composite components, expose the class instance.針對組合組件暴露出它的類執行個體 return this.publicInstance; } mount() { var element = this.currentElement; var type = element.type; var props = element.props; var publicInstance; var renderedElement; if (isClass(type)) { // 組件類 publicInstance = new type(props); // 設定props publicInstance.props = props; // 調用生命週期函數 if (publicInstance.componentWillMount) { publicInstance.componentWillMount(); } renderedElement = publicInstance.render(); } else if (typeof type === 'function') { // 函數式組件沒有執行個體 publicInstance = null; renderedElement = type(props); } // 儲存執行個體 this.publicInstance = publicInstance; // 根據元素執行個體化孩子內部執行個體 // 這個執行個體可能是DOMComponent // 也可能是CompositeComponent var renderedComponent = instantiateComponent(renderedElement); this.renderedComponent = renderedComponent; // 將繫結資料輸出 return renderedComponent.mount(); }}
這個和前面的mountComposite的實現沒有多少不同,但是我們儲存了一些對我們更新資料有用的資訊,比如this.currentElement,this.renderedComponent和this.publicInstance。
有一點要注意,我們的CompositeComponent執行個體和使用者自己執行個體化element.type是不相同的,CompositeComponenet是調解器內部的實現無法被外界使用,或者是沒有暴露出來,你並不知道它,而使用者自己通過new element.type()是不一樣的,你可以直接對他進行處理,換一句話說的是我們在類中實現的getPublicInstance()函數就是讓我們得到一個公用操作的執行個體,但是更加底層內部的執行個體呢,我們很明顯不能操作,也就只能操作當前這一層當做公用介面暴露出來的執行個體了。
為了避免出現混亂,我將CompositeComponent和DOMComponent稱為內部執行個體,他們的存在可以長久的儲存著資料,而只有渲染器和調解器能夠直接處理他們。
與此相反,我們將使用者自訂類的執行個體稱為公用執行個體(外部執行個體),這個公用執行個體你可以直接操作。
mountHost函數被重構成了DOMComponent中的mount函數。
class DOMComponent { constructor(element) { this.currentElement = element; this.renderedChildren = []; this.node = null; } getPublicInstance() { return this.node; } mount() { var element = this.currentElement; var type = element.type; var props = element.props; var children = props.children || []; if (!Array.isArray(children)) { children = [children]; } // 建立並儲存節點 var node = document.createElement(type); this.node = node; // 設定節點屬性 Object.keys(props).forEach(propName => { if (propName !== 'children') { node.setAttribute(propName, props[propName]); } }); // 建立和儲存被包含的孩子 // 他們可能是DOMComponent或者是CompositeCompoennt // 這取決與他們的type是字串還是function var renderedChildren = children.map(instantiateComponent); this.renderedChildren = renderedChildren; // 收集要綁定的節點 var childNodes = renderedChildren.map(child => child.mount()); childNodes.forEach(childNode => node.appendChild(childNode)); // 將DOM節點作為綁定的結果返回 return node; }}
這個和之前的代碼主要的不同就是我們重構了moutHost()並且儲存了當前的node和renderedChildren來關聯內部的DOM組件執行個體,以後我們就可以無需聲明就可以使用他們。
總的來說,每一個內部執行個體,不管是組合也好,host也好,現在都會有指向他們的孩子內部執行個體的變數儲存,從而構成一個內部執行個體鏈,為了更加形象的來說明他,如果<App>是一個函數式組件<Button>是一個類組件,而Button又渲染出了<div>那麼最後的內部執行個體鏈就如同下面所示。
[object CompositeComponent] { currentElement: <App />, publicInstance: null, renderedComponent: [object CompositeComponent] { currentElement: <Button />, publicInstance: [object Button], renderedComponent: [object DOMComponent] { currentElement: <div />, node: [object HTMLDivElement], renderedChildren: [] } }}
在DOM中你講只會看到<div>,然而內部執行個體樹既包括組合內部執行個體又包括host內部執行個體。
組合內部執行個體需要儲存如下一些東西:
當前的元素
如果元素的type是一個類,那麼要儲存公用執行個體
當前的元素不是DOMComponent就是CompositeComponent,我們需要儲存他們渲染的內部執行個體。
host內部執行個體需要儲存的:
當前的元素
DOM節點
所有的孩子內部執行個體,他們可能是DOMComponent也可能是CompositeComponent
你可以想象一個內部執行個體樹怎麼去構建一個複雜的應用呢,React DevTools可以給你一個非常直觀的結果,它可以用灰色高亮host執行個體,用紫色來高亮組合執行個體
然而如果要完成最終的重構,我們還要設計一個函數去進行真實的綁定操作像是ReactDOM.render(),它還會返回一個公用執行個體,前面的mount得到的只是要進行綁定的節點,而沒有進行真實的綁定。
function mountTree(element, containerNode) { // 建立頂部內部執行個體 var rootComponent = instantiateComponent(element); // 將頂部組件加入最終DOM容器中實現真正的綁定 var node = rootComponent.mount(); containerNode.appendChild(node); // 返回公用執行個體 var publicInstance = rootComponent.getPublicInstance(); return publicInstance;}var rootEl = document.getElementById('root');mountTree(<App />, rootEl);
6.卸載
現在我們已經有了儲存著孩子和DOM節點的內部執行個體,接下來我們就可以實現卸載,對於Composite Component,卸載會遞迴的調用生命週期函數。
class CompositeComponent { // ... unmount() { // 調用生命週期函數 var publicInstance = this.publicInstance; if (publicInstance) { if (publicInstance.componentWillUnmount) { publicInstance.componentWillUnmount(); } } // 卸載渲染 var renderedComponent = this.renderedComponent; renderedComponent.unmount(); }}
對於DOMComponent,需要告訴每一個孩子都要卸載
class DOMComponent { // ... unmount() { // 卸載所有的孩子 var renderedChildren = this.renderedChildren; renderedChildren.forEach(child => child.unmount()); }}
實際上,卸載DOM組件也需要移除事件監聽器,清除緩衝,不過這些細節我先暫時跳過。
我們現在再增加一個全新的全域函數unmountTree(containerNode),他的功能和ReactDOM.unmountComponentAtNode()類似。與mountTree功能相反
function unmountTree(containerNode) { // 從DOM節點中讀取一個內部執行個體 // 這一個_internalInstance內部執行個體我們會在mountTree給它增加 var node = containerNode.firstChild; var rootComponent = node._internalInstance; // 卸載樹並清理容器 rootComponent.unmount(); containerNode.innerHTML = '';}
為了讓上述的代碼可以正常的工作了,我們需要為DOM節點讀取一個root內部執行個體,我們修改mountTree()增加一個_internalInstance屬性為rootDOM節點,我們要告訴mountTree應該摧毀已經存在的樹,這樣才可以多次調用:
function mountTree(element, containerNode) { // 摧毀已經存在的樹 if (containerNode.firstChild) { unmountTree(containerNode); } // 建立一個頂級的內部執行個體 var rootComponent = instantiateComponent(element); // 將頂級內部執行個體的渲染結果加入DOM中 var node = rootComponent.mount(); containerNode.appendChild(node); // 儲存內部執行個體 node._internalInstance = rootComponent; // 返回一個公用執行個體 var publicInstance = rootComponent.getPublicInstance(); return publicInstance;}
7.更新
在上面,我們實現了卸載,然而如果每一次prop改變都卸載舊的樹,構造性的樹,React就沒有存在的必要了,而調解器的作用就是為了重複使用已經存在執行個體,從而達到效能的提升。
var rootEl = document.getElementById('root');mountTree(<App />, rootEl);// 下面的語句相當於沒有執行mountTree(<App />, rootEl);
我們將擴充我們的內部執行個體實現一個方法,DOMComponent和CompositeComponent都需要實現一個新的函數叫做receive(nextElement)
class CompositeComponent { // ... receive(nextElement) { // ... }}class DOMComponent { // ... receive(nextElement) { // ... }}
這個函數的工作就是讓組件和它的孩子可以及時的瞭解到nextElement的資訊,進行更新。
這一部分在前面被描述為“虛擬DOM diff”,通過我們沿著內部執行個體樹遞迴往下走,讓每一個內部執行個體都可以接受到更新。 8.更新群組合組件
當一個組合組件接受到一個新的元素的時候,我們會運行componeentWillUpdate()生命週期函數,然後會通過新的porps去重渲染組件,得到一個新的渲染元素。
class CompositeComponent { // ... receive(nextElement) { var prevProps = this.currentElement.props; var publicInstance = this.publicInstance; var prevRenderedComponent = this.renderedComponent; var prevRenderedElement = prevRenderedComponent.currentElement; // 更新自己的元素 this.currentElement = nextElement; var type = nextElement.type; var nextProps = nextElement.props; // 得出新render內容 var nextRenderedElement; if (isClass(type)) { if (publicInstance.componentWillUpdate) { publicInstance.componentWillUpdate(nextProps); } // 更新props publicInstance.props = nextProps; // 重新渲染 nextRenderedElement = publicInstance.render(); } else if (typeof type === 'function') { nextRenderedElement = type(nextProps); } // ...
得到了nextRenderedElement我們就可以查看渲染的元素的type,跟我之前將更新的時候的判斷是一樣的,如果type沒有改變,那麼就向下遞迴,而不改變當前組件。
比如說,如果render第一次返回了<Button color="red"/>,第二次返回<Button color="blue">,我們就可以告訴相應的內部執行個體receive新元素。
// ... // 如果渲染的元素type沒有變化 // 則重使用已經存在的執行個體,不去新建立執行個體 if (prevRenderedElement.type === nextRenderedElement.type) { prevRenderedComponent.receive(nextRenderedElement); return; } // ...
但是,如果新渲染的元素和之前的元素type不一樣的話,那麼我們就無法進行更新操作了,因為<button>是無法成為<input>、所以,我們不得不卸載摧毀已經存在的內部執行個體然後裝載相應的新的渲染元素:
// ... // 得到舊的節點 var prevNode = prevRenderedComponent.getHostNode(); // 卸載舊的孩子裝載新的孩子 prevRenderedComponent.unmount(); var nextRenderedComponent = instantiateComponent(nextRenderedElement); var nextNode = nextRenderedComponent.mount(); // 替換引用 this.renderedComponent = nextRenderedComponent; // 注意:這部分代碼理論上應該放在CompositeComponent外面而不是裡面 prevNode.parentNode.replaceChild(nextNode, prevNode); }}
綜上所述,一個組合組件接收到一個新的元素,他會直接更新內部執行個體,或者是卸載舊的執行個體,裝載新的執行個體。
這裡還有另外一種情況,當一個元素的key發生變化時組件就是被重裝載而不是接受一個新的元素,當然我們這裡先不討論key造成的改變,因為它的會比較複雜。
值得注意的是,我們需要為內部執行個體增加一個叫做getHostNode()的函數,以至於在更新階段找到指定的節點然後更新它,下面是它在兩個類中的簡單實現:
class CompositeComponent { // ... getHostNode() { // 遞迴處理 return this.renderedComponent.getHostNode(); }}class DOMComponent { // ... getHostNode() { return this.node; } }
9.更新Host組件
Host組件的實現就像是DOMComponent,和CompositeComponent更新截然不同,當他們接收到一個元素時,他們需要更新底層的DOM節點,就React的DOM而言,他們會更新DOM屬性:
class DOMComponent { // ... receive(nextElement) { var node = this.node; var prevElement = this.currentElement; var prevProps = prevElement.props; var nextProps = nextElement.props; this.currentElement = nextElement; // 移除舊的屬性 Object.keys(prevProps).forEach(propName => { if (propName !== 'children' && !nextProps.hasOwnProperty(propName)) { node.removeAttribute(propName); } }); // 設定新的屬性 Object.keys(nextProps).forEach(propName => { if (propName !== 'children') { node.setAttribute(propName, nextProps[propName]); } }); // ...
然後,host組件就開始更新他們的孩子,不像組合組件那樣,他們只會包含最多一個孩子。
在下面這個例子中,我是用一個數組的內部執行個體,然後遍曆它,或者是進行更新還是替換內部執行個體取決於新的元素和舊的元素的type是否相同。真正的調解器還會使用元素的key去處理,當然我們暫時不會涉及這個處理邏輯。
我們可以收集DOM節點需要操作的孩子然後批量的處理他們來節省時間:
// ... // 在這裡React元素是一個數組