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 测试