💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
>[success] # patch 函数 1. `patch` 函数 是通过调用`init`闭包返回的函数,会传入新老的`Vnode`,进行比较后进行视图渲染,返回新的` VNode`,作为下一次 **patch() 的 oldVnod** 2. 代码的执行过程: 2.1. 首先执行模块中的**钩子函数 pre** 2.2. 执行`sameVnode(oldVnode, vnode)`比较`oldVnode 和 Vnode`,返回`true`调用 `patchVnode()`,找节点的`差异并更新 DOM`,返回`false`,从`oldVnode`中`elm `获取**真实渲染dom**,在通过`createElm` 根据新的**Vnode创建出对应dom**,获取**老dom**位置,将**新dom**插入原来老的位置**并删除老的dom** ~~~ // init 内部返回 patch函数,把vnode渲染成真实dom,并返回vnode // 不需要额外传递modules domapi return function patch(oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node; // 保存新插入节点的队列,为了触发钩子函数 const insertedVnodeQueue: VNodeQueue = []; // 执行模块的 pre 钩子函数 for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i](); // 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm if (!isVnode(oldVnode)) { // 把 DOM 元素转换成空的 VNode oldVnode = emptyNodeAt(oldVnode); } // 如果新旧节点是相同节点(key 和 sel 相同) if (sameVnode(oldVnode, vnode)) { // 找节点的差异并更新 DOM patchVnode(oldVnode, vnode, insertedVnodeQueue); } else { // 如果新旧节点不同,vnode 创建对应的 DOM // 获取当前的 DOM 元素 elm = oldVnode.elm!; parent = api.parentNode(elm); // 触发 init/create 钩子函数,创建 DOM createElm(vnode, insertedVnodeQueue); if (parent !== null) { // 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中 api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)); // 移除老节点 removeVnodes(parent, [oldVnode], 0, 0); } } // 执行用户设置的 insert 钩子函数 for (i = 0; i < insertedVnodeQueue.length; ++i) { insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]); } // 执行模块的 post 钩子函数 for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); // 返回 vnode return vnode; }; ~~~ * 简化这个模型代码,实际只需要做的`patch` 的主要功能是比对两个 `VNode `节点,**将「差异」更新到视图上** ~~~ function patch (oldVnode, vnode, parentElm) { if (!oldVnode) { addVnodes(parentElm, null, vnode, 0, vnode.length - 1); } else if (!vnode) { removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1); } else { if (sameVnode(oldVNode, vnode)) { // 相同开始diff 算法比较 patchVnode(oldVNode, vnode); } else { // 不同直接删除旧的,增加新节点。 removeVnodes(parentElm, oldVnode, 0, oldVnode.length - 1); addVnodes(parentElm, null, vnode, 0, vnode.length - 1); } } } ~~~ >[danger] ##### 几个工具函数说明 * `sameVnode`函数比较新老虚拟dom(Vnode)是否相同,比较的几个点,`key`,`sel`即选择器比较创**建元素节点**,`is`是**string**类型作为判断是否是自定义元素 ~~~ function sameVnode(vnode1: VNode, vnode2: VNode): boolean { const isSameKey = vnode1.key === vnode2.key; const isSameIs = vnode1.data?.is === vnode2.data?.is; const isSameSel = vnode1.sel === vnode2.sel; const isSameTextOrFragment = !vnode1.sel && vnode1.sel === vnode2.sel ? typeof vnode1.text === typeof vnode2.text : true; return isSameSel && isSameKey && isSameIs && isSameTextOrFragment; } ~~~ * `emptyNodeAt` 函数将真实dom 转换为Vnode节点,为了进行Vnode 新老比较,回顾一下`Vnode`结构`sel,data,children,text,elm`,elm记录的就是真实dom,将真实dom 属性依次对应传入到Vnode 函数中 ~~~ function emptyNodeAt (elm: Element) { const id = elm.id ? '#' + elm.id : '' const c = elm.className ? '.' + elm.className.split(' ').join('.') : '' return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm) } ~~~ * `createElm`函数作用,将虚拟 dom 转变为真实dom元素,插入回`Vnode`对象的`elm`属性,此时`Vnode`既有**js对象表现形式**,并且表现形式中`elm `属性记录了**真实dom** 整段代码执行顺序 1. 首先触发 `vnode` 中 `init` 钩子函数 ~~~ h( 'div', { hook: { init() { console.log(1111111111) // 执行后控制台打印 }, }, }, [(h('span', '子节点'), 1, 23, 34)] ) ~~~ 2. 开始解析 `h` 函数中`sel `参数,sel参数设置可能是**选择器字符串**`div#container.cls`可能是**单纯的标签**`div`,有可能是 `!`**评论节点**,或者是没有`sel`参数 只是**单纯的文本节点**,因此接下来就是对这些标签分析转换成对应真实`dom` ~~~ // 整体代码流程 // 解析选择器,设置标签的 id 和 class 属性 // 执行模块的 create 钩子函数 // 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树 // 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树 // 行用户设置的 create 钩子函数 // 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中 function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { let i: any, data = vnode.data; if (data !== undefined) { // 执行用户设置的 init 钩子函数 const init = data.hook ? .init; if (isDef(init)) { init(vnode); data = vnode.data; } } let children = vnode.children, sel = vnode.sel; if (sel === '!') { // 如果选择器是!,创建评论节点 if (isUndef(vnode.text)) { vnode.text = ''; } vnode.elm = api.createComment(vnode.text!); } else if (sel !== undefined) { // 如果选择器不为空 // 解析选择器 // Parse selector 如果是选择器字符串div#container.cls,需要获取sel 标签(div) let vnode = h('div#container.cls', 'hello word') const hashIdx = sel.indexOf('#'); const dotIdx = sel.indexOf('.', hashIdx); const hash = hashIdx > 0 ? hashIdx : sel.length; const dot = dotIdx > 0 ? dotIdx : sel.length; const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel; const elm = vnode.elm = isDef(data) && isDef(i = data.ns) ? api.createElementNS(i, tag) : api.createElement(tag); // 在这之前都是对虚拟dom 的sel属性进行解析,为了对提供的sel 选择器而创建dom准备的 // 注意创建真实dom 有两种一种是createElementNS这是创建svg,一种是createElement 创建普通dom // --------------------------------------------- if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot)); if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' ')); // 执行模块的 create 钩子函数 for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode); // 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素并追加到 DOM 树上 if (is.array(children)) { for (i = 0; i < children.length; ++i) { const ch = children[i]; if (ch != null) { api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue)); } } } else if (is.primitive(vnode.text)) { // 如果 vnode 的 text 值是 string/number,创建文本节点并追加到 DOM 树 api.appendChild(elm, api.createTextNode(vnode.text)); } const hook = vnode.data!.hook; if (isDef(hook)) { // 执行用户传入的钩子 create hook.create ? .(emptyNode, vnode); if (hook.insert) { // 把 vnode 添加到队列中,为后续执行 insert 钩子做准备 insertedVnodeQueue.push(vnode); } } } else { // 如果选择器为空,创建文本节点 vnode.elm = api.createTextNode(vnode.text!); } // 返回新创建的 DOM return vnode.elm; } ~~~ * 其他帮助函数 ~~~ function addVnodes(parentElm: Node, before: Node | null, vnodes: Array<VNode>, startIdx: number, endIdx: number, insertedVnodeQueue: VNodeQueue) { for (; startIdx <= endIdx; ++startIdx) { const ch = vnodes[startIdx]; if (ch != null) { api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before); } } } function removeVnodes(parentElm: Node, vnodes: Array<VNode>, startIdx: number, endIdx: number): void { for (; startIdx <= endIdx; ++startIdx) { let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx]; if (ch != null) { // 如果 sel 有值 if (isDef(ch.sel)) { // 执行 destroy 钩子函数(会执行所有子节点的 destroy 钩子函数) invokeDestroyHook(ch); listeners = cbs.remove.length + 1; // 创建删除的回调函数 rm = createRmCb(ch.elm as Node, listeners); for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm); // 执行用户设置的 remove 钩子函数 if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) { i(ch, rm); } else { // 如果没有用户钩子函数,直接调用删除元素的方法 rm(); } } else { // Text node // 如果是文本节点,直接调用删除元素的方法 api.removeChild(parentElm, ch.elm as Node); } } } } function invokeDestroyHook(vnode: VNode) { let i: any, j: number, data = vnode.data; if (data !== undefined) { // 执行用户设置的 destroy 钩子函数 if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode); // 调用模块的 distroy 钩子函数 for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode); // 执行子节点的 distroy 钩子函数 if (vnode.children !== undefined) { for (j = 0; j < vnode.children.length; ++j) { i = vnode.children[j]; if (i != null && typeof i !== "string") { invokeDestroyHook(i); } } } } } function createRmCb(childElm: Node, listeners: number) { // 返回删除元素的回调函数 return function rmCb() { if (--listeners === 0) { const parent = api.parentNode(childElm); api.removeChild(parent, childElm); } }; } ~~~ >[info] ## patchVnode `patchVnode` 函数作为`patch`函数中新老`Vnode`进行比较依靠的是属性的`标签和key`比较相同可以触发 `patchVnode` 函数,找节点的**差异并更新 DOM** ~~~ function patchVnode( oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue ) { const hook = vnode.data?.hook hook?.prepatch?.(oldVnode, vnode) const elm = (vnode.elm = oldVnode.elm)! if (oldVnode === vnode) return if ( vnode.data !== undefined || (isDef(vnode.text) && vnode.text !== oldVnode.text) ) { vnode.data ??= {} oldVnode.data ??= {} for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) vnode.data?.hook?.update?.(oldVnode, vnode) } const oldCh = oldVnode.children as VNode[] const ch = vnode.children as VNode[] // 如果 vnode.text 未定义,有text 就没有children if (isUndef(vnode.text)) { // 如果新老节点都有 children if (isDef(oldCh) && isDef(ch)) { // 使用 diff 算法对比子节点,更新子节点 if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) } else if (isDef(ch)) { // 如果新节点有 children,老节点没有 children // 如果老节点有text,清空dom 元素的内容 if (isDef(oldVnode.text)) api.setTextContent(elm, '') // 批量添加子节点 addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) } else if (isDef(oldCh)) { // 如果老节点有children,新节点没有children // 批量移除子节点 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (isDef(oldVnode.text)) { // 如果老节点有 text,清空 DOM 元素 api.setTextContent(elm, '') } } else if (oldVnode.text !== vnode.text) { // 如果没有设置 vnode.text if (isDef(oldCh)) { // 如果老节点有 children,移除 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } // 设置 DOM 元素的 textContent 为 vnode.text api.setTextContent(elm, vnode.text!) } hook?.postpatch?.(oldVnode, vnode) } ~~~ 代码中在开始时有两行代码,`elm`之前说过是`Vnode `一个属性,用来记录`Vnode `真实`dom`的对象,先将老`Vnode`已将创建出来的真实`dom`赋值给新`Vnode `虽然此时并不确认新老相等但先进行赋值操作,如果后续中发现新老Vnode 相等此时不需要做任何改变了,直接 return 掉 ~~~ const elm = (vnode.elm = oldVnode.elm)!; if (oldVnode === vnode) return; ~~~ * 如果不想等进入比较阶段,比较其实分两个情况,就是新`vnode`是文本节点(文本节点没有什么特点比较情况,相同不变,不同更改为新的 `vnode` 文本),新`vnode`是非文本类型 ~~~ // 如果 vnode.text 未定义,有text 就没有children if (isUndef(vnode.text)) { // ... 非文本情况 } else if (oldVnode.text !== vnode.text) { // .... 是文本的情况 // 如果没有设置 vnode.text if (isDef(oldCh)) { // 如果老节点有 children,移除 removeVnodes(elm, oldCh, 0, oldCh.length - 1) } // 设置 DOM 元素的 textContent 为 vnode.text api.setTextContent(elm, vnode.text!) } ~~~ * `oldCh`与`ch`都存在且不相同时,使用`updateChildren`函数来更新子节点,使用 `diff` 算法对比子节点,更新子节点 * 如果只有`ch`存在的时候,如果老节点是文本节点则先将节点的文本清除,然后将`ch`批量插入插入到节点elm下 * 同理当只有`oldch`存在时,说明需要将老节点通过`removeVnodes`全部清除 * 只有老节点是文本节点的时候,清除其节点文本内容 ~~~ if (oldCh && ch && (oldCh !== ch)) { updateChildren(elm, oldCh, ch); } else if (ch) { if (oldVnode.text) nodeOps.setTextContent(elm, ''); addVnodes(elm, null, ch, 0, ch.length - 1); } else if (oldCh) { removeVnodes(elm, oldCh, 0, oldCh.length - 1) } else if (oldVnode.text) { nodeOps.setTextContent(elm, '') } ~~~ >[danger] ##### updateChildren -- diff `updateChildren ` 是真正的`diff`发生区域,能进入`patchVnode `函数都是新老父节点相同的,接下来要比较的其实是新老子节点,如果新老子节点形式不同(例如我是文本你是dom元素),但如果新老子节点形式相同要做就是对应位置比较,即如何性能最优的去比较 * 最笨的方法拿每一个老节点虚拟`dom`和新节点的去比较,如下图嵌套三层的情况下时间复杂度为 `O(n^3)` ![](https://img.kancloud.cn/b5/79/b579c580b83dd523f6a3caee2ad27d81_599x280.png) * 实际采用的方法**利用指针进行找同级别的子节点依次比较**,然后再找下一级别的节点比较,这样算法的 时间复 杂度为 `O(n)`,考虑的就是dom复用,有时候重新排序只是新老dom节点位置发生变化,如果能**复用相同的dom而不是重新创建dom**这样就又可以减少一部分性能开销 `oldStartVnode / newStartVnode` (旧开始节点 / 新开始节点) `oldEndVnode / newEndVnode` (旧结束节点 / 新结束节点) `oldStartVnode / oldEndVnode` (旧开始节点 / 新结束节点) `oldEndVnode / newStartVnode` (旧结束节点 / 新开始节点) `oldStartIdx`、`newStartIdx`、`oldEndIdx`以及`newEndIdx`两两比对的过程,一共会出现 2\*2=4 * **这四种其实对应下图四种情况他们找到能复用的vnode节点**,当在相同时候会在调用`patchVnode`函数 然后触发`if (oldVnode === vnode) return` 这里 ~~~ else if (sameVnode(oldStartVnode, newStartVnode)) { patchVnode(oldStartVnode, newStartVnode); oldStartVnode = oldCh[++oldStartIdx]; newStartVnode = newCh[++newStartIdx]; } else if (sameVnode(oldEndVnode, newEndVnode)) { patchVnode(oldEndVnode, newEndVnode); oldEndVnode = oldCh[--oldEndIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldStartVnode, newEndVnode)) { patchVnode(oldStartVnode, newEndVnode); nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) { patchVnode(oldEndVnode, newStartVnode); nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } ~~~ ![](https://img.kancloud.cn/7d/ae/7dae086ded79545de9f358d61a10677d_2600x4182.png) [参考视频动画链接](https://www.bilibili.com/video/BV1b5411V7i3?t=479) 第五种情况最复杂,第五种没有规律,但是存在可以复用的节点,使用新开始节点的key值在【旧开始节点-旧结束节点】的子数组中找相同key的节点。若没有在子数组中找到相同key节点则新开始节点是新节点,创建一个该节点的真实DOM节点插入到旧开始节点指向的真实DOM节点之前;若找到相同key的节点获取该旧节点,比较新、旧节点的选择器是否相同,选择器不同则创建一个该节点的真实DOM节点插入到旧开始节点指向的真实DOM节点之前,选择器相同则对比两个节点的差异执行相应的操作(如更新DOM节点),并将子数组中key节点指向到真实DOM节点移动到到旧开始索引指向的真实DOM节点之前(移动的是真实DOM而不是虚拟DOM,虚拟DOM无变化)。最后将新开始索引指向下一个节点 ~~~ function updateChildren (parentElm: Node, oldCh: VNode[], newCh: VNode[], insertedVnodeQueue: VNodeQueue) { let oldStartIdx = 0 let newStartIdx = 0 let oldEndIdx = oldCh.length - 1 let oldStartVnode = oldCh[0] let oldEndVnode = oldCh[oldEndIdx] let newEndIdx = newCh.length - 1 let newStartVnode = newCh[0] let newEndVnode = newCh[newEndIdx] let oldKeyToIdx: KeyToIndexMap | undefined let idxInOld: number let elmToMove: VNode let before: any while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 索引变化后,可能会把节点设置为空 if (oldStartVnode == null) { // 节点为空移动索引 oldStartVnode = oldCh[++oldStartIdx] // Vnode might have been moved left } else if (oldEndVnode == null) { oldEndVnode = oldCh[--oldEndIdx] } else if (newStartVnode == null) { newStartVnode = newCh[++newStartIdx] } else if (newEndVnode == null) { newEndVnode = newCh[--newEndIdx] // 比较开始和结束节点的四种情况 } else if (sameVnode(oldStartVnode, newStartVnode)) { // 1. 比较老开始节点和新开始节点 patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue) oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 2.比较老结束节点和新的结束节点 patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue) oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 3.比较老开始节点和新的结束节点 patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)) oldStartVnode = oldCh[++oldStartIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 4.比较老结束节点和新的开始节点 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!) oldEndVnode = oldCh[--oldEndIdx] newStartVnode = newCh[++newStartIdx] } else { // 开始节点和结束节点都不相同 // 使用newStartNode 的key在老节点数组中找相同节点 // 先设置记录 key 和index对象 if (oldKeyToIdx === undefined) { oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) } // 遍历 newStartVnode,从旧节点中找相同key的oldVnode的索引 idxInOld = oldKeyToIdx[newStartVnode.key as string] // 如果是新的vnode if (isUndef(idxInOld)) { // New element // 如果没找到,newStartNode 是新节点 // 创建元素插入DOM树 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // 如果找到相同key的老节点,记录到elmToMove遍历 elmToMove = oldCh[idxInOld] if (elmToMove.sel !== newStartVnode.sel) { // 如果新旧节点的选择器不同 // 创建新开始节点对应的DOM元素,插入到DOM树种 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!) } else { // 如果相同,patchVnode() // 把elmToMove 对应的DOM元素,移动到左边 patchVnode(elmToMove, newStartVnode, insertedVnodeQueue) oldCh[idxInOld] = undefined as any api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!) } } // 重新给newStartVnode 复制,指向下一个新节点 newStartVnode = newCh[++newStartIdx] } } // 循环结束,老节点数组先遍历完成或者新节点数组先遍历完成 if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) { if (oldStartIdx > oldEndIdx) { // 如果老节点数组先遍历完成,说明有新的节点剩余 // 把剩余的新节点都插入到右边 before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) } else { // 如果新节点数组先遍历完成,说明老节点有剩余 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) } } } ~~~ >[info] ## key 作用 `Key `一般用于生成一列同类型节点时使用,这种情况下,当修改这些同类型节点的某个内容、变更位置、删除、添加等时,此时界面需要更新视图,调用 `patch `方法通过对比**新、旧节点的变化来更新视图**。其从根节点开始若**新、旧 VNode 相同**,则调用`patchVnode` `patchVnode `中若新节点没有文本,且新节点和旧节点都有有子点,则需对子节点进行 `Diff `操作,即调用` updateChildren`,`Key` 就在 `updateChildren `起了大作用 `updateChildren `中会遍历对比上步中的新、旧节点的子节点,并按 Diff 算法通过 sameVnode 来判断要对比的节点是否相同 **若这里的子节点未设置 Key**,则此时的每个新、旧子节点在执行`sameVnode `时会判定相同,然后再次执行一次 patchVnode 来对比这些子节点的子节点 **若设置了 Key**,当执行 sameVnode **若 Key 不同 sameVnode 返回 false**,然后执行后续判断; **若 Key 相同 sameVnode 返回 true**,然后再执行 patchVnode 来对比这些子节点的子节点 即,使用了 Key 后,可以优化新、旧节点的对比判断,减少了遍历子节点的层次,少使用很多次 `patchVnode`,在第五种情况最明显,会在老Vnode中找到新Vnode 能复用的节点直接使用,这样就避免了重新渲染真实dom的开销