likes
comments
collection
share

「React深入」一文吃透虚拟DOM和diff算法

作者站长头像
站长
· 阅读数 54

前言

大家好,我是小杜杜,React中的虚拟DOMdiff算法是非常核心的特型,了解它们是非常有必要,只有了解,才能深入。

我们直接来看看以下几个问题:

  • 虚拟DOM到底是什么,它与真实的DOM有什么不同?
  • React中,为什么自定义组件的首字母要大写?
  • 有了虚拟DOM,性能就一定能够得到提升吗?
  • React的diff算法与传统的diff算法有什么区别?为什么受到吹捧?
  • diff策略有哪些?它们是如何比较的?
  • 为什么在循环中不要用索引(index)做key值呢?
  • ...

如果你对上述问题有疑问,那么这篇文章一定能够帮助到你~ 「React深入」一文吃透虚拟DOM和diff算法

虚拟DOM

与真实DOM对比

结构对比

我们首先用React.createElementdocument.createElement创建以下,然后进行打印,看一下,虚拟DOM和真实DOM有什么区别:

    const VDOM = React.createElement('div', {}, '小杜杜')
    const DOM = document.createElement("div");
    DOM.innerHTML = '小杜杜'
    console.log(`虚拟DOM:`, VDOM)
    console.log(`真实DOM:`, DOM)

结果:

「React深入」一文吃透虚拟DOM和diff算法

我们可以看出虚拟DOM是一个对象的结构,而真实的DOM是一个dom的结构,而这个dom结构究竟是什么呢?我们可以通过断点去看看: 「React深入」一文吃透虚拟DOM和diff算法

我们可以看到,在真实的DOM上,默认会挂载很多属性和方法,但在实际中,我们并不需要去关心这些属性和方法(注意:这些属性和方法是默认的,因为标准是这么设计的)

所以从结构上来看:虚拟DOM要比真实DOM轻很多

操作对比

假设我们有以下列表:

  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul>

我们现在要将 1、2、3 替换为 4,5,6,7,我们直接操纵节点该如何处理?

  • 第一种:我们可以将原列表的1、2、3替换为4、5、6,在新增一个li为7
  • 第二种:我们直接把原列表的1、2、3对应的li删掉,在新增4、5、6、7
  • 第三中:直接替换 ul的内容,用innerHTML直接覆盖

单纯操作来讲,第三种无疑是最方便的,第一种明显复杂一点,但从性能上来讲,第三种的性能最高,因为存在重排重绘的问题,我们知道浏览器处理DOM是很慢的,如果页面比较复杂,频繁的操做DOM会造成很大的开销

所以在原生的DOM中我们要想性能高,就只能选择第一种方案,但这样明显给我们带来了复杂度,不利于目前的开发(会在下文详细讲到~)

流程对比

在传统的Web应用中,数据的变化会实时地更新到用户界面中,于是每次数据微小的变化都会引起DOM的渲染。

而虚拟DOM的目:是将所有的操作聚集到一块,计算出所有的变化后,统一更新一次虚拟DOM

也就是说,一个页面如果有500次变化,没有虚拟DOM的就会渲染500次,而虚拟DOM只需要渲染一次,从这点上来看,页面越复杂,虚拟DOM的优势越大

「React深入」一文吃透虚拟DOM和diff算法

虚拟DOM是什么?

在上面我们说过虚拟DOM实际上就是对象,接下来详细看看这个对象有什么,栗子🌰:

    <div className='Index'>
      <div>我是小杜杜</div>
      <ul>
        <li>React</li>
        <li>Vue</li>
      </ul>
    </div>

转化后:

    {
        type: 'div',
        props: { class: 'Index' },
        children: [
            {
                type: 'div',
                children: '我是小杜杜'
            },
            {
                type: 'ul',
                children: [
                    {
                        type: 'li',
                        children: 'React'
                    },
                    {
                        type: 'li',
                        children: 'Vue'
                    },
                ]
            }
        ]
    }

主要转化为:

  • type:实际的标签
  • props:标签内部的属性(除keyref,会形成单独的key名)
  • children: 为节点内容,依次循环

从结构上来说,虚拟DOM并没有真实DOM哪些乱七八糟的东西,因此,我们就算直接把虚拟DOM删除后,重新建一个也是非常快的

React中,组件为何要大写?

作为一个前端人,多多少少都知道React的核心是JSX语法,说白了,JSX就是JS上的扩展,就像一个拥有javascript全部功能的模板语言

我们写的代码最终是要呈现在浏览器上,浏览器会识别你的代码是React吗?很显然,浏览器并不知道你的代码是React,更不会识别JSX了,实际上浏览器对ES6的一些语法都识别不了,要想让浏览器识别,就需要借助Babel

要通过Babel去对JSX进行转化为对应的JS对象,才能让浏览器识别,此时就会有个依据去判断是原生DOM标签,还是React组件,而这个依据就是标签的首字母

如果标签的首字母是小写,就会被认定为原生标签,反之就是React组件

举个栗子🌰:

    class Info extends React.Component {
        render(){
            return(
                <div>
                    Hi!我是小杜杜
                    <p>欢迎</p>
                    <Children>我是子组件</Children>
                </div>
            )
        }
    }

上述代码会被翻译为:

    class Info extends React.Component {
        render(){
            return React.createElement(
                'div', 
                null, 
                "Hi!我是小杜杜",
                React.createElement('p', null, '欢迎'), // 原生标签
                React.createElement( 
                    Children, //自定义组件
                    null, // 属性
                    '我是子组件'  //child文本内容
                )
            )
        }
    }

换言之,我们的JSX结构最终会被翻译为React.createElement的结构,那么为什么要使用JSX而不用 createElement书写呢?

其实这两种写法都是可以的,但JSX形式明显要比createElement方便很多。

综上所诉,在React中,组件大写的原因是Babel进行转化,需要一个条件去判断是原生标签还是自定义组件,通过首字母的大小写去判断

扩展 React.Fragment

在这里,额外说一下React.Fragment这个组件,熟悉React的小伙伴应该知道,在React中,组件是不允许返回多个节点的,如:

    return <p>我是小杜杜</p>
           <p>React</p>
           <p>Vue</p>

我们想要解决这种情况需要给为此套一个容器元素,如<div></div>

    return <div>
       <p>我是小杜杜</p>
       <p>React</p>
       <p>Vue</p>
    </div>

但这样做,无疑会多增加一个节点,所以在16.0后,官方推出了Fragment碎片概念,能够让一个组件返回多个元素,React.Fragment 等价于<></>

    return <React.Fragment> 
       <p>我是小杜杜</p>
       <p>React</p>
       <p>Vue</p>
    </React.Fragment>

可以看到React.Fragment实际上是没有节点的 「React深入」一文吃透虚拟DOM和diff算法 那么这个特殊的组件,会被createElement翻译的不一样吗?

其实是一样的,还是会被翻译为React.createElement(React.Fragment, null, "")这样的形式,这点要注意

同时在React也支持返回数组的形式,如:

  [1,2,3].map(item=><p key={item} >{item}</p>)

实际上这种会被React的底层进行处理,默认会加入Fragment,也就是等价于

    <React.Fragment> 
       <p>1</p>
       <p>2</p>
       <p>3</p>
    </React.Fragment>

我们知道 <React.Fragment> </React.Fragment> 等价于 <></>,那么他们有不同吗?

在上述讲过,keyref会被单独存放,ref不用考虑,在循环数组时,我们必须要有key,实际上<React.Fragment>允许有key的,而<></>无法附上key,所以这是两者的差距

虚拟DOM的优势所在

提高效率

使用原生JS的时候,我们需要的关注点在操作DOM上,而React会通过虚拟DOM来确保DOM的匹配,也就是说,我们关注的点不在时如何操作DOM,怎样更新DOMReact会将这一切处理好

此时,我们更加关注于业务逻辑,从而提高开发效率

性能提升

经过之前的讲解,我们发现虚拟DOM优势明显强于真实的DOM,我们来看看虚拟DOM如何工作的?

实际上,React会将整个DOM保存为虚拟DOM,如果有更新,都会维护两个虚拟DOM,以此来比较之前的状态当前的状态,并会确定哪些状态被修改,然后将这些变化更新到实际DOM上,一旦真正的DOM发生改变,也会更新UI

要牢记一句话:浏览器在处理DOM的时候会很慢,处理JavaScript会很快

所以在虚拟DOM感受到变化的时候,只会更新局部,而非整体。同时,虚拟DOM会减少了非常多的DOM操作 ,所以性能会提升很多

虚拟DOM一定会提高性能吗?

通过上面的理解,很多人认为虚拟DOM一定会提高性能,一定会更快,其实这个说法有点片面,因为虚拟DOM虽然会减少DOM操作,但也无法避免DOM操作

它的优势是在于diff算法批量处理策略,将所有的DOM操作搜集起来,一次性去改变真实的DOM,但在首次渲染上,虚拟DOM会多了一层计算,消耗一些性能,所以有可能会比html渲染的要慢

注意,虚拟DOM实际上是给我们找了一条最短,最近的路径,并不是说比DOM操作的更快,而是路径最简单

就好比条条大路通罗马,虽然走的方向不同,但最终到达的目的地都是相通的,不同的路径对应的时间不同,虚拟DOM就是规划出最短的路径,但最终还是需要人(真实DOM)去走的(有不对的地方,欢迎评论区讨论~)

超强的兼容性

React具有超强的兼容性,可分为:浏览器的兼容跨平台兼容

  • React基于虚拟DOM实现了一套自己的事件机制,并且模拟了事件冒泡和捕获的过程,采取事件代理批量更新等方法,从而磨平了各个浏览器的事件兼容性问题
  • 对于跨平台,ReactReact Native都是根据虚拟DOM画出相应平台的UI层,只不过不同的平台画法不同而已

虚拟DOM如何实现?

构建虚拟DOM

我们构建的JSX代码会被转为React.createElement的形式,如下图:

React.createElement:它的功能是将props子元素进行处理后返回一个ReactElement对象(keyref会特殊处理)

ReactElement

ReactElement这个对象会将传入的几个属性进行组合并返回

  • type:实际的标签
  • props:标签内部的属性(除keyref,会形成单独的key名)
  • children: 为节点内容,依次循环
  • type:实际的标签,原生的标签(如'div'),自定义组件(类或是函数式)
  • props:标签内部的属性(除keyref,会形成单独的key名)
  • key:组件内的唯一标识,用于Diff算法
  • ref:用于访问原生dom节点
  • owner:当前正在构建的Component所属的Component
  • ?typeof:默认为REACT_ELEMENT_TYP,可以防止XXS

扩展 预防XSS

XSS攻击(跨站脚本攻击):通常指的是通过利用发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。

React自身可以预防XSS,主要依靠的就是 ?typeof

    var REACT_ELEMENT_TYPE = 
    (typeof Symbol === 'function' && Symbol.for && Symbol.for('react.element')) || 0xeac7;

从上述代码我们知道?typeof实际上是Symbol类型,当然Symbol是ES6的,如果环境不支持ES6?typeof会被赋值于 0xeac7

那么这个变量为什么可以预防XSS呢?

简单的说,用户存储的JSON对象可以是任意的字符串,这可能会带来潜在的危险,而JSON对象不能存储于Symbol类型的变量,React 可以在渲染的时候把没有?type 标识的组件过滤掉,从而达到预防XSS的功能

转化为真实DOM

虚拟DOM转化为真实DOM的这个过程实际上非常复杂,大体上可以分为四步: 处理参数批量处理生成html渲染html

  • 处理参数:当我们处理好组件后,我们需要ReactDOM.render(element, container[, callback])将组件进行渲染,这里会判断是原生标签还是React自定义组件
  • 批量处理:这个过程就会统一进行处理,具体的执行机制,之后会单独写篇文章讲解
  • 生成html:对特殊的DOM标签、props进行处理,并根据对应的标签类型创造对应的DOM节点,利用updateDOMPropertiesprops插入到DOM节点,最后渲染到上面
  • 渲染html:渲染html节点,渲染文本节点,但不同的浏览器可能会做不同的处理

diff算法

经过上面的讲解,我们知道React会维护两个虚拟DOM,那么是如何来比较,如何来判断,做出最优的解呢?这就用到了diff算法

与传统的diff算法相比较

React中,最值得夸赞的地方就是虚拟DOMdiff算法的结合,发展至今,个人认为React的diff算法远比传统的diff算法出名很多,那么原因究竟是什么呢?

React中的diff算法并非首创,而是引入,React团队为diff算法做出了的优化,举个🌰

在计算一颗树转化为另一颗树有哪些改变时,传统的diff算法通过循环递归对节点进行依此对比,其算法复杂度达到了O(n^ 3),也就是说,如果展示 一千个节点,就要计算十亿次

再来看看React中的diff算法,算法复杂度为O(n),如果展示一千个节点,就要计算一千次

从十亿次更新到一千次,这可不是一点点的优化,而是非常巨大的优化,真心的佩服

diff策略

那么,如何将O(n^ 3) 转化为O(n) 呢?

React通过三大策略完成了优化:

  1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
  2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
  3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。

分别对应:tree diffcomponent diffelement diff

tree diff

tree diff: 同级比较,既然DOM 节点跨层级的移动操作少到可以忽略不计,那么React通过updateDepthVirtual DOM 树进行层级控制,也就是同一层,在对比的过程中,如果发现节点不在了,会完全删除不会对其他地方进行比较,这样只需要对树遍历一次就OK了

栗子🌰:

「React深入」一文吃透虚拟DOM和diff算法

  • 如上图,比较的时候会一层一层比较,也就是图中蓝框的比较
  • 到第二层的时候我们发现,L 带着BCA的下面,跑到了R的下面,按理说应该把L移到R的下方,但这样会牵扯到跨层级比较,有可能在层级上移动的非常多,导致时间复杂度陡然上升
  • 所以在这里,React会删掉整个A,然后重新创建,但这种情况在实际中会非常少见

注意:保持DOM的稳定会有助于性能的提升,合理的利用显示和隐藏效果会更好,而不是真正的删除增加DOM节点

component diff

component diff组件比较React对于组件的策略有两种方式,一种是相同类型的组件和不同类型的组件

  • 对同种类型组件对比,按照层级比较继续比较虚拟DOM树即可,但有种特殊的情况,当组件A如果变化为组件B的时候,有可能虚拟DOM并没有任何变化,所以用户可以通过shouldComponentUpdate() 来判断是否需要更新,判断是否计算
  • 对于不同组件来说,React会直接判定该组件为dirty component(脏组件),无论结构是否相似,只要判断为脏组件就会直接替换整个组件的所有节点

举个栗子🌰:

「React深入」一文吃透虚拟DOM和diff算法

在比较时发现D => G,虽然两个组件的结构非常相似,React判断这两个组件并不是同一个组件(dirty component),就会直接删除 D,重新构建 G,在实际中,两个组件不同,但结构又非常相似,这样的情况会很少的

element diff

element diff节点比较,对于同一层级的一子自节点,通过唯一的key进行比较

当所有节点处以同一层级时,React 提供了三种节点操作:插入(INSERT_MARKUP)移动(MOVE_EXISTING)删除(REMOVE_NODE)

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

如:C 不在集合AB中需要插入

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

如:当组件D在集合 A、B、C、D中,且集合更新时,D没有发生更新,只是位置发生了改变,如:A、D、B、CD的位置有4变换到了2

如果是传统的diff,会让旧集合的第二个B和新集合的D做比较,删除第二个B,在插入D

React中的diff并不会这么做,而是通过key来进行直接移动

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

如: 组件D在集合 A、B、C、D中,如果集合变成了 新的集合A、B、CD就需要删除

如果D的节点发生改变,不能复用更新,此时会删除旧的D,再创建新的

情形一:相同节点位置,如何移动

「React深入」一文吃透虚拟DOM和diff算法

顺序:

  1. React会判断(新中)第一个B是否在旧的中出现过,如果发现旧的中存在,然后判断是否去移动B
  2. 判断B是否移动的条件为index < lastIndex,及在旧的Index1lastIndex为0,所以并不满足条件,因此不会移动B
  3. 有的小伙伴可能会对lastIndex产生疑问,它到底是什么?实际上它是一个浮标,或者说是一个map的索引,一开始是默认的0,当每次比较后,会改变对应的值,也就是 lastIndex=(index, lastIndex)中的最大值,对第一步来说,就是lastIndex=(1, 0) => lastIndex为1
  4. 此时到了A的比较,在旧的中Aindex为0,lastIndex为1,满足index < lastIndex,因此对A进行移动,lastIndex还是为1
  5. 相同的方法到Dindex为3,lastIndex为1,D不移动,并且lastIndex为3
  6. 相同的方法到Cindex为2,lastIndex为3,C移动,lastIndex不变,此时操作结束

情形二:有新的节点加入,删除节点

「React深入」一文吃透虚拟DOM和diff算法

顺序:

  1. B与上述讲的一样,不移动,lastIndex为1
  2. E时,发现在旧的中并没有E这个节点,所以此时会建立,此时的lastIndex还是为1
  3. C中,index 为 2,lastIndex为 1,所以此时不满足index < lastIndex,故C不移动,lastIndex更新为 2 4.A同理,A移动,lastIndex不更新,为2
  4. 在新集合遍历完毕中,发现并没有D这个节点,所以会删除D,操作结束

存在的问题

「React深入」一文吃透虚拟DOM和diff算法

我们来看看这种情况,如果将D移入到第一个,我们发现lastIndex为 3,之后在进行比较,发现lastIndex都大于index,所以剩下的节点都会移动,所以在开发的过程中应该尽量减少节点移入首部的操作,会影响其性能

扩展 如何在循环中正确的使用key?

我们知道,在我们进行循环的时候要加入key,那么key为什么说不能使用索引做为key值呢?有的时候在面试中也会问到,你在项目中key是如何设置的?为什么?

为什么不能用index做为key值 ?

「React深入」一文吃透虚拟DOM和diff算法

我们发现,当我们判断第一个B时,由于此时的key为0在旧的中key为0是ABA明显不是一个组件,所以会删除重建

所以无论是删除还是新增,或是移动,都会进行重新建立,这种方式与是否有key根本无关

为什么不能用index拼接其它值?

这种方式于上面的一样,因为每一个节点都找不到对应的key,导致所有的节点都不能复用,都会重新创建,所以不能

正确的方法,唯一值

只有通过唯一值,才能做到每一个节点都做到了复用,真正起到了diff算法的作用

玩转 React Hooks 小册

知其然,知其所以然。React Hooks 带来的全新机制让人耳目一新,因为它拓展了 React 的开发思路,为 React 开发者提供了一种更方便、更简洁的选择。

在引入 Hooks 的概念后,函数组件既保留了原本的简洁,也具备了状态管理、生命周期管理等能力,在原来 Class 组件所具备的能力基础上,还解决了 Class 组件存在的一些代码冗余、逻辑难以复用等问题。因此,在如今的 React 中,Hooks 已经逐渐取代了 Class 的地位,成了主导。

而且,Hooks 相对于 Class 而言,更容易上手,其简洁性、逻辑复用性等特性深受开发者喜爱,可谓是前端界的"流量明星",不止 React,Vue 3.0 、Preact、Solid.js 等框架也都选择加入 Hooks 的大家庭,前端的日常工作也在趋向于 Hooks 开发。

因此,掌握好 React Hooks 是非常有必要的一件事。本小册会通过基础篇、原码篇、实践篇 三大方向 探讨 Hooks,从原码的角度探寻 React 的奥秘。

除此之外,小册会以 React Hooks 为核心,同时穿插其他知识,如 TS、Jest、Fiber 等核心知识,并包含 React v18 的并发、数据撕裂等概念,最后结合 Hooks 写一个简易版 react-redux 和 Form 表单,通过其设计思想,助你在面试中脱颖而出。

小册整体设计如下思维导图所示:

「React深入」一文吃透虚拟DOM和diff算法

End

虚拟DOMdiff算法React中比较核心的,也是面试中比较常见的,在网上找了许多资料,整理学习,在这里面牵扯到一些React事件机制的问题,之后会专门做一章进行总结,还请多多关注~

说实话,写这种硬文真的有点累,而且花费的时间也较长,但如果你耐心看下去,一定会让你受益良多的,【点赞】+ 【收藏】= 【学会了】,还请各位小伙伴多多支持,后续还会有 React 的硬文,关注我,一起上车学习React吧~

其他React好文:

转载自:https://juejin.cn/post/7116326409961734152
评论
请登录