React 源碼深度解讀(十):Diff 算法詳解

歡迎關注個人公衆號睿Talk,獲取我最新的文章:
clipboard.pngjavascript

1、前言

React 是一個十分龐大的庫,因爲要同時考慮 ReactDom 和 ReactNative ,還有服務器渲染等,致使其代碼抽象化程度很高,嵌套層級很是深。閱讀 React 源碼是一個很是艱辛的過程,在學習過程當中給我幫助最大的就是這個系列文章。做者對代碼的調用關係梳理得很是清楚,並且還有配圖幫助理解,很是值得一讀。站在巨人的肩膀之上,我嘗試再加入本身的理解,但願對有志於學習 React 源碼的讀者帶來一點啓發。前端

本系列文章基於 React 15.4.2 ,如下是本系列其它文章的傳送門:
React 源碼深度解讀(一):首次 DOM 元素渲染 - Part 1
React 源碼深度解讀(二):首次 DOM 元素渲染 - Part 2
React 源碼深度解讀(三):首次 DOM 元素渲染 - Part 3
React 源碼深度解讀(四):首次自定義組件渲染 - Part 1
React 源碼深度解讀(五):首次自定義組件渲染 - Part 2
React 源碼深度解讀(六):依賴注入
React 源碼深度解讀(七):事務 - Part 1
React 源碼深度解讀(八):事務 - Part 2
React 源碼深度解讀(九):單個元素更新
React 源碼深度解讀(十):Diff 算法詳解java

2、Diff 策略

React 在比較新舊 2 棵虛擬 DOM 樹的時候,會同時考慮兩點:react

  • 儘可能少的建立 / 刪除節點,多使用移動節點的方式
  • 比較次數要儘可能少,算法要足夠的快

若是隻考慮第一點,算法的時間複雜度要達到 O(n3) 的級別。也就是說對於一個有 1000 個節點的樹,要進行 10 億次的比較,這對於前端應用來講是徹底不可接受的。所以,React 選用了啓發式的算法,將時間複雜度控制在 O(n) 的級別。這個算法基於如下 2 個假設:git

  • 若是 2 個節點的類型不同,以這 2 個節點爲根結點的樹會徹底不一樣
  • 對於屢次 render 中結構保持不變的節點,開發者會用一個 key 屬性標識出來,以便重用

另外,React 只會對同一層的節點做比較,不會跨層級比較,如圖所示:github

clipboard.png

Diff 使用的是深度優先算法,當遇到下圖這樣的狀況:算法

clipboard.png

最高效的算法應該是直接將 A 子樹移動到 D 節點,但這樣就涉及到跨層級比較,時間複雜度會陡然上升。React 的作法比較簡單,它會先刪除整個 A 子樹,而後再從新建立一遍。結合到實際的使用場景,改變一個組件的從屬關係的狀況也是不多的。segmentfault

clipboard.png

一樣道理,當 D 節點改成 G 節點時,整棵 D 子樹也會被刪掉,E、F 節點會從新建立。數組

對於列表的 Diff,節點的 key 有助於節點的重用:服務器

clipboard.png

如上圖所示,當沒有 key 的時候,若是中間插入一個新節點,Diff 過程當中從第三個節點開始的節點都是刪除舊節點,建立新節點。當有 key 的時候,除了第三個節點是新建立外,第四和第五個節點都是經過移動實現的。

3、無 Key Diff

在瞭解了整體的 Diff 策略後,咱們來近距離的審視源碼。先更新示例代碼,注意 li 元素沒有使用 key :

class App extends Component {
  constructor(props) {
    super(props);
    this.state = {
      data : ['one', 'two'],
    };

    this.timer = setInterval(
      () => this.tick(),
      5000
    );
  }

  tick() {
    this.setState({
      data: ['new', 'one', 'two'],
    });
  }

  render() {
    return (
      <ul>
      {
        this.state.data.map(function(val, i) {
          return <li>{ val }</li>;
        })
      }
      </ul>
    );
  }
}

export default App;

元素變化以下圖所示,5 秒後會新增一個 new 元素。

clipboard.png

咱們跳過前面的邏輯,直接來看 Diff 的代碼

_updateRenderedComponent: function (transaction, context) {
    var prevComponentInstance = this._renderedComponent;
    // 以前的 Virtual DOM
    var prevRenderedElement = prevComponentInstance._currentElement;
    // 最新的 Virtual DOM
    var nextRenderedElement = this._renderValidatedComponent();

    var debugID = 0;
    if (__DEV__) {
        debugID = this._debugID;
    }

    if (shouldUpdateReactComponent(prevRenderedElement,
            nextRenderedElement)) {
        ReactReconciler.receiveComponent(
            prevComponentInstance,
            nextRenderedElement,
            transaction,
            this._processChildContext(context)
        );
    } else {
        ...
    }
},

// shouldUpdateReactComponent.js
function shouldUpdateReactComponent(prevElement, nextElement) {
    var prevEmpty = prevElement === null || prevElement === false;
    var nextEmpty = nextElement === null || nextElement === false;
    if (prevEmpty || nextEmpty) {
        return prevEmpty === nextEmpty;
    }

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

_updateRenderedComponent方法位於 ReactCompositeComponent 內。它先獲取新、舊 2 個 Virtual DOM,而後經過shouldUpdateReactComponent判斷節點類型是否相同。在咱們的例子裏,跟節點都是 ul 元素,所以跳過中間的層級後,會走到 ReactDOMComponent 的 updateComponent 方法。他會更新屬性和子元素,更新屬性部分上一篇文章已經講清楚了,下面看看更新子元素部分。最終會調用 ReactMultiChild 的 _updateChildren :

_updateChildren: function (nextNestedChildrenElements, transaction, context) {
    ...
    
    var nextChildren = this._reconcilerUpdateChildren(
                prevChildren,
                nextNestedChildrenElements,
                mountImages,
                removedNodes,
                transaction,
                context
            );
            
    ...
    
    for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
            continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {
            updates = enqueue(
                updates,
                this.moveChild(prevChild, lastPlacedNode,
                    nextIndex, lastIndex)
            );
            lastIndex = Math.max(prevChild._mountIndex,
                lastIndex);
            prevChild._mountIndex = nextIndex;
        } else {
            if (prevChild) {
                // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
                lastIndex = Math.max(prevChild._mountIndex,
                    lastIndex);
                // The `removedNodes` loop below will actually remove the child.
            }
            // The child must be instantiated before it's mounted.
            updates = enqueue(
                updates,
                this._mountChildAtIndex(
                    nextChild,
                    mountImages[nextMountIndex],
                    lastPlacedNode,
                    nextIndex,
                    transaction,
                    context
                )
            );
            nextMountIndex++;
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getHostNode(nextChild);
    }
    // Remove children that are no longer present.
    for (name in removedNodes) {
        if (removedNodes.hasOwnProperty(name)) {
            updates = enqueue(
                updates,
                this._unmountChild(prevChildren[name],
                    removedNodes[name])
            );
        }
    }
    if (updates) {
        processQueue(this, updates);
    }
    this._renderedChildren = nextChildren;
},

_reconcilerUpdateChildren: function (
    prevChildren,
    nextNestedChildrenElements,
    mountImages,
    removedNodes,
    transaction,
    context
) {
    var nextChildren;
    var selfDebugID = 0;
    
    nextChildren = flattenChildren(nextNestedChildrenElements,
        selfDebugID);
        
    ReactChildReconciler.updateChildren(
        prevChildren,
        nextChildren,
        mountImages,
        removedNodes,
        transaction,
        this,
        this._hostContainerInfo,
        context,
        selfDebugID
    );
    return nextChildren;
},

在開始 Diff li 元素以前,它會先調用_reconcilerUpdateChildren去更新 li 元素的子元素,也就是文本。在函數中調用了flattenChildren方法,將數組轉換成對象:

function flattenSingleChildIntoContext(
    traverseContext: mixed,
    child: ReactElement < any > ,
    name: string,
    selfDebugID ? : number,
): void {
    // We found a component instance.
    if (traverseContext && typeof traverseContext === 'object') {
        const result = traverseContext;
        const keyUnique = (result[name] === undefined);
        
        if (keyUnique && child != null) {
            result[name] = child;
        }
    }
}

function flattenChildren(
    children: ReactElement < any > ,
    selfDebugID ? : number,
): ? {
    [name: string]: ReactElement < any >
} {
    if (children == null) {
        return children;
    }
    var result = {};

    traverseAllChildren(children, flattenSingleChildIntoContext, result);   
    
    return result;
}

// traverseAllChildren.js
function traverseAllChildren(children, callback, traverseContext) {
    if (children == null) {
        return 0;
    }

    return traverseAllChildrenImpl(children, '', callback, traverseContext);
}

function traverseAllChildrenImpl(
    children,
    nameSoFar,
    callback,
    traverseContext
) {
    var type = typeof children;

    if (type === 'undefined' || type === 'boolean') {
        // All of the above are perceived as null.
        children = null;
    }

    if (children === null ||
        type === 'string' ||
        type === 'number' ||
        // The following is inlined from ReactElement. This means we can optimize
        // some checks. React Fiber also inlines this logic for similar purposes.
        (type === 'object' && children.$$typeof === REACT_ELEMENT_TYPE)) {
        callback(
            traverseContext,
            children,
            // If it's the only child, treat the name as if it was wrapped in an array
            // so that it's consistent if the number of children grows.
            nameSoFar === '' ? SEPARATOR + getComponentKey(children, 0) :
            nameSoFar
        );
        return 1;
    }

    var child;
    var nextName;
    var subtreeCount = 0; // Count of children found in the current subtree.
    var nextNamePrefix = nameSoFar === '' ? SEPARATOR : nameSoFar +
        SUBSEPARATOR;

    if (Array.isArray(children)) {
        for (var i = 0; i < children.length; i++) {
            child = children[i];
            nextName = nextNamePrefix + getComponentKey(child, i);
            subtreeCount += traverseAllChildrenImpl(
                child,
                nextName,
                callback,
                traverseContext
            );
        }
    }
    
    ...

    return subtreeCount;
}

function getComponentKey(component, index) {
    // Do some typechecking here since we call this blindly. We want to ensure
    // that we don't block potential future ES APIs.
    if (component && typeof component === 'object' && component.key != null) {
        // Explicit key
        return KeyEscapeUtils.escape(component.key);
    }
    // Implicit key determined by the index in the set
    return index.toString(36);
}

flattenSingleChildIntoContext做爲flattenChildren的回調函數,會做用在每個數組元素上,構造一個對象(result)。對象的 key 是經過getComponentKey這個方法生成的,能夠看到若是沒有定義 key 屬性,則默認會用數組的 index 做爲 key 。最終構造出來的對象是這個樣子的:

clipboard.png

而後就會調用ReactChildReconciler.updateChildren方法,去更新 li 的文本和建立新的 li 元素。

updateChildren: function (
    prevChildren,
    nextChildren,
    mountImages,
    removedNodes,
    transaction,
    hostParent,
    hostContainerInfo,
    context,
    selfDebugID // 0 in production and for roots
) {
    if (!nextChildren && !prevChildren) {
        return;
    }
    var name;
    var prevChild;
    for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
            continue;
        }
        prevChild = prevChildren && prevChildren[name];
        var prevElement = prevChild && prevChild._currentElement;
        var nextElement = nextChildren[name];
        
        if (prevChild != null &&
            shouldUpdateReactComponent(prevElement, nextElement)) {
            ReactReconciler.receiveComponent(
                prevChild, nextElement, transaction, context
            );
            nextChildren[name] = prevChild;
        } else {
            if (prevChild) {
                removedNodes[name] = ReactReconciler.getHostNode(
                    prevChild);
                ReactReconciler.unmountComponent(prevChild, false);
            }
            // The child must be instantiated before it's mounted.
            var nextChildInstance = instantiateReactComponent(
                nextElement, true);
            nextChildren[name] = nextChildInstance;
            // Creating mount image now ensures refs are resolved in right order
            // (see https://github.com/facebook/react/pull/7101 for explanation).
            var nextChildMountImage = ReactReconciler.mountComponent(
                nextChildInstance,
                transaction,
                hostParent,
                hostContainerInfo,
                context,
                selfDebugID
            );
            mountImages.push(nextChildMountImage);
        }
    }
    // Unmount children that are no longer present.
    for (name in prevChildren) {
        if (prevChildren.hasOwnProperty(name) &&
            !(nextChildren && nextChildren.hasOwnProperty(name))) {
            prevChild = prevChildren[name];
            removedNodes[name] = ReactReconciler.getHostNode(
                prevChild);
            ReactReconciler.unmountComponent(prevChild, false);
        }
    }
},

在更新 li 前,會根據 key 去取上一次 render 對應的元素prevChild = prevChildren && prevChildren[name]。以第 0 個元素爲例,prevElement 爲 ReactElement[3]( key 爲‘.0’),而 nextElement 爲 ReactElement[2],所以會調用ReactReconciler.receiveComponent來更新文本元素,過程與上一篇文章同樣。

clipboard.png

當遍歷到最後一個 li ,因爲沒有 prevChild,會建立一個新的實例。

再回到 ReactMultiChild 的 _updateChildren 方法,這時nextChildren已經建立好,開始遍歷:

_updateChildren: function (nextNestedChildrenElements, transaction, context) {
    ...
    
    var nextChildren = this._reconcilerUpdateChildren(
                prevChildren,
                nextNestedChildrenElements,
                mountImages,
                removedNodes,
                transaction,
                context
            );
            
    ...
    
    for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
            continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {    ------------------------------------ 1)
            updates = enqueue(
                updates,
                this.moveChild(prevChild, lastPlacedNode,
                    nextIndex, lastIndex)
            );
            lastIndex = Math.max(prevChild._mountIndex,
                lastIndex);
            prevChild._mountIndex = nextIndex;
        } else {                          ------------------------------------ 2)
            if (prevChild) {
                // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
                lastIndex = Math.max(prevChild._mountIndex,
                    lastIndex);
                // The `removedNodes` loop below will actually remove the child.
            }
            // The child must be instantiated before it's mounted.
            updates = enqueue(
                updates,
                this._mountChildAtIndex(
                    nextChild,
                    mountImages[nextMountIndex],
                    lastPlacedNode,
                    nextIndex,
                    transaction,
                    context
                )
            );
            nextMountIndex++;
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getHostNode(nextChild);
    }
    // Remove children that are no longer present.
    for (name in removedNodes) {
        if (removedNodes.hasOwnProperty(name)) {
            updates = enqueue(
                updates,
                this._unmountChild(prevChildren[name],
                    removedNodes[name])
            );
        }
    }
    if (updates) {
        processQueue(this, updates);
    }
    this._renderedChildren = nextChildren;
},

前面 2 個 li 元素會走到分支 1),第三個元素會到分支 2),建立新的 li 元素,過程與上一篇的相似。

4、有 Key Diff

例子改一下,加入 key:

class App extends Component {
    constructor(props) {
        super(props);
        this.state = {
            data: ['one', 'two']
        };

        this.timer = setTimeout(() => this.tick(), 5000);
    }

    tick() {
        this.setState({
            data: ['new', 'one', 'two']
        });
    }

    render() {
        return (
            <ul>
                {
                    this.state.data.map(function (val, i) {
                         return <li key={val}>{ val }</li>;
                    })
                }
            </ul>
        );
    }
}

流程與無 key 相似,再也不贅述。flattenChildren後的對象是這個樣子的:

clipboard.png

因爲使用了 key ,ReactChildReconciler.updateChildren再也不須要更新 text 了,只須要建立一個新的實例。

clipboard.png

而後到 ReactMultiChild 的 _updateChildren :

_updateChildren: function (nextNestedChildrenElements, transaction, context) {
    ...
    
    for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
            continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {    ------------------------------------ 1)
            updates = enqueue(
                updates,
                this.moveChild(prevChild, lastPlacedNode,
                    nextIndex, lastIndex)
            );
            lastIndex = Math.max(prevChild._mountIndex,
                lastIndex);
            prevChild._mountIndex = nextIndex;
        } else {                          ------------------------------------ 2)
            if (prevChild) {
                // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
                lastIndex = Math.max(prevChild._mountIndex,
                    lastIndex);
                // The `removedNodes` loop below will actually remove the child.
            }
            // The child must be instantiated before it's mounted.
            updates = enqueue(
                updates,
                this._mountChildAtIndex(
                    nextChild,
                    mountImages[nextMountIndex],
                    lastPlacedNode,
                    nextIndex,
                    transaction,
                    context
                )
            );
            nextMountIndex++;
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getHostNode(nextChild);
    }
    // Remove children that are no longer present.
    for (name in removedNodes) {
        if (removedNodes.hasOwnProperty(name)) {
            updates = enqueue(
                updates,
                this._unmountChild(prevChildren[name],
                    removedNodes[name])
            );
        }
    }
    if (updates) {
        processQueue(this, updates);
    }
    this._renderedChildren = nextChildren;
},

匹配第一個元素的時候,會到分支 2),後面 2 個元素都是走分支 1)。

有 key 跟沒 key 相比,減小了 2 次文本元素的更新,提升了效率。

5、深挖 Key Diff

再來考慮更復雜的狀況:

clipboard.png

假設要作上圖的變化,再來分析下源碼:

_updateChildren: function (nextNestedChildrenElements, transaction, context) {
    ...
    
    var nextIndex = 0;
    var lastIndex = 0;
    var nextMountIndex = 0;
    var lastPlacedNode = null;
            
    for (name in nextChildren) {
        if (!nextChildren.hasOwnProperty(name)) {
            continue;
        }
        var prevChild = prevChildren && prevChildren[name];
        var nextChild = nextChildren[name];
        if (prevChild === nextChild) {    ------------------------------------ 1)
            updates = enqueue(
                updates,
                this.moveChild(prevChild, lastPlacedNode, nextIndex, lastIndex)
            );
            lastIndex = Math.max(prevChild._mountIndex, lastIndex);
            prevChild._mountIndex = nextIndex;
        } else {                          ------------------------------------ 2)
            if (prevChild) {
                // Update `lastIndex` before `_mountIndex` gets unset by unmounting.
                lastIndex = Math.max(prevChild._mountIndex, lastIndex);
                // The `removedNodes` loop below will actually remove the child.
            }
            // The child must be instantiated before it's mounted.
            updates = enqueue(
                updates,
                this._mountChildAtIndex(
                    nextChild,
                    mountImages[nextMountIndex],
                    lastPlacedNode,
                    nextIndex,
                    transaction,
                    context
                )
            );
            nextMountIndex++;
        }
        nextIndex++;
        lastPlacedNode = ReactReconciler.getHostNode(nextChild);
    }
    ...
},

moveChild: function (child, afterNode, toIndex, lastIndex) {
    // If the index of `child` is less than `lastIndex`, then it needs to
    // be moved. Otherwise, we do not need to move it because a child will be
    // inserted or moved before `child`.
    if (child._mountIndex < lastIndex) {
        return makeMove(child, afterNode, toIndex);
    }
},

這裏要先搞清楚 3 個 index:

  • nextIndex:遍歷 nextChildren 時候的 index,每遍歷一個元素加 1
  • lastIndex:上一次從 prevChildren 中取出來元素時,這個元素在 prevChildren 中的 index
  • _mountIndex:元素在數組中的位置

下面咱們來走一遍流程:

  • nextChildren 的第一個元素是 B ,在 prevChildren 中的位置是 1(_mountIndex),nextIndex 和 lastIndex 都是 0。節點移動的前提是_mountIndex < lastIndex,所以 B 不須要移動。lastIndex 更新爲 _mountIndex 和 lastIndex 中較大的:1 。nextIndex 自增:1;
  • nextChildren 的第二個元素是 A ,在 prevChildren 中的位置是 0(_mountIndex),nextIndex 和 lastIndex 都是 1。這時_mountIndex < lastIndex,所以將 A 移動到 lastPlacedNode (B)的後面 。lastIndex 更新爲 _mountIndex 和 lastIndex 中較大的:1 。nextIndex 自增:2;
  • nextChildren 的第三個元素是 D ,中 prevChildren 中的位置是 3(_mountIndex),nextIndex 是 2 ,lastIndex 是 1。這時不知足_mountIndex < lastIndex,所以 D 不須要移動。lastIndex 更新爲 _mountIndex 和 lastIndex 中較大的:3 。nextIndex 自增:3;
  • nextChildren 的第四個元素是 C ,中 prevChildren 中的位置是 2(_mountIndex),nextIndex 是 3 ,lastIndex 是 3。這時_mountIndex < lastIndex,所以將 C 移動到 lastPlacedNode (D)的後面。循環結束。

觀察整個過程,移動的原則是將原來的元素往右邊移,經過 lastIndex 來控制。在 lastIndex 左邊的,就往 lastIndex 的右邊移動;在 lastIndex 左邊的,不須要動。

6、總結

本文詳細講解了 Diff 過程當中 key 的做用以及複用節點的移動原則,並結合源碼作了深刻的討論。到此爲止,整個 React 源碼解讀系列先告一段落了,後會有期。

相關文章
相關標籤/搜索