React 原始碼分析

Tianlikai發表於2018-10-25

寫在前面

React 開發一年多,最近仔細研究了 React 原始碼,在這裡總結一下原理。React 原始碼比較複雜不適合初學者去學習。所以本文通過實現一套簡易版的 React,使得理解原理更加容易(本文基於 React v15)。包括:

  • React 的幾種元件以及首次渲染實現
  • React 更新機制的實現以及 React diff 演算法

React 的程式碼還是非常複雜的,雖然這裡是一個簡化版本。但是還是需要有不錯的物件導向思維的。React 的核心主要有一下幾點。

  • 虛擬 dom 物件(Virtual DOM)
  • 虛擬 dom 差異化演算法(diff algorithm)
  • 單向資料流
  • 元件宣告週期
  • 事件處理

本文程式碼倉庫

  • 直接在遊覽器中開啟 main.html 中檢視效果
  • 更改程式碼請先執行執行npm i安裝依賴(使用了部分 es6 程式碼)
  • 修改程式碼後請執行npm run dev重新編譯程式碼

實現一個 hello React!的渲染

看如下程式碼:

// js
React.render('hello React!',document.getElementById("root"))

// html
<div id="root"></div>

// 生成程式碼
<div id="root">
    <span data-reactid="0">hello React!</span>
</div>
複製程式碼

針對上面程式碼的具體實現

/**
 * component 類
 * 文字型別
 * @param {*} text 文字內容
 */
function ReactDOMTextComponent(text) {
  // 存下當前的字串
  this._currentElement = "" + text;
  // 用來標識當前component
  this._rootNodeID = null;
}

/**
 * component 類 裝載方法,生成 dom 結構
 * @param {number} rootID 元素id
 * @return {string} 返回dom
 */
ReactDOMTextComponent.prototype.mountComponent = function(rootID) {
  this._rootNodeID = rootID;
  return (
    '<span data-reactid="' + rootID + '">' + this._currentElement + "</span>"
  );
};

/**
 * 根據元素型別例項化一個具體的component
 * @param {*} node ReactElement
 * @return {*} 返回一個具體的component例項
 */
function instantiateReactComponent(node) {
  //文字節點的情況
  if (typeof node === "string" || typeof node === "number") {
    return new ReactDOMTextComponent(node);
  }
}

const React = {
 nextReactRootIndex: 0,

 /**
  * 接收一個React元素,和一個dom節點
  * @param {*} element React元素
  * @param {*} container 負責裝載的dom
  */
  render: function(element, container) {
    // 例項化元件
    var componentInstance = instantiateReactComponent(element);
    // 元件完成dom裝載
    var markup = componentInstance.mountComponent(React.nextReactRootIndex++);
    // 將裝載好的 dom 放入 container 中
    $(container).html(markup);
    $(document).trigger("mountReady");
  }
};
複製程式碼

這裡程式碼分為三個部分:

  • 1 React.render 作為入口接受一個 React 元素和遊覽器中的 dom 負責呼叫渲染,nextReactRootIndex 為每個 component 的唯一標識
  • 2 引入 component 類的概念,ReactDOMTextComponent 是一個 component 類定義。ReactDOMTextComponent 針對於文字節點進行處理。並且在 ReactDOMTextComponent 的原型上實現了 mountComponent 方法,用於對元件的渲染,返回元件的 dom 結構。當然 component 還具有更新和刪除操作,這裡將在後續講解。
  • 3 instantiateReactComponent 用來根據 element 的型別(現在只有一種 string 型別),返回一個 component 的例項。其實就是個類工廠。

在這裡我們把邏輯分為幾個部分,渲染邏輯則由 component 內部定義,React.render 負責排程整個流程,在呼叫 instantiateReactComponent 生成一個對應 component 型別的例項物件,再呼叫物件的 mountComponent 返回 dom,最後再寫到 container 節點中

虛擬 dom

虛擬 dom 無疑是 React 的核心概念,在程式碼中我們會使用 React.createElement 來建立一個虛擬 dom 元素。

虛擬 dom 分為兩種一種是遊覽器自帶的基本元素比如 div,還有一種是自定義元素(文字節點不算虛擬 dom)

虛擬節點的使用方式

// 繫結事件監聽方法
function sayHello(){
    alert('hello!')
}
var element = React.createElement('div',{id:'jason',onclick:hello},'click me')
React.render(element,document.getElementById("root"))

// 最終生成的html

<div data-reactid="0" id="jason">
    <span data-reactid="0.0">click me</span>
</div>
複製程式碼

我們使用 React.createElement 來建立一個虛擬 dom 元素,以下是簡易實現

/**
 * ReactElement 就是虛擬節點的概念
 * @param {*} key 虛擬節點的唯一標識,後期可以進行優化
 * @param {*} type 虛擬節點型別,type可能是字串('div', 'span'),也可能是一個functionfunction時為一個自定義元件
 * @param {*} props 虛擬節點的屬性
 */
function ReactElement(type, key, props) {
  this.type = type;
  this.key = key;
  this.props = props;
}

const React = {
  nextReactRootIndex: 0,
  /**
   * @param {*} type 元素的 component 型別
   * @param {*} config 元素配置
   * @param {*} children 元素的子元素
   */
  createElement: function(type, config, children) {
    var props = {};
    var propName;
    config = config || {};

    var key = config.key || null;

    for (propName in config) {
      if (config.hasOwnProperty(propName) && propName !== "key") {
        props[propName] = config[propName];
      }
    }

    var childrenLength = arguments.length - 2;
    if (childrenLength === 1) {
      props.children = Array.isArray(children) ? children : [children];
    } else if (childrenLength > 1) {
      var childArray = [];
      for (var i = 0; i < childrenLength; i++) {
        childArray[i] = arguments[i + 2];
      }
      props.children = childArray;
    }
    return new ReactElement(type, key, props);
  },

  /**
   * 自行新增上文中的render方法
   */
};
複製程式碼

createElement 方法對傳入的引數做了一些處理,最終會返回一個 ReactElement 虛擬元素例項,key 的定義可以提高更新時的效率

有了虛擬元素例項,我們需要改造一下 instantiateReactComponent 方法

/**
 * 根據元素型別例項化一個具體的component
 * @param {*} node ReactElement
 * @return {*} 返回一個具體的component例項
 */
function instantiateReactComponent(node) {
  //文字節點的情況
  if (typeof node === "string" || typeof node === "number") {
    return new ReactDOMTextComponent(node);
  }
  //瀏覽器預設節點的情況
  if (typeof node === "object" && typeof node.type === "string") {
    //注意這裡,使用了一種新的component
    return new ReactDOMComponent(node);
  }
}
複製程式碼

我們增加了一個判斷,這樣當 render 的不是文字而是瀏覽器的基本元素時。我們使用另外一種 component 來處理它渲染時應該返回的內容。這裡就體現了工廠方法 instantiateReactComponent 的好處了,不管來了什麼型別的 node,都可以負責生產出一個負責渲染的 component 例項。這樣 render 完全不需要做任何修改,只需要再做一種對應的 component 型別(這裡是 ReactDOMComponent)就行了。

ReactDOMComponent的具體實現

/**
 * component 類
 * react 基礎標籤型別,類似與html中的('div','span' 等)
 * @param {*} element 基礎元素
 */
function ReactDOMComponent(element) {
  // 存下當前的element物件引用
  this._currentElement = element;
  this._rootNodeID = null;
}

/**
 * component 類 裝載方法
 * @param {*} rootID 元素id
 * @param {string} 返回dom
 */
ReactDOMComponent.prototype.mountComponent = function(rootID) {
  this._rootNodeID = rootID;
  var props = this._currentElement.props;

  // 外層標籤
  var tagOpen = "<" + this._currentElement.type;
  var tagClose = "</" + this._currentElement.type + ">";

  // 加上reactid標識
  tagOpen += " data-reactid=" + this._rootNodeID;

  // 拼接標籤屬性
  for (var propKey in props) {
    // 屬性為繫結事件
    if (/^on[A-Za-z]/.test(propKey)) {
      var eventType = propKey.replace("on", "");
      // 對當前節點新增事件代理
      $(document).delegate(
        '[data-reactid="' + this._rootNodeID + '"]',
        eventType + "." + this._rootNodeID,
        props[propKey]
      );
    }

    // 對於props 上的children和事件屬性不做處理
    if (
      props[propKey] &&
      propKey != "children" &&
      !/^on[A-Za-z]/.test(propKey)
    ) {
      tagOpen += " " + propKey + "=" + props[propKey];
    }
  }
  // 渲染子節點dom
  var content = "";
  var children = props.children || [];

  var childrenInstances = []; // 儲存子節點component 例項
  var that = this;

  children.forEach((child, key) => {
    var childComponentInstance = instantiateReactComponent(child);
    // 為子節點新增標記
    childComponentInstance._mountIndex = key;
    childrenInstances.push(childComponentInstance);
    var curRootId = that._rootNodeID + "." + key;

    // 得到子節點的渲染內容
    var childMarkup = childComponentInstance.mountComponent(curRootId);

    // 拼接在一起
    content += " " + childMarkup;
  });

  // 儲存component 例項
  this._renderedChildren = childrenInstances;

  // 拼出整個html內容
  return tagOpen + ">" + content + tagClose;
};
複製程式碼

對於虛擬 dom 的渲染邏輯,本質上還是個遞迴渲染的東西,reactElement 會遞迴渲染自己的子節點。可以看到我們通過 instantiateReactComponent 遮蔽了子節點的差異,只需要使用不同的 component 類,這樣都能保證通過 mountComponent 最終拿到渲染後的內容。

另外這邊的事件也要說下,可以在傳遞 props 的時候傳入{onClick:function(){}}這樣的引數,這樣就會在當前元素上新增事件,代理到 document。由於 React 本身全是在寫 js,所以監聽的函式的傳遞變得特別簡單。

這裡很多東西沒有考慮,這裡為了保持簡單就不再擴充套件了,另外 React 的事件處理其實很複雜,實現了一套標準的 w3c 事件。這裡偷懶直接使用 jQuery 的事件代理到 document 上了。

自定義元素的實現 隨著前端技術的發展瀏覽器的那些基本元素已經滿足不了我們的需求了,如果你對 web components 有一定的瞭解,就會知道人們一直在嘗試擴充套件一些自己的標記。

React 通過虛擬 dom 做到了類似的功能,還記得我們上面 node.type 只是個簡單的字串,如果是個類呢?如果這個類恰好還有自己的生命週期管理,那擴充套件性就很高了。

在 React 中使用自定義元素

var CompositeComponent = React.createClass({
  getInitialState: function() {
    return {
      count: 0
    };
  },
  componentWillMount: function() {
    console.log("宣告週期: " + "componentWillMount");
  },
  componentDidMount: function() {
    console.log("宣告週期: " + "componentDidMount");
  },
  onChange: function(e) {
    var count = ++this.state.count;
    this.setState({
      count: count
    });
  },
  render: function() {
    const count = this.state.count;
    var h3 = React.createElement(
      "h3",
      { onclick: this.onChange.bind(this), class: "h3" },
      `click me ${count}`
    );
    var children = [h3];

    return React.createElement("div", null, children);
  }
});

var CompositeElement = React.createElement(CompositeComponent);

var root = document.getElementById("container");

React.render(CompositeElement, root);
複製程式碼

React.createElement接受的不再是字串,而是一個 class。 React.createClass 生成一個自定義標記類,帶有基本的生命週期:

  • getInitialState 獲取最初的屬性值 this.state
  • componentWillmount 在元件準備渲染時呼叫
  • componentDidMount 在元件渲染完成後呼叫

React.createClass 的實現

/**
 * 所有自定義元件的超類
 * @function render所有自定義元件都有該方法
 */
function ReactClass() {}

ReactClass.prototype.render = function() {};

/**
 * 更新
 * @param {*} newState 新狀態
 */
ReactClass.prototype.setState = function(newState) {
  // 拿到ReactCompositeComponent的例項
  this._reactInternalInstance.receiveComponent(null, newState);
};

const React = {
  nextReactRootIndex: 0,

  /**
   * 建立 ReactClass
   * @param {*} spec 傳入的物件
   */
  createClass: function(spec) {
    var Constructor = function(props) {
      this.props = props;
      this.state = this.getInitialState ? this.getInitialState() : null;
    };

    Constructor.prototype = new ReactClass();
    Constructor.prototype.constructor = Constructor;

    Object.assign(Constructor.prototype, spec);
    return Constructor;
  },

  /**
   * 自己上文的createElement方法
   */

  /**
   * 自己上文的render方法
   */
};
複製程式碼

這裡 createClass 生成了一個繼承 ReactClass 的子類,在建構函式裡呼叫 this.getInitialState 獲得最初的 state。

為了演示方便,我們這邊的 ReactClass 相當簡單,實際上原始的程式碼處理了很多東西,比如類的 mixin 的組合繼承支援,比如 componentDidMount 等可以定義多次,需要合併呼叫等等,有興趣的去翻原始碼吧,不是本文的主要目的,這裡就不詳細展開了。

看看我們上面的兩種型別就知道,我們是時候為自定義元素也提供一個 component 類了,在那個類裡我們會例項化 ReactClass,並且管理生命週期,還有父子元件依賴。

首先改造 instantiateReactComponent

/**
 * 根據元素型別例項化一個具體的component
 * @param {*} node ReactElement
 * @return {*} 返回一個具體的component例項
 */
function instantiateReactComponent(node) {
  // 文字節點的情況
  if (typeof node === "string" || typeof node === "number") {
    return new ReactDOMTextComponent(node);
  }
  //瀏覽器預設節點的情況
  if (typeof node === "object" && typeof node.type === "string") {
    // 注意這裡,使用了一種新的component
    return new ReactDOMComponent(node);
  }
  // 自定義的元素節點
  if (typeof node === "object" && typeof node.type === "function") {
    // 注意這裡,使用新的component,專門針對自定義元素
    return new ReactCompositeComponent(node);
  }
}
複製程式碼

這裡我們新增了一個判斷,處理自定義型別的 component

ReactCompositeComponent 的具體實現如下

/**
 * component 類
 * 複合元件型別
 * @param {*} element 元素
 */
function ReactCompositeComponent(element) {
  // 存放元素element物件
  this._currentElement = element;
  // 存放唯一標識
  this._rootNodeID = null;
  // 存放對應的ReactClass的例項
  this._instance = null;
}

/**
 * component 類 裝載方法
 * @param {*} rootID 元素id
 * @param {string} 返回dom
 */
ReactCompositeComponent.prototype.mountComponent = function(rootID) {
  this._rootNodeID = rootID;

  // 當前元素屬性
  var publicProps = this._currentElement.props;
  // 對應的ReactClass
  var ReactClass = this._currentElement.type;

  var inst = new ReactClass(publicProps);
  this._instance = inst;

  // 保留對當前 component的引用
  inst._reactInternalInstance = this;

  if (inst.componentWillMount) {
    // 生命週期
    inst.componentWillMount();
    //這裡在原始的 reactjs 其實還有一層處理,就是  componentWillMount 呼叫 setstate,不會觸發 rerender 而是自動提前合併,這裡為了保持簡單,就略去了
  }

  // 呼叫 ReactClass 例項的render 方法,返回一個element或者文字節點
  var renderedElement = this._instance.render();
  var renderedComponentInstance = instantiateReactComponent(renderedElement);
  this._renderedComponent = renderedComponentInstance; //存起來留作後用

  var renderedMarkup = renderedComponentInstance.mountComponent(
    this._rootNodeID
  );

  // dom 裝載到html 後呼叫生命週期
  $(document).on("mountReady", function() {
    inst.componentDidMount && inst.componentDidMount();
  });

  return renderedMarkup;
};
複製程式碼

自定義元素本身不負責具體的內容,他更多的是負責生命週期。具體的內容是由它的 render 方法返回的虛擬節點來負責渲染的。

本質上也是遞迴的去渲染內容的過程。同時因為這種遞迴的特性,父元件的 componentWillMount 一定在某個子元件的 componentWillMount 之前呼叫,而父元件的 componentDidMount 肯定在子元件之後,因為監聽 mountReady 事件,肯定是子元件先監聽的。

需要注意的是自定義元素並不會處理我們 createElement 時傳入的子節點,它只會處理自己 render 返回的節點作為自己的子節點。不過我們在 render 時可以使用 this.props.children 拿到那些傳入的子節點,可以自己處理。其實有點類似 web components 裡面的 shadow dom 的作用。

初始化渲染的大致流程如下:

React 原始碼分析

實現一個簡單的更新機制

一般在 React 中我們需要更新時都是呼叫的 setState 方法。所以本文的更新就基於 setState 實現。看下面的呼叫方式:

/**
 * ReactCompositeComponent元件
 */
var CompositeComponent = React.createClass({
  getInitialState: function() {
    return {
      count: 0
    };
  },
  componentWillMount: function() {
    console.log("宣告週期: " + "componentWillMount");
  },
  componentDidMount: function() {
    console.log("宣告週期: " + "componentDidMount");
  },
  onChange: function(e) {
    var count = ++this.state.count;
    this.setState({
      count: count
    });
  },
  render: function() {
    const count = this.state.count;
    var h3 = React.createElement(
      "h3",
      { onclick: this.onChange.bind(this), class: "h3" },
      `click me ${count}`
    );
    var children = [h3];

    return React.createElement("div", null, children);
  }
});
var CompositeElement = React.createElement(CompositeComponent);
var root = document.getElementById("root");

React.render(CompositeElement, root);

// 生成html
<div id="root">
  <div data-reactid="0">
    <h3 data-reactid="0.0" class="h3">
      <span data-reactid="0.0.0">click me 0</span>
    </h3>
  </div>
</div>

// 點選click me 計數會遞增
複製程式碼

點選文字就會呼叫 setState 走更新流程,我們回顧一下 ReactClass,看一下 setState 的實現

/**
 * 更新
 * @param {*} newState 新狀態
 */
ReactClass.prototype.setState = function(newState) {
  // 拿到ReactCompositeComponent的例項
  // 在裝載的時候儲存
  // 程式碼:this._reactInternalInstance = this
  this._reactInternalInstance.receiveComponent(null, newState);
};
複製程式碼

可以看到 setState 主要呼叫了對應的 component 的 receiveComponent 來實現更新。所有的掛載,更新都應該交給對應的 component 來管理。所以就像所有的 component 都實現了 mountComponent 來處理第一次渲染,所有的 component 類都應該實現 receiveComponent 用來處理自己的更新。

文字節點的 receiveComponent

文字節點的更新比較簡單,拿到新的文字進行比較,不同則直接替換整個節點

/**
 * component 類 更新
 * @param {*} newText
 */
ReactDOMTextComponent.prototype.receiveComponent = function(nextText) {
  var nextStringText = "" + nextText;
  // 跟以前儲存的字串比較
  if (nextStringText !== this._currentElement) {
    this._currentElement = nextStringText;
    // 替換整個節點
    $('[data-reactid="' + this._rootNodeID + '"]').html(this._currentElement);
  }
};
複製程式碼

自定義元素的 receiveComponent

先來看自定義元素的 receiveComponent 的實現

/**
 * component 類 更新
 * @param {*} nextElement
 * @param {*} newState
 */
ReactCompositeComponent.prototype.receiveComponent = function(
  nextElement,
  newState
) {
  // 如果接受了新的element,則直接使用最新的element
  this._currentElement = nextElement || this._currentElement;

  var inst = this._instance;
  // 合併state
  var nextState = Object.assign(inst.state, newState);
  var nextProps = this._currentElement.props;

  // 更新state
  inst.state = nextState;

  // 生命週期方法
  if (
    inst.shouldComponentUpdate &&
    inst.shouldComponentUpdate(nextProps, nextState) === false
  ) {
    // 如果例項的 shouldComponentUpdate 返回 false,則不需要繼續往下執行更新
    return;
  }

  // 生命週期方法
  if (inst.componentWillUpdate) inst.componentWillUpdate(nextProps, nextState);

  // 獲取老的element
  var prevComponentInstance = this._renderedComponent;
  var prevRenderedElement = prevComponentInstance._currentElement;

  // 通過重新render 獲取新的element
  var nextRenderedElement = this._instance.render();

  // 比較新舊元素
  if (_shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement)) {
    // 兩種元素為相同,需要更新,執行位元組點更新
    prevComponentInstance.receiveComponent(nextRenderedElement);
    // 生命週期方法
    inst.componentDidUpdate && inst.componentDidUpdate();
  } else {
    // 兩種元素的型別不同,直接重新裝載dom
    var thisID = this._rootNodeID;

    this._renderedComponent = this._instantiateReactComponent(
      nextRenderedElement
    );

    var nextMarkup = _renderedComponent.mountComponent(thisID);
    // 替換整個節點
    $('[data-reactid="' + this._rootNodeID + '"]').replaceWith(nextMarkup);
  }
};

/**
 * 通過比較兩個元素,判斷是否需要更新
 * @param {*} preElement  舊的元素
 * @param {*} nextElement 新的元素
 * @return {boolean}
 */
function _shouldUpdateReactComponent(prevElement, nextElement) {
  if (prevElement != null && nextElement != null) {
    var prevType = typeof prevElement;
    var nextType = typeof nextElement;
    if (prevType === "string" || prevType === "number") {
      // 文字節點比較是否為相同型別節點
      return nextType === "string" || nextType === "number";
    } else {
      // 通過type 和 key 判斷是否為同型別節點和同一個節點
      return (
        nextType === "object" &&
        prevElement.type === nextElement.type &&
        prevElement.key === nextElement.key
      );
    }
  }
  return false;
}
複製程式碼

上述程式碼的大致流程是:

  • 合併 state
  • 更新 state
  • 然後看業務程式碼中是否實現生命週期方法 shouldComponentUpdate 有則呼叫,如果返回值為 false 則停止往下執行
  • 然後是生命週期方法 componentWillUpdate
  • 然後通過拿到新 state 的 instance 呼叫 render 方法拿到新的 element 和之舊的 element 進行比較
  • 如果要更新就繼續呼叫對應的 component 類對應的 receiveComponent 就好啦,其實就是直接當甩手掌櫃,事情直接丟給手下去辦了。當然還有種情況是,兩次生成的 element 差別太大,就不是一個型別的,那好辦直接重新生成一份新的程式碼重新渲染一次就 o 了

_shouldUpdateReactComponent 是一個全域性方法,這個是一種 React 的優化機制。用來決定是直接全部替換,還是使用很細微的改動。當兩次 render 出來的子節點 key 不同,直接全部重新渲染一遍,替換就好了。否則,我們就得來個遞迴的更新,保證最小化的更新機制,這樣可以不會有太大的閃爍。

在這裡本質上還是遞迴呼叫 receiveComponent 的過程。

基本元素的 receiveComponent

基礎元素的更新包括兩方面

  • 屬性的更新,包括對特殊屬性比如事件的處理
  • 子節點的更新

子節點的更新比較複雜,是提升效率的關鍵,所以需要處理以下問題:

  • diff - 拿新的子節點樹跟以前老的子節點樹對比,找出他們之間的差別。
  • patch - 所有差別找出後,再一次性的去更新。

下面是基礎元素更新的基本結構

/**
 * component 類 更新
 * @param {*} nextElement
 */
ReactDOMComponent.prototype.receiveComponent = function(nextElement) {
  var lastProps = this._currentElement.props;
  var nextProps = nextElement.props;
  this._currentElement = nextElement;
  // 處理當前節點的屬性
  this._updateDOMProperties(lastProps, nextProps);
  // 處理當前節點的子節點變動
  this._updateDOMChildren(nextElement.props.children);
};
複製程式碼

先看看,更新屬性怎麼變更:

/**
 * 更新屬性
 * @param {*} lastProps
 * @param {*} nextProps
 */
ReactDOMComponent.prototype._updateDOMProperties = function(
  lastProps,
  nextProps
) {
  // 當老屬性不在新屬性的集合裡時,需要刪除屬性
  var propKey;
  for (propKey in lastProps) {
    if (
      nextProps.hasOwnProperty(propKey) ||
      !lastProps.hasOwnProperty(propKey)
    ) {
      // 新屬性中有,且不再老屬性的原型中
      continue;
    }
    if (/^on[A-Za-z]/.test(propKey)) {
      var eventType = propKey.replace("on", "");
      // 特殊事件,需要去掉事件監聽
      $(document).undelegate(
        '[data-reactid="' + this._rootNodeID + '"]',
        eventType,
        lastProps[propKey]
      );
      continue;
    }

    // 刪除不需要的屬性
    $('[data-reactid="' + this._rootNodeID + '"]').removeAttr(propKey);
  }

  // 對於新的事件,需要寫到dom上
  for (propKey in nextProps) {
    if (/^on[A-Za-z]/.test(propKey)) {
      var eventType = propKey.replace("on", "");
      // 刪除老的事件繫結
      lastProps[propKey] &&
        $(document).undelegate(
          '[data-reactid="' + this._rootNodeID + '"]',
          eventType,
          lastProps[propKey]
        );
      // 針對當前的節點新增事件代理,以_rootNodeID為名稱空間
      $(document).delegate(
        '[data-reactid="' + this._rootNodeID + '"]',
        eventType + "." + this._rootNodeID,
        nextProps[propKey]
      );
      continue;
    }

    if (propKey == "children") continue;

    // 新增新的屬性,重寫同名屬性
    $('[data-reactid="' + this._rootNodeID + '"]').prop(
      propKey,
      nextProps[propKey]
    );
  }
};
複製程式碼

屬性的變更並不是特別複雜,主要就是找到以前老的不用的屬性直接去掉,新的屬性賦值,並且注意其中特殊的事件屬性做出特殊處理就行了。

子節點更新,也是最複雜的部分:

// 全域性的更新深度標識
var updateDepth = 0;
// 全域性的更新佇列,所有的差異都存在這裡
var diffQueue = [];

ReactDOMComponent.prototype._updateDOMChildren = function(
  nextChildrenElements
) {
  updateDepth++;
  // _diff用來遞迴找出差別,組裝差異物件,新增到更新佇列diffQueue。
  this._diff(diffQueue, nextChildrenElements);
  updateDepth--;
  if (updateDepth == 0) {
    // 在需要的時候呼叫patch,執行具體的dom操作
    this._patch(diffQueue);
    diffQueue = [];
  }
};
複製程式碼

就像我們之前說的一樣,更新子節點包含兩個部分,一個是遞迴的分析差異,把差異新增到佇列中。然後在合適的時機呼叫_patch 把差異應用到 dom 上。那麼什麼是合適的時機,updateDepth 又是幹嘛的?這裡需要注意的是,_diff 內部也會遞迴呼叫子節點的 receiveComponent 於是當某個子節點也是瀏覽器普通節點,就也會走_updateDOMChildren 這一步。所以這裡使用了 updateDepth 來記錄遞迴的過程,只有等遞迴回來 updateDepth 為 0 時,代表整個差異已經分析完畢,可以開始使用 patch 來處理差異佇列了。

diff 實現

// 差異更新的幾種型別
var UPDATE_TYPES = {
  MOVE_EXISTING: 1,
  REMOVE_NODE: 2,
  INSERT_MARKUP: 3
};

/**
 * 生成子節點 elements 的 component 集合
 * @param {object} prevChildren 前一個 component 集合
 * @param {Array} nextChildrenElements 新傳入的子節點element陣列
 * @return {object} 返回一個對映
 */
function generateComponentChildren(prevChildren, nextChildrenElements) {
  var nextChildren = {};
  nextChildrenElements = nextChildrenElements || [];
  $.each(nextChildrenElements, function(index, element) {
    var name = element.key ? element.key : index;
    var prevChild = prevChildren && prevChildren[name];
    var prevElement = prevChild && prevChild._currentElement;
    var nextElement = element;

    // 呼叫_shouldUpdateReactComponent判斷是否是更新
    if (_shouldUpdateReactComponent(prevElement, nextElement)) {
      // 更新的話直接遞迴呼叫子節點的receiveComponent就好了
      prevChild.receiveComponent(nextElement);
      // 然後繼續使用老的component
      nextChildren[name] = prevChild;
    } else {
      // 對於沒有老的,那就重新新增一個,重新生成一個component
      var nextChildInstance = instantiateReactComponent(nextElement, null);
      // 使用新的component
      nextChildren[name] = nextChildInstance;
    }
  });

  return nextChildren;
}

/**
 * 將陣列轉換為對映
 * @param {Array} componentChildren
 * @return {object} 返回一個對映
 */
function flattenChildren(componentChildren) {
  var child;
  var name;
  var childrenMap = {};
  for (var i = 0; i < componentChildren.length; i++) {
    child = componentChildren[i];
    name =
      child && child._currentelement && child._currentelement.key
        ? child._currentelement.key
        : i.toString(36);
    childrenMap[name] = child;
  }
  return childrenMap;
}

/**
 * _diff用來遞迴找出差別,組裝差異物件,新增到更新佇列diffQueue。
 * @param {*} diffQueue
 * @param {*} nextChildrenElements
 */
ReactDOMComponent.prototype._diff = function(diffQueue, nextChildrenElements) {
  var self = this;
  // 拿到之前的子節點的 component型別物件的集合,這個是在剛開始渲染時賦值的,記不得的可以翻上面
  // _renderedChildren 本來是陣列,我們搞成map
  var prevChildren = flattenChildren(self._renderedChildren);
  // 生成新的子節點的component物件集合,這裡注意,會複用老的component物件
  var nextChildren = generateComponentChildren(
    prevChildren,
    nextChildrenElements
  );
  // 重新賦值_renderedChildren,使用最新的。
  self._renderedChildren = [];
  $.each(nextChildren, function(key, instance) {
    self._renderedChildren.push(instance);
  });

  /**注意新增程式碼**/
  var lastIndex = 0; // 代表訪問的最後一次的老的集合的位置

  var nextIndex = 0; // 代表到達的新的節點的index
  // 通過對比兩個集合的差異,組裝差異節點新增到佇列中
  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }
    var prevChild = prevChildren && prevChildren[name];
    var nextChild = nextChildren[name];
    // 相同的話,說明是使用的同一個component,所以我們需要做移動的操作
    if (prevChild === nextChild) {
      // 新增差異物件,型別:MOVE_EXISTING
      /**注意新增程式碼**/
      prevChild._mountIndex < lastIndex &&
        diffQueue.push({
          parentId: self._rootNodeID,
          parentNode: $("[data-reactid=" + self._rootNodeID + "]"),
          type: UPDATE_TYPES.MOVE_EXISTING,
          fromIndex: prevChild._mountIndex,
          toIndex: nextIndex
        });
      /**注意新增程式碼**/
      lastIndex = Math.max(prevChild._mountIndex, lastIndex);
    } else {
      // 如果不相同,說明是新增加的節點
      // 但是如果老的還存在,就是element不同,但是component一樣。我們需要把它對應的老的element刪除。
      if (prevChild) {
        // 新增差異物件,型別:REMOVE_NODE
        diffQueue.push({
          parentId: self._rootNodeID,
          parentNode: $("[data-reactid=" + self._rootNodeID + "]"),
          type: UPDATE_TYPES.REMOVE_NODE,
          fromIndex: prevChild._mountIndex,
          toIndex: null
        });

        // 如果以前已經渲染過了,記得先去掉以前所有的事件監聽,通過名稱空間全部清空
        if (prevChild._rootNodeID) {
          $(document).undelegate("." + prevChild._rootNodeID);
        }

        /**注意新增程式碼**/
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
      }
      // 新增加的節點,也組裝差異物件放到佇列裡
      // 新增差異物件,型別:INSERT_MARKUP
      diffQueue.push({
        parentId: self._rootNodeID,
        parentNode: $("[data-reactid=" + self._rootNodeID + "]"),
        type: UPDATE_TYPES.INSERT_MARKUP,
        fromIndex: null,
        toIndex: nextIndex,
        markup: nextChild.mountComponent(self._rootNodeID + "." + name) //新增的節點,多一個此屬性,表示新節點的dom內容
      });
    }
    // 更新mount的index
    nextChild._mountIndex = nextIndex;
    nextIndex++;
  }

  // 對於老的節點裡有,新的節點裡沒有的那些,也全都刪除掉
  for (name in prevChildren) {
    if (
      prevChildren.hasOwnProperty(name) &&
      !(nextChildren && nextChildren.hasOwnProperty(name))
    ) {
      // 新增差異物件,型別:REMOVE_NODE
      diffQueue.push({
        parentId: self._rootNodeID,
        parentNode: $("[data-reactid=" + self._rootNodeID + "]"),
        type: UPDATE_TYPES.REMOVE_NODE,
        fromIndex: prevChildren[name]._mountIndex,
        toIndex: null
      });
      // 如果以前已經渲染過了,記得先去掉以前所有的事件監聽
      if (prevChildren[name]._rootNodeID) {
        $(document).undelegate("." + prevChildren[name]._rootNodeID);
      }
    }
  }
};
複製程式碼

注意 flattenChildren 我們這裡把陣列集合轉成了物件 map,以 element 的 key 作為標識,當然對於 text 文字或者沒有傳入 key 的 element,直接用 index 作為標識。通過這些標識,我們可以從型別的角度來判斷兩個 component 是否是一樣的。

generateComponentChildren 會盡量的複用以前的 component,也就是那些坑,當發現可以複用 component(也就是 key 一致)時,就還用以前的,只需要呼叫他對應的更新方法 receiveComponent 就行了,這樣就會遞迴的去獲取子節點的差異物件然後放到佇列了。如果發現不能複用那就是新的節點,我們就需要 instantiateReactComponent 重新生成一個新的 component。

lastIndex,這個代表最後一次訪問的老集合節點的最大的位置。 而我們加了個判斷,只有_mountIndex 小於這個 lastIndex 的才會需要加入差異佇列。有了這個判斷上面的例子 2 就不需要 move。而程式也可以好好的執行,實際上大部分都是 2 這種情況。

這是一種順序優化,lastIndex 一直在更新,代表了當前訪問的最右的老的集合的元素。 我們假設上一個元素是 A,新增後更新了 lastIndex。 如果我們這時候來個新元素 B,比 lastIndex 還大說明當前元素在老的集合裡面就比上一個 A 靠後。所以這個元素就算不加入差異佇列,也不會影響到其他人,不會影響到後面的 path 插入節點。因為我們從 patch 裡面知道,新的集合都是按順序從頭開始插入元素的,只有當新元素比 lastIndex 小時才需要變更。其實只要仔細推敲下上面那個例子,就可以理解這種優化手段了。 檢視React diff 策略

_patch 的實現

/**
 *
 * @param {*} parentNode
 * @param {*} childNode
 * @param {*} index
 */ function insertChildAt(parentNode, childNode, index) {
  var beforeChild = parentNode.children().get(index);
  beforeChild
    ? childNode.insertBefore(beforeChild)
    : childNode.appendTo(parentNode);
}

/**
 *
 * @param {*} diffQueue
 */
ReactDOMComponent.prototype._patch = function(diffQueue) {
  var update;
  var initialChildren = {};
  var deleteChildren = [];
  for (var i = 0; i < updates.length; i++) {
    update = updates[i];
    if (
      update.type === UPDATE_TYPES.MOVE_EXISTING ||
      update.type === UPDATE_TYPES.REMOVE_NODE
    ) {
      var updatedIndex = update.fromIndex;
      var updatedChild = $(update.parentNode.children().get(updatedIndex));
      var parentID = update.parentID;

      // 所有需要更新的節點都儲存下來,方便後面使用
      initialChildren[parentID] = initialChildren[parentID] || [];
      // 使用parentID作為簡易名稱空間
      initialChildren[parentID][updatedIndex] = updatedChild;

      // 所有需要修改的節點先刪除,對於move的,後面再重新插入到正確的位置即可
      deleteChildren.push(updatedChild);
    }
  }

  // 刪除所有需要先刪除的
  $.each(deleteChildren, function(index, child) {
    $(child).remove();
  });

  // 再遍歷一次,這次處理新增的節點,還有修改的節點這裡也要重新插入
  for (var k = 0; k < updates.length; k++) {
    update = updates[k];
    switch (update.type) {
      case UPDATE_TYPES.INSERT_MARKUP:
        insertChildAt(update.parentNode, $(update.markup), update.toIndex);
        break;
      case UPDATE_TYPES.MOVE_EXISTING:
        insertChildAt(
          update.parentNode,
          initialChildren[update.parentID][update.fromIndex],
          update.toIndex
        );
        break;
      case UPDATE_TYPES.REMOVE_NODE:
        // 什麼都不需要做,因為上面已經幫忙刪除掉了
        break;
    }
  }
};
複製程式碼

_patch 主要就是挨個遍歷差異佇列,遍歷兩次,第一次刪除掉所有需要變動的節點,然後第二次插入新的節點還有修改的節點。這裡為什麼可以直接挨個的插入呢?原因就是我們在 diff 階段新增差異節點到差異佇列時,本身就是有序的,也就是說對於新增節點(包括 move 和 insert 的)在佇列裡的順序就是最終 dom 的順序,所以我們才可以挨個的直接根據 index 去塞入節點。

這樣整個的更新機制就完成了。我們再來簡單回顧下 React 的差異演算法:

首先是所有的 component 都實現了 receiveComponent 來負責自己的更新,而瀏覽器預設元素的更新最為複雜,也就是經常說的 diff algorithm。

react 有一個全域性_shouldUpdateReactComponent 用來根據 element 的 key 來判斷是更新還是重新渲染,這是第一個差異判斷。比如自定義元素裡,就使用這個判斷,通過這種標識判斷,會變得特別高效。

每個型別的元素都要處理好自己的更新:

  • 自定義元素的更新,主要是更新 render 出的節點,做甩手掌櫃交給 render 出的節點的對應 component 去管理更新。

  • text 節點的更新很簡單,直接更新文案。

  • 瀏覽器基本元素的更新,分為兩塊:

    • 先是更新屬性,對比出前後屬性的不同,區域性更新。並且處理特殊屬性,比如事件繫結。
    • 然後是子節點的更新,子節點更新主要是找出差異物件,找差異物件的時候也會使用上面的_shouldUpdateReactComponent 來判斷,如果是可以直接更新的就會遞迴呼叫子節點的更新,這樣也會遞迴查詢差異物件,這裡還會使用 lastIndex 這種做一種優化,使一些節點保留位置,之後根據差異物件操作 dom 元素(位置變動,刪除,

end

這只是個玩具,但實現了 React 最核心的功能,虛擬節點,差異演算法,單向資料更新都在這裡了。還有很多 React 優秀的東西沒有實現,比如物件生成時記憶體的執行緒池管理,批量更新機制,事件的優化,服務端的渲染,immutable data 等等。這些東西受限於篇幅就不具體展開了。

React 作為一種解決方案,虛擬節點的想法比較新奇,不過個人還是不能接受這種彆扭的寫法。使用 React,就要使用他那一整套的開發方式,而他核心的功能其實只是一個差異演算法,而這種其實已經有相關的庫實現了。

相關資料:

相關文章