源码维度解读React的dom Diff

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重维度

  1. 【Tree Diff】:React 首先对整个组件树的结构进行比较。在这个阶段,React 会比较两棵树的每一层,快速识别出哪些组件类型已经改变。如果发现某个组件的类型在新旧树之间不同,那么这个组件及其所有子组件都会被替换。这是一个全局层面的比较,可以快速识别出需要进行大规模更新的区域
  2. 【Component Diff】 :在确定了需要更新的组件后,React 接下来会比较同一类型的组件。如果组件的类型相同,React 会进行更细致的比较。这包括比较组件的 props 和 state,以确定是否需要更新组件(即调用组件的 render 方法)。如果组件的类型不同,React 会直接卸载旧组件,并挂载新组件。
  3. 【Element Diff】: 最后,React 会对组件 render 方法返回的 JSX 结构中同一层级的子元素进行比较。React 通过标记子元素的 key 属性来优化这个过程。如果子元素具有 key,React 会使用这个 key 来匹配新旧两棵树中的子元素。这可以确保当子元素在列表中改变位置时,React 能够正确地识别并保留这些子元素,而不是销毁并重新创建它们。如果没有 key,React 默认使用子元素的索引作为 key,这可能会导致不必要的元素重建和性能问题。

将上面3个维度进行详细的解释,如下:

【Tree Diff】组件树维度的对比

Tree Diff 的目的是快速识别出在新旧两棵树中位置相同但类型不同的组件,这样可以避免进行不必要的深度比较。具体来说,这个过程包括以下几个方面:

  1. 层级比较:React 会逐层遍历旧的组件树,与新的组件树进行对比。这个比较是按层级进行的,即从根节点开始,逐层向下比较每个节点。
  2. 类型检查 :在每一层中,React 会检查新旧两棵树中相同位置的组件是否为同一类型。这里的"类型"指的是组件的构造函数或者由 React.createElement 创建的元素类型。如果类型相同,React 认为这两个组件是相同的,可以进行更深入的比较(即 Component Diff);如果类型不同,React 则会认为整个组件及其子树都已经改变,于是会卸载整个旧组件树,替换为新的组件树。
  3. 快速识别改变:由于组件树的结构可能非常庞大,如果逐个节点进行深度比较会非常耗时。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节点进行对比,涉及如下几种情况:

  1. 新节点、旧节点都不存在
  2. 新节点存在、旧节点不存在
  3. 新节点不存在、旧节点存在
  4. 新节点存在,旧节点也存在,但是类型不同
  5. 新节点存在,旧节点也存在,类型相同 这里就需要进行深度比较

深度对比是整个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())
    }
  })
}
相关推荐
September_ning22 分钟前
React.lazy() 懒加载
前端·react.js·前端框架
web行路人32 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00134 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
Rattenking3 小时前
React 源码学习01 ---- React.Children.map 的实现与应用
javascript·学习·react.js
熊的猫4 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
小牛itbull8 小时前
ReactPress:重塑内容管理的未来
react.js·github·reactpress
FinGet19 小时前
那总结下来,react就是落后了
前端·react.js
王解1 天前
Jest项目实战(2): 项目开发与测试
前端·javascript·react.js·arcgis·typescript·单元测试
AIoT科技物语2 天前
免费,基于React + ECharts 国产开源 IoT 物联网 Web 可视化数据大屏
前端·物联网·react.js·开源·echarts
初遇你时动了情2 天前
react 18 react-router-dom V6 路由传参的几种方式
react.js·typescript·react-router