在 react 中, diff 实现的思路是将新老 virtual dom 进行比较, 将比较后的 patch(补丁)渲染到页面上, 从而实现局部刷新;本文借鉴了 preact 和 simple-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 = oldDomif (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)}}
遍历对比子节点的策略如下:
在 cpreact 的代码实现中, 1 的目的降低了空间复杂度(避免了更深层次的遍历);2 的目的目前看来是少了一次新老类型的判断消耗。
代码如下:
// 对比子节点function diffChild(oldDom, newVdom) {const keyed = {}const children = []const oldChildNodes = oldDom.childNodesfor (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.childrenif (isArray(newVdom.children[0])) { // https://github.com/MuYunyun/cpreact/issues/9newChildNodes = newVdom.children[0]}for (let i = 0; i < newChildNodes.length; i++) {let child = nullif (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 为 nullif (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()}}
来测试下生命周期小节中最后的测试用例: