React源码剖析:手写React DOM DIFF比对
可能用过React或者Vue这样的前端框架的人可能都应该清楚,在React或者Vue中完成节点的更新渲染最重要的应该就是这DOM DIFF的比对了,它实际的需求就是尽可能的复用页面上已经创建的老节点,做一些补丁操作使得尽可能的复用已有的DOM,从而提高渲染更新的性能。之前有实现了Vue中的DOM DIFF的比对,但是React中的DOM DIFF和Vue中还是有比较大的区别的,那就来看看吧!::
-
React中的DOM DIFF的比对采用逐层比对,深度优先的比对方法,及从虚拟DOM树的根节点开始,先遍历节点的子元素,再是兄弟元素这样的比对顺序进行比对的,而在每一层的比对方法见下图:
-
这是一张在网上找到的DOM DIFF的比对图,(当然和我需求的还是有一定的区别,所以这里需要做一点简单的说明)
-
React中的DOM DIFF算法在每层上的比对是从前往后的,它不像VUE那样是两头往中间进行比对。将新的虚拟DOM从前往后开始,在老的虚拟DOM节点中找出可以复用的节点,在这个过程中会有一个指向当前最后一个位置不会变动的老的DOM节点的指针,它的作用就是表示当前指针前面的老的DOM元素的位置是不需要变动的,然后之后如果找的可复用的节点的位置在这个指针指向的索引之前,就需要将这个节点移动到指针指向的位置之后去,同时将这个指向指向移动后的新值。相当于需要向前移动的元素不需要改变位置,而往后移动的元素则移动到当前不需要移动的元素的后面。所以这也就是React在完全需要倒序元素节点的效率比Vue低的原因。(当然在16版本之后的Fiber出现后可以从某些方面提高这个性能,但是DIFF算法的比对还是不变的)
-
根据以上的情况实现React DOM DIFF算法的比对。
// 将要用到的两个全局变量
let updateDepth = 0; //记录当前更新的深度,当深度最后又回到0时表示深度遍历已经结束
let diffQueue = []; // 记录当前哪些节点需要打补丁,那些节点需要移除或者移动等内容
比较两个虚拟DOM节点函数
function compareTwoElements(oldRenderElement, newRenderElement){
// 一个组件只有一个根节点,所以onlyOne函数的作用就是只取一个根节点元素
oldRenderElement = onlyOne(oldRenderElement);
newRenderElement = onlyOne(newRenderElement);
// 在创建DOM挂载的时候会在虚拟DOM上挂载一个dom属性,用于指向当前虚拟DOM指向的真实DOM节点
let currentDOM = oldRenderElement.dom;
let currentElement = oldRenderElement;
if(newRenderElement == null){
// 如果新的虚拟DOM节点为null,则需要从老的真实DOM节点上找到其父节点将当前的自己删掉
currentDOM.parentNode.removeChild(currentDOM);
currentDOM = null;
}else if(oldRenderElement.type !== newRenderElement.type){
// 如果老的标签节点和新的标签节点不同,则直接重新渲染新的虚拟DOM进行挂载
// 这里就表示如果一个DOM树的父节点不同,则子节点就不用再比较了,之间删掉重建
let newDOM = createDOM(newRenderElement);
currentDOM.parentNode.replaceChild(currentDOM, newDOM); // 替换掉老的节点
currentElement = newRenderElement;
}else{
// 当新旧节点都有,且当前的类型也是一样的,则开始进行dom diff: 深度比较,比较其子节点,并尽可能的复用节点
updateElement(oldRenderElement, newRenderElement);
}
return currentElement;
}
开始比较新旧子节点
function updateElement(oldElement, newElement){
// 获取老的真实的DOM,dom diff的操作仍然是在老的真实DOM上执行, 目的就是为了复用老的DOM节点
let currentDOM = newElement.dom = oldElement.dom;
// 如果是文本节点,则直接更新文本内容,$$typeof是在创建虚拟DOM的时候为每一类节点指定的类型属性
if(oldElement.$$typeof === TEXT && newElement.$$typeof === TEXT){
// 如果旧的文本内容和新的文本内容一样,则不进行更新,content属性是文本节点上的文本信息
if(oldElement.content !== newElement.content){
currentDOM.textContent = newElement.content;
}
}else if(oldElement.$$typeof === ELEMENT){
// 如果节点是React原生DOM节点,则对其标签属性进行补丁,同时递归比对其子节点
updateDOMProperties(currentDOM, oldElement.props, newElement.props)
updateChildrenElements(currentDOM, oldElement.props.children, newElement.props.children); //递归的更新其子元素
//这里会把newElement的props赋值给oldElement.props
// 如果当前是element,会把newElement.props(包括children)赋值给oldElement.props,赋完值之后老的虚拟DOM值就变成了新的属性,使用新的虚拟DOM值
oldElement.props = newElement.props;
}else if(oldElement.$$typeof === FUNCTION_COMPONENT){
// 更新React函数组件
updateFunctionComponent(oldElement, newElement);
}else if(oldElement.$$typeof === CLASS_COMPONENT){
// 更新React类组件
updateClassComponent(oldElement, newElement);
}
}
react原生DOM元素的属性补丁;
// 跟新可重用的真实的DOM的节点属性
function updateDOMProperties(dom, oldProps, newProps){
// 旧的节点有,新的节点没有的属性,删除
// 旧的有的属性,新的也有的属性,则更新
// 新的有的属性,旧的没有的属性则添加
for(let key in oldProps){
if(key !== "children"){ // 这里只会处理自己的节点属性,不会对子节点的属性进行处理
if(!newProps.hasOwnProperty(key)){
// 老属性在新的中没有,则删除
dom.removeAttribute(key);
}
}
}
for(let key in newProps){
if(key !== "children"){
setProp(dom, key, newProps[key]); // 覆盖和添加属性
}
}
}
// 设置属性的方法
function setProp(dom, key, value){
// 如果事件处理函数在新旧的对象都有的话,需要先将旧的事件绑定函数删除
if(/^on/.test(key)){ // 如果属性名是以on开头的,说明这个属性是要绑定事件
// 由于DOM中的事件绑定都是小写,而在React中传递进来的时间名是驼峰命名: onclick | onClick
// 这里只是为了简便,而实际react中会使用合成事件机制处理事件绑定,关于事件合成在上一篇文章中说到,这里就不在阐述
dom[key.toLowerCase()] = value;
}else if(key === "className"){
// 单独处理className属性
dom.className = value;
}else if(key === "style"){ // 单独处理style样式,因为他是一个对象
for(let styleName in value){
dom.style[styleName] = value[styleName];
}
}else{
dom.setAttribute(key, value); // 否则直接设置属性
}
}
递归更新原生DOM的子元素
/**
* dom: 当前比对子元素的父元素节点
* oldChildrenElements:老的子元素
* newChildrenElements:新的子元素
*/
function updateChildrenElements(dom, oldChildrenElements, newChildrenElements){
updateDepth++; // 每次进入一个新的子层级,就让updateDepth深度加一
diff(dom, oldChildrenElements, newChildrenElements);
updateDepth--; // 每次比较完一层返回上一层的时候就将updateDepth深度减一
if(updateDepth === 0){ // 如果深度为0,说明此次的diff比对就完成了,就可以开始打补丁了
patch(diffQueue); // 调用patch方法将收集到的差异进行打补丁更新
diffQueue.length = 0; // 清空变更队列
}
}
diff 比对算法:
/**
* dom diff 对比
* @param {*} parentNode 当前子元素挂载的父元素
* @param {*} oldChildrenElements 旧的子元素节点
* @param {*} newChildrenElements 新的子元素节点
*
* 实现原理:
* 1. 创建老的子元素的节点和key属性之间的映射(key => 值为当前子元素),用于之后通过映射取得老元素复用
* 2. 获取新的元素数组(尽可能的复用老的节点,能复用就复用,不能复用就新建)
* -- 流程: 从新元素数组的开始从前往后遍历,并且同时在旧的元素数组中查找是否有可复用的元素
* 能否复用根据key值和type类型判定,如果有可复用的元素,就复用,复用的时候需要判断其位置是否需要
* 移动(判断方法: 定义一个指向老元素数组中当前能够复用的元素索引,如果之后查找出新的可复用的
* 元素的索引值小于这个值,就移动位置,同时在每次判断后都需要对这个指针索引进行赋值, 赋值为
* 当前的老的元素节点的索引值和这个指针索引比较取最大值)
*
*/
function diff(parentNode, oldChildrenElements, newChildrenElements){
// 创建老元素的key和节点之间的映射
let oldChildrenElementMap = getChildrenElementMap(oldChildrenElements)
// 创建新元素的key和节点之间的映射
let newChildrenElementMap = getNewChildrenElementMap(oldChildrenElementMap, newChildrenElements);
// 一个指向当前最后一个不需要变动位置的节点的索引指针
let lastIndex = 0;
for(let i = 0; i < newChildrenElements.length; i++){
// 遍历新的虚拟DOM数组,完成和老元素的对比
let newChildElement = newChildrenElements[i];
if(newChildElement){
// 如果比对的元素上没有key值就取当前这个元素在子元素数组中的位置索引为key
let newKey = newChildElement.key || i.toString();
// 根据新元素的key值在老元素的映射关系中取出老元素(如果有的话)
let oldChildElement = oldChildrenElementMap[newKey];
if(newChildElement == oldChildElement){ // 说明他们是同一个节点,是可复用节点
// 判断元素是否需要移动,这里的_mountIndex也是在创建DOM节点的时候挂载上的元素的索引值
if(oldChildElement._mountIndex < lastIndex){
// 如果需要变更位置,则将变更操作加入到diffQueue中记录,最后在进行统一的更新
diffQueue.push({
parentNode, // 需要移动哪个父节点的下的元素
type: MOVE,
fromIndex: oldChildElement._mountIndex, // 初始移动的位置
toIndex: i // 移动到的位置
})
}
// 重新取得lastIndex的值
lastIndex = Math.max(oldChildElement._mountIndex, lastIndex);
}else{ // 如果新老元素不相等,则直接插入元素
diffQueue.push({
parentNode,
type: INSERT,
toIndex: i,
dom: createDOM(newChildElement)
})
}
// 更细挂载的索引,这里直接对新元素操作是因为之前已经将新元素的属性赋值给老元素
newChildElement._mountIndex = i;
}else{
let newKey = i.toString();
let oldChildElement = oldChildrenElementMap[newKey];
// 这里说明newChildrenElement为null,及老的组件将要被卸载,则执行将要卸载的生命周期
if(oldChildElement.componentInstance && oldChildElement.componentInstance.componentWillUnmount){
oldChildElement.componentInstance.componentWillUnmount();
}
}
}
// 遍历老的映射数组,如果在新的数组中没有,则删除调老的数组中元素
for(let oldKey in oldChildrenElementMap){
if(!newChildrenElementMap.hasOwnProperty(oldKey)){
let oldChildElement = oldChildrenElementMap[oldKey];
diffQueue.push({
parentNode,
type: REMOVE,
fromIndex: oldChildElement._mountIndex,
})
}
}
}
创建新老元素key和元素值之间的映射
// 获得老的子节点的key和节点之间的映射
function getChildrenElementMap(oldChildrenElements){
let oldChildrenElementMap = {};
for(let i = 0; i < oldChildrenElements.length; i++){
let oldKey = oldChildrenElements[i].key || i.toString();
oldChildrenElementMap[oldKey] = oldChildrenElements[i];
}
return oldChildrenElementMap;
}
// 获得新的子元素节点的key和元素之间的映射
function getNewChildrenElementMap(oldChildrenElementMap, newChildrenElements){
let newChildrenElementMap = {};
for(let i = 0; i< newChildrenElements.length; i++){
let newChildElement = newChildrenElements[i];
if(newChildElement){ // 如果新节点不为空,则在老的映射中去找
let newKey = newChildElement.key || i.toString();
let oldChildElement = oldChildrenElementMap[newKey];
// 比较节点是否可以复用(可复用就需要进行深比较)
if(canDeepCompare(oldChildElement, newChildElement)){
// 递归更新当前节点的子节点,复用老的DOM节点
updateElement(oldChildElement, newChildElement);
newChildrenElements[i] = oldChildElement;
}
newChildrenElementMap[newKey] = newChildrenElements[i];
}
}
return newChildrenElementMap;
}
// 判断节点是否需要进行深层比较
function canDeepCompare(oldChildElement, newChildElement){
if(!!oldChildElement && !!newChildElement){
return oldChildElement.type === newChildElement.type; // 类型一样key也是一样就可以复用
}
return false;
}
对diffQueue中记录的更新内容进行更新
function patch(diffQueue){
// 1. 将需要移动的和删除的删除、
// 2. 将需要插入和移动的删除
let deleteMap = {};
let deleteChildren = [];
for(let i = 0; i < diffQueue.length; i++){
let difference = diffQueue[i]
let {type, fromIndex, toIndex} = difference
// 如果是需要移动的节点和删除的节点,则删除
if(type === MOVE || type === REMOVE){
let oldChildDOM = difference.parentNode.children[fromIndex];
oldChildDOM && (deleteMap[fromIndex] = oldChildDOM); // 为了方便后面复用移动的节点
oldChildDOM && deleteChildren.push(oldChildDOM);
}
}
//这里作删除是因为在diffQueue中存放的只是执行相应操作的对象
deleteChildren.forEach(childDOM => {
childDOM.parentNode.removeChild(childDOM)
})
for(let i = 0; i < diffQueue.length; i++){
let {type, fromIndex, toIndex, parentNode, dom} = diffQueue[i];
switch(type){
case INSERT:
// 在某个位置插入元素
insertChildAt(parentNode, dom, toIndex)
break;
case MOVE:
// 移动位置操作也是在某个位置将移动前被删掉的元素节点从新插入到新的位置
insertChildAt(parentNode, deleteMap[fromIndex], toIndex)
break;
default:
break;
}
}
}
// 插入元素函数
function insertChildAt(parentNode, newChildDOM, index){
let oldChild = parentNode.children[index];
oldChild ? parentNode.insertBefore(newChildDOM, oldChild) : parentNode.appendChild(newChildDOM)
}
对函数组件进行更新
// 更新函数组件
// 1. 拿到老的元素 -> 2. 重新执行函数组件拿到新元素 -> 判断元素进行对比
function updateFunctionComponent(oldElement, newElement){
let oldRenderElement = oldElement.renderElement; // 获取老的dom元素
let newRenderElement = newElement.type(newElement.props);
// 获得了新老组件的虚拟DOM就可以开始再次调用compareTwoElements进行递归比较了
let currentElement = compareTwoElements(oldRenderElement, newRenderElement);
// 比较更新之后重新挂在新的dom属性
newElement.renderElement = currentElement;
}
更新类组件
function updateClassComponent(oldElement, newElement){
// componentInstance 属性是在创建类组件的时候挂载在每个类实例上的指向当前组件实例的属性
let componentInstance = newElement.componentInstance = oldElement.componentInstance; // 获取老的类组件实例进行更新
//$updater是类组件中的更新器,关于类组件React.Component将在下一节中实现
let updater = componentInstance.$updater;
let nextProps = newElement.props;
// 更新时,同样处理类上的contentType属性,取出新的Provider的值传递给组件的context属性
if(oldElement.type.contentType){
componentInstance.context = oldElement.type.contentType.Provider.value;
}
// 调用类组件将要更新的生命周期函数
if(componentInstance.componentWillReceiveProps){
componentInstance.componentWillReceiveProps(nextProps)
}
if(newElement.type.getDerivedStateFormProps){
let newState = newElement.type.getDerivedStateFormProps(nextProps, componentInstance.state);
if(newState){
componentInstance.state = {...componentInstance.state, ...newState};
}
}
// 类组件的更新调用类更新器的方法
updater.emitUpdate(nextProps);
}
- 关于React中的DOM DIFF 实现基本就完成了,当然这里面对类的更新没有详细展开,将在下一节中说到,其次也没有在实现合成事件机制进行事件的绑定,并且这里也没有明确按照什么顺序之类的,所以在看的时候可以拷贝到编辑器中一块看可能会更好些哦。大家 加油!!
下一篇: react 虚拟DOM的diff算法