Reactjs原始碼分析

風靈使發表於2018-03-28

前端的發展特別快,經歷過jQuery一統天下的工具庫時代後,現在各種框架又開始百家爭鳴了。angular,ember,backbone,vue,avalon,ploymer還有reactjs,作為一個前端真是稍不留神就感覺要被淘汰了,就在去年大家還都是angularjs的粉絲,到了今年又開始各種狂追reactjs了。前端都是喜新厭舊的,不知道最後這些框架由誰來一統天下,用句很俗的話說,這是最好的時代也是最壞的時代。作為一個前端,只能多學點,儘量多的瞭解他們的原理。

reactjs的程式碼非常繞,對於沒有後臺開發經驗的前端來說看起來會比較吃力。其實reactjs的核心內容並不多,主要是下面這些:

  • 虛擬dom物件(Virtual DOM)
  • 虛擬dom差異化演算法(diff algorithm)
  • 單向資料流渲染(Data Flow)
  • 元件生命週期
  • 事件處理

下面我們將一點點的來實現一個簡易版的reactjs,實現上面的那些功能,最後用這個reactjs做一個todolist的小應用,看完這個,或者跟著敲一遍程式碼。希望讓大家能夠更好的理解reactjs的執行原理。

先從最簡單的開始

我們先從渲染hello world開始吧。

我們看下面的程式碼:

<script type="text/javascript">
React.render('hello world',document.getElementById("container"))
</script>

/**
對應的html為

<div id="container"></div>


生成後的html為:

<div id="container">
    <span data-reactid="0">hello world</span>
</div>

*/

假定這一行程式碼,就可以把hello world渲染到對應的div裡面。

我們來看看我們需要為此做些什麼:

//component類,用來表示文字在渲染,更新,刪除時應該做些什麼事情
function ReactDOMTextComponent(text) {
    //存下當前的字串
    this._currentElement = '' + text;
    //用來標識當前component
    this._rootNodeID = null;
}

//component渲染時生成的dom結構
ReactDOMTextComponent.prototype.mountComponent = function(rootID) {
    this._rootNodeID = rootID;
    return '<span data-reactid="' + rootID + '">' + this._currentElement + '</span>';
}


//component工廠  用來返回一個component例項
function instantiateReactComponent(node){
    if(typeof node === 'string' || typeof node === 'number'){
        return new ReactDOMTextComponent(node)
    }
}


React = {
    nextReactRootIndex:0,
    render:function(element,container){

        var componentInstance = instantiateReactComponent(element);
        var markup = componentInstance.mountComponent(React.nextReactRootIndex++);
        $(container).html(markup);
        //觸發完成mount的事件
        $(document).trigger('mountReady');    }
}

程式碼分為三個部分:

  1. React.render 作為入口負責呼叫渲染
  2. 我們引入了component類的概念,ReactDOMTextComponent是一個component類定義,定義對於這種文字型別的節點,在渲染,更新,刪除時應該做什麼操作,這邊暫時只用到渲染,另外兩個可以先忽略
  3. instantiateReactComponent用來根據element的型別(現在只有一種string型別),返回一個component的例項。其實就是個類工廠。

nextReactRootIndex作為每個component的標識id,不斷加1,確保唯一性。這樣我們以後可以通過這個標識找到這個元素。

可以看到我們把邏輯分為幾個部分,主要的渲染邏輯放在了具體的componet類去定義。React.render負責排程整個流程,這裡是呼叫instantiateReactComponent生成一個對應component型別的例項物件,然後呼叫此物件的mountComponent獲取生成的內容。最後寫到對應的container節點中。

可能有人問,這麼p大點功能,有必要這麼複雜嘛,別急。往下看才能體會這種分層的好處。

引入基本elemetnt

我們知道reactjs最大的賣點就是它的虛擬dom概念,我們一般使用React.createElement來建立一個虛擬dom元素。

虛擬dom元素分為兩種,一種是瀏覽器自帶的基本元素比如 div p input form 這種,一種是自定義的元素。

這邊需要說一下我們上節提到的文字節點,它不算虛擬dom,但是reacjs為了保持渲染的一致性。文字節點是在外面包了一層span標記,也給它配了個簡化版component(ReactDOMTextComponent)。

這節我們先討論瀏覽器的基本元素。

reactjs裡,當我們希望在hello world外面包一層div,並且帶上一些屬性,甚至事件時我們可以這麼寫:

//演示事件監聽怎麼用
function hello(){
    alert('hello')
}


var element = React.createElement('div',{id:'test',onclick:hello},'click me')

React.render(element,document.getElementById("container"))


/**

//生成的html為:

<div data-reactid="0" id="test">
    <span data-reactid="0.0">click me</span>
</div>


//點選文字,會彈出hello的對話方塊

*/

上面使用React.createElement建立了一個基本元素,我們來看看簡易版本React.createElement的實現:

//ReactElement就是虛擬dom的概念,具有一個type屬性代表當前的節點型別,還有節點的屬性props
//比如對於div這樣的節點type就是div,props就是那些attributes
//另外這裡的key,可以用來標識這個element,用於優化以後的更新,這裡可以先不管,知道有這麼個東西就好了
function ReactElement(type,key,props){
  this.type = type;
  this.key = key;
  this.props = props;
}


React = {
    nextReactRootIndex:0,
    createElement:function(type,config,children){
        var props = {},propName;
        config = config || {}
        //看有沒有key,用來標識element的型別,方便以後高效的更新,這裡可以先不管
        var key = config.key || null;

        //複製config裡的內容到props
        for (propName in config) {
            if (config.hasOwnProperty(propName) && propName !== 'key') {
                props[propName] = config[propName];
            }
        }

        //處理children,全部掛載到props的children屬性上
        //支援兩種寫法,如果只有一個引數,直接賦值給children,否則做合併處理
        var childrenLength = arguments.length - 2;
        if (childrenLength === 1) {
            props.children = $.isArray(children) ? children : [children] ;
        } else if (childrenLength > 1) {
            var childArray = Array(childrenLength);
            for (var i = 0; i < childrenLength; i++) {
                childArray[i] = arguments[i + 2];
            }
            props.children = childArray;
        }

        return new ReactElement(type, key,props);

    },
    render:function(element,container){
        var componentInstance = instantiateReactComponent(element);
        var markup = componentInstance.mountComponent(React.nextReactRootIndex++);
        $(container).html(markup);
        //觸發完成mount的事件
        $(document).trigger('mountReady');
    }
}

createElement只是做了簡單的引數修正,最終返回一個ReactElement例項物件也就是我們說的虛擬元素的例項。

這裡注意key的定義,主要是為了以後更新時優化效率,這邊可以先不管忽略。

好了有了元素例項,我們得把他渲染出來,此時render接受的是一個ReactElement而不是文字,我們先改造下instantiateReactComponent

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類,用來表示文字在渲染,更新,刪除時應該做些什麼事情
function ReactDOMComponent(element){
    //存下當前的element物件引用
    this._currentElement = element;
    this._rootNodeID = null;
}

//component渲染時生成的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) {

        //這裡要做一下事件的監聽,就是從屬性props裡面解析拿出on開頭的事件屬性的對應事件監聽
        if (/^on[A-Za-z]/.test(propKey)) {
            var eventType = propKey.replace('on', '');
            //針對當前的節點新增事件代理,以_rootNodeID為名稱空間
            $(document).delegate('[data-reactid="' + this._rootNodeID + '"]', eventType + '.' + this._rootNodeID, props[propKey]);
        }

        //對於children屬性以及事件監聽的屬性不需要進行字串拼接
        //事件會代理到全域性。這邊不能拼到dom上不然會產生原生的事件監聽
        if (props[propKey] && propKey != 'children' && !/^on[A-Za-z]/.test(propKey)) {
            tagOpen += ' ' + propKey + '=' + props[propKey];
        }
    }
    //獲取子節點渲染出的內容
    var content = '';
    var children = props.children || [];

    var childrenInstances = []; //用於儲存所有的子節點的componet例項,以後會用到
    var that = this;
    $.each(children, function(key, child) {
        //這裡再次呼叫了instantiateReactComponent例項化子節點component類,拼接好返回
        var childComponentInstance = instantiateReactComponent(child);
        childComponentInstance._mountIndex = key;

        childrenInstances.push(childComponentInstance);
        //子節點的rootId是父節點的rootId加上新的key也就是順序的值拼成的新值
        var curRootId = that._rootNodeID + '.' + key;
        //得到子節點的渲染內容
        var childMarkup = childComponentInstance.mountComponent(curRootId);
        //拼接在一起
        content += ' ' + childMarkup;

    })

    //留給以後更新時用的這邊先不用管
    this._renderedChildren = childrenInstances;

    //拼出整個html內容
    return tagOpen + '>' + content + tagClose;
}

我們增加了虛擬dom reactElement的定義,增加了一個新的componetReactDOMComponent。 這樣我們就實現了渲染瀏覽器基本元素的功能了。

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

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

這裡很多東西沒有考慮,比如一些特殊的型別input select等等,再比如img不需要有對應的tagClose等。這裡為了保持簡單就不再擴充套件了。另外reactjs的事件處理其實很複雜,實現了一套標準的w3c事件。這裡偷懶直接使用jQuery的事件代理到document上了。

自定義元素

上面實現了基本的元素內容,我們下面實現自定義元素的功能。

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

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

如果對生命週期等概念不是很理解的,可以看看我以前的另一片文章:javascript元件化

我們看下reactjs怎麼使用自定義元素:

var HelloMessage = React.createClass({
  getInitialState: function() {
    return {type: 'say:'};
  },
  componentWillMount: function() {
    console.log('我就要開始渲染了。。。')
  },
  componentDidMount: function() {
    console.log('我已經渲染好了。。。')
  },
  render: function() {
    return React.createElement("div", null,this.state.type, "Hello ", this.props.name);
  }
});


React.render(React.createElement(HelloMessage, {name: "John"}), document.getElementById("container"));

/**
結果為:

html:
<div data-reactid="0">
    <span data-reactid="0.0">say:</span>
    <span data-reactid="0.1">Hello </span>
    <span data-reactid="0.2">John</span>
</div>

console:
我就要開始渲染了。。。
我已經渲染好了。。。

*/

React.createElement接受的不再是字串,而是一個class

React.createClass生成一個自定義標記類,帶有基本的生命週期:

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

reactjs稍微有點了解的應該都可以明白上面的用法。

我們先來看看React.createClass的實現:

//定義ReactClass類,所有自定義的超級父類
var ReactClass = function(){
}
//留給子類去繼承覆蓋
ReactClass.prototype.render = function(){}



React = {
    nextReactRootIndex:0,
    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;
        //混入spec到原型
        $.extend(Constructor.prototype,spec);
        return Constructor;

    },
    createElement:function(type,config,children){
        ...
    },
    render:function(element,container){
        ...
    }
}

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

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

我們這裡只是返回了一個繼承類的定義,那麼具體的componentWillmount,這些生命週期函式在哪裡呼叫呢。

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

好,我們老規矩先改造instantiateReactComponent

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的具體實現:

function ReactCompositeComponent(element){
    //存放元素element物件
    this._currentElement = element;
    //存放唯一標識
    this._rootNodeID = null;
    //存放對應的ReactClass的例項
    this._instance = null;
}

//用於返回當前自定義元素渲染時應該返回的內容
ReactCompositeComponent.prototype.mountComponent = function(rootID){
    this._rootNodeID = rootID;
    //拿到當前元素對應的屬性值
    var publicProps = this._currentElement.props;
    //拿到對應的ReactClass
    var ReactClass = this._currentElement.type;
    // Initialize the public class
    var inst = new ReactClass(publicProps);
    this._instance = inst;
    //保留對當前comonent的引用,下面更新會用到
    inst._reactInternalInstance = this;

    if (inst.componentWillMount) {
        inst.componentWillMount();
        //這裡在原始的reactjs其實還有一層處理,就是  componentWillMount呼叫setstate,不會觸發rerender而是自動提前合併,這裡為了保持簡單,就略去了
    }
    //呼叫ReactClass的例項的render方法,返回一個element或者一個文字節點
    var renderedElement = this._instance.render();
    //得到renderedElement對應的component類例項
    var renderedComponentInstance = instantiateReactComponent(renderedElement);
    this._renderedComponent = renderedComponentInstance; //存起來留作後用

    //拿到渲染之後的字串內容,將當前的_rootNodeID傳給render出的節點
    var renderedMarkup = renderedComponentInstance.mountComponent(this._rootNodeID);

    //之前我們在React.render方法最後觸發了mountReady事件,所以這裡可以監聽,在渲染完成後會觸發。
    $(document).on('mountReady', function() {
        //呼叫inst.componentDidMount
        inst.componentDidMount && inst.componentDidMount();
    });

    return renderedMarkup;
}

實現並不難,ReactClassrender一定是返回一個虛擬節點(包括elementtext),這個時候我們使用instantiateReactComponent去得到例項,再使用mountComponent拿到結果作為當前自定義元素的結果。

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

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

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

上面實現了三種型別的元素,其實我們發現本質上沒有太大的區別,都是有自己對應component類來處理自己的渲染過程。

大概的關係是下面這樣。

結構圖

於是我們發現初始化的渲染流程都已經完成了。

虛擬dom差異化演算法(diff algorithm)是reactjs最核心的東西,按照官方的說法。他非常快,非常高效。目前已經有一些分析此演算法的文章,但是僅僅停留在表面。大部分小白看完並不能瞭解(博主就是 = =)。所以我們下面自己動手實現一遍,等你完全實現了,再去看那些文字圖片流的介紹文章,就會發現容易理解多了。

實現更新機制

下面我們探討下更新的機制。

一般在reactjs中我們需要更新時都是呼叫的setState。看下面的例子:

var HelloMessage = React.createClass({
  getInitialState: function() {
    return {type: 'say:'};
  },
  changeType:function(){
    this.setState({type:'shout:'})
  },
  render: function() {
    return React.createElement("div", {onclick:this.changeType},this.state.type, "Hello ", this.props.name);
  }
});


React.render(React.createElement(HelloMessage, {name: "John"}), document.getElementById("container"));



/**

//生成的html為:

<div data-reactid="0" id="test">
    <span data-reactid="0.0">hello world</span>
</div>


點選文字,say會變成shout

*/

點選文字,呼叫setState就會更新,所以我們擴充套件下ReactClass,看下setState的實現:

//定義ReactClass類
var ReactClass = function(){
}

ReactClass.prototype.render = function(){}

//setState
ReactClass.prototype.setState = function(newState) {

    //還記得我們在ReactCompositeComponent裡面mount的時候 做了賦值
    //所以這裡可以拿到 對應的ReactCompositeComponent的例項_reactInternalInstance
    this._reactInternalInstance.receiveComponent(null, newState);
}

可以看到setState主要呼叫了對應的componentreceiveComponent來實現更新。所有的掛載,更新都應該交給對應的component來管理。

就像所有的component都實現了mountComponent來處理第一次渲染,所有的componet類都應該實現receiveComponent用來處理自己的更新。

自定義元素的receiveComponent

所以我們照葫蘆畫瓢來給自定義元素的對應component類(ReactCompositeComponent)實現一個receiveComponent方法:

//更新
ReactCompositeComponent.prototype.receiveComponent = function(nextElement, newState) {

    //如果接受了新的,就使用最新的element
    this._currentElement = nextElement || this._currentElement

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


    //改寫state
    inst.state = nextState;


    //如果inst有shouldComponentUpdate並且返回false。說明元件本身判斷不要更新,就直接返回。
    if (inst.shouldComponentUpdate && (inst.shouldComponentUpdate(nextProps, nextState) === false)) return;

    //生命週期管理,如果有componentWillUpdate,就呼叫,表示開始要更新了。
    if (inst.componentWillUpdate) inst.componentWillUpdate(nextProps, nextState);


    var prevComponentInstance = this._renderedComponent;
    var prevRenderedElement = prevComponentInstance._currentElement;
    //重新執行render拿到對應的新element;
    var nextRenderedElement = this._instance.render();


    //判斷是需要更新還是直接就重新渲染
    //注意這裡的_shouldUpdateReactComponent跟上面的不同哦 這個是全域性的方法
    if (_shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement)) {
        //如果需要更新,就繼續呼叫子節點的receiveComponent的方法,傳入新的element更新子節點。
        prevComponentInstance.receiveComponent(nextRenderedElement);
        //呼叫componentDidUpdate表示更新完成了
        inst.componentDidUpdate && inst.componentDidUpdate();

    } else {
        //如果發現完全是不同的兩種element,那就乾脆重新渲染了
        var thisID = this._rootNodeID;
        //重新new一個對應的component,
        this._renderedComponent = this._instantiateReactComponent(nextRenderedElement);
        //重新生成對應的元素內容
        var nextMarkup = _renderedComponent.mountComponent(thisID);
        //替換整個節點
        $('[data-reactid="' + this._rootNodeID + '"]').replaceWith(nextMarkup);

    }

}

//用來判定兩個element需不需要更新
//這裡的key是我們createElement的時候可以選擇性的傳入的。用來標識這個element,當發現key不同時,我們就可以直接重新渲染,不需要去更新了。
var _shouldUpdateReactComponent = function(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 {
      return nextType === 'object' && prevElement.type === nextElement.type && prevElement.key === nextElement.key;
    }
  }
  return false;
}

不要被這麼多程式碼嚇到,其實流程很簡單。 它主要做了什麼事呢?首先會合並改動,生成最新的state,props然後拿以前的render返回的element跟現在最新呼叫render生成的element進行對比(_shouldUpdateReactComponent),看看需不需要更新,如果要更新就繼續呼叫對應的component類對應的receiveComponent就好啦,其實就是直接當甩手掌櫃,事情直接丟給手下去辦了。當然還有種情況是,兩次生成的element差別太大,就不是一個型別的,那好辦直接重新生成一份新的程式碼重新渲染一次就o了。

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

這裡注意兩個函式:

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

另外可以看到這裡還處理了一套更新的生命週期呼叫機制。

文字節點的receiveComponent

我們再看看文字節點的,比較簡單:

ReactDOMTextComponent.prototype.receiveComponent = function(nextText) {
    var nextStringText = '' + nextText;
    //跟以前儲存的字串比較
    if (nextStringText !== this._currentElement) {
        this._currentElement = nextStringText;
        //替換整個節點
        $('[data-reactid="' + this._rootNodeID + '"]').html(this._currentElement);

    }
}

沒什麼好說的,如果不同的話,直接找到對應的節點,更新就好了。

基本元素elementreceiveComponent

最後我們開始看比較複雜的瀏覽器基本元素的更新機制。 比如我們看看下面的html:

<div id="test" name="hello">
    <span></span>
    <span></span>
</div>

想一下我們怎麼以最小代價去更新這段html呢。不難發現其實主要包括兩個部分:

  1. 屬性的更新,包括對特殊屬性比如事件的處理
  2. 子節點的更新,這個比較複雜,為了得到最好的效率,我們需要處理下面這些問題:
    拿新的子節點樹跟以前老的子節點樹對比,找出他們之間的差別。我們稱之為diff
    所有差別找出後,再一次性的去更新。我們稱之為patch

所以更新程式碼結構如下:

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);
}

整體上也不復雜,先是處理當前節點屬性的變動,後面再去處理子節點的變動

我們一步步來,先看看,更新屬性怎麼變更:

ReactDOMComponent.prototype._updateDOMProperties = function(lastProps, nextProps) {
    var propKey;
    //遍歷,當一個老的屬性不在新的屬性集合裡時,需要刪除掉。

    for (propKey in lastProps) {
        //新的屬性裡有,或者propKey是在原型上的直接跳過。這樣剩下的都是不在新屬性集合裡的。需要刪除
        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;
        }

        //從dom上刪除不需要的屬性
        $('[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])
    }

}

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

下面我們看子節點的更新,也是最複雜的部分。

ReactDOMComponent.prototype.receiveComponent = function(nextElement){
    var lastProps = this._currentElement.props;
    var nextProps = nextElement.props;

    this._currentElement = nextElement;
    //需要單獨的更新屬性
    this._updateDOMProperties(lastProps,nextProps);
    //再更新子節點
    this._updateDOMChildren(nextProps.children);
}

//全域性的更新深度標識
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_patch兩個方法。

我們先看_diff的實現:

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


//普通的children是一個陣列,此方法把它轉換成一個map,key就是element的key,如果是text節點或者element建立時並沒有傳入key,就直接用在陣列裡的index標識
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;
}


//主要用來生成子節點elements的component集合
//這邊注意,有個判斷邏輯,如果發現是更新,就會繼續使用以前的componentInstance,呼叫對應的receiveComponent。
//如果是新的節點,就會重新生成一個新的componentInstance,
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;
}

//_diff用來遞迴找出差別,組裝差異物件,新增到更新佇列diffQueue。
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 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
      diffQueue.push({
        parentId: self._rootNodeID,
        parentNode: $('[data-reactid=' + self._rootNodeID + ']'),
        type: UPATE_TYPES.MOVE_EXISTING,
        fromIndex: prevChild._mountIndex,
        toIndex: nextIndex
      })
    } else { //如果不相同,說明是新增加的節點
      //但是如果老的還存在,就是element不同,但是component一樣。我們需要把它對應的老的element刪除。
      if (prevChild) {
        //新增差異物件,型別:REMOVE_NODE
        diffQueue.push({
          parentId: self._rootNodeID,
          parentNode: $('[data-reactid=' + self._rootNodeID + ']'),
          type: UPATE_TYPES.REMOVE_NODE,
          fromIndex: prevChild._mountIndex,
          toIndex: null
        })

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

      }
      //新增加的節點,也組裝差異物件放到佇列裡
      //新增差異物件,型別:INSERT_MARKUP
      diffQueue.push({
        parentId: self._rootNodeID,
        parentNode: $('[data-reactid=' + self._rootNodeID + ']'),
        type: UPATE_TYPES.INSERT_MARKUP,
        fromIndex: null,
        toIndex: nextIndex,
        markup: nextChild.mountComponent() //新增的節點,多一個此屬性,表示新節點的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: UPATE_TYPES.REMOVE_NODE,
        fromIndex: prevChild._mountIndex,
        toIndex: null
      })
      //如果以前已經渲染過了,記得先去掉以前所有的事件監聽
      if (prevChildren[name]._rootNodeID) {
        $(document).undelegate('.' + prevChildren[name]._rootNodeID);
      }
    }
  }
}

我們分析下上面的程式碼,咋一看好多,好複雜,不急我們從入口開始看。

首先我們拿到之前的component的集合,如果是第一次更新的話,這個值是我們在渲染時賦值的。然後我們呼叫generateComponentChildren生成最新的component集合。我們知道component是用來放element的,一個蘿蔔一個坑。

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

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

這裡的flattenChildren需要給予很大的關注,比如對於一個表格列表,我們在最前面插入了一條資料,想一下如果我們建立element時沒有傳入key,所有的key都是null,這樣reactjs在generateComponentChildren時就會預設通過順序(index)來一一對應改變前跟改變後的子節點,這樣變更前與變更後的對應節點判斷(_shouldUpdateReactComponent)其實是不合適的。也就是說對於這種列表的情況,我們最好給予唯一的標識key,這樣reactjs找對應關係時會更方便一點。

當我們生成好新的component集合以後,我們需要做出對比。組裝差異物件。

對比老的集合和新的集合。我們需要找出涵蓋四種情況,包括三種型別(UPATE_TYPES)的變動:

所以我們找出了這三種型別的差異,組裝成具體的差異物件,然後加到了差異佇列裡面。

比如我們看下面這個例子,假設下面這些是某個父元素的子元素集合,上面到下面代表了變動流程:

變動

數字我們可以理解為給element的key。

正方形代表element。圓形代表了component。當然也是實際上的dom節點的位置。

從上到下,我們的4 2 1裡 2 ,1可以複用之前的component,讓他們通知自己的子節點更新後,再告訴2和1,他們在新的集合裡需要移動的位置(在我們這裡就是組裝差異物件加到佇列)。3需要刪除,4需要新增。

好了,整個的diff就完成了,這個時候當遞迴完成,我們就需要開始做patch的動作了,把這些差異物件實打實的反映到具體的dom節點上。

我們看下_patch的實現:

//用於將childNode插入到指定位置
function insertChildAt(parentNode, childNode, index) {
    var beforeChild = parentNode.children().get(index);
    beforeChild ? childNode.insertBefore(beforeChild) : childNode.appendTo(parentNode);
}

ReactDOMComponent.prototype._patch = function(updates) {
    var update;
    var initialChildren = {};
    var deleteChildren = [];
    for (var i = 0; i < updates.length; i++) {
        update = updates[i];
        if (update.type === UPATE_TYPES.MOVE_EXISTING || update.type === UPATE_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 UPATE_TYPES.INSERT_MARKUP:
                insertChildAt(update.parentNode, $(update.markup), update.toIndex);
                break;
            case UPATE_TYPES.MOVE_EXISTING:
                insertChildAt(update.parentNode, initialChildren[update.parentID][update.fromIndex], update.toIndex);
                break;
            case UPATE_TYPES.REMOVE_NODE:
                // 什麼都不需要做,因為上面已經幫忙刪除掉了
                break;
        }
    }
}

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

但是其實你會發現這裡有個問題,就是所有的節點都會被刪除,包括複用以前的component型別為UPATE_TYPES.MOVE_EXISTING的,所以閃爍會很嚴重。其實我們再看看上面的例子,其實2是不需要記錄到差異佇列的。這樣後面patch也是ok的。想想是為什麼呢?

我們來改造下程式碼:

//_diff用來遞迴找出差別,組裝差異物件,新增到更新佇列diffQueue。
ReactDOMComponent.prototype._diff = function(diffQueue, nextChildrenElements){
    。。。
    /**注意新增程式碼**/
    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:this._rootNodeID,
                parentNode:$('[data-reactid='+this._rootNodeID+']'),
                type: UPATE_TYPES.REMOVE_NODE,
                fromIndex: prevChild._mountIndex,
                toIndex:null
          })
          lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        } else {
          //如果不相同,說明是新增加的節點,
          if (prevChild) {
            //但是如果老的還存在,就是element不同,但是component一樣。我們需要把它對應的老的element刪除。
            //新增差異物件,型別:REMOVE_NODE
            。。。。。
            /**注意新增程式碼**/
            lastIndex = Math.max(prevChild._mountIndex, lastIndex);
          }
          。。。
        }
        //更新mount的inddex
        nextChild._mountIndex = nextIndex;
        nextIndex++;
      }

      //對於老的節點裡有,新的節點裡沒有的那些,也全都刪除掉
      。。。
}

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

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

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

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

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

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

  1. 自定義元素的更新,主要是更新render出的節點,做甩手掌櫃交給render出的節點的對應component去管理更新。
  2. text節點的更新很簡單,直接更新文案。
  3. 瀏覽器基本元素的更新,分為兩塊:
    先是更新屬性,對比出前後屬性的不同,區域性更新。並且處理特殊屬性,比如事件繫結。
    然後是子節點的更新,子節點更新主要是找出差異物件,找差異物件的時候也會使用上面的_shouldUpdateReactComponent來判斷,如果是可以直接更新的就會遞迴呼叫子節點的更新,這樣也會遞迴查詢差異物件,這裡還會使用lastIndex這種做一種優化,使一些節點保留位置,之後根據差異物件操作dom元素(位置變動,刪除,新增等)。

整個reactjs的差異演算法就是這個樣子。最核心的兩個_shouldUpdateReactComponent以及diff,patch演算法。

小試牛刀

有了上面簡易版的reaactjs,我們來實現一個簡單的todolist吧。

var TodoList = React.createClass({
  getInitialState: function() {
    return {items: []};
  },
  add:function(){
    var nextItems = this.state.items.concat([this.state.text]);
    this.setState({items: nextItems, text: ''});
  },
  onChange: function(e) {
    this.setState({text: e.target.value});
  },
  render: function() {
    var createItem = function(itemText) {
      return React.createElement("div", null, itemText);
    };

    var lists = this.state.items.map(createItem);
    var input = React.createElement("input", {onkeyup: this.onChange.bind(this),value: this.state.text});
    var button = React.createElement("p", {onclick: this.add.bind(this)}, 'Add#' + (this.state.items.length + 1))
    var children = lists.concat([input,button])

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

React.render(React.createElement(TodoList), document.getElementById("container"));

效果如下:

todolist

整個的流程是這樣:

  • 初次渲染時先使用ReactCompositeComponent渲染自定義元素TodoList,呼叫getInitialState拿到初始值,然後使用ReactDOMComponent渲染render返回的div基本元素節點。div基本元素再一層層的使用ReactDOMComponent去渲染各個子節點,包括input,還有p。
  • 在input框輸入文字觸發onchange事件,開始呼叫setState做出變更,直接變更render出來的節點,經過差異演算法,一層層的往下。最後改變value值。
  • 點選按鈕,觸發add然後開始更新,經過差異演算法,新增一個節點。同時更新按鈕上面的文案。

基本上,整個流程都梳理清楚了

結語

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

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

最後再吐槽下前端真是苦命,各種新技術,各種新知識腦細胞不夠用了。也難怪前端永遠都缺人。

相關文章