作者:刘自龙Sophisten | 来源:互联网 | 2023-05-17 09:06
React 使用虚拟 DOM 将计算好之后的更新发送到真实的 DOM 树上,减少了频繁操作真实 DOM 的时间消耗,但将成本转移到了 Javascript 中,因为要计算新旧 DOM 树的差异嘛。所以这个计算差异的算法是否高效,就很关键了。React 中其计算差异的过程叫 Reconciliation,可理解成调和前后两次渲染的差异。
正式讨论前,先来看个问题。
问题
假设我们有一个展示百分比的柱状条组件,其宽度由是传入的数值决定。并且它带动画,如果传入的值变化,那么柱状条应该由 0 动画到需要展示的宽度。
即想要实现的效果如下:
预期的百分比柱状条效果
所以我们写了如下的柱状条组件:
function Bar ({ score }) {
const [width , setWidth ] = useState (0 );
// 调试用
useEffect (() => {
console .log (" 组件初始化完成" );
return () => {
console .log (" 组件即将销毁" );
};
}, []);
useEffect (() => {
console .log (" score 发生变化" );
const timer = setTimeout (() => {
setWidth (score);
}, 0 );
return () => {
clearTimeout (timer);
};
}, [score]);
const style = {
width: ` ${ width} %`
};
return (
< div className= " bar-wrap'" >
< div className= " bar" style= {style}>
{width}
< / div>
< / div>
);
}
因为要实现动画,所以一开始我们并不将组件接收到的值应用到样式上,而是先将宽度设置为 0,等组件完成初始化之后,再在 setTimeout
中将组件的宽度设置为传入的 props 上的值,这样就能看到动画了。
调用:
const data1 = [10 , 20 ];
const data2 = [50 , 20 , 10 ];
function App () {
const [data , setData ] = useState (data1);
return (
< div>
< button
onClick= {() => {
setData (prev => (prev === data1 ? data2 : data1));
}}
>
switch data
{data .map ((score , index ) => {
return (
< div>
< Bar score= {score} / >
< / div>
);
})}
< / div>
);
}
实际得到的结果:
实际得到的结果
每次的动画不会从 0 开始,第二个元素根本就没有动画。通过查看打印到控制台的信息,可发现在数据发生变化后,
组件是没有销毁的,说明该组件在 props 更新时进行了复用,这是观察到的一点线索。
你可能会说,这里应该在每次渲染前,也就是 setTimeout
之前,先重置一下数据将宽度设置为 0,这样便能得到想要实现的效果:每次都从 0 开始动画。同时,为了看清过程,不防将 setTimeout
的时间暂时加大。
useEffect(() => {
console.log("score 发生变化");
+ setWidth(0);
const timer = setTimeout(() => {
setWidth(score);
- }, 0);
+ }, 1000);
return () => {
clearTimeout(timer);
};
}, [score]);
每次动画前初始化
可以看到,并没有什么用。依然会有一个减小的动画。如果将 setTimeout
置回到 0,只是看不到这个缩减到 0 过程,而是缩减到目标值的这一过程。并且对于第二个元素,因为前后 props 并没有发生变化,连缩小的过程也没有。
React 的 diff 机制
对于树的差异检测,按照这个论文中描述的算法实现,其时间复杂度为 O(n3 ) 。而页面中 DOM 节点很容易上千,这样一次渲染需要 diff 的操作超过十亿,显然不可行。所以 React 在进行 diff 时作了两个假设前提:
如果父元素不同,其子节点产生不同的树。
开发者可通过为元素指定 key
来标识元素的唯一性,提高 React 差异检测时的效率。
基于这两点假设,在进行 diff 时可以少很多工作量,
diff 过程中,如果发现原来某个位置的元素其类型变化了,则无需继续遍历其子元素,直接认为该元素连同所有子节点都需要被替换掉。
diff 过程中,如果元素的 key
与上一次渲染时没发生变化,则判定为不需要重新渲染,进而也无需往下继续遍历其子元素。
这样假设之后,React 的 diff 算法做到了时间复杂度为 O(n)。
DOM 节点的 diff
区分为节点类型变化与没变化两种情况,
对于前后再次渲染中,同一位置元素类型变化的情况,如前文所述,对该元素及其子节点整个更新。比如由
变成 ,该位置的
及其所有子节点将整个销毁,其中的状态也丢弃掉,创建
及相应子元素替换在该位置。
对于类型没变的情况则比较元素的属性,得出差异后只更新相应属性,比如 className
。样式有更新也只计算出变化的样式属性然后只更新该属性。
组件节点的 diff
对于自己写的组件,类型变化时同 DOM 节点一样,将整个组件实例销毁,其中各状态将丢失,所有子节点也都销毁,这些组件的 componentWillUnmount()
生命周期函数将被触发。然后实例化新类型的组件替换在该位置,新实例化的组件其 componentWillMount()
及 componentDidMount()
生命周期函数将顺次触发。
如果该位置组件类型没变,说明只需要根据变化的 props
更新组件即可,无需重新实例化新的组件。组件实例中的状态将在两次渲染中被保留复用 ,组件的 componentWillReceiveProps()
及 componentWillUpdate()
生命周期函数将触发。
子节点的遍历及 key
属性
上面描述了节点对比后的处理。对于节点内子节点,递归遍历时,应用相同的逻辑。考察下面的示例代码:
React 在遍历
的子节点时,能够将前两个
元素匹配,保持不动,然后将新增的 third
附加在列表最后,完成更新。
如果新插入的元素不在列表最后,而是在最前面或中间,事情就开始发生变化。
+ Connecticut
Duke
Villanova
这时 React 简单地按位置来对比更的模式就变得不那么智能了。由前文所述,
在进行第一个子元素
的对比时,发现其内容由 Duke
变为了 Connecticut
,于是将该位置的元素更新。
继续对比,发现原来第二个位置的 Villanova
变为了 Duke
,执行更新操作。
再继续发现需要新增 Villanova
元素。
这是 React 真实的流程,并不是我们一眼就能看出来的那个样子,只需要在列表开头插入那个新增的元素,将其他子元素保留即可。
所以,对于这样的列表类型,如果元素频繁变动,势必导致更新的效率会很低。问题的根本在于 React 不能识别前后两次渲染哪些元素其实是同一个,而是根据其在组件树中的位置来进行 diff 的。如果我们手动为元素指定一个唯一标识,这个标识在前后再次渲染时如果不变的话,这样就相当于告诉 React 它们是同一个元素,而不是按照其所在列表中的位置来进行 diff。
这便是元素身上的 key
属性。其值一定是能够唯一标识该元素的,这个唯一是指兄弟节点之间唯一即可,比如列表中同类型的列表元素。如果兄弟节点 key
重复,React 会有警告提醒。
再来看上面的例,
< ul>
< li key= " 2015" > Duke< / li>
< li key= " 2016" > Villanova< / li>
< / ul>
< ul>
< li key= " 2014" > Connecticut< / li>
< li key= " 2015" > Duke< / li>
< li key= " 2016" > Villanova< / li>
< / ul>
通过读取元素身上的 key
,再次比较时 React 能够智能地得出结论,本次更新只需要插入 Connecticut
,剩余的其他子节点可直接复用。这样处理子节点的 diff 时效率就大大提升了。
所以你通过遍历方式生成一堆子节点时,React 会提示你需要为元素设置 key
属性。
Warning: Each child in a list should have a unique "key" prop.
默认情况下,如果没有显式指定 key
,React 默认使用其在列表中的索引作为 key
。但这个属性最好是来自需要渲染的数据条目的 id,这样能够最大程度地与数据保持一致,如果数据变化了,id 必然变化,则重新渲染。直接使用 for
循环中的 index
索引来做为 key
是不推荐的。因为索引不体现数据的变化,如果列表数据变化了,比如进行了排序,原来位置的数据可能不是原来的那条数据了,但因为索引没变 ,React 按照每个位置还是同一个元素的 diff 逻辑来处理,该位置的组件复用前一次渲染的状态,势必产生 bug。下面是一个简单展示这一问题的示例:
function Item ({ name }) {
const [score , setScore ] = useState ();
return (
< div>
name: {name}
< input
type= " text"
onChange= {e => {
setScore (e .target .value );
}}
/ >
score is: {score}
< / div>
);
}
function App () {
const [persons , updatePersons ] = useState ([" tom" , " david" ]);
return (
< div>
< h3> set age for each person< / h3>
{persons .map ((name , index ) => {
return < Item key= {index} name= {name} / > ;
})}
< div>
< button
onClick= {() => {
updatePersons (prev => [" lily" , ... prev]);
}}
>
add person
< / button>
< / div>
< / div>
);
}
上面的示例遍历一个包含了姓名的数组,为每个人生成一行可输入分数的表单项。同时我们将每个生成项的 key
设置成索引 index
。
展示将 `key` 设置成索引导致组件内部状态不对的问题
可以看到,分数设置在列表中子组件中,当添加新的条目后,原来索引位置的组件复用之前的组件状态,因为该位置 key
相同,不会整个重新渲染。所以新增在第一位的 lily
,本来还没有为其设置分数,但它使用了原来在那个位置的 tom
的分数,同时,其他元素因为位置变化了,他们所持有的状态都错位了。
修正 key
之后再次操作表现就正常了。
function App() {
const [persons, updatePersons] = useState(["tom", "david"]);
return (
set age for each person
{persons.map((name, index) => {
- return ;
+ return ;
})}
{
updatePersons(prev => ["lily", ...prev]);
}}
>
add person
);
}
这里假设每条数据其 name
值是不一样的,所以将它作为列表元素的唯一标识。
修正 `key` 之后的正常表现
问题的解决
回到文章开头的问题,就可以理解其表现了。
const data1 = [10 , 20 ];
const data2 = [50 , 20 , 10 ];
默认情况下,React 使用 index 作为 key
。
对于第一条数据,其值由 10 变化到 50,动画正常,当再次设置时,其由 50 变回到 10。因为组件并没有重新初始化,所以其初始值确实是 50,所以看到了由 50 到 10 这个缩减的动画。
而对于第二条数据,因为前后值没变化,执行动画的 setTimeout
都不会执行。
修正的方法可以为元素指定一个随机的 key
,这样每次组件都会重新渲染,不会复用之前的状态。
function App() {
const [data, setData] = useState(data1);
return (
{
setData(prev => (prev === data1 ? data2 : data1));
}}
>
switch data
{data.map((score, index) => {
return (
-
);
}
修正后的百分比柱状条效果
将 key
设置成随机值是不推荐的做法,因为这样 React 就没法在渲染过程中对组件进行重用的优化。但像这里的特殊情况,你需要知道的是其中的原理,然后清楚自己在这样做时的影响。
总结
虚拟 DOM 将操作浏览器 DOM 的成本一部分转嫁到了 Javascript 中,即进行差异计算的成本。提高了渲染的效率,但某些情况下也会是一个坑。
需要注意的是,React 的差异算法高效性是在两个假设前提下进行的,
如果父元素不同,其子节点产生不同的树。
开发者可通过为元素指定 key
来标识元素的唯一性,提高 React 差异检测时的效率。
相关资源
Reconciliation
Index as a key is an anti-pattern
推荐阅读
本文介绍了绕过WAF的XSS检测机制的方法,包括确定payload结构、测试和混淆。同时提出了一种构建XSS payload的方法,该payload与安全机制使用的正则表达式不匹配。通过清理用户输入、转义输出、使用文档对象模型(DOM)接收器和源、实施适当的跨域资源共享(CORS)策略和其他安全策略,可以有效阻止XSS漏洞。但是,WAF或自定义过滤器仍然被广泛使用来增加安全性。本文的方法可以绕过这种安全机制,构建与正则表达式不匹配的XSS payload。 ...
[详细]
蜡笔小新 2023-12-11 19:42:30
本文介绍了数据库的存储结构及其重要性,强调了关系数据库范例中将逻辑存储与物理存储分开的必要性。通过逻辑结构和物理结构的分离,可以实现对物理存储的重新组织和数据库的迁移,而应用程序不会察觉到任何更改。文章还展示了Oracle数据库的逻辑结构和物理结构,并介绍了表空间的概念和作用。 ...
[详细]
蜡笔小新 2023-12-14 16:00:02
最近在做listview时发现OnItemClickListener失效的问题,经过查找发现是因为button的原因。不仅listitem中存在button会影响OnItemClickListener事件的失效,还会导致单击后listview每个item的背景改变,使得item中的所有有关焦点的事件都失效。本文给出了一个范例来说明这种情况,并提供了解决方法。 ...
[详细]
蜡笔小新 2023-12-14 14:25:50
本文介绍了Metasploit攻击渗透实践的内容和要求,包括主动攻击、针对浏览器和客户端的攻击,以及成功应用辅助模块的实践过程。其中涉及使用Hydra在不知道密码的情况下攻击metsploit2靶机获取密码,以及攻击浏览器中的tomcat服务的具体步骤。同时还讲解了爆破密码的方法和设置攻击目标主机的相关参数。 ...
[详细]
蜡笔小新 2023-12-14 12:14:09
本文介绍了在Mac上搭建php环境后无法使用localhost连接mysql的问题,并通过将localhost替换为127.0.0.1或本机IP解决了该问题。文章解释了localhost和127.0.0.1的区别,指出了使用socket方式连接导致连接失败的原因。此外,还提供了相关链接供读者深入了解。 ...
[详细]
蜡笔小新 2023-12-13 17:48:58
本文介绍了Webmin远程命令执行漏洞CVE-2019-15107的漏洞详情和复现方法,同时提供了防护方法。漏洞存在于Webmin的找回密码页面中,攻击者无需权限即可注入命令并执行任意系统命令。文章还提供了相关参考链接和搭建靶场的步骤。此外,还指出了参考链接中的数据包不准确的问题,并解释了漏洞触发的条件。最后,给出了防护方法以避免受到该漏洞的攻击。 ...
[详细]
蜡笔小新 2023-12-13 16:14:53
本文介绍了作者在开发过程中遇到的问题,即播放框架内容安全策略设置不起作用的错误。作者通过使用编译时依赖注入的方式解决了这个问题,并分享了解决方案。文章详细描述了问题的出现情况、错误输出内容以及解决方案的具体步骤。如果你也遇到了类似的问题,本文可能对你有一定的参考价值。 ...
[详细]
蜡笔小新 2023-12-13 16:03:19
本文介绍了如何快速安装单机版的Skywalking,包括下载、环境需求和端口检查等步骤。同时提供了百度盘下载地址和查询端口是否被占用的命令。 ...
[详细]
蜡笔小新 2023-12-14 19:05:47
Monkey《大话移动——Android与iOS应用测试指南》的预购信息已经发布,可以在京东和当当网进行预购。感谢几位大牛给出的书评,并呼吁大家的支持。明天京东的链接也将发布。 ...
[详细]
蜡笔小新 2023-12-14 18:57:09
本文介绍了Linux系统中正则表达式的基础知识,包括正则表达式的简介、字符分类、普通字符和元字符的区别,以及在学习过程中需要注意的事项。同时提醒读者要注意正则表达式与通配符的区别,并给出了使用正则表达式时的一些建议。本文适合初学者了解Linux系统中的正则表达式,并提供了学习的参考资料。 ...
[详细]
蜡笔小新 2023-12-13 14:24:45
nsitionalENhttp:www.w3.orgTRxhtml1DTDxhtml1-transitional.dtd ...
[详细]
蜡笔小新 2023-12-13 13:30:30
本文分享了作者在国庆期间在thinkpad X60上成功安装Sabayon Linux的经验。通过修改CHOST和执行emerge命令,作者顺利完成了安装过程。Sabayon Linux是一个基于Gentoo Linux的发行版,可以将电脑快速转变为一个功能强大的系统。除了作为一个live DVD使用外,Sabayon Linux还可以被安装在硬盘上,方便用户使用。 ...
[详细]
蜡笔小新 2023-12-13 11:35:40
本文详细解读了CSS中的position属性absolute和relative的区别和用法。通过解释绝对定位和相对定位的含义,以及配合TOP、RIGHT、BOTTOM、LEFT进行定位的方式,说明了它们的特性和能够实现的效果。同时指出了在网页居中时使用Absolute可能会出错的原因,即以浏览器左上角为原始点进行定位,不会随着分辨率的变化而变化位置。最后总结了一些使用这两个属性的技巧。 ...
[详细]
蜡笔小新 2023-12-12 13:16:49
本文介绍了前端人员必须知道的三个问题,即前端都做哪些事、前端都需要哪些技术,以及前端的发展阶段。初级阶段包括HTML、CSS、JavaScript和jQuery的基础知识。进阶阶段涵盖了面向对象编程、响应式设计、Ajax、HTML5等新兴技术。高级阶段包括架构基础、模块化开发、预编译和前沿规范等内容。此外,还介绍了一些后端服务,如Node.js。 ...
[详细]
蜡笔小新 2023-12-12 12:45:59
本文介绍了使用shell脚本判断IP是否在同一网段、判断IP地址是否在某个范围内、计算IP地址范围、判断网段之间的包含关系的方法和原理。通过对IP和掩码进行与计算,可以判断两个IP是否在同一网段。同时,还提供了一段用于验证IP地址的正则表达式和判断特殊IP地址的方法。 ...
[详细]
蜡笔小新 2023-12-12 11:19:14
刘自龙Sophisten
这个家伙很懒,什么也没留下!