1 基本概念:Component(組件)、instance(組件實例)、 element、jsx、dom
Component(組件)
Component就是我們經常實現的組件,可以是類組件(class component)或者函數式組件(functional component)
1.而類組件又可以分為普通類組件(React.Component)以及純類組件(React.PureComponent),總之這兩類都屬于類組件,只不過PureComponent基于shouldComponentUpdate做了一些優化。
2.函數式組件則用來簡化一些簡單組件的實現,用起來就是寫一個函數,
入參是組件屬性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
,即類組件
的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組件最初渲染到頁面后先生成第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樹與上一幀可能會產生如下區別:
1.新增了某個組件;
2.刪除了某個組件;
3.更新了某個組件的部分屬性。
因此,我們在實現diff算法的過程會在相應的時間節點
調用這些生命周期函數。
這里需要重點說明下前面提到的第1幀
,我們知道每個react應用的入口都是:
ReactDOM.render(
<h1>Hello, world!</h1>,
document.getElementById('root')
);
ReactDom.render
也會生成一棵虛擬dom樹
,但是這棵虛擬dom樹是開天辟地生成的``第一幀,沒有前一幀用來做diff,因此這棵
虛擬dom樹對應的所有組件都只會調用掛載期的生命周期函數,比如
componentDidMount,
componentWillUnmount`。
4 實現
掌握了前面介紹的這些概念,實現一個簡版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};
}
// 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;
}
rende
r函數實現很簡單,只是進行了兩幀虛擬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
算法的處理流程
可以看到,我們會根據不同的情況做不同的處理:
1.如果是新增instance
,那么需要實例化一個instance并且appendChild
;
2.如果是不是新增instance
,而是刪除instance
,那么需要removeChild
;
3.如果既不是新增也不是刪除instance
,那么需要看instance
的type
是否變化,如果有變化,那節點就無法復用了,也需要實例化instance
,然后replaceChild
;
4.如果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
架構,這一問題得到了解決。
把前面實現的所有這些代碼組合起來就是完整的簡版react,不到200行代碼,希望大家多度指教