200行程式碼實現簡版react

iamswf發表於2019-03-03

現在(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的一種表示方式而已(後面詳細介紹)。雖然元件例項用的不多,但是偶爾也會用到,其實就是refref可以指向一個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節點的描述。如果typestring型別,則表示dom節點,如果typefunction或者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`}childrenHello!,就這點資訊量而已,因此完全跟下面這種純物件的表示是等價的:

{
  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
複製程式碼

關於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
      };
      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;
        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.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

相關文章