一、 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值。
# 参考内容: