七、「深入React源码」--- 手写实现DOM-DIFF算法

573 阅读7分钟

DOM-DIFF的相关知识不再赘述,本文将逐步实现完整的DOM-DIFF。欢迎大家观看并指导~

一、实现思路

image.png

  • 绿色元素:更新
  • 蓝色元素:插入
  • 橙色元素:插入并更新
  • 红色元素:删除

1. 构建map

把老节点的key和其对应的虚拟DOM节点关联起来存到map中,形成:

map = { 
    'A': 'A对应的虚拟DOM',
    'B': 'B对应的虚拟DOM',
    'C': 'C对应的虚拟DOM',
    'D': 'D对应的虚拟DOM',
    'E': 'E对应的虚拟DOM',
    'F': 'F对应的虚拟DOM',
}

这样我们就可以通过key去查找对应的老节点

2. 循环新节点数组

遍历的时候记录每一个新节点的key,同时获取这个key对应的虚拟DOM节点。此时,拿着key去第一步的map中查找,如果找得到就说明有对应的老节点,那么我们就复用老节点:

2-1. 把新节点的属性更新到老节点上

2-2. 判断节点是否移动

判断是否移动,借助变量lastPlaceIndex上一个不需要移动的节点的索引判断当前节点是否需要移动。如果老节点挂载的索引,大于lastPlaceIndex的值,就不需要移动;如果小于lastPlaceIndex的值,就移动到当前索引得到位置。移动的元素新增MOVE类型,然后push到补丁包patch中,之后删除这个被复用的节点。

2-3. 获取需要移动的元素

经过上面的步骤,此时老节点数组中剩余的就是没有被复用的节点。我们需要把节点从父节点中删除掉。

2-4. 插入或移动节点

根据新的虚拟DOM创建新的真实DOM后,获取老DOM对应的索引处的新DOM,然后inserBefore新DOM前。如果没有新DOM则直接appenedChild

二、代码实现

1. src/index.js

import React from "./react";
import ReactDOM from "./react-dom";

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      list: ["A", "B", "C", "D", "E", "F"],
    };
  }

  handClick = () => {
    this.setState({
      list: ["A", "C", "E", "B", "G"],
    });
  };

  render() {
    return (
      <div>
        <ul>
          {this.state.list.map((item) => (
            <li key={item}>{item}</li>
          ))}
        </ul>
        <button onClick={this.handClick}>按钮</button>
      </div>
    );
  }
}
ReactDOM.render(<Counter />, document.getElementById("root"));

2. src/constants.js

//React元素:h1 span div
export const REACT_ELEMENT = Symbol("react.element");

//文本:字符串或数字
export const REACT_TEXT = Symbol("react.text");

// 函数组件转发的ref
export const REACT_FORWARD_REF = Symbol("react.forward_ref");

> > > // 插入节点
> > > export const PLACEMENT = Symbol("PLACEMENT");
> > > 
> > > // 移动节点
> > > export const MOVE = Symbol("MOVE");

3. src/react-dom.js

这个文件较长,所以与DOM-DIFF无关的代码做了删减,如果想要查看完整代码,请移步我的码云仓库,或者在前一节文章中查看。

> > > import { REACT_TEXT, REACT_FORWARD_REF, MOVE, PLACEMENT } from "./constants";
import { addEvent } from "./event";

/**
 *把虚拟DOM变成真实DOM插入容器
 * @param {*} vdom 虚拟DOM/React元素
 * @param {*} container 真实DOM容器
 */
function render(vdom, container) {
  mount(vdom, container);
}

/** 页面挂载真实DOM */
function mount(vdom, parentDOM) {}

/**
 * 把虚拟DOM变成真实DOM
 * @param {*} vdom 虚拟DOM
 * @return 真实DOM
 */
function createDOM(vdom) {}

/** 挂载类组件 */
function mountClassComponent(vdom) {}

/** 挂载函数组件 */
function mountFunctionComponent(vdom) {}

/** 挂载经过转发的ref的函数组件 */
function mountForwardComponent(vdom) {}

/** 如果子元素为数组,遍历挂载到容器 */
function reconcileChildren(children, parentDOM) {
  // 给每个虚拟DOM挂载mountIndex属性记录其索引
  children.forEach((childVdom, index) => {
> > >     childVdom.mountIndex = index;
> > >     mount(childVdom, parentDOM);
  });
}

/**
 * 把新的属性更新到真实DOM上
 * @param {*} dom 真实DOM
 * @param {*} oldProps 旧的属性对象
 * @param {*} newProps 新的属性对象
 */
function updateProps(dom, oldProps, newProps) {}

/**
 * DOM-DIFF:递归比较老的虚拟DOM和新的虚拟DOM,找出两者的差异,把这些差异最小化的同步到真实DOM上
 * @param {*} parentDOM 父真实DOM
 * @param {*} oldVdom 老的虚拟DOM
 * @param {*} newVdom 新的虚拟DOM
 * @param {*} nextDOM 新的虚拟DOM
 *
 */
export function compareToVdom(parentDOM, oldVdom, newVdom, nextDOM) {
  // 1.老-无 新-无:啥也不干
  if (!oldVdom && !newVdom) return;
  // 2.老-有 新-无:直接删除老节点
  if (oldVdom && !newVdom) {
    unMountVdom(oldVdom);
  }
  // 3.老-无 新-有:插入节点
  if (!oldVdom && newVdom) {
    mountVdom(parentDOM, newVdom, nextDOM);
  }
  // 4-1.老-有 新-有:判断类型不一样,删除老的,添加新的
  if (oldVdom && newVdom && oldVdom.type !== newVdom.type) {
    unMountVdom(oldVdom);
    mountVdom(parentDOM, newVdom, nextDOM);
  }
  // 4-2.老-有 新-有:判断类型一样,进行DOM-DIFF,并且节点可复用
  if (oldVdom && newVdom && oldVdom.type == newVdom.type) {
    updateElement(oldVdom, newVdom);
  }
}

/**
 * DOM-DIFF精髓之处 --- 新老DOM类型一样的更新
 * 如果新老DOM的类型一样,那么节点就可以复用
 */
function updateElement(oldVdom, newVdom) {
  // 新老节点都是文本节点:复用老的节点,替换内容
  if (oldVdom.type === REACT_TEXT) {
    // 老的真实DOM给新的DOM的dom属性,把内容改掉
    let currentDOM = (newVdom.dom = findDOM(oldVdom));
    currentDOM.textContent = newVdom.props.content;
    // 原生节点
  } else if (typeof oldVdom.type === "string") {
    let currentDOM = (newVdom.dom = findDOM(oldVdom));
    // 更新属性
    updateProps(currentDOM, oldVdom.props, newVdom.props);
    // 递归比较儿子
    updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children);
    // 类组件或函数组件
  } else if (typeof oldVdom.type === "function") {
    // 类组件
    if (oldVdom.type.isReactComponent) {
      // 先同步实例
      newVdom.classInstance = oldVdom.classInstance;
      updateClassComponent(oldVdom, newVdom);
      // 函数组件
    } else {
      updateFunctionComponent(oldVdom, newVdom);
    }
  }
}

/**
 * 更新类组件
 * @param {*} oldVdom
 * @param {*} newVdom
 */
function updateClassComponent(oldVdom, newVdom) {
  // 复用老的类组件实例
  let classInstance = (newVdom.classInstance = oldVdom.classInstance);
  if (classInstance.componentWillReceiveProps) {
    classInstance.componentWillReceiveProps(newVdom.props);
  }
  classInstance.updater.emitUpdate(newVdom.props);
}

/**
 * 更新函数组件
 * @param {*} oldVdom
 * @param {*} newVdom
 */
function updateFunctionComponent(oldVdom, newVdom) {
  // 获取老的真实DOM的父节点
  let parentDOM = findDOM(oldVdom).parentNode;
  let { type, props } = newVdom;
  let newRenderVdom = type(props);
  // 函数组件更新每次都要重新执行函数,拿到新的虚拟DOM
  compareToVdom(parentDOM, oldVdom.oldRenderVdom, newRenderVdom);
  newVdom.newRenderVdom = newRenderVdom;
}

/**
 * 递归比较子节点
 * @param {*} parentDOM
 * @param {*} oldVChildren
 * @param {*} newVChildren
 */
function updateChildren(parentDOM, oldVChildren, newVChildren) {
  // 为方便后续进行DOM-DIFF,以数组形式保存
  oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : [oldVChildren];
  newVChildren = Array.isArray(newVChildren) ? newVChildren : [newVChildren];
> > >   // DOM-DIFF 1.构建老map {虚拟DOM的key: 虚拟DOM}
> > >   let keyedOldMap = {};
> > >   oldVChildren.forEach((oldVChild, index) => {
> > >     let oldKey = oldVChild.key ? oldVChild.key : index;
> > >     keyedOldMap[oldKey] = oldVChild;
> > >   });
> > >   // 补丁包:存放要进行的操作
> > >   let patch = [];
> > >   // 上一个放置好的、不需要移动的索引
> > >   let lastPlaceIndex = 0;
> > >   // DOM-DIFF 2.遍历新数组查找老虚拟DOM
> > >   newVChildren.forEach((newVChild, index) => {
> > >     newVChild.mountIndex = index;
> > >     let newKey = newVChild.key ? newVChild.key : index;
> > >     // 查找老的虚拟DOM中是否存在这个key的节点
> > >     let oldVChild = keyedOldMap[newKey];
> > >     // 如果找到,复用老节点
> > >     if (oldVChild) {
> > >       // 先更新
> > >       updateElement(oldVChild, newVChild);
> > >       // 判断是否移动 把oldVChild移动到mountIndex当前索引处
> > >       if (oldVChild.mountIndex < lastPlaceIndex) {
> > >         patch.push({
> > >           type: MOVE,
> > >           oldVChild,
> > >           newVChild,
> > >           mountIndex: index,
> > >         });
> > >       }
> > >       // 删除已经被复用的节点
> > >       delete keyedOldMap[newKey];
> > >       lastPlaceIndex = Math.max(oldVChild.mountIndex, lastPlaceIndex);
> > >     } else {
> > >       // 如果没找到,插入新节点
> > >       patch.push({
> > >         type: PLACEMENT,
> > >         newVChild,
> > >         mountIndex: index,
> > >       });
> > >     }
> > >   });
> > >   // DOM-DIFF 3.获取需要移动的元素
> > >   let moveChildren = patch
> > >     .filter((action) => action.type === MOVE)
> > >     .map((action) => action.oldVChild);
> > >   // 遍历map留下的元素(其实就是没有被复用的)
> > >   Object.values(keyedOldMap)
> > >     .concat(moveChildren)
> > >     .forEach((oldVChild) => {
> > >       // 获取老DOM
> > >       let currentDOM = findDOM(oldVChild);
> > >       parentDOM.removeChild(currentDOM);
> > >     });
> > >   // DOM-DIFF 4.插入或移动节点
> > >   patch.forEach((action) => {
> > >     let { type, oldVChild, newVChild, mountIndex } = action;
> > >     // 真实DOM节点集合
> > >     let childNodes = parentDOM.childNodes;
> > >     if (type === PLACEMENT) {
> > >       // 根据新的虚拟DOM创建新真实DOM
> > >       let newDOM = createDOM(newVChild);
> > >       // 获取老DOM中对应的索引处的真实DOM
> > >       let childNode = childNodes[mountIndex];
> > >       if (childNode) {
> > >         parentDOM.insertBefore(newDOM, childNode);
> > >       } else {
> > >         parentDOM.appendChild(newDOM);
> > >       }
> > >     } else if (type === MOVE) {
> > >       let oldDOM = findDOM(oldVChild);
> > >       let childNode = childNodes[mountIndex];
> > >       if (childNode) {
> > >         parentDOM.insertBefore(oldDOM, childNode);
> > >       } else {
> > >         parentDOM.appendChild(oldDOM);
> > >       }
> > >     }
> > >   });
> > > 
> > >   // // 最大长度
> > >   // let maxLength = Math.max(oldVChildren.length, newVChildren.length);
> > >   // // 每一个都进行深度对比
> > >   // for (let i = 0; i < maxLength; i++) {
> > >   //   // 在老的虚拟DOM查找,有老节点并且老节点真的对应一个真实DOM节点,并且这个索引要比我大(目的是找到本身的下一个节点)
> > >   //   let nextVdom = oldVChildren.find(
> > >   //     (item, index) => index > i && item && findDOM(item)
> > >   //   );
> > >   //   compareToVdom(
> > >   //     parentDOM,
> > >   //     oldVChildren[i],
> > >   //     newVChildren[i],
> > >   //     nextVdom && findDOM(nextVdom)
> > >   //   );
> > >   // }
}

/**
 * 插入新的真实DOM
 * @param {}} parentDOM
 * @param {*} vdom
 * @param {*} nextDOM
 */
function mountVdom(parentDOM, newVdom, nextDOM) {
  let newDOM = createDOM(newVdom);
  if (nextDOM) {
    parentDOM.insertBefore(newDOM, nextDOM);
  } else {
    parentDOM.appendChild(newDOM);
  }
  if (newDOM.componentDidMount) {
    newDOM.componentDidMount();
  }
}

/**
 * 删除老的真实DOM
 * @param {*} vdom 老的虚拟DOM
 */
function unMountVdom(vdom) {
  let { type, props, ref } = vdom;
  // 获取老的真实DOM
  let currentDOM = findDOM(vdom);
  // 如果这个子节点是类组件,还要执行它的卸载的生命周期函数
  if (vdom.classInstance && vdom.classInstance.componentWillUnmount) {
    vdom.classInstance.componentWillUnmount();
  }
  // 如果有ref,删除ref对应的真实DOM
  if (ref) ref.current = null;
  // 取消监听函数
  Object.keys(props).forEach((propName) => {
    if (propName.slice(0, 2) === "on") {
      // 事件在真实dom就这样做
      //   const eventName = propName.slice(2).toLowerCase()
      //   currentDOM.removeEventListener(eventName, props[propName])
      //但是我们先处理了合成事件,事件注册再store上
      delete currentDOM.store;
    }
  });
  // 如果有子节点,递归删除所有子节点
  if (props.children) {
    let children = Array.isArray(props.children)
      ? props.children
      : [props.children];
    children.forEach(unMountVdom);
  }
  // 从父节点中把自己删除
  if (currentDOM) currentDOM.parentNode.removeChild(currentDOM);
}

/** 虚拟DOM返回的真实DOM */
export function findDOM(vdom) {
  if (!vdom) return null;
  // 如果有dom属性,说明这个vdom是原生组件的虚拟DOM,会有dom属性指向真实dom
  if (vdom.dom) {
    return vdom.dom;
  } else {
    return findDOM(vdom.oldRenderVdom);
  }
}

const ReactDOM = {
  render,
};
export default ReactDOM;

三、总结

移动原则:新数组中索引较小的,地位较高的不移动。按照以上逻辑,如果把最后一个元素移动到第一个元素,其余的元素都要发生移动,所以我们应该尽量减少置顶的操作。