个人备案网站 论坛,上海做网站比较好的公司有哪些,三年抗疫国库空虚殆尽,网站seo服务公司目录
9.1 减少 DOM 操作的性能开销
9.2 DOM 复用与 key 的作用
9.3 找到需要移动的元素
9.4 如何移动元素
9.5 添加新元素
9.6 移除不存在的元素
9.7 总结 当新旧 vnode 的子节点都是一组节点时#xff0c;为了以最小的性能开销完成更新操作#xff0c;需要比较两组子…目录
9.1 减少 DOM 操作的性能开销
9.2 DOM 复用与 key 的作用
9.3 找到需要移动的元素
9.4 如何移动元素
9.5 添加新元素
9.6 移除不存在的元素
9.7 总结 当新旧 vnode 的子节点都是一组节点时为了以最小的性能开销完成更新操作需要比较两组子节点用于比较的算法就叫作 Diff 算法。
9.1 减少 DOM 操作的性能开销
之前我们在更新子节点时简单地移除所有旧的子节点然后添加所有新的子节点。 这种方式虽然简单直接但会产生大量的性能开销因为它没有复用任何 DOM 元素。 考虑下面的新旧虚拟节点示例
// 旧 vnode
const oldVNode {type: div,children: [{ type: p, children: 1 },{ type: p, children: 2 },{ type: p, children: 3 }]
}// 新 vnode
const newVNode {type: div,children: [{ type: p, children: 4 },{ type: p, children: 5 },{ type: p, children: 6 }]
}上述代码我们会执行六次操作三次卸载旧节点三次添加新节点但实际上旧新节点都是 p 标签只是它们的文本内容变了。 理想情况下我们只需要更新这些 p 标签的文本内容就可以了这样只需要 3 次 DOM 操作性能提升了一倍。 我们可以调整 patchChildren 函数让它只更新变化的部分
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {// 重新实现两组子节点的更新方式// 新旧 childrenconst oldChildren n1.childrenconst newChildren n2.children// 遍历旧的 childrenfor (let i 0; i oldChildren.length; i) {// 调用 patch 函数逐个更新子节点patch(oldChildren[i], newChildren[i])}} else {// 省略部分代码}
}上述代码patch 函数在执行更新时发现新旧子节点只有文本内容不同因此只会更新其文本节点的内容 但是这段代码假设新旧子节点的数量总是一样的实际上新旧节点的数量可能发生变化如果新节点较多我们应该添加节点反之则删除节点。 所以我们应遍历长度较短的那组子节点以便尽可能多地调用 patch 函数进行更新。然后比较新旧子节点组的长度如果新组长度更长就挂载新子节点反之就卸载旧子节点
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.children// 旧的一组子节点的长度const oldLen oldChildren.length// 新的一组子节点的长度const newLen newChildren.length// 两组子节点的公共长度即两者中较短的那一组子节点的长度const commonLength Math.min(oldLen, newLen)// 遍历 commonLength 次for (let i 0; i commonLength; i) {patch(oldChildren[i], newChildren[i], container)}// 如果 newLen oldLen说明有新子节点需要挂载if (newLen oldLen) {for (let i commonLength; i newLen; i) {patch(null, newChildren[i], container)}} else if (oldLen newLen) {// 如果 oldLen newLen说明有旧子节点需要卸载for (let i commonLength; i oldLen; i) {unmount(oldChildren[i])}}} else {// 省略部分代码}
}这样无论新旧子节点组的数量如何我们的渲染器都能正确地挂载或卸载它们。
9.2 DOM 复用与 key 的作用
上面我们通过减少操作次数提高了性能但仍有优化空间。 以新旧两组子节点为例它们的内容如下
// oldChildren
[{ type: p },{ type: div },{ type: span }
]// newChildren
[{ type: span },{ type: p },{ type: div }
]若使用之前的算法更新子节点需要执行6次 DOM 操作。观察新旧子节点发现它们只是顺序不同。 因此最优处理方式是通过 DOM 移动来完成更新而非频繁卸载和挂载。为实现这一目标需确保新旧子节点中存在可复用节点。 为判断新子节点是否在旧子节点中出现可以引入 key 属性作为虚拟节点的标识。只要两个虚拟节点的 type 和 key 属性相同我们认为它们相同可以复用DOM。例如 // oldChildren
[{ type: p, children: 1, key: 1 },{ type: p, children: 2, key: 2 },{ type: p, children: 3, key: 3 }
]// newChildren
[{ type: p, children: 3, key: 3 },{ type: p, children: 1, key: 1 },{ type: p, children: 2, key: 2 }
]我们根据子节点的 key 属性能够明确知道新子节点在旧子节点中的位置这样就可以进行相应的 DOM 移动操作了。 注意 DOM 可复用并不意味着不需要更新它可能内部子节点不一样
const oldVNode { type: p, key: 1, children: text 1 }
const newVNode { type: p, key: 1, children: text 2 }所以补丁操作是在移动 DOM 元素之前必须完成的步骤如下面的 patchChildren 函数所示
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.children// 遍历新的 childrenfor (let i 0; i newChildren.length; i) {const newVNode newChildren[i]// 遍历旧的 childrenfor (let j 0; j oldChildren.length; j) {const oldVNode oldChildren[j]// 如果找到了具有相同 key 值的两个节点说明可以复用但仍然需要调用 patch 函数更新if (newVNode.key oldVNode.key) {patch(oldVNode, newVNode, container)break // 这里需要 break}}}} else {// 省略部分代码}
}在这段代码中我们更新了新旧两组子节点。通过两层 for 循环外层遍历新的子节点内层遍历旧的子节点我们寻找并更新了所有可复用的节点。 例如有如下的新旧两组子节点
const oldVNode {type: div,children: [{ type: p, children: 1, key: 1 },{ type: p, children: 2, key: 2 },{ type: p, children: hello, key: 3 }]
}const newVNode {type: div,children: [{ type: p, children: world, key: 3 },{ type: p, children: 1, key: 1 },{ type: p, children: 2, key: 2 }]
}// 首次挂载
renderer.render(oldVNode, document.querySelector(#app))
setTimeout(() {// 1 秒钟后更新renderer.render(newVNode, document.querySelector(#app))
}, 1000);运行上述代码1 秒钟后key 为 3 的子节点对应的真实 DOM 的文本内容将从 hello 更新为 world。让我们仔细分析一下这段代码在执行更新操作时的过程
第一步我们选取新的子节点组中的第一个子节点即 key 为 3 的节点。然后在旧的子节点组中寻找具有相同 key 的节点。我们发现旧子节点 oldVNode[2] 的 key 为 3因此调用 patch 函数进行补丁操作。这个操作完成后渲染器会将 key 为 3 的虚拟节点对应的真实 DOM 的文本内容从 hello 更新为 world。第二步我们取新的子节点组中的第二个子节点即 key 为 1 的节点并在旧的子节点组中寻找具有相同 key 的节点。我们发现旧的子节点 oldVNode[0] 的 key 为 1于是再次调用 patch 函数进行补丁操作。由于 key 为 1 的新旧子节点没有任何差异所以这里并未进行任何操作。第三步最后我们取新的子节点组中的最后一个子节点即 key 为 2 的节点这一步的结果与第二步相同。
经过以上更新操作后所有节点对应的真实 DOM 元素都已更新。 但真实 DOM 仍保持旧的子节点顺序即 key 为 3 的节点对应的真实 DOM 仍然是最后一个子节点。 然而在新的子节点组中key 为 3 的节点已经变为第一个子节点因此我们还需要通过移动节点来完成真实 DOM 顺序的更新。
9.3 找到需要移动的元素
现在我们已经能够通过 key 值找到可复用的节点了。 下一步我们确定哪些节点需要移动以及如何移动。我们逆向思考下什么条件下节点无需移动。答案直观新旧子节点顺序未变时无需额外操作 新旧子节点顺序未变举例说明旧子节点索引
key 为 1 的节点在旧 children 数组中的索引为 0key 为 2 的节点在旧 children 数组中的索引为 1key 为 3 的节点在旧 children 数组中的索引为 2。
应用我们上节的更新算法
第一步取新的一组子节点中的第一个节点 p-1它的 key 为 1。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 0。第二步取新的一组子节点中的第二个节点 p-2它的 key 为 2。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 1。第三步取新的一组子节点中的第三个节点 p-3它的 key 为 3。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 2。
如果每次找到可复用节点记录他们原先在旧子节点的位置索引把这些位置索引值按照先后顺序排列则可以得到一个序列0、1、2。这是一个递增的序列在这种情况下不需要移动任何节点。 我们再来看看另外一个例子 第一步取新的一组子节点中的第一个节点 p-3它的 key 为 3。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 2。第二步取新的一组子节点中的第二个节点 p-1它的 key 为 1。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 0。 到了这一步我们发现索引值递增的顺序被打破了。节点 p-1 在旧 children 中的索引是 0它小于节点 p-3 在旧 children 中的索引 2。这说明节点 p-1 在旧 children 中排在节点 p-3 前面但在新的 children 中它排在节点 p-3 后面。因此我们能够得出一个结论节点 p-1 对应的真实 DOM 需要移动。 第三步取新的一组子节点中的第三个节点 p-2它的 key 为 2。尝试在旧的一组子节点中找到具有相同 key 值的可复用节点发现能够找到并且该节点在旧的一组子节点中的索引为 1。 到了这一步我们发现节点 p-2 在旧 children 中的索引 1 要小于节点 p-3 在旧 children 中的索引 2。这说明节点 p-2 在旧 children 中排在节点 p-3 前面但在新的 children 中它排在节点 p-3 后面。因此节点 p-2 对应的真实 DOM 也需要移动。
在上面的例子中我们得出了节点 p-1 和节点 p-2 需要移动的结论。这是因为它们在旧 children 中的索引要小于节点 p-3 在旧 children 中的索引。如果我们按照先后顺序记录在寻找节点过程中所遇到的位置索引将会得到序列2、0、1。可以发现这个序列不具有递增的趋势。 我们可以用 lastIndex 变量存储整个寻找过程中遇到的最大索引值如下面的代码所示
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.children// 用来存储寻找过程中遇到的最大索引值let lastIndex 0for (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) {// 如果当前找到的节点在旧 children 中的索引小于最大索引值 lastIndex// 说明该节点对应的真实 DOM 需要移动} else {// 如果当前找到的节点在旧 children 中的索引不小于最大索引值// 则更新 lastIndex 的值lastIndex j}break // 这里需要 break}}}} else {// 省略部分代码}
}上述代码如果新旧节点的 key 值相同我们就找到了可以复用的节点。我们比较这个节点在旧子节点数组中的索引 j 与 lastIndex。 如果 j 小于 lastIndex说明当前 oldVNode 对应的真实 DOM 需要移动。 否则不需要移动。并将变量 j 的值赋给 lastIndex以确保寻找节点过程中变量 lastIndex 始终存储着当前遇到的最大索引值。
9.4 如何移动元素
移动节点指的是移动一个虚拟节点所对应的真实 DOM 节点并不是移动虚拟节点本身。 既然移动的是真实 DOM 节点那么就需要取得对它的引用才行。当一个虚拟节点被挂载后其对应的真实 DOM 节点会存储在它的 vnode.el 属性中 因此我们可以通过 vnode.el 属性取得它对应的真实 DOM 节点。
当更新操作发生时渲染器会调用 patchElement 函数在新旧虚拟节点之间进行打补丁
function patchElement(n1, n2) {// 新的 vnode 也引用了真实 DOM 元素const el n2.el n1.el// 省略部分代码
}上述代码 patchElement 函数首先将旧节点的 n1.el 属性赋值给新节点的 n2.el 属性这个赋值的意义其实就是 DOM 元素的复用。 在复用了 DOM 元素之后新节点也将持有对真实 DOM 的引用 此时无论是新旧节点都引用着真实 DOM在此基础上我们就可以进行 DOM 移动了。
为了阐述如何移动 DOM我们仍然引用上一节的更新案例 它的更新步骤如下。
第一步在新的子节点集合中选取第一个节点 p-3key 为 3并在旧的子节点集合中寻找具有相同 key 的可复用节点。找到了这样的节点其在旧集合中的索引为 2。由于当前 lastIndex 为 0且 2 大于 0因此p-3 的实际 DOM 无需移动但需要将 lastIndex 更新为 2。第二步选取新集合中的第二个节点 p-1key 为 1并尝试在旧集合中找到相同 key 的可复用节点。找到了这样的节点其在旧集合中的索引为 0。此时由于 lastIndex 为 2且 0 小于 2所以p-1 的实际 DOM 需要移动。此时我们知道**新 children 的顺序即为更新后实际 DOM 应有的顺序。**因此p-1 在新 children 中的位置决定了其在更新后实际 DOM 中的位置。由于 p-1 在新 children 中排在 p-3 后面因此我们需要将 p-1 的实际 DOM 移动到 p-3 的实际 DOM 后面。移动后的实际 DOM 顺序为 p-2、p-3、p-1 把节点 p-1 对应的真实 DOM 移动到节点 p-3 对应的真实 DOM 后面第三步选取新集合中的第三个节点 p-2key 为 2并尝试在旧集合中找到相同 key 的可复用节点。找到了这样的节点其在旧集合中的索引为1。此时由于 lastIndex 为 2且 1 小于 2所以p-2 的实际 DOM 需要移动。此步骤与步骤二类似我们需要将 p-2 的实际 DOM 移动到 p-1 的实际 DOM 后面。经过移动后实际 DOM 的顺序与新的子节点集合的顺序相同即为p-3、p-1、p-2。至此更新操作完成 把节点 p-2 对应的真实 DOM 移动到节点 p-1 对应的真实 DOM 后面
接下来我们来看一下如何实现这个过程。具体的代码如下
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.childrenlet lastIndex 0for (let i 0; i newChildren.length; i) {const newVNode newChildren[i]let j 0for (j; j oldChildren.length; j) {const oldVNode oldChildren[j]if (newVNode.key oldVNode.key) {patch(oldVNode, newVNode, container)if (j lastIndex) {// 代码运行到这里说明 newVNode 对应的真实 DOM 需要移动// 先获取 newVNode 的前一个 vnode即 prevVNodeconst prevVNode newChildren[i - 1]// 如果 prevVNode 不存在则说明当前 newVNode 是第一个节点它不需要移动if (prevVNode) {// 由于我们要将 newVNode 对应的真实 DOM 移动到 prevVNode 所对应真实 DOM 后面// 所以我们需要获取 prevVNode 所对应真实 DOM 的下一个兄弟节点并将其作为锚点const anchor prevVNode.el.nextSibling// 调用 insert 方法将 newVNode 对应的真实 DOM 插入到锚点元素前面// 也就是 prevVNode 对应真实 DOM 的后面insert(newVNode.el, container, anchor)}} else {lastIndex j}break}}}} else {// 省略部分代码}
}上述代码中如果 j lastIndex 成立则说明当前 newVNode 对应的真实 DOM 需要移动。 根据之前的分析可知我们需要获取当前 newVNode 节点的前一个虚拟节点 newChildren[i - 1]然后使用 insert 函数完成节点的移动其中 insert 函数依赖浏览器原生的 insertBefore 函数。如下所示
const renderer createRenderer({// 省略部分代码insert(el, parent, anchor null) {// insertBefore 需要锚点元素 anchorparent.insertBefore(el, anchor)}// 省略部分代码
})9.5 添加新元素 上图我们有一个新的节点p-4 它的 key 值为 4这个节点在旧的节点集中不存在。该新增节点我们应该挂载
找到新节点将新节点挂载到正确位置 根据上图我们开始模拟执行简单 Diff 算法的更新逻辑
第一步我们首先检查新的节点集中的第一个节点 p-3。这个节点在旧的节点集中存在因此我们不需要移动对应的 DOM 元素但是我们需要将变量lastIndex的值更新为 2。第二步取新的一组子节点中第二个节点 p-1它的 key 值为 1尝试在旧的一组子节点中寻找可复用的节点。发现能够找到并且该节点在旧的一组子节点中的索引值为 0。此时变量 lastIndex 的值为 2索引值 0 小于 lastIndex 的值 2所以节点 p-1 对应的真实 DOM 需要移动并且应该移动到节点 p-3 对应的真实DOM 后面。移动后DOM 的顺序将变为 p-2、p-3、p-1 第三步我们现在查看新的节点集中的第三个节点 p-4。在旧的节点集中我们找不到这个节点我们需要观察节点 p-4 在新的一组子节点中的位置。由于节点 p-4 出现在节点 p-1 后面所以我们应该把节点 p-4 挂载到节点 p-1 所对应的真实 DOM 后面。DOM 元素后面。挂载后DOM的顺序将变为 p-2、p-3、p-1、p-4 第四步最后我们查看新的节点集中的第四个节点 p-2。在旧的节点集中这个节点的索引值为 1这个值小于 lastIndex 的值 2因此我们需要移动 p-2 对应的 DOM 元素。应该移动到节点 p-4 对应的真实DOM 后面。
在此我们看到真实 DOM 的顺序为p-3、p-1、p-4、p-2。这表明真实 DOM 的顺序已经与新子节点的顺序一致更新已经完成。 接下来让我们通过 patchChildren 函数的代码实现来详细讲解
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.childrenlet lastIndex 0for (let i 0; i newChildren.length; i) {const newVNode newChildren[i]let j 0// 在第一层循环中定义变量 find代表是否在旧的一组子节点中找到可复用的节点// 初始值为 false代表没找到let find falsefor (j; j oldChildren.length; j) {const oldVNode oldChildren[j]if (newVNode.key oldVNode.key) {// 一旦找到可复用的节点则将变量 find 的值设为 truefind truepatch(oldVNode, newVNode, container)if (j lastIndex) {const prevVNode newChildren[i - 1]if (prevVNode) {const anchor prevVNode.el.nextSiblinginsert(newVNode.el, container, anchor)}} else {lastIndex j}break}}// 如果代码运行到这里find 仍然为 false// 说明当前 newVNode 没有在旧的一组子节点中找到可复用的节点// 也就是说当前 newVNode 是新增节点需要挂载if (!find) {// 为了将节点挂载到正确位置我们需要先获取锚点元素// 首先获取当前 newVNode 的前一个 vnode 节点const prevVNode newChildren[i - 1]let anchor nullif (prevVNode) {// 如果有前一个 vnode 节点则使用它的下一个兄弟节点作为锚点元素anchor prevVNode.el.nextSibling} else {// 如果没有前一个 vnode 节点说明即将挂载的新节点是第一个子节点// 这时我们使用容器元素的 firstChild 作为锚点anchor container.firstChild}// 挂载 newVNodepatch(null, newVNode, container, anchor)}}} else {// 省略部分代码}
}上述代码我们通过外层循环中定义的变量 find查找是否存在可复用的节点。 如果内层循环结束后find 的值仍为 false说明当前 newVNode 是全新的节点需要进行挂载。 挂载的位置由 anchor 确定这个 anchor 可以是 newVNode 的前一个虚拟节点的下一个兄弟节点或者容器元素的第一个子节点。 现在我们需要调整 patch 函数以支持接收第四个参数 anchor如下所示
// patch 函数需要接收第四个参数即锚点元素
function patch(n1, n2, container, anchor) {// 省略部分代码if (typeof type string) {if (!n1) {// 挂载时将锚点元素作为第三个参数传递给 mountElement 函数mountElement(n2, container, anchor)} else {patchElement(n1, n2)}} else if (type Text) {// 省略部分代码} else if (type Fragment) {// 省略部分代码}
}// mountElement 函数需要增加第三个参数即锚点元素
function mountElement(vnode, container, anchor) {// 省略部分代码// 在插入节点时将锚点元素透传给 insert 函数insert(el, container, anchor)
}9.6 移除不存在的元素
在更新子节点时不仅会遇到新增元素还会出现元素被删除的情况 假设在新的子节点组中节点 p-2 已经不存在这说明该节点被删除了。
我们像上面一样模拟执行更新逻辑这之前我们先看看新旧两组子节点以及真实 DOM 节点的当前状态 第一步取新的子节点组中的第一个节点 p-3它的 key 值为 3。在旧的子节点组中寻找可复用的节点发现索引为 2 的节点可复用此时变量 lastIndex 的值为 0索引 2 不小于 lastIndex 的值 0所以 节点 p-3 对应的真实 DOM 不需要移动但需要更新变量 lastIndex 的值为 2。第二步取新的子节点组中的第二个节点 p-1它的 key 值为 1。在旧的子节点组中发现索引为 0 的节点可复用。 并且该节点在旧的一组子节点中的索引值为 0。此时变量 lastIndex 的值为 2索引 0 小于 lastIndex 的值 2所以节 点 p-1 对应的真实 DOM 需要移动并且应该移动到节点 p-3 对 应的真实 DOM 后面 最后我们发现节点 p-2 对应的真实 DOM 仍然存在所以需要增加逻辑来删除遗留节点。
我们可以在基本更新结束后遍历旧的子节点组然后去新的子节点组中寻找具有相同 key 值的节点。如果找不到说明应删除该节点如下面 patchChildren 函数的代码所示
function patchChildren(n1, n2, container) {if (typeof n2.children string) {// 省略部分代码} else if (Array.isArray(n2.children)) {const oldChildren n1.childrenconst newChildren n2.childrenlet lastIndex 0for (let i 0; i newChildren.length; i) {// 省略部分代码}// 上一步的更新操作完成后// 遍历旧的一组子节点for (let i 0; i oldChildren.length; i) {const oldVNode oldChildren[i]// 拿旧子节点 oldVNode 去新的一组子节点中寻找具有相同 key 值的节点const has newChildren.find(vnode vnode.key oldVNode.key)if (!has) {// 如果没有找到具有相同 key 值的节点则说明需要删除该节点// 调用 unmount 函数将其卸载unmount(oldVNode)}}} else {// 省略部分代码}
}上述代码在上一步的更新操作完成之后我们还需要遍历旧的一组子节点目的是检查旧子节点在新的一组子节点中是否仍然存在如果已经不存在了则调用 unmount 函数将其卸载。
9.7 总结
本章我们讨论 Diff 算法的作用Diff 是用来计算两组子节点的差异并最大程度复用 DOM 元素。 最开始我们采用了一种简单的方式来更新子节点即卸载所有旧子节点再挂载所有新子节点。然而这种操作无疑是非常消耗性能的。 于是我们改进为遍历新旧两组子节点中数量较少的那一组并逐个调用 patch 函数进行打补丁然后比较新旧两组子节点的数量如果新的一组子节点数量更多说明有新子节点需要挂载否则说明在旧的一组子节点中有节点需要卸载。 然后我们讨论了 key 值作用它就像虚拟节点 的“身份证号”。渲染器通过 key 找到可复用元素避免对 DOM 元素过多的销毁重建。 接着我们讨论了简单 Diff 逻辑在新的一组节点中去寻找旧节点可复用的元素。如果找到了则记录该节点的位置索引。我们把这个位置索引称为最大索引。在整个更新过程中如果一个节点的索引值小于最大索引则说明该节点对应的真实 DOM 元素需要移动。 最后我们讲解了渲染器是如何移动、添加、删除 虚拟节点所对应的 DOM 元素的。