注意:下文中,反覆提到"例項"一詞,如無特別交代,它指的是第三篇章的instance
的這個概念。
在react中,“component”概念可以理解為一個身份的象徵。假如我們將root virtual DOM節點比作virtual DOM世界的周天子的話,那麼“component”就是管轄著一塊或大或小疆域的分封諸侯。只不過,這塊疆域不是土地,而是“virtual DOM”。
在沒有引入“component”這個概念之前,我們面臨這以下幾個問題:
- state是全域性的。
- 每更新一次介面,都需要手動地呼叫一次個render函式。
- 對render函式的呼叫,會導致整樹協調的發生,介面渲染效能不高。
因此,為了解決以上幾個問題,我們引入“component”這個概念。“component”通過自身的兩個特徵來解決以上問題:
- local state。代表component所處的介面狀態。
- setState API。通過呼叫它來修改local state,從而使協調在整顆子樹上發生。
通過呼叫setState來更新UI,就是我們所要實現的子樹協調。
“component”概念的落地工作,首先從實現base class開始。熟悉react的讀者大概都知道,component的定義中,唯一不能缺少的方法是render方法。所以這個父類的類圖大概是這樣的:
好,下面我們一起來實現這個父類大概的shape:
class Component {
constructor(props){
this.props = props || {}
this.state = this.state || {}
}
setState(partialState){
this.state = Object.assign({},this.state,partialState)
}
render(){
console.error('You should implement your own render method!')
}
}
複製程式碼
上面的程式碼其實就是做了兩個事:
- 將使用者呼叫setState時傳入的區域性state合併到已有的state當中去。
- 提醒使用者,render方法必須自己實現。
為了區分,react把DOM elment和text element分別稱為“DOM Component”和“Text Component”,把我們這裡的“component”稱之為“Composite Component”。在引入“component”這個概念後,我們也沿用這些叫法。
目前為止,我們還沒讓local state跟子樹的協調聯絡起來。我們呼叫setState,介面將不會發生任何變化。要想local state改變能跟協調演算法聯動起來,本質上就是要求我們先後回答三個問題:
一. 如何對接jsx的編譯?
目前我們createElement的實現是這樣的:
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
};
}
複製程式碼
這一次,我們不動createElement的實現。因為我們可以通過改動轉換jsx的babel外掛的具體實現來滿足我們的需求。大概原理是,讓babel外掛將以大寫字母開頭的標籤識別為Composite Component,然後原封不動地把使用者自定義的元件類傳給我們。我們通過在後續的例項化(物件導向意義上的例項化),來拿到我們想要的資料。屆時,我們寫的是這樣的jsx:
class App extends Component {
render(){.......}
}
render(<App />,rootDOM)
複製程式碼
轉換jsx的babel外掛將結合我們實現的createElment函式編譯為:
class App extends Component {
render(){.......}
}
render(createElement(App,{}),rootDOM)
複製程式碼
對Composite Component呼叫createElement返回的virtual DOM的資料將會是這樣的:
{
type:App,// 從ES6的角度看,APP是一個“類”;從ES5的角度來看,它還是一個函式
props:{
children:[]
}
}
複製程式碼
如何改動jsx轉換babel外掛不在我們的討論範圍內,故略過不表。 凡是認真閱讀過第一篇章的讀者可能就注意到了,Composite Component跟DOM Component和Text Component所對應的virtual DOM結構不同的一點就是:type欄位的值的型別是函式(提醒:ES6的類最後還是會被編譯為function),而不是string了。這一點很重要。Composite Component的例項化對接工作正是基於這一點。
二. 如何對接“例項”概念?
這個問題包含了兩個問題。
第一是:Composite Component所對應的instance的資料結構是如何?
第二是:如何被例項化?
這兩位問題對應兩個任務:
task1: 確定Composite Component所對應例項的資料結構。
顯然,Composite Component跟DOM Component和Text Component都是屬於“元件”概念範疇的,它也需要被掛載到具體的DOM節點上,也有對應的element,也應該有childInstance。只不過不同於先前的DOM Component和Text Component,這些欄位的取值基於Composite Component的特殊性,肯定會有所不同。在react的原始碼中,Composite Component所對應的instance確實有子例項的欄位,只不過這個子例項的含義並不能跟我們從jsx結構所看到的層級關係對應上。舉個例子,DOM component裡面,如果我們看到
<div>
我是文字節點
<span>我是另外一個節點</span>
</div>
複製程式碼
這種結構,我們就可以說我是文字節點
和<span>我是另外一個節點</span>
所對應的例項是<div>
元件的子例項。在Composite Component的概念裡,情況就不一樣了。也就是說,如果我們看到
<MyComponent>
我是文字節點
<span>我是另外一個節點</span
<MyComponent>
複製程式碼
這種結構,我們不能說我是文字節點
和<span>我是另外一個節點</span>
所對應的例項是<MyComponent>
元件的子例項。實際上,<MyComponent>
元件的子例項是另有其人。它就是元件的render函式所返回的react element所對應的例項,為什麼呢?原因很簡單,有二:
<MyComponent>
只是一個身份的象徵,象徵著元件render方法所返回的react element。這好比日本的天皇是沒有實權的,他只是一個國家的象徵而已,掌握實權的是日本的首相。<MyComponent>
的子元件(我是文字節點
和<span>我是另外一個節點</span>
)最後都是被render方法通過this.props.children消費掉,成為它返回的react element的一部分。
所以,從實現的角度來說,render方法返回的element所對應的例項才是<MyComponent>
的子例項。
因為render方法只能返回一個element,所以Composite Component只有一個子例項,也就是說Composite Component所對應的的子例項的值並不是由子例項組成的陣列,只是單個例項而已。同樣的,因為元件名只是一個象徵而已,那麼Composite Component對應例項的dom節點的值應該是由子例項所對應的DOM節點來充當。最後一點,如果我們想把Composite Component的例項和它的真正例項(這裡的真正例項就是指通過new操作符呼叫函式所返回的物件,react裡面稱之為publicInstance。為了區分,第三篇章所引入instance
概念又稱之為internalInstance)對應起來,那麼我們都需要在彼此的身上儲存對方的引用。綜上所述,Composite Component所對應的例項的資料結構如下:
const instance = {
dom: DOMObject,
element:reactElement,
childInstance:childInstance,
publicInstance:realInstance // 元件類通過new操作符運算所返回的真正意義上的例項
}
複製程式碼
task2: 用程式碼實現Composite Component的例項化。
既然上面已經弄清楚Composite Component所對應例項的資料結構(有什麼欄位,欄位的值是什麼),那麼實現它的例項化也是順水推舟的事了,我們在原有的程式碼上新增上Composite Component所對應的條件分支:
function instantiate(element) {
const { type, props } = element;
// 根據type欄位值的型別來判斷是否是Composite Component
const isDomElement = typeof type === "string";
// 建立對應的DOM節點
if(isDomElement){ // 例項化DOM Component 和 Text Component
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;
}else { // 例項化Composite Component
const { type, props } = element;
const instance = {};
// component類的真正例項化
const publicInstance = new type(props);
// 將render方法返回的element的this指向publicInstance
// 結合“this關鍵字的指向是由它執行的上下文所決定的”這句話來理解一下
const childElement = publicInstance.render();
// 對於Composite Component來說,render方法返回element對應的instance的dom就是它對應例項的dom
const childInstance = instantiate(childElement);
const dom = childInstance.dom;
// 按照我們在task1討論出的資料結構,組裝component element所對應的例項
Object.assign(instance, { dom, element, childInstance, publicInstance });
// 最後,把 Composite Component所對應例項的引用儲存在publicInstance身上,打通兩者之間的訪問
publicInstance.__internalInstance = instance;
return instance;
}
}
複製程式碼
三. 如何對接“協調”概念?
Composite Component在協調演算法中,對應的“初始掛載”,“刪除”和“替換”的實現跟DOM component和Text component的實現也是一樣的,比較簡單。兩者之間不同的是“更新”部分的實現邏輯。我們先來看看reconcile函式的簽名:
reconcile:(instance, element, domContainer) => instance
複製程式碼
在這系列接近尾聲的時候,大家可能也觀察出來了。reconcile函式的第一引數instance,第二引數element是reconcile函式語義上的標誌。換句話說,協調,協調,協調的物件是誰跟誰呢?答曰:正是instance和elment。我們要記住,無論何時何刻,傳入reconcile函式的element引數都是代表著我們渲染介面的最新意圖。而instance從設計開始,它就被定義為用於儲存當前這一幀介面的相關資訊。簡而言之,我們可以簡單地把instance理解為“舊的”,而element理解為“新的”。我們需要實現的協調,本質上就是看看目前“舊的”有什麼東西是可以複用的。回到“component”對接“協調”概念上來,大致步驟也是一樣,不過細節有所不同。歸納起來可以分為三步走:
- 更新publicInstance的state。
- 更新publicInstance的props。
- 更新childInstance。
這裡值得一提的是,第一步的完成不是在reconcile函式的內部來完成的,而是在我們提供給開發者的component父類中去完成。所以,我們得更新一下父類的實現:
class Component {
constructor(props) {
this.props = props;
this.state = this.state || {};
}
setState(partialState) {
// 1. 更新publicInstance的state
this.state = Object.assign({}, this.state, partialState);
const {
dom,
element
} = this.__internalInstance
const parentDom = dom.parentNode;
reconcile(this.__internalInstance, element,parentDom);
}
}
複製程式碼
第一步完成之後,我們通過在setState內部呼叫reconcile函式進入第二和第三步:
function reconcile(instance, element, domContainer) {
let newInstance = {};
// 整樹的初始掛載
if (instance === null) {
// .......
} else if (element === null) { // 整樹的刪除
// .......
}else if(element.type !== instance.element.type){ // 整樹的替換
// .......
} else { // 整樹的更新
// DOM component或者Text component
if(typeof element.type === 'string'){
// .......
}else { // composite component
// 2.更新publicInstance的props
instance.publicInstance.props = element.props;
// 3.更新childInstance
const childElement = instance.publicInstance.render();
const oldChildInstance = instance.childInstance;
const childInstance = reconcile(oldChildInstance, childElement,domContainer);
// 跟例項化過程一樣, 更新後的childInstance就是Composite Component所對應instance的childInstance;
// 更新後的childInstance的dom就是Composite Component所對應instance的dom。
// element原封不動地掛載上去即可
instance.dom = childInstance.dom;
instance.childInstance = childInstance;
instance.element = element;
return instance;
}
}
return newInstance;
}
複製程式碼
至此,我們已經完成了“component”概念和“協調”概念的對接工作。也就是說,現在如果我們想要區域性更新UI的話,只需要定義自己的component,然後呼叫setState API,這個區域性UI所對應的子樹的協調就會發生了。
《循序漸進DIY一個react》系列到此結束。雖然,這是一個玩具版的react,但是通過這個DIY過程,我加深了對react思想,概念和基本原理的理解。當然,還有很多基本react feature沒有實現,比如:ref,key,生命週期函式等等,更不用說改用Fiber架構之後所帶來的新feature啦。最後,真心希望這個系列能對你理解react世界帶來些許幫助,至於完整的程式碼,我稍後再整理,放到codepen或者codesandbox供大家玩弄玩弄。
再見!