解析React 虚拟DOM和Diff算法

一、 JSX

众所周知,React 使用 JSX 来替代常规的 JavaScript。JSX 是一个看起来很像 XML 的 JavaScript 语法扩展,其本质是 createElement()方法的语法糖 (语法糖:更加直观、简洁、友好)。

JSX 代码会经过babel-loader 会解析为 React.createElement()嵌套对象。React.createElement() 创建的就是一个虚拟DOM结构。

二、 虚拟DOM

通过React.createElement()创建的虚拟DOM描述了DOM树的结构,其本质是一个轻量级的javaScript对象。该JS对象包含如下属性:

- type:元素的类型,可以是原生html类型(字符串),或者自定义组件(函数或class)

- key:组件的唯一标识,用于Diff算法,之后会详细介绍

- ref:用于访问原生dom节点

-  props:传入组件的props,children是props中的一个属性,它存储了当前组件的子节点,可以是数组(多个子节点)或对象(只有一个子节点)

- owner:当前正在构建的Component所属的Component

- self:(非生产环境)指定当前位于哪个组件实例

-  _source:(非生产环境)指定调试代码来自的文件(fileName)和代码行数(lineNumber)

为更好理解,下面我们来看一组转换流程。

存在JSX代码如下:

const element = (

  <div className="title">

    <span>Hello JSX</span>

    <ul>

      <li>test1</li>

      <li>test2</li>

    </ul>

  </div>

);

通过babel-loader 解析后:

const element = React.createElement(

  "div",

  { className: "title" },

  React.createElement("span", null, "Hello JSX"),

  React.createElement(

    "ul",

    null,

    React.createElement("li", null, "test1"),

    React.createElement("li", null, "test2")

  )

);

转换成虚拟Dom后,会变成如下JS代码(为方便查看,删除部分不必要属性)

const element = {

  type: "div",

  props: { class: "title" },

  children: [

    { type: "span", children: "Hello JSX" },

    {

      type: "ul",

      children: [

        { type: "li", children: "test1" },

        { type: "li", children: "test2" },

      ],

    },

  ],

};

完整的虚拟Dom代码如下:

由此可见,虚拟DOM就是JS对象。最后,ReactDom.render 将生成好的虚拟DOM渲染到指定容器上,其中采用了批处理,事务等机制,并且对特定浏览器进行了性能优化,最终转换为真实DOM。

1. 为什么需要虚拟DOM呢?

- 提高性能

我们都知道,每次DOM操作会引起重绘或者回流,频繁的真实DOM的修改会触发多次的排版和重绘,相当耗性能。

虚拟DOM可以提高性能,不是说不操作DOM,而是减少操作真实DOM的次数。即当状态/数据改变时,React会自动更新虚拟DOM,产生一个新的虚拟DOM树。通过diff算法对新旧虚拟DOM进行比较,找出最小的有变化的部分,将这个变化的部分Patch(即需要修改的部分)加入队列,最终,批量的更新这些Patch到真实DOM上,以减少重绘和回流,从而达到性能优化的目的。

此外,React还提供了componentShouldUpdate生命周期来让开发者手动控制减少数据变化后不必要的虚拟dom对比,提升性能和渲染效率。

- 跨浏览器兼容

React 基于 虚拟DOM自己实现了一套事件机制,自己模拟了事件冒泡和捕获的过程,采用了事件代理,批量更新等方法,抹平了各个浏览器的事件兼容性。

- 跨平台兼容

虚拟DOM为React带来了跨平台渲染的能力,以React-native为例子,React根据虚拟DOM画出相应平台的UI.

- 提高开发效率

三、 Diff算法

传统的diff算法是使用递归循环对节点进行依次对比,即使在最前沿的算法中 将前后两棵树完全比对的算法的复杂程度为 O(n^3),其中 n 是树中元素的数量。 如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。 这个开销实在是太过高昂。

为了提高性能,React同时维护着两棵虚拟DOM树:一棵为当前的DOM结构(旧虚拟DOM),另一棵为React状态变更后生成的DOM结构(新虚拟DOM)。React通过比较这两棵树的差异,决定是否需要修改DOM结构,以及如何修改。这种算法称作**React 的 Diff算法**

React的 Diff算法会帮助我们计算出虚拟DOM 中真正发生变化的部分,并且只针对该部分进行实际的DOM操作,而不是对整个页面进行重新渲染。为了降低算法复杂度,React的 Diff算法提出三种策略:

- 针对同一层级的节点进行比较。即如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用它(因为跨层级的DOM移动操作特别少,可以忽略不计)。

- 不同类型的元素会产生出不同的树。即相同类的两个组件将会生成相似的树形结构,不同类的两个组件将会生成不同的树形结构。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

- 同一层级的一组子节点,可以通过唯一的id区分(key)

基于以上三个策略,React 的 Diff算法分别对Tree Diff、Component Diff以及Element diff进行了算法优化。

 1. Tree Diff

基于第一个策略,React只会对同一层次的节点进行比较,即如果父节点不同,React将不会再去对比子节点。因为不同的组件DOM结构会不相同,所以就没有必要再去对比子节点了。这样就只需要遍历一次,就能完成对整个DOM树的比较,进而提高了对比的效率,把事件复杂度降低为O(n).


React对于不同层级的节点,只有创建和删除操作。如图所示,如果A节点整个被移动到D节点下,当根节点发现子节点中A不见了,就会直接销毁A;而当D发现自己多了一个子节点A,则会创建一个新的A作为子节点。


因此对于这种结构的转变的实际操作是:

A.destroy();

A = new A();

A.append(new B());

A.append(new C());

D.append(A);

由于React 的Diff 算法没有针对跨层级的DOM移动操作进行深入比较,对于节点跨层级移动时,只是进行简单的创建和删除。这会影响 React 性能的操作,因此,官方建议不要进行 DOM 节点跨层级的操作。在组件开发时,推荐通过 CSS 隐藏或显示节点,不做真正地移除或添加 DOM 节点的操作,进而保证稳定的 DOM 结构,提升性能。

2. Component Diff

Component Diff是专门针对更新前后的同一层级间的React组件比较的Diff 算法。React对于组件间的比较采取的策略如下:

- 如果是同一类型的组件,按照原策略继续进行虚拟DOM 比较。

- 如果不是,则将该组件判断为dirty component,从而替换整个组件下的所有子节点, 即销毁原组件,创建新组件。

- 对于同一类型的组件,有可能其虚拟DOM没有任何变化,如果能够确切的知道这点那可以节省大量的Diff运算的时间,因此,React允许用户通过shouldComponentUpdate()判断该组件是否需要进行diff 算法分析。

举个例子来说,当下图中componentD改变为componentG时,即使这两个compoent结构很相似,但是react会判断D和G并不是同类型组件,也就不会比较二者的结构了,而是直接删除了D,重新创建G及其子节点。

因此对于这种结构的转变的实际操作是:

D.destroy();

G = new G();

G.append(new E());

G.append(new F());

V.append(G);

3. Element Diff

Element Diff是专门针对同一层级的所有节点(包括元素节点和组件节点)的Diff算法。当节点处于同一层级时,React的Diff提供了三种节点操作:插入、移动和删除。

- 插入:新的component类型不在老集合里,即全新的节点,需要对新节点执行插入操作

- 移动:在老集合里有新component类型,且element是可更新的类型,generateComponentChildren已调用receiveComponent,这种情况下prevChild=nextChild,就可以复用以前的DOM节点,执行移动操作。

- 删除:当老的component类型,在新集合中也有,但对应的element不同则不能直接复用和更新,需要执行删除操作;当老component不在新集合里,也需要执行删除操作。

如下图,老集合为节点A、B、C、D,想生成新集合B、A、D、C,通过新老集合差异化对比,最简单粗暴的方法为: 发现B != A,则新集合创建B,老集合删除A;以此类推,在老集合删除B、C、D,在新集合添加A、D、C。

可以发现这类操作烦琐冗余,因为这些都是相同的节点,只是由于位置顺序发生变化,就需要进行繁杂低效的删除、创建操作,其实只要对这些节点执行移动操作即可。为此,react提出了优化机制 ---  Key机制

四、 Key机制

React允许开发者对同一层级的同组子节点,添加唯一key进行区分。React会根据key来决定是删除重新创建组件还是更新(移动)组件,原则是:

- key相同,组件有所变化,React会只更新组件对应变化的属性。

- key不同,组件会销毁之前的组件,将整个组件重新渲染。

1. 移动规则

添加了key 之后,按如下步骤确认是否移动: 首先,对新集合中的节点进行循环遍历 for (name in nextChildren),通过唯一的 key 判断新旧集合中是否存在相同的节点,if (prevChild === nextChild)。如果存在相同节点,且child.mountIndex(当前节点在老集合中的位置)与 lastIndex(参考位置,类似浮标)进行比较满足 child._mountIndex < lastIndex,则进行移动操作,否则不执行移动操作。

这是一种顺序优化手段,lastIndex = Math.max(prevChild.mountIndex, lastIndex) 将一直更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。

基于移动规则,我们看几个实例:

实例(1):同一层级的所有节点只发生了位置变化

按新集合中顺序开始遍历:

1. B在新集合中 lastIndex = 0, 在旧集合中 mountIndex = 1,mountIndex > lastIndex 就认为 B 对于集合中其他元素位置无影响,不进行移动。此时,lastIndex = max(prevChild.mountIndex, lastIndex) = 1,其中,prevChild.mountIndex表示B在老集合中的位置。

2. A在旧集合中 mountIndex = 0, 此时, 满足 mountIndex < lastIndex, 则对A进行移动操作。此时,lastIndex = max(prevChild.mountIndex, lastIndex) = 1。

3. D和B操作相同,同(1),不进行移动,此时lastIndex = 3。

4. C和A操作相同,同(2),进行移动,此时lastIndex = 3。

上述结论中的移动操作即对节点进行更新渲染,而不进行移动则表示无需更新渲染。可见有key值后,相比于之前的繁琐冗余做法,极大的提升React 的性能。

实例(2): 同一层级的节点发生了节点增删和节点位置变化

按新、老集合中顺序开始遍历:

1. 同上面那种情形,B不进行移动,lastIndex=1。

2. 新集合中取得E,发现旧中不存在E,在 lastIndex处创建E,lastIndex++。

3. 在旧集合中取到C,C不移动,lastIndex=2。

4. 在旧集合中取到A,A移动到新集合中的位置,lastIndex=2。

5. 完成新集合中所有节点diff后,对老集合进行循环遍历,寻找新集合中不存在但老集合中的节点(此例中为D),删除D节点。

2. key值的缺陷

如图所示,若新集合的节点更新为 D、A、 B、C,与旧集合相比只有 D 节点移动,而 A、B、C 仍然保持原有的顺序,理论上 diff 应该只需对 D 执行移动操作,然而由于 D 在旧集合中的位置是最大的,导致其他节点的 mountIndex < lastIndex,造成 D 没有执行移动操作,而是 A、B、C 全部移动到 D 节点后面的现象。

因此,在开发过程中,尽量减少类似将最后一个节点移动到列表首部的操作。因为当节点数量过大或更新操作过于频繁时,这在一定程度上会影响 React 的渲染性能。

3. key值设置

如果没有添加唯一的key值时,会遇到这个错:

这是React在遇到列表时却又找不到key时提示的警告。虽然无视这条警告大部分界面也会正确工作,但这通常意味着潜在的性能问题,因为React觉得自己可能无法高效的去更新这个列表。

同时,key值必须是稳定的(不能使用Math.random去创建key), 可预测并且是唯一的,且React官方建议不要用遍历的index作为这种场景下的节点的key属性值。因为使用index作key的情况时,如果当前遍历的所有节点类型都相同、内部文本不同,当我们对原始的数据list进行了某些元素的顺序改变操作,则会导致新旧集合中进行diff比较时,相同index所对应的新旧的节点的文本不一致了,促使一些节点需要更新渲染文本。而如果用了其他稳定的唯一标识符作为key,则只会发生位置顺序变化,无需更新渲染文本,提升了性能。

此外,使用index作为key很可能会存在一些出人意料的显示错误的问题。例如;存在三个input输入框,以index作为其key进行渲染时。

若想实现点击第二个删除按钮,删除第二列。则会发现,第二列未成功删除,第三列被删除掉了。

为什么呢?

这是因为你认为你删除了2,但React会认为你做了两件事:「把2变成了3」以及「把3删除了」。

看看这两个数组:[123]和[13],人类会说,这不就是少了个2吗?但是计算机会遍历数组:首先对比1和1,发现1没变;然后对比2和3,发现2变成了3; 最后对比undefined和3,发现「3被删除了」。所以计算机的结论是:「2变成了3」以及「3被删除了」。

因此,React渲染逻辑为: 1没变,复用之前的1和三角形;「2变成了3」,正方形左边的2改为3。里面的正方形就地复用(正方形没有被删除);「3被删除了」,之前的「圆形」当然应该被删掉,里面的子元素也要删除。

因此,为了避免此类型错误,也为了提升性能,不要使用index作为key值。

# 参考内容:

[React 源码剖析系列]

[虚拟DOM与DOM Diff 的原理]

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容

  • diff算法作为Virtual DOM的加速器,其算法的改进优化是React整个界面渲染的基础和性能的保障,同时也...
    指尖跳动阅读 1,250评论 0 1
  • 为何采用虚拟DOM 尤雨溪曾在知乎正面的回答这个问题: 为函数式的 UI 编程方式打开了大门;可以渲染到 DOM ...
    yiludege阅读 2,508评论 0 4
  • 一、diff策略 1.Web UI中DOM节点跨层级的移动特别少,可以忽略不计 2.拥有相同类的两个组件将会生成相...
    南慕瑶阅读 5,324评论 0 0
  • 原文:https://segmentfault.com/a/1190000010686582 React框架使用的...
    宋00阅读 766评论 0 0
  • 什么是diff算法 react 作为一款最主流的前端框架之一,在设计的时候除了简化操作之外,最注重的地方就是节省性...
    鹤仔z阅读 1,234评论 0 7