React 源码理会系列 - 难以想象的 react diff

现在,前端领域中 React 势头正盛,使用者浩瀚却少有能够深切理会内部完成机制和道理。本系列文章愿望经由历程理会 React 源码,明白其内部的完成道理,知其然更要知其所以然。

React diff 作为 Virtual DOM 的加速器,其算法上的革新优化是 React 悉数界面衬着的基本,以及机能进步的保证,同时也是 React 源码中最神奇、最难以设想的部份,本文从源码入手,深切理会 React diff 的难以设想的处所。

  • 浏览本文须要对 React 有肯定的相识,假如你不知作甚 React,请详读 React 官方文档

  • 假如你对 React diff 存在些许迷惑,或许你对算法优化感兴趣,那末本文值得浏览和议论。

媒介

React 中最值得称道的部份莫过于 Virtual DOM 与 diff 的圆满连系,迥殊是其高效的 diff 算法,让用户能够无需忌惮机能题目而”率性自在”的革新页面,让开辟者也能够无需体贴 Virtual DOM 背地的运作道理,因为 React diff 会协助我们盘算出 Virtual DOM 中真正变化的部份,并只针对该部份举行现实 DOM 操纵,而非重新衬着悉数页面,从而保证了每次操纵更新后页面的高效衬着,因而 Virtual DOM 与 diff 是保证 React 机能口碑的幕后推手。

行文至此,能够会有读者质疑:React 不过就是引入 diff 这一观点,且 diff 算法也并非其开创,何须揄扬的云云信口开河呢?

实在,恰是因为 diff 算法的普识度高,就更应当承认 React 针对 diff 算法优化所做的勤奋与孝敬,更能表现 React 开辟者们的魅力与伶俐!

传统 diff 算法

盘算一棵树形构造转换成另一棵树形构造的起码操纵,是一个庞杂且值得研讨的题目。传统 diff 算法经由历程轮回递归对节点举行顺次对照,效力低下,算法庞杂度到达 O(n3),个中 n 是树中节点的总数。O(n3) 到底有多恐怖,这意味着假如要展现1000个节点,就要顺次实行上十亿次的比较。这类指数型的机能斲丧关于前端衬着场景来讲价值太高了!当今的 CPU 每秒钟能实行约莫30亿条指令,即便是最高效的完成,也不能够在一秒内盘算出差别状况。

因而,假如 React 只是纯真的引入 diff 算法而没有任何的优化革新,那末其效力是远远没法满足前端衬着所请求的机能。

经由历程下面的 demo 能够清楚的形貌传统 diff 算法的完成历程。

let result = [];
// 比较恭弘=叶 恭弘子节点
const diffLeafs = function(beforeLeaf, afterLeaf) {
  // 猎取较大节点树的长度
  let count = Math.max(beforeLeaf.children.length, afterLeaf.children.length);
  // 轮回遍历
  for (let i = 0; i < count; i++) {
    const beforeTag = beforeLeaf.children[i];
    const afterTag = afterLeaf.children[i];
    // 增加 afterTag 节点
    if (beforeTag === undefined) {
      result.push({type: "add", element: afterTag});
    // 删除 beforeTag 节点
    } else if (afterTag === undefined) {
      result.push({type: "remove", element: beforeTag});
    // 节点名转变时,删除 beforeTag 节点,增加 afterTag 节点
    } else if (beforeTag.tagName !== afterTag.tagName) {
      result.push({type: "remove", element: beforeTag});
      result.push({type: "add", element: afterTag});
    // 节点稳固而内容转变时,转变节点
    } else if (beforeTag.innerHTML !== afterTag.innerHTML) {
      if (beforeTag.children.length === 0) {
        result.push({
          type: "changed",
          beforeElement: beforeTag,
          afterElement: afterTag,
          html: afterTag.innerHTML
      });
      } else {
        // 递归比较
        diffLeafs(beforeTag, afterTag);
      }
    }
  }
  return result;
}

因而,假如想要将 diff 头脑引入 Virtual DOM,就须要设想一种稳固高效的 diff 算法,而 React 做到了!

那末,React diff 究竟是如何完成的呢?

详解 React diff

传统 diff 算法的庞杂度为 O(n3),明显这是没法满足机能请求的。React 经由历程制订斗胆勇敢的战略,将 O(n3) 庞杂度的题目转换成 O(n) 庞杂度的题目

diff 战略

  1. Web UI 中 DOM 节点跨层级的挪动操纵迥殊少,能够忽略不计。

  2. 具有雷同类的两个组件将会天生相似的树形构造,具有差别类的两个组件将会天生差别的树形构造。

  3. 关于统一层级的一组子节点,它们能够经由历程唯一 id 举行辨别。

基于以上三个前提战略,React 分别对 tree diff、component diff 以及 element diff 举行算法优化,现实也证实这三个前提战略是合理且正确的,它保证了团体界面构建的机能。

  • tree diff

  • component diff

  • element diff

本文中源码 ReactMultiChild.js

tree diff

基于战略一,React 对树的算法举行了简约明了的优化,即对树举行分层比较,两棵树只会对统一条理的节点举行比较。

既然 DOM 节点跨层级的挪动操纵少到能够忽略不计,针对这一征象,React 经由历程 updateDepth 对 Virtual DOM 树举行层级掌握,只会对雷同色彩方框内的 DOM 节点举行比较,即统一个父节点下的一切子节点。当发明节点已不存在,则该节点及其子节点会被完整删撤除,不会用于进一步的比较。如许只须要对树举行一次遍历,便能完成悉数 DOM 树的比较。

《React 源码理会系列 - 难以想象的 react diff》

updateChildren: function(nextNestedChildrenElements, transaction, context) {
  updateDepth++;
  var errorThrown = true;
  try {
    this._updateChildren(nextNestedChildrenElements, transaction, context);
    errorThrown = false;
  } finally {
    updateDepth--;
    if (!updateDepth) {
      if (errorThrown) {
        clearQueue();
      } else {
        processQueue();
      }
    }
  }
}

剖析至此,大部份人能够都存在如许的疑问:假如涌现了 DOM 节点跨层级的挪动操纵,React diff 会有如何的表现呢?是的,对此我也猎奇不已,不如实验一番。

如下图,A 节点(包括其子节点)悉数被挪动到 D 节点下,因为 React 只会简朴的斟酌同层级节点的位置变更,而关于差别层级的节点,只要建立和删除操纵。当根节点发明子节点中 A 消逝了,就会直接烧毁 A;当 D 发明多了一个子节点 A,则会建立新的 A(包括子节点)作为其子节点。此时,React diff 的实行状况:create A -> create B -> create C -> delete A

由此可发明,当涌现节点跨层级挪动时,并不会涌现设想中的挪动操纵,而是以 A 为根节点的树被悉数重新建立,这是一种影响 React 机能的操纵,因而 React 官方发起不要举行 DOM 节点跨层级的操纵

注重:在开辟组件时,坚持稳固的 DOM 构造会有助于机能的提拔。比方,能够经由历程 CSS 隐蔽或显现节点,而不是真的移除或增加 DOM 节点。

《React 源码理会系列 - 难以想象的 react diff》

component diff

React 是基于组件构建运用的,关于组件间的比较所采用的战略也是简约高效。

  • 假如是统一范例的组件,根据原战略继承比较 virtual DOM tree。

  • 假如不是,则将该组件推断为 dirty component,从而替代悉数组件下的一切子节点。

  • 关于统一范例的组件,有能够其 Virtual DOM 没有任何变化,假如能够确实的晓得这点那能够节约大批的 diff 运算时候,因而 React 许可用户经由历程 shouldComponentUpdate() 来推断该组件是不是须要举行 diff。

如下图,当 component D 转变成 component G 时,纵然这两个 component 构造相似,一旦 React 推断 D 和 G 是差别范例的组件,就不会比较两者的构造,而是直接删除 component D,重新建立 component G 以及其子节点。虽然当两个 component 是差别范例但构造相似时,React diff 会影响机能,但正如 React 官方博客所言:差别范例的 component 是很少存在相似 DOM tree 的时机,因而这类极度要素很难在完成开辟历程当中形成严重影响的。

《React 源码理会系列 - 难以想象的 react diff》

element diff

当节点处于统一层级时,React diff 供应了三种节点操纵,分别为:INSERT_MARKUP(插进去)、MOVE_EXISTING(挪动)和 REMOVE_NODE(删除)。

  • INSERT_MARKUP,新的 component 范例不在老鸠合里, 等于全新的节点,须要对新节点实行插进去操纵。

  • MOVE_EXISTING,在老鸠合有新 component 范例,且 element 是可更新的范例,generateComponentChildren 已挪用 receiveComponent,这类状况下 prevChild=nextChild,就须要做挪动操纵,能够复用之前的 DOM 节点。

  • REMOVE_NODE,老 component 范例,在新鸠合里也有,但对应的 element 差别则不能直接复用和更新,须要实行删除操纵,或许老 component 不在新鸠合里的,也须要实行删除操纵。

function enqueueInsertMarkup(parentInst, markup, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.INSERT_MARKUP,
    markupIndex: markupQueue.push(markup) - 1,
    content: null,
    fromIndex: null,
    toIndex: toIndex,
  });
}

function enqueueMove(parentInst, fromIndex, toIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.MOVE_EXISTING,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: toIndex,
  });
}

function enqueueRemove(parentInst, fromIndex) {
  updateQueue.push({
    parentInst: parentInst,
    parentNode: null,
    type: ReactMultiChildUpdateTypes.REMOVE_NODE,
    markupIndex: null,
    content: null,
    fromIndex: fromIndex,
    toIndex: null,
  });
}

如下图,老鸠合中包括节点:A、B、C、D,更新后的新鸠合中包括节点:B、A、D、C,此时新老鸠合举行 diff 差别化对照,发明 B != A,则建立并插进去 B 至新鸠合,删除老鸠合 A;以此类推,建立并插进去 A、D 和 C,删除 B、C 和 D。

《React 源码理会系列 - 难以想象的 react diff》

React 发明这类操纵烦琐冗余,因为这些都是雷同的节点,但因为位置发生变化,致使须要举行冗杂低效的删除、建立操纵,实在只要对这些节点举行位置挪动即可。

针对这一征象,React 提出优化战略:许可开辟者对统一层级的同组子节点,增加唯一 key 举行辨别,虽然只是小小的修改,机能上却发生了天翻地覆的变化!

新老鸠合所包括的节点,如下图所示,新老鸠合举行 diff 差别化对照,经由历程 key 发明新老鸠合中的节点都是雷同的节点,因而无需举行节点删除和建立,只须要将老鸠合中节点的位置举行挪动,更新为新鸠合中节点的位置,此时 React 给出的 diff 效果为:B、D 不做任何操纵,A、C 举行挪动操纵,即可。

《React 源码理会系列 - 难以想象的 react diff》

那末,云云高效的 diff 究竟是如何运作的呢?让我们经由历程源码举行详细剖析。

起首对新鸠合的节点举行轮回遍历,for (name in nextChildren),经由历程唯一 key 能够推断新老鸠合中是不是存在雷同的节点,if (prevChild === nextChild),假如存在雷同节点,则举行挪动操纵,但在挪动前须要将当前节点在老鸠合中的位置与 lastIndex 举行比较,if (child._mountIndex < lastIndex),则举行节点挪动操纵,否则不实行该操纵。这是一种递次优化手腕,lastIndex 一直在更新,示意接见过的节点在老鸠合中最右的位置(即最大的位置),假如新鸠合中当前接见的节点比 lastIndex 大,申明当前接见节点在老鸠合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因而不必增加到差别行列中,即不实行挪动操纵,只要当接见的节点比 lastIndex 小时,才须要举行挪动操纵。

以上图为例,能够更加清楚直观的形貌 diff 的差别对照历程:

  • 重新鸠合中获得 B,推断老鸠合中存在雷同节点 B,经由历程对照节点位置推断是不是举行挪动操纵,B 在老鸠合中的位置 B._mountIndex = 1,此时 lastIndex = 0,不满足 child._mountIndex < lastIndex 的前提,因而不对 B 举行挪动操纵;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),个中 prevChild._mountIndex 示意 B 在老鸠合中的位置,则 lastIndex = 1,并将 B 的位置更新为新鸠合中的位置 prevChild._mountIndex = nextIndex,此时新鸠合中 B._mountIndex = 0nextIndex++ 进入下一个节点的推断。

  • 重新鸠合中获得 A,推断老鸠合中存在雷同节点 A,经由历程对照节点位置推断是不是举行挪动操纵,A 在老鸠合中的位置 A._mountIndex = 0,此时 lastIndex = 1,满足 child._mountIndex < lastIndex的前提,因而对 A 举行挪动操纵enqueueMove(this, child._mountIndex, toIndex),个中 toIndex 实在就是 nextIndex,示意 A 须要挪动到的位置;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 1,并将 A 的位置更新为新鸠合中的位置 prevChild._mountIndex = nextIndex,此时新鸠合中 A._mountIndex = 1nextIndex++ 进入下一个节点的推断。

  • 重新鸠合中获得 D,推断老鸠合中存在雷同节点 D,经由历程对照节点位置推断是不是举行挪动操纵,D 在老鸠合中的位置 D._mountIndex = 3,此时 lastIndex = 1,不满足 child._mountIndex < lastIndex的前提,因而不对 D 举行挪动操纵;更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 D 的位置更新为新鸠合中的位置 prevChild._mountIndex = nextIndex,此时新鸠合中 D._mountIndex = 2nextIndex++ 进入下一个节点的推断。

  • 重新鸠合中获得 C,推断老鸠合中存在雷同节点 C,经由历程对照节点位置推断是不是举行挪动操纵,C 在老鸠合中的位置 C._mountIndex = 2,此时 lastIndex = 3,满足 child._mountIndex < lastIndex 的前提,因而对 C 举行挪动操纵 enqueueMove(this, child._mountIndex, toIndex);更新 lastIndex = Math.max(prevChild._mountIndex, lastIndex),则 lastIndex = 3,并将 C 的位置更新为新鸠合中的位置 prevChild._mountIndex = nextIndex,此时新鸠合中 A._mountIndex = 3nextIndex++ 进入下一个节点的推断,因为 C 已是末了一个节点,因而 diff 到此完成。

以上重要剖析新老鸠合中存在雷同节点但位置差别时,对节点举行位置挪动的状况,假如新鸠合中有新到场的节点且老鸠合存在须要删除的节点,那末 React diff 又是如何对照运作的呢?

以下图为例:

  • 重新鸠合中获得 B,推断老鸠合中存在雷同节点 B,因为 B 在老鸠合中的位置 B._mountIndex = 1,此时 lastIndex = 0,因而不对 B 举行挪动操纵;更新 lastIndex = 1,并将 B 的位置更新为新鸠合中的位置 B._mountIndex = 0nextIndex++进入下一个节点的推断。

  • 重新鸠合中获得 E,推断老鸠合中不存在雷同节点 E,则建立新节点 E;更新 lastIndex = 1,并将 E 的位置更新为新鸠合中的位置,nextIndex++进入下一个节点的推断。

  • 重新鸠合中获得 C,推断老鸠合中存在雷同节点 C,因为 C 在老鸠合中的位置C._mountIndex = 2,此时 lastIndex = 1,因而对 C 举行挪动操纵;更新 lastIndex = 2,并将 C 的位置更新为新鸠合中的位置,nextIndex++ 进入下一个节点的推断。

  • 重新鸠合中获得 A,推断老鸠合中存在雷同节点 A,因为 A 在老鸠合中的位置A._mountIndex = 0,此时 lastIndex = 2,因而不对 A 举行挪动操纵;更新 lastIndex = 2,并将 A 的位置更新为新鸠合中的位置,nextIndex++ 进入下一个节点的推断。

  • 当完成新鸠合中一切节点 diff 时,末了还须要对老鸠合举行轮回遍历,推断是不是存在新鸠合中没有但老鸠合中仍存在的节点,发明存在如许的节点 D,因而删除节点 D,到此 diff 悉数完成。

《React 源码理会系列 - 难以想象的 react diff》

_updateChildren: function(nextNestedChildrenElements, transaction, context) {
  var prevChildren = this._renderedChildren;
  var nextChildren = this._reconcilerUpdateChildren(
    prevChildren, nextNestedChildrenElements, transaction, context
  );
  if (!nextChildren && !prevChildren) {
    return;
  }
  var name;
  var lastIndex = 0;
  var nextIndex = 0;
  for (name in nextChildren) {
    if (!nextChildren.hasOwnProperty(name)) {
      continue;
    }
    var prevChild = prevChildren && prevChildren[name];
    var nextChild = nextChildren[name];
    if (prevChild === nextChild) {
      // 挪动节点
      this.moveChild(prevChild, nextIndex, lastIndex);
      lastIndex = Math.max(prevChild._mountIndex, lastIndex);
      prevChild._mountIndex = nextIndex;
    } else {
      if (prevChild) {
        lastIndex = Math.max(prevChild._mountIndex, lastIndex);
        // 删除节点
        this._unmountChild(prevChild);
      }
      // 初始化并建立节点
      this._mountChildAtIndex(
        nextChild, nextIndex, transaction, context
      );
    }
    nextIndex++;
  }
  for (name in prevChildren) {
    if (prevChildren.hasOwnProperty(name) &&
        !(nextChildren && nextChildren.hasOwnProperty(name))) {
      this._unmountChild(prevChildren[name]);
    }
  }
  this._renderedChildren = nextChildren;
},
// 挪动节点
moveChild: function(child, toIndex, lastIndex) {
  if (child._mountIndex < lastIndex) {
    this.prepareToManageChildren();
    enqueueMove(this, child._mountIndex, toIndex);
  }
},
// 建立节点
createChild: function(child, mountImage) {
  this.prepareToManageChildren();
  enqueueInsertMarkup(this, mountImage, child._mountIndex);
},
// 删除节点
removeChild: function(child) {
  this.prepareToManageChildren();
  enqueueRemove(this, child._mountIndex);
},

_unmountChild: function(child) {
  this.removeChild(child);
  child._mountIndex = null;
},

_mountChildAtIndex: function(
  child,
  index,
  transaction,
  context) {
  var mountImage = ReactReconciler.mountComponent(
    child,
    transaction,
    this,
    this._nativeContainerInfo,
    context
  );
  child._mountIndex = index;
  this.createChild(child, mountImage);
},

固然,React diff 照样存在些许不足与待优化的处所,如下图所示,若新鸠合的节点更新为:D、A、B、C,与老鸠合对照只要 D 节点挪动,而 A、B、C 依然坚持原有的递次,理论上 diff 应当只需对 D 实行挪动操纵,但是因为 D 在老鸠合的位置是最大的,致使其他节点的 _mountIndex < lastIndex,形成 D 没有实行挪动操纵,而是 A、B、C 悉数挪动到 D 节点背面的征象。

在此,读者们能够议论思索:如何优化上述题目?

发起:在开辟历程当中,只管削减相似将末了一个节点挪动到列表首部的操纵,当节点数目过大或更新操纵过于频仍时,在肯定水平上会影响 React 的衬着机能。

《React 源码理会系列 - 难以想象的 react diff》

总结

  • React 经由历程制订斗胆勇敢的 diff 战略,将 O(n3) 庞杂度的题目转换成 O(n) 庞杂度的题目;

  • React 经由历程分层求异的战略,对 tree diff 举行算法优化;

  • React 经由历程雷同类天生相似树形构造,差别类天生差别树形构造的战略,对 component diff 举行算法优化;

  • React 经由历程设置唯一 key的战略,对 element diff 举行算法优化;

  • 发起,在开辟组件时,坚持稳固的 DOM 构造会有助于机能的提拔;

  • 发起,在开辟历程当中,只管削减相似将末了一个节点挪动到列表首部的操纵,当节点数目过大或更新操纵过于频仍时,在肯定水平上会影响 React 的衬着机能。

参考资料

假如本文能够为你处理些许关于 React diff 算法的迷惑,请点个赞吧!

    原文作者:twobin
    原文地址: https://segmentfault.com/a/1190000004003055
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞