diff 的实现

在 react 中, diff 实现的思路是将新老 virtual dom 进行比较, 将比较后的 patch(补丁)渲染到页面上, 从而实现局部刷新;本文借鉴了 preactsimple-react 中的 diff 实现, 总体思路是将旧的 dom 节点和新的 virtual dom 节点进行了比较, 根据不同的比较类型(文本节点、非文本节点、自定义组件)调用相应的逻辑, 从而实现页面的局部渲染。代码总体结构如下:

/**
 * 比较旧的 dom 节点和新的 virtual dom 节点:
 * @param {*} oldDom  旧的 dom 节点
 * @param {*} newVdom 新的 virtual dom 节点
 */
function diff(oldDom, newVdom) {
  ...
  if (_.isString(newVdom)) {
    return diffTextDom(oldDom, newVdom)   // 对比文本 dom 节点
  }

  if (oldDom.nodeName.toLowerCase() !== newVdom.nodeName) {
    diffNotTextDom(oldDom, newVdom)       // 对比非文本 dom 节点
  }

  if (_.isFunction(newVdom.nodeName)) {
    return diffComponent(oldDom, newVdom) // 对比自定义组件
  }

  diffAttribute(oldDom, newVdom)          // 对比属性

  if (newVdom.children.length > 0) {
    diffChild(oldDom, newVdom)            // 遍历对比子节点
  }

  return oldDom
}

下面列出不同比较类型对应的逻辑实现。

对比文本节点

首先进行较为简单的文本节点的比较, 代码如下:

// 对比文本节点
function diffTextDom(oldDom, newVdom) {
  let dom = oldDom
  if (oldDom && oldDom.nodeType === 3) {  // 如果老节点是文本节点
    if (oldDom.textContent !== newVdom) { // 这里一个细节: textContent/innerHTML/innerText 的区别
      oldDom.textContent = newVdom
    }
  } else {                                // 如果旧 dom 元素不为文本节点
    dom = document.createTextNode(newVdom)
    if (oldDom && oldDom.parentNode) {
      oldDom.parentNode.replaceChild(dom, oldDom)
    }
  }
  return dom
}

对比非文本节点

对比非文本节点, 其思路为将同层级的旧节点替换为新节点, 代码如下:

// 对比非文本节点
function diffNotTextDom(oldDom, newVdom) {
  const newDom = document.createElement(newVdom.nodeName);
  [...oldDom.childNodes].map(newDom.appendChild) // 将旧节点下的元素添加到新节点下
  if (oldDom && oldDom.parentNode) {
    oldDom.parentNode.replaceChild(oldDom, newDom)
  }
}

对比自定义组件

对比自定义组件的思路为: 如果新老组件不同, 则直接将新组件替换老组件;如果新老组件相同, 则将新组件的 props 赋到老组件上, 然后再对获得新 props 前后的老组件做 diff 比较。代码如下:

// 对比自定义组件
function diffComponent(oldDom, newVdom) {
  if (oldDom._component && (oldDom._component.constructor !== newVdom.nodeName)) { // 如果新老组件不同, 则直接将新组件替换老组件
    const newDom = vdomToDom(newVdom)
    oldDom._component.parentNode.insertBefore(newDom, oldDom._component)
    oldDom._component.parentNode.removeChild(oldDom._component)
  } else {
    setProps(oldDom._component, newVdom.attributes) // 如果新老组件相同, 则将新组件的 props 赋到老组件上
    renderComponent(oldDom._component)              // 对获得新 props 前后的老组件做 diff 比较(renderComponent 中调用了 diff)
  }
}

遍历对比子节点

遍历对比子节点的策略如下:

  1. 比较新旧 dom 元素相同层级相同位置的节点类型, 若节点类型不相等, 则直接将新节点替换掉旧节点;
  2. 给节点加上 key 属性;

在 cpreact 的代码实现中, 1 的目的降低了空间复杂度(避免了更深层次的遍历);2 的目的目前看来是少了一次新老类型的判断消耗。

代码如下:

// 对比子节点
function diffChild(oldDom, newVdom) {
  const keyed = {}
  const children = []
  const oldChildNodes = oldDom.childNodes
  for (let i = 0; i < oldChildNodes.length; i++) {
    if (oldChildNodes[i].key) {
      keyed[oldChildNodes[i].key] = oldChildNodes[i]
    } else { // 如果不存在 key, 则优先找到节点类型相同的元素
      children.push(oldChildNodes[i])
    }
  }

  let newChildNodes = newVdom.children
  if (isArray(newVdom.children[0])) { // https://github.com/MuYunyun/cpreact/issues/9
    newChildNodes = newVdom.children[0]
  }

  for (let i = 0; i < newChildNodes.length; i++) {
    let child = null
    if (keyed[newChildNodes[i].key]) {
      child = keyed[newChildNodes[i].key]
      keyed[newChildNodes[i].key] = undefined
    } else { // 对应上面不存在 key 的情形
      // 在新老节点相同位置上寻找相同类型的节点进行比较;如果不满足上述条件则直接将新节点插入;
      if (children[i] && isSameNodeType(children[i], newChildNodes[i])) {
        child = children[i]
        children[i] = undefined
      } else if (children[i] && !isSameNodeType(children[i], newChildNodes[i])) { // 不是相同类型, 直接替代掉
        children[i].replaceWith(newChildNodes[i])
        continue
      }
    }

    const result = diff(child, newChildNodes[i])
    // 如果 child 为 null
    if (result === newChildNodes[i]) {
      oldDom.appendChild(vdomToDom(result))
    }
  }
}

测试

在生命周期的小节中, componentWillReceiveProps 方法还未跑通, 稍加修改 setProps 函数即可:

/**
 * 更改属性, componentWillMount 和 componentWillReceiveProps 方法
 */
function setProps(component, attributes) {
  if (attributes) {
    component.props = attributes // 这段逻辑对应上文自定义组件比较中新老组件相同时 setProps 的逻辑
  }

  if (component && component.base && component.componentWillReceiveProps) {
    component.componentWillReceiveProps(component.props)
  } else if (component && component.componentWillMount) {
    component.componentWillMount()
  }
}

来测试下生命周期小节中最后的测试用例:

  • 生命周期测试

  • diff 测试