React作为前端的主流框架,MVVM数据驱动视图这个过程中涉及的就是新旧dom的替换。
我们都知道,React中JSX的语法设置。当React执行JSX时,会优先第一步将JSX通过Babel转义成JS语法,然后调用React.createElement(),得到一个JS对象。JS对象的结构如下:
js
function createElement(type, properties, children) {
let ref = properties.ref || null;
let key = properties.key || null;
// 这些属性不需要放入props,因此过滤出去。
["key", "ref", "__self", "__source"].forEach((key) => {
delete properties[key];
});
let props = { ...properties };
// createElement这个函数的入参
if (arguments.length > 3) {
// 这段代码的含义是将arguments对象转换为数组,并从索引2开始截取后面的所有元素.
// 因为前面两个参数为标签类型和属性,此处需要获取children子元素内容
props.children = Array.prototype.slice.call(arguments, 2);
} else {
props.children = [children];
}
return {
// 这个类型代表React JSX转化成的虚拟DOM 【表示该元素的虚拟DOM对象。】
$$typeof: Symbol('react.element'),
// 标签类型
type,
// 操作DOM的
ref,
//用于DOM diff算法的
key,
// 存储虚拟对象的属性。比如classname、子元素等
props,
};
}
经过上述处理,我们可以得到一个JS对象,也就是我们常说的虚拟dom。
当我们通过setState改变变更数据时,setState会进行页面更新,在这个更新的过程中,就会涉及到我们本篇文章的核心,dom Diff这个过程。
dom diff
dom diff简单的概括来说就是对比新旧两个虚拟dom,找到两者之间的不同,然后根据不同之处去变更真实的dom树。
dom diff的过程可以总结为下面3重维度
- 【Tree Diff】:React 首先对整个组件树的结构进行比较。在这个阶段,React 会比较两棵树的每一层,快速识别出哪些组件类型已经改变。如果发现某个组件的类型在新旧树之间不同,那么这个组件及其所有子组件都会被替换。这是一个全局层面的比较,可以快速识别出需要进行大规模更新的区域
- 【Component Diff】 :在确定了需要更新的组件后,React 接下来会比较同一类型的组件。如果组件的类型相同,React 会进行更细致的比较。这包括比较组件的 props 和 state,以确定是否需要更新组件(即调用组件的
render
方法)。如果组件的类型不同,React 会直接卸载旧组件,并挂载新组件。 - 【Element Diff】: 最后,React 会对组件 render 方法返回的 JSX 结构中同一层级的子元素进行比较。React 通过标记子元素的 key 属性来优化这个过程。如果子元素具有 key,React 会使用这个 key 来匹配新旧两棵树中的子元素。这可以确保当子元素在列表中改变位置时,React 能够正确地识别并保留这些子元素,而不是销毁并重新创建它们。如果没有 key,React 默认使用子元素的索引作为 key,这可能会导致不必要的元素重建和性能问题。
将上面3个维度进行详细的解释,如下:
【Tree Diff】组件树维度的对比
Tree Diff 的目的是快速识别出在新旧两棵树中位置相同但类型不同的组件,这样可以避免进行不必要的深度比较。具体来说,这个过程包括以下几个方面:
- 层级比较:React 会逐层遍历旧的组件树,与新的组件树进行对比。这个比较是按层级进行的,即从根节点开始,逐层向下比较每个节点。
- 类型检查 :在每一层中,React 会检查新旧两棵树中相同位置的组件是否为同一类型。这里的"类型"指的是组件的构造函数或者由
React.createElement
创建的元素类型。如果类型相同,React 认为这两个组件是相同的,可以进行更深入的比较(即 Component Diff);如果类型不同,React 则会认为整个组件及其子树都已经改变,于是会卸载整个旧组件树,替换为新的组件树。 - 快速识别改变:由于组件树的结构可能非常庞大,如果逐个节点进行深度比较会非常耗时。Tree Diff 允许 React 快速识别那些不需要进一步比较的子树。如果在某个层级发现了不同类型的组件,React 会停止在该层级的比较,并直接替换整个子树。
【Component Diff】组件类型一致,同类型组件返回的JSX虚拟dom树维度的对比
当我们的组件类型一致时,就要对同类型组件返回的JSX对比,这个对比的过程会涉及到以下几种情况:
js
const diffTypeMap = {
// 原生节点
ORIGIN_NODE: typeof oldVNode.type === 'string',
// 类组件
CLASS_COMPONENT: typeof oldVNode.type === 'function' && oldVNode.type.IS_CLASS_COMPONENT,
// 函数组件
FUNCTION_COMPONENT: typeof oldVNode.type === 'function',
// 文本节点
TEXT_NODE: oldVNode.type === REACT_TEXT
}
PS:不管是什么类组件还是函数组件,最终都会解析成原生节点,然后从节点维度进行深度对比。
【Element Diff】JSX 结构中同一层级的子元素进行比较,也就是将新旧虚拟dom节点进行对比,涉及如下几种情况:
- 新节点、旧节点都不存在
- 新节点存在、旧节点不存在
- 新节点不存在、旧节点存在
- 新节点存在,旧节点也存在,但是类型不同
- 新节点存在,旧节点也存在,类型相同 这里就需要进行深度比较
深度对比是整个dom diff的核心点。 主要的逻辑代码如下:
js
/**
* 新旧节点都存在并且标签类型相同
* @param {*} oldVNode
* @param {*} newVNode
*/
function deepDOMDiff(oldVNode, newVNode) {
const diffTypeMap = {
ORIGIN_NODE: typeof oldVNode.type === 'string',
CLASS_COMPONENT: typeof oldVNode.type === 'function' && oldVNode.type.IS_CLASS_COMPONENT,
FUNCTION_COMPONENT: typeof oldVNode.type === 'function',
TEXT_NODE: oldVNode.type === REACT_TEXT
}
let DIFF_TYPE = Object.keys(diffTypeMap).filter(key => diffTypeMap[key])[0];
switch (DIFF_TYPE) {
// 原生节点(比如div、span这种)
// 不管是Class组件还是function组件,最终生成的都会是一个原生节点(原生节点是底层)
case 'ORIGIN_NODE':
// 根据旧的虚拟节点找到旧的真实节点,然后把它赋值给新的虚拟DOM
let currentDOM = newVNode.dom = findDomByVNode(oldVNode);
setPropsForDOM(currentDOM, newVNode.props);
// 更新子节点
updateChildren(currentDOM, oldVNode.props.children, newVNode.props.children)
break;
case 'CLASS_COMPONENT':
updateClassComponent(oldVNode, newVNode);
break;
case 'FUNCTION_COMPONENT':
updateFunctionComponent(oldVNode, newVNode);
break;
case 'TEXT_NODE':
newVNode.dom = findDomByVNode(oldVNode);
newVNode.dom.textContent = newVNode.props.text
break;
default:
break;
}
}
/**
* DOM-DIFF的核心
* @param {*} parentDOM
* @param {*} oldVNodeChildren
* @param {*} newVNodeChildren
*/
function updateChildren(parentDOM, oldVNodeChildren, newVNodeChildren) {
oldVNodeChildren = (Array.isArray(oldVNodeChildren) ? oldVNodeChildren : [oldVNodeChildren]).filter(Boolean);
newVNodeChildren = (Array.isArray(newVNodeChildren) ? newVNodeChildren : [newVNodeChildren]).filter(Boolean);
let lastNotChangeIndex = -1;
// 旧的节点
let oldKeyChildMap = {}
// 建立节点key与虚拟dom节点的对应关系,便于遍历新的虚拟dom时来对比旧的dom树上是否存在该节点
oldVNodeChildren.forEach((oldVNode, index) => {
let oldKey = oldVNode && oldVNode.key ? oldVNode.key : index;
oldKeyChildMap[oldKey] = oldVNode;
});
// 遍历新的子虚拟dom数组,找到可以复用但需要移动的节点、需要重新创建的节点、需要删除的节点,剩下的就是可以复用且不用移动的节点
let actions = [];
newVNodeChildren.forEach((newVNode, index) => {
newVNode.index = index;
let newKey = newVNode && newVNode.key ? newVNode.key : index;
// 查看新节点数组中的当前节点是否已经存在于旧节点数组中。
let oldVNode = oldKeyChildMap[newKey];
if (oldVNode) {
// 如果旧节点数组中存在,则需要进行深度遍历
deepDOMDiff(oldVNode, newVNode);
if (oldVNode.index < lastNotChangeIndex) {
// 移动节点
actions.push({
type: MOVE,
oldVNode,
newVNode,
index
})
}
// 删除会用到的节点,最后剩在oldKeyChildMap中的就是新dom树中用不上而需要删除的那些节点
delete oldKeyChildMap[newKey]
lastNotChangeIndex = Math.max(lastNotChangeIndex, oldVNode.index);
} else {
// 如果旧节点数组中不存在,则需要创建这个节点。
actions.push({
type: CREATE,
newVNode,
index,
})
}
})
let VNodeToMove = actions.filter(action => action.type === MOVE).map(action => action.oldVNode);
let VNodeToDelete = Object.values(oldKeyChildMap);
VNodeToMove.concat(VNodeToDelete).forEach(oldVNode => {
const currentDOM = findDomByVNode(oldVNode);
currentDOM.remove();
});
// actions里面的元素是需要创建 或 需要移动的节点的总和
// action中的index,是新节点的index位置
actions.forEach(action => {
let { type, oldVNode, newVNode, index } = action;
// parentDOM是旧的节点数组的父元素.[代码参考:deepDOMDiff函数中的传递]
let childNodes = parentDOM.childNodes;
// 这里是查看当前的index上面是否已经有节点。
// 如果已经有节点,那么需要移动。也就是把
let childNode = childNodes[index];
const getDomForInsert = () => {
if (type === CREATE) {
return createDOM(newVNode);
}
if (type === MOVE) {
return findDomByVNode(oldVNode);
}
}
if (childNode) {
// 将getDomForInsert()得到的节点,插入在childNode前面
parentDOM.insertBefore(getDomForInsert(), childNode)
} else {
parentDOM.appendChild(getDomForInsert())
}
})
}