簡版React實現(xiàn)

1 基本概念:Component(組件)、instance(組件實例)、 element、jsx、dom

Component(組件)

Component就是我們經(jīng)常實現(xiàn)的組件,可以是類組件(class component)或者函數(shù)式組件(functional component)

1.而類組件又可以分為普通類組件(React.Component)以及純類組件(React.PureComponent),總之這兩類都屬于類組件,只不過PureComponent基于shouldComponentUpdate做了一些優(yōu)化。

2.函數(shù)式組件則用來簡化一些簡單組件的實現(xiàn),用起來就是寫一個函數(shù),
入?yún)⑹墙M件屬性props,出參與類組件的render方法返回值一樣,
是react element(注意這里已經(jīng)出現(xiàn)了接下來要介紹的element哦)。

下面我們分別按三種方式實現(xiàn)下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(組件實例)

熟悉面向?qū)ο缶幊?/code>的人肯定知道實例的關(guān)系,這里也是一樣的,組件實例其實就是一個組件類實例化的結(jié)果,概念雖然簡單,但是在react這里卻容易弄不明白,為什么這么說呢?因為大家在react的使用過程中并不會自己去實例化一個組件實例,這個過程其實是react內(nèi)部幫我們完成的,因此我們真正接觸組件實例的機(jī)會并不多。我們更多接觸到的是下面要介紹的element,因為我們通常寫的jsx其實就是element的一種表示方式而已(后面詳細(xì)介紹)。雖然組件實例用的不多,但是偶爾也會用到,其實就是refref可以指向一個dom節(jié)點或者一個類組件(class component)的實例,但是不能用于函數(shù)式組件,因為函數(shù)式組件不能實例化。這里簡單介紹下ref,我們只需要知道ref可以指向一個組件實例即可,更加詳細(xì)的介紹大家可以看react官方文檔Refs and the DOM

前面已經(jīng)提到了element,即類組件render方法以及函數(shù)式組件的返回值均為
element。那么這里的element到底是什么呢?其實很簡單,就是一個純對象(plain object),而且這個純對象包含兩個屬性:type:(string|ReactClass)props:Object,注意element并不是組件實例,而是一個純對象。雖然element不是組件實例,但是又跟組件實例有關(guān)系,element是對組件實例或者dom節(jié)點的描述。如果type是string類型,則表示dom節(jié)點,如果type是function或者class類型,則表示組件實例。比如下面兩個element分別描述了一個dom節(jié)點和一個組件實例

// 描述dom節(jié)點
{
  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只是換了一種寫法,方便我們來創(chuàng)建element而已,想想如果沒有jsx那么我們開發(fā)效率肯定會大幅降低,而且代碼肯定非常不利于維護(hù)。比如我們看下面這個jsx的例子

const foo = <div id="foo">Hello!</div>;

其實說白了就是定義了一個dom節(jié)點div,并且該節(jié)點的屬性集合是{id: 'foo'},children是Hello!,就這點信息量而已,因此完全跟下面這種純對象的表示是等價的:

{
  type: 'div',
  props: {
    id: 'foo',
    children: 'Hello!'
  }
}

那么React是如何將jsx語法轉(zhuǎn)換為純對象的呢?其實就是利用Babel編譯生成的,我們只要在使用jsx的代碼里加上個編譯指示(pragma)即可,可以參考這里Babel如何編譯jsx。比如我們將編譯指示設(shè)置為指向createElement函數(shù):/** @jsx createElement */,那么前面那段jsx代碼就會編譯為:

var foo = createElement('div', {id:"foo"}, 'Hello!');

可以看出,jsx的編譯過程其實就是從<、>這種標(biāo)簽式寫法到函數(shù)調(diào)用式寫法的一種轉(zhuǎn)化而已。有了這個前提,我們只需要簡單實現(xiàn)下createElement函數(shù)不就可以構(gòu)造出element了嘛,我們后面自己實現(xiàn)簡版react也會用到這個函數(shù):

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我們這里也簡單介紹下,作為一個前端研發(fā)人員,想必大家對這個概念應(yīng)該再熟悉不過了。我們可以這樣創(chuàng)建一個dom節(jié)點div:

const divDomNode = window.document.createElement('div');

其實所有dom節(jié)點都是HTMLElement類的實例,我們可以驗證下:

window.document.createElement('div') instanceof window.HTMLElement;
// 輸出 true

關(guān)于HTMLElementAPI可以參考這里:HTMLElement介紹。因此,dom節(jié)點是HTMLElement類的實例;同樣的,在react里面,組件實例組件類的實例,而element又是對組件實例dom節(jié)點的描述,現(xiàn)在這些概念之間的關(guān)系大家應(yīng)該都清楚了吧。介紹完了這幾個基本概念,我們畫個圖來描述下這幾個概念之間的關(guān)系:

2 虛擬dom與diff算法

相信使用過react的同學(xué)都多少了解過這兩個概念:虛擬dom以及diff算法。這里的虛擬dom其實就是前面介紹的element,為什么說是虛擬dom呢,前面咱們已經(jīng)介紹過了,element只是dom節(jié)點或者組件實例的一種純對象描述而已,并不是真正的dom節(jié)點,因此是虛擬dom。react給我們提供了聲明式的組件寫法,當(dāng)組件的props或者state變化時組件自動更新。整個頁面其實可以對應(yīng)到一棵dom節(jié)點樹,每次組件props或者state變更首先會反映到虛擬dom樹,然后最終反應(yīng)到頁面dom節(jié)點樹的渲染

那么虛擬dom跟diff算法又有什么關(guān)系呢?之所以有diff算法其實是為了提升渲染效率,試想下,如果每次組件的state或者props變化后都把所有相關(guān)dom節(jié)點刪掉再重新創(chuàng)建,那效率肯定非常,所以在react內(nèi)部存在兩棵虛擬dom樹,分別表示現(xiàn)狀及下一個狀態(tài),setState調(diào)用后就會觸發(fā)diff算法的執(zhí)行,而好的diff算法肯定是盡可能復(fù)用已有的dom節(jié)點,避免重新創(chuàng)建的開銷。我用下圖來表示虛擬dom和diff算法的關(guān)系:

react組件最初渲染到頁面后先生成第1幀虛擬dom,這時current指針指向該第一幀。setState調(diào)用后會生成第2幀虛擬dom,這時next指針指向第二幀,接下來diff算法通過比較第2幀和第1幀的異同來將更新應(yīng)用到真正的dom樹以完成頁面更新。
這里再次強(qiáng)調(diào)一下setState后具體怎么生成虛擬dom,因為這點很重要,而且容易忽略。其實剛剛已經(jīng)介紹過什么是虛擬dom了,其實就是element樹而已。那element樹是怎么來的呢?其實就是render方法返回的嘛,下面的流程圖再加深下印象:

react組件最初渲染到頁面后先生成第1幀虛擬dom,這時current指針指向該第一幀。setState調(diào)用后會生成第2幀虛擬dom,這時next指針指向第二幀,接下來diff算法通過比較第2幀和第1幀的異同來將更新應(yīng)用到真正的dom樹以完成頁面更新。
這里再次強(qiáng)調(diào)一下setState后具體怎么生成虛擬dom,因為這點很重要,而且容易忽略。其實剛剛已經(jīng)介紹過什么是虛擬dom了,其實就是element樹而已。那element樹是怎么來的呢?其實就是render方法返回的嘛,下面的流程圖再加深下印象:

其實react官方對diff算法有另外一個稱呼,大家肯定會在react相關(guān)資料中看到,叫Reconciliation,我個人認(rèn)為這個詞有點晦澀難懂,不過后來又重新翻看了下詞典,發(fā)現(xiàn)其實跟diff算法一個意思:

可以看到reconcile有消除分歧、核對的意思,在react語境下就是對比虛擬dom異同的意思,其實就是說的diff算法。這里強(qiáng)調(diào)下,我們后面實現(xiàn)部實現(xiàn)reconcile函數(shù),其實就是實現(xiàn)diff算法。

3 生命周期與diff算法

生命周期與diff算法又有什么關(guān)系呢?這里我們以componentDidMountcomponentWillUnmount、ComponentWillUpdate以及componentDidUpdate為例說明下二者的關(guān)系。我們知道,setState調(diào)用后會接著調(diào)用render生成新的虛擬dom樹,而這個虛擬dom樹與上一幀可能會產(chǎn)生如下區(qū)別:

1.新增了某個組件;
2.刪除了某個組件;
3.更新了某個組件的部分屬性。

因此,我們在實現(xiàn)diff算法的過程會在相應(yīng)的時間節(jié)點調(diào)用這些生命周期函數(shù)。
這里需要重點說明下前面提到的第1幀,我們知道每個react應(yīng)用的入口都是:

ReactDOM.render(
    <h1>Hello, world!</h1>,
    document.getElementById('root')
);

ReactDom.render也會生成一棵虛擬dom樹,但是這棵虛擬dom樹是開天辟地生成的``第一幀,沒有前一幀用來做diff,因此這棵虛擬dom樹對應(yīng)的所有組件都只會調(diào)用掛載期的生命周期函數(shù),比如componentDidMount,componentWillUnmount`。

4 實現(xiàn)

掌握了前面介紹的這些概念,實現(xiàn)一個簡版react也就不難了。首先看一下我們要實現(xiàn)哪些API,我們最終會以如下方式使用:

// 聲明編譯指示
/** @jsx DiyReact.createElement */

// 導(dǎo)入我們下面要實現(xiàn)的API
const DiyReact = importFromBelow();

// 業(yè)務(wù)代碼
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節(jié)點
DiyReact.render(<App stories={stories} />, document.getElementById("root"));

我們在這段業(yè)務(wù)代碼里面使用了render、createElement以及Component三個API,因此后面的任務(wù)就是實現(xiàn)這三個API并包裝到一個函數(shù)importFromBelow內(nèi)即可。

4.1 實現(xiàn)createElement

createElement函數(shù)的功能跟jsx是緊密相關(guān)的,前面介紹jsx的部分已經(jīng)介紹過了,其實就是把類似html的標(biāo)簽式寫法轉(zhuǎn)化為純對象element,具體實現(xiàn)如下:

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參數(shù)指向新生成的虛擬dom樹
    const nextInstance = reconcile(parentDom, prevInstance, element);
    // 調(diào)用完reconcile算法(即diff算法)后將rooInstance指向最新一幀
    rootInstance = nextInstance;
}

render函數(shù)實現(xiàn)很簡單,只是進(jìn)行了兩幀虛擬dom的對比(reconcile),然后將rootInstance指向新的虛擬dom。細(xì)心點會發(fā)現(xiàn),新的虛擬dom為element,即最開始介紹的element,而reconcile后的虛擬dom是instance,不過這個instance并不是組件實例,這點看后面instantiate的實現(xiàn)。總之render方法其實就是調(diào)用了reconcile方法進(jìn)行了兩幀虛擬dom的對比而已。

4.3 實現(xiàn)instantiate

那么前面的instance到底跟element有什么不同呢?其實instance指示簡單的是把element重新包了一層,并把對應(yīng)的dom也給包了進(jìn)來,這也不難理解,畢竟我們調(diào)用reconcile進(jìn)行diff比較的時候需要把跟新應(yīng)用到真實的dom上,因此需要跟dom關(guān)聯(lián)起來,下面實現(xiàn)的instantiate函數(shù)就干這個事的。注意由于element包括dom類型和Component類型(由type字段判斷,不明白的話可以回過頭看一下第一節(jié)的element相關(guān)介紹),因此需要分情況處理:
dom類型的element.type為string類型,對應(yīng)的instance結(jié)構(gòu)為{element, dom, childInstances}。
Component類型的element.type為ReactClass類型,對應(yīng)的instance結(jié)構(gòu)為{dom, element, childInstance, publicInstance},注意這里的publicInstance就是前面介紹的組件實例。

function instantiate(element) {
    const {type, props = {}} = element;

    const isDomElement = typeof type === 'string';

    if (isDomElement) {
        // 創(chuàng)建dom
        const isTextElement = type === TEXT_ELEMENT;
        const dom = isTextElement ? document.createTextNode('') : document.createElement(type);

        // 設(shè)置dom的事件、數(shù)據(jù)屬性
        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節(jié)點組件實例都可能有孩子節(jié)點,因此instantiate函數(shù)中有遞歸實例化的邏輯。

4.4 實現(xiàn)reconcile(diff算法)

重點來了,reconcile是react的核心,顯然如何將新設(shè)置的state快速的渲染出來非常重要,因此react會盡量復(fù)用已有節(jié)點,而不是每次都動態(tài)創(chuàng)建所有相關(guān)節(jié)點。但是react強(qiáng)大的地方還不僅限于此,react16reconcile算法由之前的stack架構(gòu)升級成了fiber架構(gòu),更近一步做的性能優(yōu)化。fiber相關(guān)的內(nèi)容下一節(jié)再介紹,這里為了簡單易懂,仍然使用類似stack架構(gòu)的算法來實現(xiàn),對于fiber現(xiàn)在只需要知道其調(diào)度原理即可,當(dāng)然后面有時間可以再實現(xiàn)一版基于fiber架構(gòu)的。

首先看一下整個reconcile算法的處理流程

可以看到,我們會根據(jù)不同的情況做不同的處理:

1.如果是新增instance,那么需要實例化一個instance并且appendChild
2.如果是不是新增instance,而是刪除instance,那么需要removeChild;
3.如果既不是新增也不是刪除instance,那么需要看instancetype是否變化,如果有變化,那節(jié)點就無法復(fù)用了,也需要實例化instance,然后replaceChild;
4.如果type沒變化就可以復(fù)用已有節(jié)點了,這種情況下要判斷是原生dom節(jié)點還是我們自定義實現(xiàn)的react節(jié)點,兩種情況下處理方式不同。

大流程了解后,我們只需要在對的時間點執(zhí)行生命周期函數(shù)即可,下面看具體實現(xiàn)

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算法,這里簡單解釋一下。從前面的實現(xiàn)可以看到,每次組件的state更新都會觸發(fā)reconcile的執(zhí)行,而reconcile的執(zhí)行也是一個遞歸過程,而且一開始直到遞歸執(zhí)行完所有節(jié)點才停止,因此成為stack算法。由于是個遞歸過程,因此該diff算法一旦開始就必須執(zhí)行完,因此可能會阻塞線程,又由于js是單線程的,因此這時就可能會影響用戶的輸入或者ui的渲染幀頻,降低用戶體驗。不過react16中升級為了fiber架構(gòu),這一問題得到了解決。

把前面實現(xiàn)的所有這些代碼組合起來就是完整的簡版react,不到200行代碼,希望大家多度指教

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 1.(Didact)一個DIY教程:創(chuàng)建你自己的react1.1 引言 2.渲染dom元素2.1 什么是DOM2....
    johnzhu12閱讀 830評論 0 51
  • 參考文章:深度剖析:如何實現(xiàn)一個Virtual DOM 算法 作者:戴嘉華React中一個沒人能解釋清楚的問題——...
    waka閱讀 6,163評論 0 21
  • 秋雨隨風(fēng)更肆虐,秋風(fēng)颯爽雨絲飛。 荒涼一片足蹤滅,天地蒼茫處處灰。
    徐一村閱讀 208評論 0 4
  • NoSQL(Redis)秒殺 概念 秒殺 并發(fā) MySQL負(fù)庫存(秒殺可能出現(xiàn)的問題) 修改mysql.ini m...
    空留燈半盞閱讀 708評論 1 5
  • 等雨落下的這段時間 江水只流動了一次 江邊的塔、古寺和江對岸的 高樓大廈,江中心吃水已到甲板的貨船 都保持了靜止 ...
    伏櫪齋閱讀 194評論 0 0

友情鏈接更多精彩內(nèi)容