200行代碼實現簡版react🔥
200行代碼實現簡版react??
現在(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: "DiyReact介紹", url: "http://google.com", likes: randomLikes()},
{name: "Rendering DOM elements ", url: "http://google.com", likes: randomLikes()},
{name: "Element creation and JSX", url: "http://google.com", likes: randomLikes()},
{name: "Instances and reconciliation", url: "http://google.com", likes: randomLikes()},
{name: "Components and state", url: "http://google.com", likes: randomLikes()}
];
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>
<a href={url}>{name}</a>
</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 實現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.5 整體代碼
把前面實現的所有這些代碼組合起來就是完整的簡版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 參考資料
主要參考以下資料:
- React Components, Elements, and Instances
- Refs and the DOM
- HTMLElement介紹
- Didact: a DIY guide to build your own React
- Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
- Let’s fall in love with React Fiber
200行代碼實現簡版react🔥