注: 原文地址 ,作者:小杜杜
本站拷贝一份是为方便查看,以及本站文章搜索。非商业用途,免费查看,侵删!!!
如果这篇文章对你有帮助,请多支持原作者!!!
前言
我们直接来看看以下几个问题:
虚拟DOM到底是什么,它与真实的DOM有什么不同?- 在
React中,为什么自定义组件的首字母要大写? - 有了
虚拟DOM,性能就一定能够得到提升吗? - React的
diff算法与传统的diff算法有什么区别?为什么受到吹捧? diff策略有哪些?它们是如何比较的?- 为什么在循环中不要用索引(index)做
key值呢? - …
如果你对上述问题有疑问,那么这篇文章一定能够帮助到你~
虚拟DOM
与真实DOM对比
结构对比
我们首先用React.createElement和document.createElement创建以下,然后进行打印,看一下,虚拟DOM和真实DOM有什么区别:
|
|
结果:
我们可以看出虚拟DOM是一个对象的结构,而真实的DOM是一个dom的结构,而这个dom结构究竟是什么呢?我们可以通过断点去看看:
我们可以看到,在真实的DOM上,默认会挂载很多属性和方法,但在实际中,我们并不需要去关心这些属性和方法(注意:这些属性和方法是默认的,因为标准是这么设计的)
所以从结构上来看:虚拟DOM要比真实DOM轻很多
操作对比
假设我们有以下列表:
|
|
我们现在要将 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的优势越大
虚拟DOM是什么?
在上面我们说过虚拟DOM实际上就是对象,接下来详细看看这个对象有什么,栗子🌰:
|
|
转化后:
|
|
主要转化为:
- type:实际的标签
- props:标签内部的属性(除
key和ref,会形成单独的key名) - children: 为节点内容,依次循环
从结构上来说,虚拟DOM并没有真实DOM哪些乱七八糟的东西,因此,我们就算直接把虚拟DOM删除后,重新建一个也是非常快的
React中,组件为何要大写?
作为一个前端人,多多少少都知道React的核心是JSX语法,说白了,JSX就是JS上的扩展,就像一个拥有javascript全部功能的模板语言
我们写的代码最终是要呈现在浏览器上,浏览器会识别你的代码是React吗?很显然,浏览器并不知道你的代码是React,更不会识别JSX了,实际上浏览器对ES6的一些语法都识别不了,要想让浏览器识别,就需要借助Babel
要通过Babel去对JSX进行转化为对应的JS对象,才能让浏览器识别,此时就会有个依据去判断是原生DOM标签,还是React组件,而这个依据就是标签的首字母
如果标签的首字母是小写,就会被认定为原生标签,反之就是React组件
举个栗子🌰:
|
|
上述代码会被翻译为:
|
|
换言之,我们的JSX结构最终会被翻译为React.createElement的结构,那么为什么要使用JSX而不用 createElement书写呢?
其实这两种写法都是可以的,但JSX形式明显要比createElement方便很多。
综上所诉,在React中,组件大写的原因是Babel进行转化,需要一个条件去判断是原生标签还是自定义组件,通过首字母的大小写去判断
扩展 React.Fragment
在这里,额外说一下React.Fragment这个组件,熟悉React的小伙伴应该知道,在React中,组件是不允许返回多个节点的,如:
|
|
我们想要解决这种情况需要给为此套一个容器元素,如<div></div>
|
|
但这样做,无疑会多增加一个节点,所以在16.0后,官方推出了Fragment碎片概念,能够让一个组件返回多个元素,React.Fragment 等价于<></>
|
|
可以看到React.Fragment实际上是没有节点的 那么这个特殊的组件,会被
createElement翻译的不一样吗?
其实是一样的,还是会被翻译为React.createElement(React.Fragment, null, "")这样的形式,这点要注意
同时在React也支持返回数组的形式,如:
|
|
实际上这种会被React的底层进行处理,默认会加入Fragment,也就是等价于
|
|
我们知道
<React.Fragment> </React.Fragment>等价于<></>,那么他们有不同吗?
在上述讲过,key和ref会被单独存放,ref不用考虑,在循环数组时,我们必须要有key,实际上<React.Fragment>允许有key的,而<></>无法附上key,所以这是两者的差距
虚拟DOM的优势所在
提高效率
使用原生JS的时候,我们需要的关注点在操作DOM上,而React会通过虚拟DOM来确保DOM的匹配,也就是说,我们关注的点不在时如何操作DOM,怎样更新DOM,React会将这一切处理好
此时,我们更加关注于业务逻辑,从而提高开发效率
性能提升
经过之前的讲解,我们发现
虚拟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实现了一套自己的事件机制,并且模拟了事件冒泡和捕获的过程,采取事件代理、批量更新等方法,从而磨平了各个浏览器的事件兼容性问题- 对于跨平台,
React和React Native都是根据虚拟DOM画出相应平台的UI层,只不过不同的平台画法不同而已
虚拟DOM如何实现?
构建虚拟DOM
我们构建的JSX代码会被转为React.createElement的形式,如下图:
React.createElement:它的功能是将props和子元素进行处理后返回一个ReactElement对象(key和ref会特殊处理)
ReactElement
ReactElement这个对象会将传入的几个属性进行组合并返回
- type:实际的标签
- props:标签内部的属性(除
key和ref,会形成单独的key名) - children: 为节点内容,依次循环
- type:实际的标签,原生的标签(如’div’),自定义组件(类或是函数式)
- props:标签内部的属性(除
key和ref,会形成单独的key名) - key:组件内的唯一标识,用于
Diff算法 - ref:用于访问原生
dom节点 - owner:当前正在构建的
Component所属的Component - ?typeof:默认为
REACT_ELEMENT_TYP,可以防止XXS
扩展 预防XSS
XSS攻击(跨站脚本攻击):通常指的是通过利用发时留下的漏洞,通过巧妙的方法注入恶意指令代码到网页,使用户加载并执行攻击者恶意制造的网页程序。
React自身可以预防XSS,主要依靠的就是 ?typeof
|
|
从上述代码我们知道?typeof实际上是Symbol类型,当然Symbol是ES6的,如果环境不支持ES6,?typeof会被赋值于 0xeac7
那么这个变量为什么可以预防XSS呢?
简单的说,用户存储的JSON对象可以是任意的字符串,这可能会带来潜在的危险,而JSON对象不能存储于Symbol类型的变量,React 可以在渲染的时候把没有?type 标识的组件过滤掉,从而达到预防XSS的功能
转化为真实DOM
|
|
- 处理参数:当我们处理好组件后,我们需要
ReactDOM.render(element, container[, callback])将组件进行渲染,这里会判断是原生标签还是React自定义组件 - 批量处理:这个过程就会统一进行处理,具体的执行机制,之后会单独写篇文章讲解
- 生成html:对特殊的
DOM标签、props进行处理,并根据对应的标签类型创造对应的DOM节点,利用updateDOMProperties将props插入到DOM节点,最后渲染到上面 - 渲染html:渲染html节点,渲染文本节点,但不同的浏览器可能会做不同的处理
diff算法
经过上面的讲解,我们知道React会维护两个虚拟DOM,那么是如何来比较,如何来判断,做出最优的解呢?这就用到了diff算法
与传统的diff算法相比较
在React中,最值得夸赞的地方就是虚拟DOM与diff算法的结合,发展至今,个人认为React的diff算法远比传统的diff算法出名很多,那么原因究竟是什么呢?
React中的diff算法并非首创,而是引入,React团队为diff算法做出了质的优化,举个🌰
在计算一颗树转化为另一颗树有哪些改变时,传统的diff算法通过循环递归对节点进行依此对比,其算法复杂度达到了O(n^ 3),也就是说,如果展示 一千个节点,就要计算十亿次
再来看看React中的diff算法,算法复杂度为O(n),如果展示一千个节点,就要计算一千次
从十亿次更新到一千次,这可不是一点点的优化,而是非常巨大的优化,真心的佩服
diff策略
那么,如何将O(n^ 3) 转化为O(n) 呢?
React通过三大策略完成了优化:
- Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。
- 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。
- 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。
分别对应:tree diff、component diff、element diff
tree diff
tree diff: 同级比较,既然DOM 节点跨层级的移动操作少到可以忽略不计,那么React通过updateDepth 对 Virtual DOM 树进行层级控制,也就是同一层,在对比的过程中,如果发现节点不在了,会完全删除不会对其他地方进行比较,这样只需要对树遍历一次就OK了
栗子🌰:
- 如上图,比较的时候会一层一层比较,也就是图中蓝框的比较
- 到第二层的时候我们发现,
L带着B和C从A的下面,跑到了R的下面,按理说应该把L移到R的下方,但这样会牵扯到跨层级比较,有可能在层级上移动的非常多,导致时间复杂度陡然上升 - 所以在这里,React会删掉整个A,然后重新创建,但这种情况在实际中会非常少见
注意:保持DOM的稳定会有助于性能的提升,合理的利用显示和隐藏效果会更好,而不是真正的删除或增加DOM节点
component diff
component diff:组件比较,React对于组件的策略有两种方式,一种是相同类型的组件和不同类型的组件
- 对同种类型组件对比,按照层级比较继续比较虚拟DOM树即可,但有种特殊的情况,当组件A如果变化为组件B的时候,有可能虚拟DOM并没有任何变化,所以用户可以通过shouldComponentUpdate() 来判断是否需要更新,判断是否计算
- 对于不同组件来说,
React会直接判定该组件为dirty component(脏组件),无论结构是否相似,只要判断为脏组件就会直接替换整个组件的所有节点
举个栗子🌰:
在比较时发现D => G,虽然两个组件的结构非常相似,React判断这两个组件并不是同一个组件(dirty component),就会直接删除 D,重新构建 G,在实际中,两个组件不同,但结构又非常相似,这样的情况会很少的
element diff
element diff:节点比较,对于同一层级的一子自节点,通过唯一的key进行比较
当所有节点处以同一层级时,React 提供了三种节点操作:插入(INSERT_MARKUP)、移动(MOVE_EXISTING)、删除(REMOVE_NODE)
- INSERT_MARKUP:新的
component类型不在老集合里, 即是全新的节点,需要对新节点执行插入操作。
如:C 不在集合A、B中需要插入
- MOVE_EXISTING:在老集合有新
component类型,且element是可更新的类型,generateComponentChildren已调用receiveComponent,这种情况下prevChild=nextChild,就需要做移动操作,可以复用以前的 DOM 节点
如:当组件D在集合 A、B、C、D中,且集合更新时,D没有发生更新,只是位置发生了改变,如:A、D、B、C,D的位置有4变换到了2
如果是传统的diff,会让旧集合的第二个B和新集合的D做比较,删除第二个B,在插入D
React中的diff并不会这么做,而是通过key来进行直接移动
- REMOVE_NODE:老
component类型,在新集合里也有,但对应的element不同则不能直接复用和更新,需要执行删除操作,或者老component不在新集合里的,也需要执行删除操作。
如: 组件D在集合 A、B、C、D中,如果集合变成了 新的集合A、B、C,D就需要删除
如果D的节点发生改变,不能复用和更新,此时会删除旧的D,再创建新的
情形一:相同节点位置,如何移动
顺序:
React会判断(新中)第一个B是否在旧的中出现过,如果发现旧的中存在,然后判断是否去移动B- 判断
B是否移动的条件为index < lastIndex,及在旧的Index为1,lastIndex为0,所以并不满足条件,因此不会移动B - 有的小伙伴可能会对
lastIndex产生疑问,它到底是什么?实际上它是一个浮标,或者说是一个map的索引,一开始是默认的0,当每次比较后,会改变对应的值,也就是lastIndex=(index, lastIndex)中的最大值,对第一步来说,就是lastIndex=(1, 0)=>lastIndex为1 - 此时到了
A的比较,在旧的中A的index为0,lastIndex为1,满足index < lastIndex,因此对A进行移动,lastIndex还是为1 - 相同的方法到
D,index为3,lastIndex为1,D不移动,并且lastIndex为3 - 相同的方法到
C,index为2,lastIndex为3,C移动,lastIndex不变,此时操作结束
情形二:有新的节点加入,删除节点
顺序:
B与上述讲的一样,不移动,lastIndex为1- 到
E时,发现在旧的中并没有E这个节点,所以此时会建立,此时的lastIndex还是为1 - 在
C中,index为 2,lastIndex为 1,所以此时不满足index < lastIndex,故C不移动,lastIndex更新为 2 4.A同理,A移动,lastIndex不更新,为2 - 在新集合遍历完毕中,发现并没有
D这个节点,所以会删除D,操作结束
存在的问题
我们来看看这种情况,如果将D移入到第一个,我们发现lastIndex为 3,之后在进行比较,发现lastIndex都大于index,所以剩下的节点都会移动,所以在开发的过程中应该尽量减少节点移入首部的操作,会影响其性能
扩展 如何在循环中正确的使用key?
我们知道,在我们进行循环的时候要加入
key,那么key为什么说不能使用索引做为key值呢?有的时候在面试中也会问到,你在项目中key是如何设置的?为什么?
为什么不能用index做为key值 ?
我们发现,当我们判断第一个B时,由于此时的key为0在旧的中key为0是A,B和A明显不是一个组件,所以会删除重建
所以无论是删除还是新增,或是移动,都会进行重新建立,这种方式与是否有key根本无关
为什么不能用index拼接其它值?
这种方式于上面的一样,因为每一个节点都找不到对应的key,导致所有的节点都不能复用,都会重新创建,所以不能
正确的方法,唯一值
只有通过唯一值,才能做到每一个节点都做到了复用,真正起到了diff算法的作用
结束
虚拟DOM和diff算法是React中比较核心的,也是面试中比较常见的,在网上找了许多资料,整理学习,在这里面牵扯到一些React事件机制的问题,之后会专门做一章进行总结,还请多多关注~
说实话,写这种硬文真的有点累,而且花费的时间也较长,但如果你耐心看下去,一定会让你受益良多的,
【点赞】+【收藏】=【学会了】,还请各位小伙伴多多支持,后续还会有React的硬文,关注我,一起上车学习React吧~
其他React好文: