阅读前提
阅读本文的前提,是先把这两篇文章看懂,因为它们已经说清楚的部分,本文不打算再赘述,本文更多的是从笔者的角度去剖析这些知识点,以便大家能够更好地理解它们。
React - Reconciliation
React 源码剖析系列 - 不可思议的 react diff
前言
前端框架诸如React/Vue之类的最大贡献之一,就是将广大的前端开发人员从繁琐的DOM操作中解放出来(即便有jQuery这样的优秀DOM操作库助力,随着应用复杂度的提升,DOM的操作依然会变得异常繁琐)。通过“数据驱动视图”的方式,让开发人员的注意力集中在数据和逻辑上,从而提高开发效率。也让前端开发逐渐摆脱“应用中负责还原终端设计稿”的附属地位,逐渐往构建独立复杂应用的方向发展。
数据驱动视图
一个典型的前端应用场景,就是当用户打开页面后,前端先调用后端接口拿到数据,再将数据应用到页面模板上,最终生成完整的页面呈现给用户。前端框架所做的事情,就是将数据存储下来,在保证相同数据一定生成相同页面的前提下,即可将前端的最终关注点由“页面”前置为“数据",最终可用数据来表达\持久化/还原应用的状态。
现代框架都推崇使用组件来封装页面的一部分信息和功能,再通过不同组件的拼装来构建整个页面。以React为例,React的每个组件都有一个render方法用来输出组件当前状态下的界面信息(虚拟DOM)。而随之而来的一个问题就是,当组件的状态发生变化时,组件的render方法会返回不同的界面信息,此时该如何更新用户界面呢?
如果简单粗暴点,直接将老的DOM节点全部卸载,然后重新构建新的DOM节点再挂载上去,当然也是可以的,但是这样的做法在前端场景下的成本就太高了,因为大家都知道,DOM节点的创建和销毁都是需要耗费巨大的系统资源的。
还有另一种选择,由于组件的render方法在更新前后都会返回树状的界面描述信息,所以问题等价于如何将一棵树转换成另一棵树。传统的转换算法复杂度极高O(n3),完全不适用于前端的场景,所以我们需要寻求一种更聪明、更高效的转换算法。
React的Reconciliation(Diff)算法
为了解决这个问题,React基于前端的应用场景,设计出一种聪明的启发式Diff算法,将O(n3)复杂度的问题转换成O(n)复杂度的问题,大大提高了界面更新的效率。具体的算法设计思路和细节,请参考React 源码剖析系列 - 不可思议的 react diff相关篇幅,本文不再赘述,下面我将抛出我自己的几点疑问和相关的思考。
一、为什么要有Diff算法?
为了重用DOM节点。由于DOM节点的创建和销毁成本很高,又由于在前端的场景下,大面积的界面更新是很少的,更常见的是局部的小范围更新,所以大部分的界面在大多数时候都是保持不变的。说明我们可以只做局部更新,以避免频繁创建和销毁DOM所带来的巨大资源开销。
二、何时局部更新?何时整体切换?
我举一个常见的栗子🌰。假如我们正在手机上申请办理信用卡,那么常见的场景是:
第一步/第一个界面/表单组件:填写长长的表单提交个人信息;
第二步/第二个界面/刷脸组件:刷脸录入人脸信息。
我们假设第一个界面有部分保存已填信息到后端的功能,以便用户再次进入时,可以回填之前已经填过的用户信息。我们再假设所有操作都在同一个HTML页面上下文中完成,表单界面和刷脸界面都是用组件来进行封装,我们称之为表单组件和刷脸组件。
接下来我们模拟一下用户的使用场景,用户首先打开第一个界面,我们会先展示空表单给用户,然后等待后端接口返回用户之前已填的部分信息,那么此时第一个界面有两种状态:初始空状态和部分信息回填状态。两种状态的切换时机就是在接口数据返回前后。那么当接口数据返回时,我们有必要将之前空状态下渲染的表单节点全部卸载删除吗?当然没必要,我们只需要将对应的信息回填到表单上展示给用户即可。这就是组件在状态切换前后所引发的局部更新。
当用户填完所有信息提交之后,他将被引导至第二个界面,也就是刷脸界面。由于第二个界面与第一个界面长得完全不同,此时已没有重用DOM节点的意义和价值。那么此时我们只需要放心地卸载第一个表单组件,然后初始化并挂载第二个刷脸组件即可。此时我们不用担心由此引发的资源消耗,因为这是由于业务变化导致的功能区变化,属于必要的资源开销。
那么我来总结一下就是,每一个功能区都有它自己的生命周期,在它的生命周期以内只需要做状态变更,也就是局部更新即可。当功能区生命周期结束时,即可放心大胆地将其卸载,然后初始化并装载下一个功能区即可。这里的功能区可以是一个组件,也可以包含多个组件。
三、如何判断新老节点是否有比较的必要?
如果读者已经理解了前提中引用的两篇文章的话,那么这个问题的答案是已知的。即通过判断新老节点是否是相同类型的节点来做判断,对于html标签而言,直接判断标签名是否相同即可,而对于组件而言,也是直接判断组件的类型是否相同。组件就是一棵DOM树的描述对象标识,由于React推荐并且认为同类组件会产出相似的树形结构,所以同类组件才有深入diff比较的必要,同理不同的组件由于产出的DOM树不同,也就没有进一步比较的必要,直接做简单的删除和创建操作即可。
四、为什么要引入key属性?
先抛结论:React会为每一个虚拟DOM设置key值,要么你直接指定key,要么React用数组下标为你生成一个。具体代码如下:
// node_modules/react-dom/lib/traverseAllChildren.js
function getComponentKey(component, index) {
if (component && typeof component === 'object' && component.key != null) {
return KeyEscapeUtils.escape(component.key);
}
return index.toString(36);
}
那么React为什么要这么做呢?根据前面所言,新老节点有继续比较的价值的前提,是它们的类型要相同。那么是不是类型相同就一定有比较的价值呢?并不竟然,在一种典型的场景下就不一定,那就是子节点的位置变化。这种情况下,我们就必须显示地通过指定唯一标识key值的方式告知React这种情况的发生,否则React就只能“傻乎乎”的逐个比较了。所以实际在React中新老节点比较的方法如下:
// node_modules/react-dom/lib/shouldUpdateReactComponent.js
function shouldUpdateReactComponent(prevElement, nextElement) {
var prevEmpty = prevElement === null || prevElement === false;
var nextEmpty = nextElement === null || nextElement === false;
if (prevEmpty || nextEmpty) {
return prevEmpty === nextEmpty;
}
var prevType = typeof prevElement;
var nextType = typeof nextElement;
if (prevType === 'string' || prevType === 'number') {
return nextType === 'string' || nextType === 'number';
} else {
return nextType === 'object' && prevElement.type === nextElement.type && prevElement.key === nextElement.key;
}
}
而在Vue中也是类似的:
// node_modules/vue/src/core/vdom/patch.js
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
参考资料
React - Reconciliation
React 源码剖析系列 - 不可思议的 react diff
(完整版)快速掌握虚拟DOM和diff算法【Vue】