简单来说,当新旧vonde的子节点都是一组节点,为了以最小的性能开销来完成更新操作,需要比较两组子节点,用于比较的算法就叫做Dff算法。
首先,以最基本的更新两组子节点的方法为例,通常我们会先将旧节点全部卸载,再安装所有的新节点,那么将会执行旧节点数量+新节点数量次。但有时新节点和旧节点之间,节点类型是想相同的,只有节点内容不同,这是我们可以直接修改节点内容,而省略一次挂载操作。这样就直接将执行时间缩短为之前的一半了。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {if (Array.isArray(n1.children)) {const oldChildren = n1.children;const newChildren = n2.children;for (let i = 0; i < oldChildren.length; i++) {patch(oldChildren[i], newChildren[i]); // patch函数会比较两个节点之间的差异}} else {// ...}}
当然,上述代码的问题也很明显————只有新旧DOM的chidren一样多的时候,以上代码才能正常工作。当新一组节点的长度大于旧节点时吗,将会有新节点被直接挂载,而当旧一组节点的长度大于新节点是,将会有节点被直接卸载。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.children;const newChildren = n2.children;const oldLen = oldChildren.length; // 旧节点子节点长度const newLen = newChildren.length; // 新节点子节点长度const commonLength = Math.max(oldLen, newLen);for (let i = 0; i < commonLength; i++) {patch(oldChildren[i], newChildren[i]);}// 如果旧节点比较长,直接卸载长出来的哪一部分if (oldLen > newLen) {for (let i = commonLength; i < oldLen; i++) {unmount(oldChildren[i]);}} else if (newLen > oldLen) {// 如果新节点比较长,直接挂载长出来哪一部分for (let i = commonLength; i < newLen; i++) {patch(null, newChildren[i], container);}}}}
这样渲染器就能无视数量差异去渲染它们了。
在上节中,通过减少DOM操作降低了性能消耗,但是节点之间的比较只是最普通的顺序比较,对于存在重复节点的情况并没有考虑,比如说,对于下面两个结点:
const oldNode = {type: 'div',children: [{type: 'p'},{type: 'span'}],
}
const newNode = {type: 'div',children: [{type: 'span'},{type: 'p'}],
}
如果使用上述代码执行,将会产生四次操作。但实际上,只需要一次交换操作就行了,以为oldNode和newNode之间只是结点的顺序不一样而已。而为了确定新老Node之间是否存在相同结点,就需要引入一个key作为标记,只要两个节点的type和key属性是一样的,那么我们就认为它们是相同节点,但是我们仍然还是要对两个节点进行打补丁的操作,因为节点的内容可能发生了变化。
const oldNode = {type: 'div',children: [{type: 'p', key: 1 },{type: 'span', key:2 }],
}
const newNode = {type: 'div',children: [{type: 'span', key: 2 },{type: 'p', key: 1 }],
}function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.children;const oldChildren = n1.children;const newChildren = n2.children;for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i];for (let j = 0; j < oldChildren.length; j++) {const oldVNode = oldChildren[j];if (newVNode.key === oldVNode.key) {patch(oldVNode, newVNode, container);break;}}}}}
前面我们通过key完成了元素的更新,但是元素的移动还没有实现。这里我们可以用索引的顺序,来判断节点是否需要移动。这里我们使用一个lastIndex变量来存储当前遍历过的最大索引,如果当前遍历的元素在旧children中的索引小于当前最大索引,那么就说明该元素是需要移动的。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.children;const oldChildren = n1.children;const newChildren = n2.children;let lastIndex = 0; // 存储点前遍历的最大索引for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i];for (let j = 0; j < oldChildren.length; j++) {const oldVNode = oldChildren[j];if (newVNode.key === oldVNode.key) {patch(oldVNode, newVNode, container);if (j < lastIndex) {// 说明该节点需要移动} else {// 否则,更新lastIndexlastIndex = j;}break;}}}}}
标记完需要移动的元素之后,要做的就是移动真实DOM了,这里我们通过vnode获取到它所对应的真实DOM。而当前vnode所在位置,实际上就是在新children中的位置,我们只需要将它插入到当前newChildren节点队列里就行了。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.children;const oldChildren = n1.children;const newChildren = n2.children;let lastIndex = 0; // 存储点前遍历的最大索引for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i];for (let j = 0; j < oldChildren.length; j++) {const oldVNode = oldChildren[j];if (newVNode.key === oldVNode.key) {patch(oldVNode, newVNode, container);if (j < lastIndex) {// 说明该节点需要移动const preNode = newChildren[i - 1];if (preNode) {// 获取preNode的下一个兄弟节点,将其作为锚点const anchor = preNode.el.nextSibling;// 调用insert方法,将newNode插入到锚点元素前面insert(newVNode.el, container, anchor);} else {// 否则,更新lastIndexlastIndex = j;}break;}}}}}
对于需要新增的元素,我们同样也需要找到他在新元素列表中的位置。这里我们使用一个find变量,标记是否找到可复用元素,如果没有找到,说明该元素是需要新增的元素。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {// ...} else if (Array.isArray(n2.children)) {const oldChildren = n1.children;const oldChildren = n1.children;const newChildren = n2.children;let lastIndex = 0; // 存储点前遍历的最大索引for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i];let find = false; // 标记是否找可复用元素for (let j = 0; j < oldChildren.length; j++) {const oldVNode = oldChildren[j];if (newVNode.key === oldVNode.key) {find = true; // 一旦找到立即标记patch(oldVNode, newVNode, container);if (j < lastIndex) {// 说明该节点需要移动const preNode = newChildren[i - 1];if (preNode) {// 获取preNode的下一个兄弟节点,将其作为锚点const anchor = preNode.el.nextSibling;// 调用insert方法,将newNode插入到锚点元素前面insert(newVNode.el, container, anchor);}} else {// 否则,更新lastIndexlastIndex = j;}break;}}// 如果没有找到可复用元素,说明当前需要挂载if (!find) {const preNode = newChildren[i - 1]; let anchor = null; // 锚点if (preNode) {anchor = preNode.el.nextSibling;} else {// 如果是第一个元素,获取firstChild作为锚点anchor = preNode.el.firstChild;}patch(null, newVNode, container, anchor);}}}}
同样,对于新children中不存在的元素,我们同样需要进行移除。这里我们对oldChildren进行第二次遍历,查找newChildren是否存在oldChildren中元素的key,如果不存在,说明该元素需要被卸载。
function patchChildren(n1, n2, container) {if (typeof n2.children === "string") {if (Array.isArray(n1.children)) {n1.children.forEach((child) => unmount(child));}setElementText(container, n2.children);} else if (Array.isArray(n2.children)) {if (Array.isArray(n1.children)) {const oldChildren = n1.children;const newChildren = n2.children;let lastIndex = 0; // 存储点前遍历的最大索引for (let i = 0; i < newChildren.length; i++) {const newVNode = newChildren[i];let find = false; // 标记是否找可复用元素for (let j = 0; j < oldChildren.length; j++) {const oldVNode = oldChildren[j];if (newVNode.key === oldVNode.key) {find = true; // 一旦找到立即标记patch(oldVNode, newVNode, container);if (j < lastIndex) {// 说明该节点需要移动const preNode = newChildren[i - 1];if (preNode) {// 获取preNode的下一个兄弟节点,将其作为锚点const anchor = preNode.el.nextSibling;// 调用insert方法,将newNode插入到锚点元素前面insert(newVNode.el, container, anchor);}} else {// 否则,更新lastIndexlastIndex = j;}break;}}// 如果没有找到可复用元素,说明当前需要挂载if (!find) {const preNode = newChildren[i - 1];let anchor = null; // 锚点if (preNode) {anchor = preNode.el.nextSibling;} else {// 如果是第一个元素,获取firstChild作为锚点anchor = preNode.el.firstChild;}patch(null, newVNode, container, anchor);}}// 判断是否有需要移除的元素for (let i = 0; i < oldChildren.length; i++) {const oldVNode = oldChildren[i];const has = newChildren.find((vnode) => vnode.key === oldVNode.key);if (!has) {unmount(oldVNode);}}} else {setElementText(container, "");n2.children.forEach((child) => patch(null, child, container));}} else {if (Array.isArray(n1.children)) {n1.children.forEach((child) => unmount(child));} else if (typeof n1.child === "string") {setElementText(container, "");}}}