200行代碼實現簡版react

如今(2018年)react在前端開發領域已經愈來愈🔥了,我本身也常常在項目中使用react,可是卻老是好奇react的底層實現原理,屢次嘗試閱讀react源代碼都沒法讀下去,確實太難了。前不久在網上看到幾篇介紹如何本身動手實現react的文章,這裏基於這些資料,並加入一些本身的想法,從0開始僅用200行代碼實現一個簡版react,相信看完後你們都會對react的內部實現原理有更多瞭解。可是在動手以前咱們須要先掌握幾個react相關的重要概念,好比組件(類)組件實例的區別、diff算法以及生命週期等,下面依次介紹下,熟悉完這些概念咱們再動手實現。javascript

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

首先咱們須要弄明白幾個容易混淆的概念,最開始學習react的時候我也有些疑惑他們之間有什麼不一樣,前幾天跟一個新同窗討論一個問題,發現他居然也分不清組件組件實例,所以頗有必要弄明白這幾個概念的區別於聯繫,本篇後面咱們實現這個簡版react也是基於這些概念。html

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的一種表示方式而已(後面詳細介紹)。雖然組件實例用的很少,可是偶爾也會用到,其實就是refref能夠指向一個dom節點或者一個類組件(class component)的實例,可是不能用於函數式組件,由於函數式組件不能實例化。這裏簡單介紹下ref,咱們只須要知道ref能夠指向一個組件實例便可,更加詳細的介紹你們能夠看react官方文檔Refs and the DOMjava

element

前面已經提到了element,即類組件render方法以及函數式組件的返回值均爲element。那麼這裏的element究竟是什麼呢?其實很簡單,就是一個純對象(plain object),並且這個純對象包含兩個屬性:type:(string|ReactClass)props:Object,注意element並非組件實例,而是一個純對象。雖然element不是組件實例,可是又跟組件實例有關係,element是對組件實例或者dom節點的描述。若是typestring類型,則表示dom節點,若是typefunction或者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!'
  }
}
複製代碼
jsx

只要弄明白了element,那麼jsx就不難理解了,jsx只是換了一種寫法,方便咱們來建立element而已,想一想若是沒有jsx那麼咱們開發效率確定會大幅下降,並且代碼確定很是不利於維護。好比咱們看下面這個jsx的例子:react

const foo = <div id="foo">Hello!</div>;
複製代碼

其實說白了就是定義了一個dom節點div,而且該節點的屬性集合是{id: 'foo'}childrenHello!,就這點信息量而已,所以徹底跟下面這種純對象的表示是等價的: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咱們這裏也簡單介紹下,做爲一個前端研發人員,想必你們對這個概念應該再熟悉不過了。咱們能夠這樣建立一個dom節點div編程

const divDomNode = window.document.createElement('div');
複製代碼

其實全部dom節點都是HTMLElement類的實例,咱們能夠驗證下:

window.document.createElement('div') instanceof window.HTMLElement;
// 輸出 true
複製代碼

關於HTMLElementAPI能夠參考這裏:HTMLElement介紹。所以,dom節點是HTMLElement類的實例;一樣的,在react裏面,組件實例組件類的實例,而element又是對組件實例dom節點的描述,如今這些概念之間的關係你們應該都清楚了吧。介紹完了這幾個基本概念,咱們畫個圖來描述下這幾個概念之間的關係:

component vs instance vs dom vs element

2 虛擬dom與diff算法

相信使用過react的同窗都多少了解過這兩個概念:虛擬dom以及diff算法。這裏的虛擬dom其實就是前面介紹的element,爲何說是虛擬dom呢,前面我們已經介紹過了,element只是dom節點或者組件實例的一種純對象描述而已,並非真正的dom節點,所以是虛擬dom。react給咱們提供了聲明式的組件寫法,當組件的props或者state變化時組件自動更新。整個頁面其實能夠對應到一棵dom節點樹,每次組件props或者state變動首先會反映到虛擬dom樹,而後最終反應到頁面dom節點樹的渲染。

那麼虛擬domdiff算法又有什麼關係呢?之因此有diff算法實際上是爲了提高渲染效率,試想下,若是每次組件的state或者props變化後都把全部相關dom節點刪掉再從新建立,那效率確定很是低,因此在react內部存在兩棵虛擬dom樹,分別表示現狀以及下一個狀態setState調用後就會觸發diff算法的執行,而好的diff算法確定是儘量複用已有的dom節點,避免從新建立的開銷。我用下圖來表示虛擬domdiff算法的關係:

虛擬dom & diff算法
react組件最初渲染到頁面後先生成 第1幀虛擬dom,這時 current指針指向該第一幀。 setState調用後會生成 第2幀虛擬dom,這時 next指針指向第二幀,接下來 diff算法經過比較 第2幀第1幀的異同來將更新應用到真正的 dom樹以完成頁面更新。

這裏再次強調一下setState後具體怎麼生成虛擬dom,由於這點很重要,並且容易忽略。前面剛剛已經介紹過什麼是虛擬dom了,就是element樹而已。那element樹是怎麼來的呢?其實就是render方法返回的嘛,下面的流程圖再加深下印象:

element
其實 react官方對 diff算法有另一個稱呼,你們確定會在 react相關資料中看到,叫 Reconciliation,我我的認爲這個詞有點晦澀難懂,不事後來又從新翻看了下詞典,發現跟 diff算法一個意思:
reconcile是什麼意思
能夠看到 reconcile消除分歧覈對的意思,在 react語境下就是對比 虛擬dom異同的意思,其實就是說的 diff算法。這裏強調下,咱們後面實現部實現 reconcile函數,就是實現 diff算法。

3 生命週期與diff算法

生命週期diff算法又有什麼關係呢?這裏咱們以componentDidMountcomponentWillUnmountComponentWillUpdate以及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樹對應的全部組件都只會調用掛載期的生命週期函數,好比componentDidMountcomponentWillUnmount

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"));
複製代碼

咱們在這段業務代碼裏面使用了rendercreateElement以及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。細心點會發現,新的虛擬domelement,即最開始介紹的element,而reconcile後的虛擬dominstance,不過這個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.typestring類型,對應的instance結構爲{element, dom, childInstances}

Component類型的element.typeReactClass類型,對應的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,
        fn: type
      };
      return instance;
    }
  }
複製代碼

能夠看到,若是是函數式組件,咱們沒有實例化該組件,而是直接調用了該函數獲取虛擬dom

4.5 實現reconcile(diff算法)

重點來了,reconcilereact的核心,顯然如何將新設置的state快速的渲染出來很是重要,所以react會盡可能複用已有節點,而不是每次都動態建立全部相關節點。可是react強大的地方還不只限於此,react16reconcile算法由以前的stack架構升級成了fiber架構,更近一步作的性能優化。fiber相關的內容下一節再介紹,這裏爲了簡單易懂,仍然使用相似stack架構的算法來實現,對於fiber如今只須要知道其調度原理便可,固然後面有時間能夠再實現一版基於fiber架構的。

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

reconcile算法的處理流程
能夠看到,咱們會根據不一樣的狀況作不一樣的處理:

  1. 若是是新增instance,那麼須要實例化一個instance而且appendChild
  2. 若是是否是新增instance,而是刪除instance,那麼須要removeChild
  3. 若是既不是新增也不是刪除instance,那麼須要看instancetype是否變化,若是有變化,那節點就沒法複用了,也須要實例化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;
        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架構,這一問題獲得瞭解決。

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這個名稱的由來,由於我一開始就很好奇爲何叫作fiberfiber實際上是纖程的意思,並非一個新詞彙,你們能夠看維基百科的解釋Fiber (computer science)。其實就是想表達一種更加精細粒度的調度的意思,由於基於這種算法react能夠隨時暫停diff算法的執行,然後有空閒時間了接着執行,這是一種更加精細的調度算法,所以稱爲fiber架構。本篇對fiber就先簡單介紹這些,後面有時間再單獨總結一篇。

6 參考資料

主要參考如下資料:

  1. React Components, Elements, and Instances
  2. Refs and the DOM
  3. HTMLElement介紹
  4. Didact: a DIY guide to build your own React
  5. How Does React Tell a Class from a Function?
  6. Lin Clark - A Cartoon Intro to Fiber - React Conf 2017
  7. Let’s fall in love with React Fiber
相關文章
相關標籤/搜索