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

前言

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

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

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

如果你对上述问题有疑问,那么这篇文章一定能够帮助到你~

跟之前一样先附上这篇的知识图,还请各位多多支持:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gi7s86Es-1658297236093)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/8f82868a39244b3281538fb9bf4c7103~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

虚拟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) 

结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X9o1tAxg-1658297236093)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/58db2ee32bde44b3afd5e2e6af3e3647~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

我们可以看出虚拟DOM是一个对象的结构,而真实的DOM是一个dom的结构,而这个dom结构究竟是什么呢?我们可以通过断点去看看: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XjLhjDST-1658297236093)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c859397a03024903939f934907052cc2~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

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

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

操作对比

假设我们有以下列表:

 
  • 1
  • 2
  • 3

我们现在要将 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的优势越大

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D2aFQ9Xs-1658297236094)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/b16d46da6c7a44bfa1b3acfc2595a43b~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

虚拟DOM是什么?

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

 
我是小杜杜
  • React
  • Vue

转化后:

 {
        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(
                
Hi!我是小杜杜

欢迎

我是子组件
) } }

上述代码会被翻译为:

 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 

我是小杜杜

React

Vue

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

 return 

我是小杜杜

React

Vue

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

 return  
       

我是小杜杜

React

Vue

可以看到React.Fragment实际上是没有节点的 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VFoakHHK-1658297236094)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/2b290182958b45ba81ce6441cbb405f5~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)] 那么这个特殊的组件,会被createElement翻译的不一样吗?

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

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

 [1,2,3].map(item=>

{item}

)

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

  
       

1

2

3

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

在上述讲过,keyref会被单独存放,ref不用考虑,在循环数组时,我们必须要有key,实际上允许有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的形式,如下图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TbJrC11f-1658297236094)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/58db2ee32bde44b3afd5e2e6af3e3647~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

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了

栗子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ohbwzd2P-1658297236095)(https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/79baab17d9a14df6b30c07f2e0e7ed11~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

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

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

component diff

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

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

举个栗子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aQBiFWgH-1658297236095)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/94d5d62774064dda8ef7abd8b873dd78~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

在比较时发现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,再创建新的

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lNvOKPUy-1658297236095)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/f8db39f510b645e48e308a3fb8088fbd~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

顺序:

  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不变,此时操作结束

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gQ3JRfOj-1658297236095)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5a6946c01f3142948e2a7cced8d680dc~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

顺序:

  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 5. 在新集合遍历完毕中,发现并没有D这个节点,所以会删除D,操作结束

存在的问题

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DrPRSv3X-1658297236096)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/94dc4be27a4e46a59de6d5d9382639f7~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

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

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

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1qcSoT9Q-1658297236096)(https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d3732b5b8eb04748a488567954242f23~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

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

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

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

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

正确的方法,唯一值

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vvY7Bf06-1658297236096)(https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5a6946c01f3142948e2a7cced8d680dc~tplv-k3u1fbpfcp-zoom-in-crop-mark:4536:0:0:0.image?)]

End

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

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

你可能感兴趣的:(react.js,javascript,前端)