如今(2018年)react
在前端開發領域已經愈來愈🔥了,我本身也常常在項目中使用react
,可是卻老是好奇react
的底層實現原理,屢次嘗試閱讀react
源代碼都沒法讀下去,確實太難了。前不久在網上看到幾篇介紹如何本身動手實現react
的文章,這裏基於這些資料,並加入一些本身的想法,從0開始僅用200
行代碼實現一個簡版react
,相信看完後你們都會對react
的內部實現原理有更多瞭解。可是在動手以前咱們須要先掌握幾個react
相關的重要概念,好比組件(類)
與組件實例
的區別、diff
算法以及生命週期
等,下面依次介紹下,熟悉完這些概念咱們再動手實現。javascript
首先咱們須要弄明白幾個容易混淆的概念,最開始學習react
的時候我也有些疑惑他們之間有什麼不一樣,前幾天跟一個新同窗討論一個問題,發現他居然也分不清組件
和組件實例
,所以頗有必要弄明白這幾個概念的區別於聯繫,本篇後面咱們實現這個簡版react
也是基於這些概念。html
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>;
}
複製代碼
熟悉面向對象編程
的人確定知道類
和實例
的關係,這裏也是同樣的,組件實例
其實就是一個組件類
實例化的結果,概念雖然簡單,可是在react
這裏卻容易弄不明白,爲何這麼說呢?由於你們在react
的使用過程當中並不會本身去實例化一個組件實例
,這個過程實際上是react
內部幫咱們完成的,所以咱們真正接觸組件實例
的機會並很少。咱們更多接觸到的是下面要介紹的element
,由於咱們一般寫的jsx
其實就是element
的一種表示方式而已(後面詳細介紹)。雖然組件實例
用的很少,可是偶爾也會用到,其實就是ref
。ref
能夠指向一個dom節點
或者一個類組件(class component)
的實例,可是不能用於函數式組件
,由於函數式組件
不能實例化
。這裏簡單介紹下ref
,咱們只須要知道ref
能夠指向一個組件實例
便可,更加詳細的介紹你們能夠看react
官方文檔Refs and the DOM。java
前面已經提到了element
,即類組件
的render
方法以及函數式組件
的返回值均爲element
。那麼這裏的element
究竟是什麼呢?其實很簡單,就是一個純對象(plain object
),並且這個純對象包含兩個屬性:type:(string|ReactClass)
和props:Object
,注意element
並非組件實例
,而是一個純對象。雖然element
不是組件實例
,可是又跟組件實例有關係,element
是對組件實例
或者dom節點
的描述。若是type
是string
類型,則表示dom節點
,若是type
是function
或者class
類型,則表示組件實例
。好比下面兩個element
分別描述了一個dom節點
和一個組件實例
:node
// 描述dom節點
{
type: 'button',
props: {
className: 'button button-blue',
children: {
type: 'b',
props: {
children: 'OK!'
}
}
}
}
複製代碼
function Button(props){
// ...
}
// 描述組件實例
{
type: Button,
props: {
color: 'blue',
children: 'OK!'
}
}
複製代碼
只要弄明白了element
,那麼jsx
就不難理解了,jsx
只是換了一種寫法,方便咱們來建立element
而已,想一想若是沒有jsx
那麼咱們開發效率確定會大幅下降,並且代碼確定很是不利於維護。好比咱們看下面這個jsx
的例子:react
const foo = <div id="foo">Hello!</div>;
複製代碼
其實說白了就是定義了一個dom節點div
,而且該節點的屬性集合是{id: 'foo'}
,children
是Hello!
,就這點信息量而已,所以徹底跟下面這種純對象的表示是等價的:webpack
{
type: 'div',
props: {
id: 'foo',
children: 'Hello!'
}
}
複製代碼
那麼React
是如何將jsx
語法轉換爲純對象的呢?其實就是利用Babel
編譯生成的,咱們只要在使用jsx
的代碼里加上個編譯指示(pragma)
便可,能夠參考這裏Babel如何編譯jsx。好比咱們將編譯指示
設置爲指向createElement
函數:/** @jsx createElement */
,那麼前面那段jsx
代碼就會編譯爲:web
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節點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
節點的描述,如今這些概念之間的關係你們應該都清楚了吧。介紹完了這幾個基本概念,咱們畫個圖來描述下這幾個概念之間的關係:
相信使用過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
算法。
生命週期
與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
。
掌握了前面介紹的這些概念,實現一個簡版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
內便可。
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};
}
複製代碼
注意這個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
的對比而已。
那麼前面的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
函數中有遞歸實例化的邏輯。
前面咱們提到過,組件包括類組件
(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,
fn: type
};
return instance;
}
}
複製代碼
能夠看到,若是是函數式組件
,咱們沒有實例化該組件,而是直接調用了該函數獲取虛擬dom
。
重點來了,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;
let newChildElement;
if (instance.publicInstance) { // 類組件
instance.publicInstance.props = element.props;
newChildElement = instance.publicInstance.render();
} else { // 函數式組件
newChildElement = instance.fn(element.props);
}
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
架構,這一問題獲得瞭解決。
把前面實現的全部這些代碼組合起來就是完整的簡版react
,不到200
行代碼,so easy~!完整代碼見DiyReact。
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
就先簡單介紹這些,後面有時間再單獨總結一篇。
主要參考如下資料: