• 技术文章 >web前端 >Vue.js

    深入理解vue中的虚拟DOM和Diff算法

    青灯夜游青灯夜游2021-12-17 19:34:56转载154
    本篇文章带大家深入了解vue中的虚拟DOM和Diff算法,详细分析一下虚拟DOM和Diff算法,希望对大家有所帮助。

    最近复习到虚拟DOM与Diff,翻阅了众多资料,特此总结了这篇长文,加深自己对vue的理解。

    这篇文章比较详细的分析了vue的虚拟DOM,Diff算法,其中一些关键的地方从别处搬运了一些图进行说明(感谢制图的大佬),也包含比较详细的源码解读。如果感觉文章不错,麻烦各位帅比给小弟点个赞,如果有错误的地方,也欢迎各位大佬在评论区指出,当然如果有不明白的部分,也欢迎提问。【相关推荐:《vue.js教程》】

    真实DOM的渲染

    在讲虚拟DOM之前,先说一下真实DOM的渲染。

    1.png

    浏览器真实DOM渲染的过程大概分为以下几个部分

    为啥需要虚拟DOM

    上面这是一次DOM渲染的过程,如果dom更新,那么dom需要重新渲染一次,如果存在下面这种情况

    <body>
        <div id="container">
            <div class="content" style="color: red;font-size:16px;">
                This is a container
            </div>
    				....
            <div class="content" style="color: red;font-size:16px;">
                This is a container
            </div>
        </div>
    </body>
    <script>
        let content = document.getElementsByClassName('content');
        for (let i = 0; i < 1000000; i++) {
            content[i].innerHTML = `This is a content${i}`;
            // 触发回流
            content[i].style.fontSize = `20px`;
        }
    </script>

    那么需要真实的操作DOM100w次,触发了回流100w次。每次DOM的更新都会按照流程进行无差别的真实dom的更新。所以造成了很大的性能浪费。如果循环里面是复杂的操作,频繁触发回流与重绘,那么就很容易就影响性能,造成卡顿。另外这里要说明一下的是,虚拟DOM并不是意味着比DOM就更快,性能需要分场景,虚拟DOM的性能跟模板大小是正相关。虚拟DOM的比较过程是不会区分数据量大小的,在组件内部只有少量动态节点时,虚拟DOM依然是会对整个vdom进行遍历,相比直接渲染而言是多了一层操作的。

    <div class="list">
        <p class="item">item</p>
        <p class="item">item</p>
        <p class="item">item</p>
        <p class="item">{{ item }}</p>
        <p class="item">item</p>
        <p class="item">item</p>
      </div>

    比如上面这个例子,虚拟DOM。虽然只有一个动态节点,但是虚拟DOM依然需要遍历diff整个list的class,文本,标签等信息,最后依然需要进行DOM渲染。如果只是dom操作,就只要操作一个具体的DOM然后进行渲染。虚拟DOM最核心的价值在于,它能通过js描述真实DOM,表达力更强,通过声明式的语言操作,为开发者提供了更加方便快捷开发体验,而且在没有手动优化,大部分情景下,保证了性能下限,性价比更高。

    虚拟DOM

    虚拟DOM本质上是一个js对象,通过对象来表示真实的DOM结构。tag用来描述标签,props用来描述属性,children用来表示嵌套的层级关系。

    const vnode = {
        tag: 'div',
        props: {
            id: 'container',
        },
        children: [{
            tag: 'div',
            props: {
                class: 'content',
            },
          	text: 'This is a container'
        }]
    }
    
    //对应的真实DOM结构
    <div id="container">
      <div class="content">
        This is a container
      </div>
    </div>

    虚拟DOM的更新不会立即操作DOM,而是会通过diff算法,找出需要更新的节点,按需更新,并将更新的内容保存为一个js对象,更新完成后再挂载到真实dom上,实现真实的dom更新。通过虚拟DOM,解决了操作真实DOM的三个问题。

    另外由于虚拟DOM保存的是js对象,天然的具有跨平台的能力,而不仅仅局限于浏览器。

    优点

    总结起来,虚拟DOM的优势有以下几点

    不足

    虚拟DOM同样也有缺点,首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比innerHTML插入慢。

    虚拟DOM实现原理

    主要分三部分

    Diff算法

    为了避免不必要的渲染,按需更新,虚拟DOM会采用Diff算法进行虚拟DOM节点比较,比较节点差异,从而确定需要更新的节点,再进行渲染。vue采用的是深度优先,同层比较的策略。

    2.png

    新节点与旧节点的比较主要是围绕三件事来达到渲染目的

    如何比较新旧节点是否一致呢?

    function sameVnode(a, b) {
        return (
            a.key === b.key &&
            a.asyncFactory === b.asyncFactory && (
                (
                    a.tag === b.tag &&
                    a.isComment === b.isComment &&
                    isDef(a.data) === isDef(b.data) &&
                    sameInputType(a, b) //对input节点的处理
                ) || (
                    isTrue(a.isAsyncPlaceholder) &&
                    isUndef(b.asyncFactory.error)
                )
            )
        )
    }
    
    //判断两个节点是否是同一种 input 输入类型
    function sameInputType(a, b) {
        if (a.tag !== 'input') return true
        let i
        const typeA = isDef(i = a.data) && isDef(i = i.attrs) && i.type
        const typeB = isDef(i = b.data) && isDef(i = i.attrs) && i.type
        //input type 相同或者两个type都是text
        return typeA === typeB || isTextInputType(typeA) && isTextInputType(typeB)
    }

    可以看到,两个节点是否相同是需要比较标签(tag)属性(在vue中是用data表示vnode中的属性props), 注释节点(isComment) 的,另外碰到input的话,是会做特殊处理的。

    创建新节点

    当新节点有的,旧节点没有,这就意味着这是全新的内容节点。只有元素节点,文本节点,注释节点才能被创建插入到DOM中。

    删除旧节点

    当旧节点有,而新节点没有,那就意味着,新节点放弃了旧节点的一部分。删除节点会连带的删除旧节点的子节点。

    更新节点

    新的节点与旧的的节点都有,那么一切以新的为准,更新旧节点。如何判断是否需要更新节点呢?

      // 判断vnode与oldVnode是否完全一样
      if (oldVnode === vnode) {
        return;
      }
      // 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性
      if (
        isTrue(vnode.isStatic) &&
        isTrue(oldVnode.isStatic) &&
        vnode.key === oldVnode.key &&
        (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
      ) {
        vnode.componentInstance = oldVnode.componentInstance;
        return;
      }
    //判断新节点是否有文本
    if (isUndef(vnode.text)) {
      //如果没有文本,处理子节点的相关代码
      ....
    } else if (oldVnode.text !== vnode.text) {
      //新节点文本替换旧节点文本
      nodeOps.setTextContent(elm, vnode.text)
    }

    都有子节点

    对于都有子节点的情况,需要对新旧节点做比较,如果他们不相同,那么需要进行diff操作,在vue中这里就是updateChildren方法,后面会详细再讲,子节点的比较主要是双端比较。

    //判断新节点是否有文本
    if (isUndef(vnode.text)) {
        //新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法
        if (isDef(oldCh) && isDef(ch)) {
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
        }
    } else if (oldVnode.text !== vnode.text) {
        //新节点文本替换旧节点文本
        nodeOps.setTextContent(elm, vnode.text)
    }

    只有新节点有子节点

    只有新节点有子节点,那么就代表着这是新增的内容,那么就是新增一个子节点到DOM,新增之前还会做一个重复key的检测,并做出提醒,同时还要考虑,旧节点如果只是一个文本节点,没有子节点的情况,这种情况下就需要清空旧节点的文本内容。

    //只有新节点有子节点
    if (isDef(ch)) {
      //检查重复key
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch)
      }
      //清除旧节点文本
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
      //添加新节点
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    }
    
    //检查重复key
    function checkDuplicateKeys(children) {
      const seenKeys = {}
      for (let i = 0; i < children.length; i++) {
          const vnode = children[i]
          //子节点每一个Key
          const key = vnode.key
          if (isDef(key)) {
              if (seenKeys[key]) {
                  warn(
                      `Duplicate keys detected: '${key}'. This may cause an update error.`,
                      vnode.context
                  )
              } else {
                  seenKeys[key] = true
              }
          }
      }
    }

    只有旧节点有子节点

    只有旧节点有,那就说明,新节点抛弃了旧节点的子节点,所以需要删除旧节点的子节点

    if (isDef(oldCh)) {
      //删除旧节点
      removeVnodes(oldCh, 0, oldCh.length - 1)
    }

    都没有子节点

    这个时候需要对旧节点文本进行判断,看旧节点是否有文本,如果有就清空

    if (isDef(oldVnode.text)) {
      //清空
      nodeOps.setTextContent(elm, '')
    }

    整体的逻辑代码如下

    function patchVnode(
        oldVnode,
        vnode,
        insertedVnodeQueue,
        ownerArray,
        index,
        removeOnly
    ) {
        // 判断vnode与oldVnode是否完全一样
        if (oldVnode === vnode) {
            return
        }
    
        if (isDef(vnode.elm) && isDef(ownerArray)) {
            // 克隆重用节点
            vnode = ownerArray[index] = cloneVNode(vnode)
        }
    
        const elm = vnode.elm = oldVnode.elm
    
        if (isTrue(oldVnode.isAsyncPlaceholder)) {
            if (isDef(vnode.asyncFactory.resolved)) {
                hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
            } else {
                vnode.isAsyncPlaceholder = true
            }
            return
        }
    		// 是否是静态节点,key是否一样,是否是克隆节点或者是否设置了once属性
        if (isTrue(vnode.isStatic) &&
            isTrue(oldVnode.isStatic) &&
            vnode.key === oldVnode.key &&
            (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
        ) {
            vnode.componentInstance = oldVnode.componentInstance
            return
        }
    
        let i
        const data = vnode.data
        if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
            i(oldVnode, vnode)
        }
    
        const oldCh = oldVnode.children
        const ch = vnode.children
    
        if (isDef(data) && isPatchable(vnode)) {
          	//调用update回调以及update钩子
            for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
            if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
        }
        //判断新节点是否有文本
        if (isUndef(vnode.text)) {
          	//新旧节点都有子节点情况下,如果新旧子节点不相同,那么进行子节点的比较,就是updateChildren方法
            if (isDef(oldCh) && isDef(ch)) {
                if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
            } else if (isDef(ch)) {
              	//只有新节点有子节点
                if (process.env.NODE_ENV !== 'production') {
                  	//重复Key检测
                    checkDuplicateKeys(ch)
                }
              	//清除旧节点文本
                if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
              	//添加新节点
                addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
            } else if (isDef(oldCh)) {
              	//只有旧节点有子节点,删除旧节点
                removeVnodes(oldCh, 0, oldCh.length - 1)
            } else if (isDef(oldVnode.text)) {
              	//新旧节点都无子节点
                nodeOps.setTextContent(elm, '')
            }
        } else if (oldVnode.text !== vnode.text) {
          	//新节点文本替换旧节点文本
            nodeOps.setTextContent(elm, vnode.text)
        }
    
        if (isDef(data)) {
            if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
        }
    }

    配上流程图会更清晰点

    3.png

    4.png

    子节点的比较更新updateChildren

    新旧节点都有子节点的情况下,这个时候是需要调用updateChildren方法来比较更新子节点的。所以在数据上,新旧节点子节点,就保存为了两个数组。

    const oldCh = [oldVnode1, oldVnode2,oldVnode3];
    const newCh = [newVnode1, newVnode2,newVnode3];

    子节点更新采用的是双端比较的策略,什么是双端比较呢,就是新旧节点比较是通过互相比较首尾元素(存在4种比较),然后向中间靠拢比较(newStartIdx,与oldStartIdx递增,newEndIdx与oldEndIdx递减)的策略。

    比较过程

    5.png

    向中间靠拢

    6.png

    这里对上面出现的新前,新后,旧前,旧后做一下说明

    子节点比较过程

    接下来对上面的比较过程以及比较后做的操作做下说明

    7.png

    if (sameVnode(oldStartVnode, newStartVnode)) {
      // 更新子节点
      patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      // 新旧各向后一步
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    }

    8.png

    if (sameVnode(oldEndVnode, newEndVnode)) {
        //更新子节点
        patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        // 新旧向前
        oldEndVnode = oldCh[--oldEndIdx]
        newEndVnode = newCh[--newEndIdx]
    }

    9.png

    if (sameVnode(oldStartVnode, newEndVnode)) {
      patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
      //将旧子节点数组第一个子节点移动插入到最后
      canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      //旧向后
      oldStartVnode = oldCh[++oldStartIdx]
      //新向前
      newEndVnode = newCh[--newEndIdx]

    10.png

    if (sameVnode(oldEndVnode, newStartVnode)) {
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
      //将旧后移动插入到最前
      canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      //旧向前
      oldEndVnode = oldCh[--oldEndIdx]
      //新向后
      newStartVnode = newCh[++newStartIdx]
    }

    进行到这一步对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}

    // 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}
    if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)

    然后拿新节点的key与旧节点进行比较,找到key值匹配的节点的位置,这里需要注意的是,如果新节点也没key,那么就会执行findIdxInOld方法,从头到尾遍历匹配旧节点

    //通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找
    idxInOld = isDef(newStartVnode.key)
      ? oldKeyToIdx[newStartVnode.key]
      : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    
    //findIdxInOld方法
    function findIdxInOld(node, oldCh, start, end) {
      for (let i = start; i < end; i++) {
        const c = oldCh[i]
        //找到相同节点下标
        if (isDef(c) && sameVnode(node, c)) return i
      }
    }

    如果通过上面的方法,依旧没找到新节点与旧节点匹配的下标,那就说明这个节点是新节点,那就执行新增的操作。

    //如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作
    if (isUndef(idxInOld)) {
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    }

    如果找到了,那么说明在旧节点中找到了key值一样,或者节点和key都一样的旧节点。如果节点一样,那么在patchVnode之后,需要将旧节点移动到所有未处理节点之前,对于key一样,元素不同的节点,将其认为是新节点,执行新增操作。执行完成后,新节点向后一步。

    11.png

    //如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作
    if (isUndef(idxInOld)) {
      // 新增元素
      createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
    } else {
      // 在旧节点中找到了key值一样的节点
      vnodeToMove = oldCh[idxInOld]
      if (sameVnode(vnodeToMove, newStartVnode)) {
        // 相同子节点更新操作
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        // 更新完将旧节点赋值undefined
        oldCh[idxInOld] = undefined
        //将旧节点移动到所有未处理节点之前
        canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      } else {
        // 如果是相同的key,不同的元素,当做新节点,执行创建操作
        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
      }
    }
    //新节点向后
    newStartVnode = newCh[++newStartIdx]

    当完成对旧节点的遍历,但是新节点还没完成遍历,那就说明后续的都是新增节点,执行新增操作,如果完成对新节点遍历,旧节点还没完成遍历,那么说明旧节点出现冗余节点,执行删除操作。

    //完成对旧节点的遍历,但是新节点还没完成遍历,
    if (oldStartIdx > oldEndIdx) {
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      // 新增节点
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) {
      // 发现多余的旧节点,执行删除操作
      removeVnodes(oldCh, oldStartIdx, oldEndIdx)
    }

    子节点比较总结

    上面就是子节点比较更新的一个完整过程,这是完整的逻辑代码

    function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
        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, idxInOld, vnodeToMove, refElm
    
        // removeOnly is a special flag used only by <transition-group>
        // to ensure removed elements stay in correct relative positions
        // during leaving transitions
        const canMove = !removeOnly
    
        if (process.env.NODE_ENV !== 'production') {
            checkDuplicateKeys(newCh)
        }
    
        //双端比较遍历
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
            if (isUndef(oldStartVnode)) {
                //旧前向后移动
                oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
            } else if (isUndef(oldEndVnode)) {
                // 旧后向前移动
                oldEndVnode = oldCh[--oldEndIdx]
            } else if (sameVnode(oldStartVnode, newStartVnode)) {
                //新前与旧前
                //更新子节点
                patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
                    // 新旧各向后一步
                oldStartVnode = oldCh[++oldStartIdx]
                newStartVnode = newCh[++newStartIdx]
            } else if (sameVnode(oldEndVnode, newEndVnode)) {
                //新后与旧后
                //更新子节点
                patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
                    //新旧各向前一步
                oldEndVnode = oldCh[--oldEndIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldStartVnode, newEndVnode)) {
                // 新后与旧前
                //更新子节点
                patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
                    //将旧前移动插入到最后
                canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
                    //新向前,旧向后
                oldStartVnode = oldCh[++oldStartIdx]
                newEndVnode = newCh[--newEndIdx]
            } else if (sameVnode(oldEndVnode, newStartVnode)) {
                // 新前与旧后
                patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
    
                //将旧后移动插入到最前
                canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
    
                //新向后,旧向前
                oldEndVnode = oldCh[--oldEndIdx]
                newStartVnode = newCh[++newStartIdx]
            } else {
                // 对于没有设置key的节点,第一次会通过createKeyToOldIdx建立key与index的映射 {key:index}
                if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
    
                //通过新节点的key,找到新节点在旧节点中所在的位置下标,如果没有设置key,会执行遍历操作寻找
                idxInOld = isDef(newStartVnode.key) ?
                    oldKeyToIdx[newStartVnode.key] :
                    findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
    
                //如果新节点无法在旧节点中找到自己的位置下标,说明是新元素,执行新增操作
                if (isUndef(idxInOld)) {
                    // 新增元素
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
                } else {
                    // 在旧节点中找到了key值一样的节点
                    vnodeToMove = oldCh[idxInOld]
                    if (sameVnode(vnodeToMove, newStartVnode)) {
                        // 相同子节点更新操作
                        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
                            // 更新完将旧节点赋值undefined
                        oldCh[idxInOld] = undefined
                            //将旧节点移动到所有未处理节点之前
                        canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
                    } else {
                        // 如果是相同的key,不同的元素,当做新节点,执行创建操作
                        createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
                    }
                }
                //新节点向后一步
                newStartVnode = newCh[++newStartIdx]
            }
        }
    
        //完成对旧节点的遍历,但是新节点还没完成遍历,
        if (oldStartIdx > oldEndIdx) {
            refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
                // 新增节点
            addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
        } else if (newStartIdx > newEndIdx) {
            // 发现多余的旧节点,执行删除操作
            removeVnodes(oldCh, oldStartIdx, oldEndIdx)
        }
    }

    更多编程相关知识,请访问:编程入门!!

    以上就是深入理解vue中的虚拟DOM和Diff算法的详细内容,更多请关注php中文网其它相关文章!

    声明:本文转载于:掘金社区,如有侵犯,请联系admin@php.cn删除
    专题推荐:vue 虚拟DOM Diff算法
    上一篇:以Vuex为例,揭开状态管理的神秘面纱 下一篇:不看吃亏的Vue基础教程(附示例详解)

    相关文章推荐

    • Vue动态绑定类时要怎么避免出现 空类 情况!• 解析vue3响应式原理以及api编写• 介绍Vue3中Composition API及其核心用法• TP6+vue-element-admin实现后台登录验证码• 浅谈vue3组件通信的几种方式

    全部评论我要评论

  • 取消发布评论发送
  • 1/1

    PHP中文网