現在(2018年)react
在前端開發領域已經越來越?了,我自己也經常在專案中使用react
,但是卻總是好奇react
的底層實現原理,多次嘗試閱讀react
原始碼都無法讀下去,確實太難了。前不久在網上看到幾篇介紹如何自己動手實現react
的文章,這裡基於這些資料,並加入一些自己的想法,從0開始僅用200
行程式碼實現一個簡版react
,相信看完後大家都會對react
的內部實現原理有更多瞭解。但是在動手之前我們需要先掌握幾個react
相關的重要概念,比如元件(類)
與元件例項
的區別、diff
演算法以及生命週期
等,下面依次介紹下,熟悉完這些概念我們再動手實現。
1 基本概念:Component(元件)、instance(元件例項)、 element、jsx、dom
首先我們需要弄明白幾個容易混淆的概念,最開始學習react
的時候我也有些疑惑他們之間有什麼不同,前幾天跟一個新同學討論一個問題,發現他竟然也分不清元件
和元件例項
,因此很有必要弄明白這幾個概念的區別於聯絡,本篇後面我們實現這個簡版react
也是基於這些概念。
Component(元件)
Component
就是我們經常實現的元件,可以是類元件
(class component
)或者函式式元件
(functional component
),而類元件
又可以分為普通類元件(React.Component
)以及純類元件(React.PureComponent
),總之這兩類都屬於類元件
,只不過PureComponent
基於shouldComponentUpdate
做了一些優化,這裡不展開說。函式式元件
則用來簡化一些簡單元件的實現,用起來就是寫一個函式,入參是元件屬性props
,出參與類元件
的render
方法返回值一樣,是react element
(注意這裡已經出現了接下來要介紹的element
哦)。
下面我們分別按三種方式實現下Welcome
元件:
// Component
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
複製程式碼
// PureComponent
class Welcome extends React.PureComponent {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
複製程式碼
// functional component
function Welcome(props) {
return <h1>Hello, {props.name}</h1>;
}
複製程式碼
instance(元件例項)
熟悉物件導向程式設計
的人肯定知道類
和例項
的關係,這裡也是一樣的,元件例項
其實就是一個元件類
例項化的結果,概念雖然簡單,但是在react
這裡卻容易弄不明白,為什麼這麼說呢?因為大家在react
的使用過程中並不會自己去例項化一個元件例項
,這個過程其實是react
內部幫我們完成的,因此我們真正接觸元件例項
的機會並不多。我們更多接觸到的是下面要介紹的element
,因為我們通常寫的jsx
其實就是element
的一種表示方式而已(後面詳細介紹)。雖然元件例項
用的不多,但是偶爾也會用到,其實就是ref
。ref
可以指向一個dom節點
或者一個類元件(class component)
的例項,但是不能用於函式式元件
,因為函式式元件
不能例項化
。這裡簡單介紹下ref
,我們只需要知道ref
可以指向一個元件例項
即可,更加詳細的介紹大家可以看react
官方文件Refs and the DOM。
element
前面已經提到了element
,即類元件
的render
方法以及函式式元件
的返回值均為element
。那麼這裡的element
到底是什麼呢?其實很簡單,就是一個純物件(plain object
),而且這個純物件包含兩個屬性:type:(string|ReactClass)
和props:Object
,注意element
並不是元件例項
,而是一個純物件。雖然element
不是元件例項
,但是又跟元件例項有關係,element
是對元件例項
或者dom節點
的描述。如果type
是string
型別,則表示dom節點
,如果type
是function
或者class
型別,則表示元件例項
。比如下面兩個element
分別描述了一個dom節點
和一個元件例項
:
// 描述dom節點
{
type: `button`,
props: {
className: `button button-blue`,
children: {
type: `b`,
props: {
children: `OK!`
}
}
}
}
複製程式碼
function Button(props){
// ...
}
// 描述元件例項
{
type: Button,
props: {
color: `blue`,
children: `OK!`
}
}
複製程式碼
jsx
只要弄明白了element
,那麼jsx
就不難理解了,jsx
只是換了一種寫法,方便我們來建立element
而已,想想如果沒有jsx
那麼我們開發效率肯定會大幅降低,而且程式碼肯定非常不利於維護。比如我們看下面這個jsx
的例子:
const foo = <div id="foo">Hello!</div>;
複製程式碼
其實說白了就是定義了一個dom節點div
,並且該節點的屬性集合是{id: `foo`}
,children
是Hello!
,就這點資訊量而已,因此完全跟下面這種純物件的表示是等價的:
{
type: `div`,
props: {
id: `foo`,
children: `Hello!`
}
}
複製程式碼
那麼React
是如何將jsx
語法轉換為純物件的呢?其實就是利用Babel
編譯生成的,我們只要在使用jsx
的程式碼里加上個編譯指示(pragma)
即可,可以參考這裡Babel如何編譯jsx。比如我們將編譯指示
設定為指向createElement
函式:/** @jsx createElement */
,那麼前面那段jsx
程式碼就會編譯為:
var foo = createElement(`div`, {id:"foo"}, `Hello!`);
複製程式碼
可以看出,jsx
的編譯過程其實就是從<
、>
這種標籤式
寫法到函式呼叫式
寫法的一種轉化而已。有了這個前提,我們只需要簡單實現下createElement
函式不就可以構造出element
了嘛,我們後面自己實現簡版react
也會用到這個函式:
function createElement(type, props, ...children) {
props = Object.assign({}, props);
props.children = [].concat(...children)
.filter(child => child != null && child !== false)
.map(child => child instanceof Object ? child : createTextElement(child));
return {type, props};
}
複製程式碼
dom
dom我們這裡也簡單介紹下,作為一個前端研發人員,想必大家對這個概念應該再熟悉不過了。我們可以這樣建立一個dom節點div
:
const divDomNode = window.document.createElement(`div`);
複製程式碼
其實所有dom節點都是HTMLElement類
的例項,我們可以驗證下:
window.document.createElement(`div`) instanceof window.HTMLElement;
// 輸出 true
複製程式碼
關於HTMLElement
API可以參考這裡:HTMLElement介紹。因此,dom
節點是HTMLElement類
的例項;同樣的,在react
裡面,元件例項
是元件類
的例項,而element
又是對元件例項
和dom
節點的描述,現在這些概念之間的關係大家應該都清楚了吧。介紹完了這幾個基本概念,我們畫個圖來描述下這幾個概念之間的關係:
2 虛擬dom與diff演算法
相信使用過react
的同學都多少了解過這兩個概念:虛擬dom
以及diff演算法
。這裡的虛擬dom
其實就是前面介紹的element
,為什麼說是虛擬
dom呢,前面我們們已經介紹過了,element
只是dom
節點或者元件例項
的一種純物件描述而已,並不是真正的dom
節點,因此是虛擬
dom。react
給我們提供了宣告式
的元件寫法,當元件的props
或者state
變化時元件自動更新。整個頁面其實可以對應到一棵dom
節點樹,每次元件props
或者state
變更首先會反映到虛擬dom
樹,然後最終反應到頁面dom
節點樹的渲染。
那麼虛擬dom
跟diff演算法
又有什麼關係呢?之所以有diff
演算法其實是為了提升渲染
效率,試想下,如果每次元件的state
或者props
變化後都把所有相關dom
節點刪掉再重新建立,那效率肯定非常低,所以在react
內部存在兩棵虛擬dom
樹,分別表示現狀
以及下一個狀態
,setState
呼叫後就會觸發diff
演算法的執行,而好的diff
演算法肯定是儘可能複用已有的dom
節點,避免重新建立的開銷。我用下圖來表示虛擬dom
和diff演算法
的關係:
react
元件最初渲染到頁面後先生成第1幀
虛擬dom,這時current指標
指向該第一幀。setState
呼叫後會生成第2幀
虛擬dom,這時next指標
指向第二幀,接下來diff
演算法通過比較第2幀
和第1幀
的異同來將更新應用到真正的dom
樹以完成頁面更新。
這裡再次強調一下setState
後具體怎麼生成虛擬dom
,因為這點很重要,而且容易忽略。前面剛剛已經介紹過什麼是虛擬dom
了,就是element
樹而已。那element
樹是怎麼來的呢?其實就是render
方法返回的嘛,下面的流程圖再加深下印象:
其實react
官方對diff演算法
有另外一個稱呼,大家肯定會在react
相關資料中看到,叫Reconciliation
,我個人認為這個詞有點晦澀難懂,不過後來又重新翻看了下詞典,發現跟diff演算法
一個意思:
可以看到reconcile
有消除分歧
、核對
的意思,在react
語境下就是對比虛擬dom
異同的意思,其實就是說的diff演算法
。這裡強調下,我們後面實現部實現reconcile
函式,就是實現diff
演算法。
3 生命週期與diff演算法
生命週期
與diff演算法
又有什麼關係呢?這裡我們以componentDidMount
、componentWillUnmount
、ComponentWillUpdate
以及componentDidUpdate
為例說明下二者的關係。我們知道,setState
呼叫後會接著呼叫render
生成新的虛擬dom
樹,而這個虛擬dom
樹與上一幀可能會產生如下區別:
- 新增了某個元件;
- 刪除了某個元件;
- 更新了某個元件的部分屬性。
因此,我們在實現diff演算法
的過程會在相應的時間節點呼叫這些生命週期
函式。
這裡需要重點說明下前面提到的第1幀
,我們知道每個react
應用的入口都是:
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById(`root`)
);
複製程式碼
ReactDom.render
也會生成一棵虛擬dom
樹,但是這棵虛擬dom
樹是開天闢地生成的第一幀
,沒有前一幀用來做diff,因此這棵虛擬dom
樹對應的所有元件都只會呼叫掛載期
的生命週期函式,比如componentDidMount
、componentWillUnmount
。
4 實現
掌握了前面介紹的這些概念,實現一個簡版react
也就不難了。這裡需要說明下,本節實現部分是基於這篇部落格的實現Didact: a DIY guide to build your own React。
現在首先看一下我們要實現哪些API,我們最終會以如下方式使用:
// 宣告編譯指示
/** @jsx DiyReact.createElement */
// 匯入我們下面要實現的API
const DiyReact = importFromBelow();
// 業務程式碼
const randomLikes = () => Math.ceil(Math.random() * 100);
const stories = [
{name: "React", url: "https://reactjs.org/", likes: randomLikes()},
{name: "Node", url: "https://nodejs.org/en/", likes: randomLikes()},
{name: "Webpack", url: "https://webpack.js.org/", likes: randomLikes()}
];
const ItemRender = props => {
const {name, url} = props;
return (
<a href={url}>{name}</a>
);
};
class App extends DiyReact.Component {
render() {
return (
<div>
<h1>DiyReact Stories</h1>
<ul>
{this.props.stories.map(story => {
return <Story name={story.name} url={story.url} />;
})}
</ul>
</div>
);
}
componentWillMount() {
console.log(`execute componentWillMount`);
}
componentDidMount() {
console.log(`execute componentDidMount`);
}
componentWillUnmount() {
console.log(`execute componentWillUnmount`);
}
}
class Story extends DiyReact.Component {
constructor(props) {
super(props);
this.state = {likes: Math.ceil(Math.random() * 100)};
}
like() {
this.setState({
likes: this.state.likes + 1
});
}
render() {
const {name, url} = this.props;
const {likes} = this.state;
const likesElement = <span />;
return (
<li>
<button onClick={e => this.like()}>{likes}<b>❤️</b></button>
<ItemRender {...itemRenderProps} />
</li>
);
}
// shouldcomponentUpdate() {
// return true;
// }
componentWillUpdate() {
console.log(`execute componentWillUpdate`);
}
componentDidUpdate() {
console.log(`execute componentDidUpdate`);
}
}
// 將元件渲染到根dom節點
DiyReact.render(<App stories={stories} />, document.getElementById("root"));
複製程式碼
我們在這段業務程式碼裡面使用了render
、createElement
以及Component
三個API,因此後面的任務就是實現這三個API幷包裝到一個函式importFromBelow
內即可。
4.1 實現createElement
createElement
函式的功能跟jsx
是緊密相關的,前面介紹jsx
的部分已經介紹過了,其實就是把類似html
的標籤式寫法轉化為純物件element
,具體實現如下:
function createElement(type, props, ...children) {
props = Object.assign({}, props);
props.children = [].concat(...children)
.filter(child => child != null && child !== false)
.map(child => child instanceof Object ? child : createTextElement(child));
return {type, props};
}
複製程式碼
4.2 實現render
注意這個render
相當於ReactDOM.render
,不是元件
的render
方法,元件
的render
方法在後面Component
實現部分。
// rootInstance用來快取一幀虛擬dom
let rootInstance = null;
function render(element, parentDom) {
// prevInstance指向前一幀
const prevInstance = rootInstance;
// element引數指向新生成的虛擬dom樹
const nextInstance = reconcile(parentDom, prevInstance, element);
// 呼叫完reconcile演算法(即diff演算法)後將rooInstance指向最新一幀
rootInstance = nextInstance;
}
複製程式碼
render
函式實現很簡單,只是進行了兩幀虛擬dom
的對比(reconcile),然後將rootInstance
指向新的虛擬dom
。細心點會發現,新的虛擬dom
為element
,即最開始介紹的element
,而reconcile
後的虛擬dom
是instance
,不過這個instance
並不是元件例項
,這點看後面instantiate
的實現。總之render
方法其實就是呼叫了reconcile
方法進行了兩幀虛擬dom
的對比而已。
4.3 實現instantiate
那麼前面的instance
到底跟element
有什麼不同呢?其實instance
指示簡單的是把element
重新包了一層,並把對應的dom
也給包了進來,這也不難理解,畢竟我們呼叫reconcile
進行diff
比較的時候需要把跟新應用到真實的dom
上,因此需要跟dom
關聯起來,下面實現的instantiate
函式就幹這個事的。注意由於element
包括dom
型別和Component
型別(由type
欄位判斷,不明白的話可以回過頭看一下第一節的element
相關介紹),因此需要分情況處理:
dom
型別的element.type
為string
型別,對應的instance
結構為{element, dom, childInstances}
。
Component
型別的element.type
為ReactClass
型別,對應的instance
結構為{dom, element, childInstance, publicInstance}
,注意這裡的publicInstance
就是前面介紹的元件例項
。
function instantiate(element) {
const {type, props = {}} = element;
const isDomElement = typeof type === `string`;
if (isDomElement) {
// 建立dom
const isTextElement = type === TEXT_ELEMENT;
const dom = isTextElement ? document.createTextNode(``) : document.createElement(type);
// 設定dom的事件、資料屬性
updateDomProperties(dom, [], element.props);
const children = props.children || [];
const childInstances = children.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstance.dom);
childDoms.forEach(childDom => dom.appendChild(childDom));
const instance = {element, dom, childInstances};
return instance;
} else {
const instance = {};
const publicInstance = createPublicInstance(element, instance);
const childElement = publicInstance.render();
const childInstance = instantiate(childElement);
Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance});
return instance;
}
}
複製程式碼
需要注意,由於dom節點
和元件例項
都可能有孩子節點,因此instantiate
函式中有遞迴例項化的邏輯。
4.4 區分類元件與函式式元件
前面我們提到過,元件包括類元件
(class component
)與函式式元件
(functional component
)。我在平時的業務中經常用到這兩類元件,如果一個元件僅用來渲染,我一般會使用函式式元件
,畢竟程式碼邏輯簡單清晰易懂。那麼React
內部是如何區分出來這兩種元件的呢?這個問題說簡單也簡單,說複雜也複雜。為什麼這麼說呢,是因為React
內部實現方式確實比較簡單,但是這種簡單的實現方式卻是經過各種考量後確定下來的實現方式。蛋總(Dan
)有一篇文章詳細分析了下React
內部如何區分二者,強烈推薦大家閱讀,這裡我直接拿過來用,文章連結見這裡How Does React Tell a Class from a Function?。其實很簡答,我們實現類元件
肯定需要繼承自類React.Component
,因此首先給React.Component
打個標記,然後在例項化元件時判斷element.type
的原型鏈上是否有該標記即可。
// 打標記
Component.prototype.isReactComponent = {};
// 區分元件型別
const type = element.type;
const isDomElement = typeof type === `string`;
const isClassElement = !!(type.prototype && type.prototype.isReactComponent);
複製程式碼
這裡我們升級下前面的例項化函式instantiate
以區分出函式式元件
與類元件
:
function instantiate(element) {
const {type, props = {}} = element;
const isDomElement = typeof type === `string`;
const isClassElement = !!(type.prototype && type.prototype.isReactComponent);
if (isDomElement) {
// 建立dom
const isTextElement = type === TEXT_ELEMENT;
const dom = isTextElement ? document.createTextNode(``) : document.createElement(type);
// 設定dom的事件、資料屬性
updateDomProperties(dom, [], element.props);
const children = props.children || [];
const childInstances = children.map(instantiate);
const childDoms = childInstances.map(childInstance => childInstance.dom);
childDoms.forEach(childDom => dom.appendChild(childDom));
const instance = {element, dom, childInstances};
return instance;
} else if (isClassElement) {
const instance = {};
const publicInstance = createPublicInstance(element, instance);
const childElement = publicInstance.render();
const childInstance = instantiate(childElement);
Object.assign(instance, {dom: childInstance.dom, element, childInstance, publicInstance});
return instance;
} else {
const childElement = type(element.props);
const childInstance = instantiate(childElement);
const instance = {
dom: childInstance.dom,
element,
childInstance
};
return instance;
}
}
複製程式碼
可以看到,如果是函式式元件
,我們沒有例項化該元件,而是直接呼叫了該函式獲取虛擬dom
。
4.5 實現reconcile(diff演算法)
重點來了,reconcile
是react
的核心,顯然如何將新設定的state
快速的渲染出來非常重要,因此react
會盡量複用已有節點,而不是每次都動態建立所有相關節點。但是react
強大的地方還不僅限於此,react16
將reconcile
演算法由之前的stack
架構升級成了fiber
架構,更近一步做的效能優化。fiber
相關的內容下一節再介紹,這裡為了簡單易懂,仍然使用類似stack
架構的演算法來實現,對於fiber
現在只需要知道其排程
原理即可,當然後面有時間可以再實現一版基於fiber
架構的。
首先看一下整個reconcile
演算法的處理流程:
可以看到,我們會根據不同的情況做不同的處理:
- 如果是新增
instance
,那麼需要例項化一個instance
並且appendChild
; - 如果是不是新增
instance
,而是刪除instance
,那麼需要removeChild
; - 如果既不是新增也不是刪除
instance
,那麼需要看instance
的type
是否變化,如果有變化,那節點就無法複用了,也需要例項化instance
,然後replaceChild
; - 如果
type
沒變化就可以複用已有節點了,這種情況下要判斷是原生dom
節點還是我們自定義實現的react
節點,兩種情況下處理方式不同。
大流程瞭解後,我們只需要在對的時間點執行生命週期
函式即可,下面看具體實現:
function reconcile(parentDom, instance, element) {
if (instance === null) {
const newInstance = instantiate(element);
// componentWillMount
newInstance.publicInstance
&& newInstance.publicInstance.componentWillMount
&& newInstance.publicInstance.componentWillMount();
parentDom.appendChild(newInstance.dom);
// componentDidMount
newInstance.publicInstance
&& newInstance.publicInstance.componentDidMount
&& newInstance.publicInstance.componentDidMount();
return newInstance;
} else if (element === null) {
// componentWillUnmount
instance.publicInstance
&& instance.publicInstance.componentWillUnmount
&& instance.publicInstance.componentWillUnmount();
parentDom.removeChild(instance.dom);
return null;
} else if (instance.element.type !== element.type) {
const newInstance = instantiate(element);
// componentDidMount
newInstance.publicInstance
&& newInstance.publicInstance.componentDidMount
&& newInstance.publicInstance.componentDidMount();
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
} else if (typeof element.type === `string`) {
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.childInstances = reconcileChildren(instance, element);
instance.element = element;
return instance;
} else {
if (instance.publicInstance
&& instance.publicInstance.shouldcomponentUpdate) {
if (!instance.publicInstance.shouldcomponentUpdate()) {
return;
}
}
// componentWillUpdate
instance.publicInstance
&& instance.publicInstance.componentWillUpdate
&& instance.publicInstance.componentWillUpdate();
instance.publicInstance.props = element.props;
const newChildElement = instance.publicInstance.render();
const oldChildInstance = instance.childInstance;
const newChildInstance = reconcile(parentDom, oldChildInstance, newChildElement);
// componentDidUpdate
instance.publicInstance
&& instance.publicInstance.componentDidUpdate
&& instance.publicInstance.componentDidUpdate();
instance.dom = newChildInstance.dom;
instance.childInstance = newChildInstance;
instance.element = element;
return instance;
}
}
function reconcileChildren(instance, element) {
const {dom, childInstances} = instance;
const newChildElements = element.props.children || [];
const count = Math.max(childInstances.length, newChildElements.length);
const newChildInstances = [];
for (let i = 0; i < count; i++) {
newChildInstances[i] = reconcile(dom, childInstances[i], newChildElements[i]);
}
return newChildInstances.filter(instance => instance !== null);
}
複製程式碼
看完reconcile
演算法後肯定有人會好奇,為什麼這種演算法叫做stack
演算法,這裡簡單解釋一下。從前面的實現可以看到,每次元件的state
更新都會觸發reconcile
的執行,而reconcile
的執行也是一個遞迴過程,而且一開始直到遞迴執行完所有節點才停止,因此稱為stack
演算法。由於是個遞迴過程,因此該diff
演算法一旦開始就必須執行完,因此可能會阻塞執行緒,又由於js是單執行緒的,因此這時就可能會影響使用者的輸入或者ui的渲染幀頻,降低使用者體驗。不過react16
中升級為了fiber
架構,這一問題得到了解決。
4.6 整體程式碼
把前面實現的所有這些程式碼組合起來就是完整的簡版react
,不到200
行程式碼,so easy~!完整程式碼見DiyReact。
5 fiber架構
react16
升級了reconcile
演算法架構,從stack
升級為fiber
架構,前面我們已經提到過stack
架構的缺點,那就是使用遞迴實現,一旦開始就無法暫停,只能一口氣執行完畢,由於js是單執行緒的,這就有可能阻塞使用者輸入或者ui渲染,會降低使用者體驗。
而fiber
架構則不一樣。底層是基於requestIdleCallback
來排程diff
演算法的執行,關於requestIdleCallback
的介紹可以參考我之前寫的一篇關於js事件迴圈
的文章javascript事件迴圈(瀏覽器端、node端)。requestIdlecallback
的特點顧名思義就是利用空閒時間來完成任務。注意這裡的空閒時間
就是相對於那些優先順序更高的任務(比如使用者輸入、ui渲染)來說的。
這裡再簡單介紹一下fiber
這個名稱的由來,因為我一開始就很好奇為什麼叫做fiber
。fiber
其實是纖程
的意思,並不是一個新詞彙,大家可以看維基百科的解釋Fiber (computer science)。其實就是想表達一種更加精細粒度的排程
的意思,因為基於這種演算法react
可以隨時暫停diff
演算法的執行,而後有空閒時間了接著執行,這是一種更加精細
的排程演算法,因此稱為fiber
架構。本篇對fiber
就先簡單介紹這些,後面有時間再單獨總結一篇。
6 參考資料
主要參考以下資料: