vue diff 알고리즘을 이해하시나요? 원리분석

青灯夜游
풀어 주다: 2022-08-08 19:21:01
앞으로
3114명이 탐색했습니다.

diff 알고리즘은 동일한 수준의 트리 노드를 비교하는 효율적인 알고리즘으로, 트리를 계층별로 검색하고 탐색할 필요가 없습니다. 그렇다면 diff 알고리즘에 대해 얼마나 알고 있나요? 다음 글은 vue의 diff 알고리즘에 대한 심층 분석을 제공할 것입니다. 도움이 되길 바랍니다!

vue diff 알고리즘을 이해하시나요? 원리분석

1.

diff 알고리즘이란 동일한 수준의 트리 노드를 비교하는 효율적인 알고리즘입니다. (학습 영상 공유: vue video tutorialdiff 算法是一种通过同层的树节点进行比较的高效算法。(学习视频分享:vue视频教程

其有两个特点:

  • 比较只会在同层级进行, 不会跨层级比较
  • 在diff比较的过程中,循环从两边向中间比较

diff 算法在很多场景下都有应用,在 vue 中,作用于虚拟 dom 渲染成真实 dom 的新旧 VNode 节点比较

二、比较方式

diff整体策略为:深度优先,同层比较

  • 比较只会在同层级进行, 不会跨层级比较

vue diff 알고리즘을 이해하시나요? 원리분석

  • 比较的过程中,循环从两边向中间收拢

vue diff 알고리즘을 이해하시나요? 원리분석

下面举个vue通过diff算法更新的例子:

新旧VNode节点如下图所示:

vue diff 알고리즘을 이해하시나요? 원리분석

第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为diff后的第一个真实节点,同时旧节点endIndex移动到C,新节点的 startIndex 移动到了 C

vue diff 알고리즘을 이해하시나요? 원리분석

第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,diff 后创建了 C 的真实节点插入到第一次创建的 D 节点后面。同时旧节点的 endIndex 移动到了 B,新节点的 startIndex 移动到了 E

vue diff 알고리즘을 이해하시나요? 원리분석

第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 startIndexendIndex 都保持不动

vue diff 알고리즘을 이해하시나요? 원리분석

第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了 B,新节点的startIndex 移动到了 B

vue diff 알고리즘을 이해하시나요? 원리분석

第五次循环中,情形同第四次循环一样,因此 diff 后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex移动到了 C,新节点的 startIndex 移动到了 F

vue diff 알고리즘을 이해하시나요? 원리분석

新节点的 startIndex 已经大于 endIndex 了,需要创建 newStartIdxnewEndIdx 之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面

vue diff 알고리즘을 이해하시나요? 원리분석

三、原理分析

当数据发生改变时,set方法会调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图

源码位置:src/core/vdom/patch.js

function patch(oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) { // 没有新节点,直接执行destory钩子函数
        if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
        return
    }

    let isInitialPatch = false
    const insertedVnodeQueue = []

    if (isUndef(oldVnode)) {
        isInitialPatch = true
        createElm(vnode, insertedVnodeQueue) // 没有旧节点,直接用新节点生成dom元素
    } else {
        const isRealElement = isDef(oldVnode.nodeType)
        if (!isRealElement && sameVnode(oldVnode, vnode)) {
            // 判断旧节点和新节点自身一样,一致执行patchVnode
            patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
        } else {
            // 否则直接销毁及旧节点,根据新节点生成dom元素
            if (isRealElement) {

                if (oldVnode.nodeType === 1 && oldVnode.hasAttribute(SSR_ATTR)) {
                    oldVnode.removeAttribute(SSR_ATTR)
                    hydrating = true
                }
                if (isTrue(hydrating)) {
                    if (hydrate(oldVnode, vnode, insertedVnodeQueue)) {
                        invokeInsertHook(vnode, insertedVnodeQueue, true)
                        return oldVnode
                    }
                }
                oldVnode = emptyNodeAt(oldVnode)
            }
            return vnode.elm
        }
    }
}
로그인 후 복사

patch函数前两个参数位为oldVnodeVnode)

🎜있습니다 두 가지 기능: 🎜
  • 교차 수준 비교가 아닌 동일한 수준에서만 비교가 수행됩니다.
  • 차이 비교 프로세스 중에 루프는 양쪽에서 중간까지 비교합니다.
  • ul>🎜diff 알고리즘은 많은 시나리오에서 사용됩니다. vue에서는 가상 dom에서 작동하며 실제 dom< /code>🎜<h2 id="2.Comparison method">🎜2. Comparison method🎜🎜🎜<code>에서 이전 <code>VNode 노드 비교 diff 전반적인 전략은 깊이 우선, 동일한 수준에서의 비교입니다🎜
    • 🎜비교는 동일한 수준에서만 수행되며 전체 비교는 수행되지 않습니다. 레벨🎜
    🎜 vue diff 알고리즘을 이해하시나요? 원리분석🎜
    • 🎜비교 과정에서 루프가 양쪽에서 가운데로 줄어듭니다🎜
    🎜 vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜다음은 diff 알고리즘을 전달하는 vue의 예 업데이트된 예: 🎜🎜이전 및 새 VNode 노드는 다음과 같습니다. 🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜첫 번째 주기 후에는 이전 노드 D가 새 노드 D와 동일하고 이전 노드 D를 diff로 직접 재사용하고 이전 노드 endIndex를 C로 이동한 것으로 확인되고, 새 노드의 startIndex는 C🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜두 번째 사이클 이후에도 기존 노드의 끝이고 새 노드(모두 C)의 시작도 동일합니다. 마찬가지로 실제 노드 C도 마찬가지입니다. diff가 처음 생성된 D 노드 뒤에 삽입된 후에 생성됩니다. 동시에 기존 노드의 endIndex는 B로 이동했고, 새 노드의 startIndex는 E🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜세 번째 루프에서 발견되었습니다 E를 찾을 수 없습니다. 이때 새로운 실제 노드는 두 번째로 생성된 C 노드 뒤에 삽입되는 E만 직접 생성할 수 있습니다. 동시에 새 노드의 startIndex가 A로 이동됩니다. 이전 노드의 startIndexendIndex는 변경되지 않습니다🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜4번째 사이클에서는 이전 노드와 새 노드(모두 A)의 시작이 동일한 것으로 확인되었으므로 < code>diff 그런 다음 A의 실제 노드가 생성되고 이전에 생성된 E 노드 뒤에 삽입됩니다. 동시에 기존 노드의 startIndex는 B로 이동했고, 새 노드의 startIndex는 B🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜5번째 사이클 상황은 네 번째 주기와 동일하므로 diff</ code 이후에 생성된 실제 노드 B가 이전에 생성된 A 노드 뒤에 삽입됩니다. 동시에 기존 노드의 <code>startIndex는 C로 이동했고, 새 노드의 startIndex는 F🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜🎜새 노드의 startIndex는 이미 endIndex보다 크고 를 생성해야 함 newStartIdxnewEndIdx 사이의 모든 노드, 즉 노드 F에 대해 해당 실제 노드를 직접 생성합니다. F 노드에 추가하고 B 노드 뒤에 배치하세요🎜🎜vue diff 알고리즘을 이해하시나요? 원리분석🎜

    🎜3. 원리 분석🎜🎜🎜데이터가 변경되면 set 메서드는 Dep을 호출합니다. 통지를 통해 모든 구독자에게 Watcher를 알리면 구독자는 패치실제 DOM를 패치하고 해당 뷰🎜🎜소스를 업데이트합니다. 코드 위치: src/core/vdom/patch.js🎜
    function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
        // 如果新旧节点一致,什么都不做
        if (oldVnode === vnode) {
          return
        }
        // 让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化
        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
        // 当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上
        // 也不用再有其他操作
        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)) {
          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)
        }
        // 如果vnode不是文本节点或者注释节点
        if (isUndef(vnode.text)) {
          // 并且都有子节点
          if (isDef(oldCh) && isDef(ch)) {
            // 并且子节点不完全一致,则调用updateChildren
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
            // 如果只有新的vnode有子节点
          } else if (isDef(ch)) {
            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, &#39;&#39;)
            // elm已经引用了老的dom节点,在老的dom节点上添加子节点
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
            // 如果新vnode没有子节点,而vnode有子节点,直接删除老的oldCh
          } else if (isDef(oldCh)) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
            // 如果老节点是文本节点
          } else if (isDef(oldVnode.text)) {
            nodeOps.setTextContent(elm, &#39;&#39;)
          }
          // 如果新vnode和老vnode是文本节点或注释节点
          // 但是vnode.text != oldVnode.text时,只需要更新vnode.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)
        }
      }
    로그인 후 복사
    로그인 후 복사
    🎜patch함수의 처음 두 매개변수는 oldVnodeVnode입니다. 각각 새 노드와 이전 노드를 나타냅니다. 주로 네 가지 판단을 내립니다. 🎜
    • 没有新节点,直接触发旧节点的destory钩子
    • 没有旧节点,说明是页面刚开始初始化的时候,此时,根本不需要比较了,直接全是新建,所以只调用 createElm
    • 旧节点和新节点自身一样,通过 sameVnode 判断节点是否一样,一样时,直接调用 patchVnode去处理这两个节点
    • 旧节点和新节点自身不一样,当两个节点不一样的时候,直接创建新节点,删除旧节点

    下面主要讲的是patchVnode部分

    function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
        // 如果新旧节点一致,什么都不做
        if (oldVnode === vnode) {
          return
        }
        // 让vnode.el引用到现在的真实dom,当el修改时,vnode.el会同步变化
        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
        // 当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上
        // 也不用再有其他操作
        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)) {
          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)
        }
        // 如果vnode不是文本节点或者注释节点
        if (isUndef(vnode.text)) {
          // 并且都有子节点
          if (isDef(oldCh) && isDef(ch)) {
            // 并且子节点不完全一致,则调用updateChildren
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
            // 如果只有新的vnode有子节点
          } else if (isDef(ch)) {
            if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, &#39;&#39;)
            // elm已经引用了老的dom节点,在老的dom节点上添加子节点
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
            // 如果新vnode没有子节点,而vnode有子节点,直接删除老的oldCh
          } else if (isDef(oldCh)) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
            // 如果老节点是文本节点
          } else if (isDef(oldVnode.text)) {
            nodeOps.setTextContent(elm, &#39;&#39;)
          }
          // 如果新vnode和老vnode是文本节点或注释节点
          // 但是vnode.text != oldVnode.text时,只需要更新vnode.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)
        }
      }
    로그인 후 복사
    로그인 후 복사

    patchVnode主要做了几个判断:

    • 新节点是否是文本节点,如果是,则直接更新dom的文本内容为新节点的文本内容
    • 新节点和旧节点如果都有子节点,则处理比较更新子节点
    • 只有新节点有子节点,旧节点没有,那么不用比较了,所有节点都是全新的,所以直接全部新建就好了,新建是指创建出所有新DOM,并且添加进父节点
    • 只有旧节点有子节点而新节点没有,说明更新后的页面,旧节点全部都不见了,那么要做的,就是把所有的旧节点删除,也就是直接把DOM 删除

    子节点不完全一致,则调用updateChildren

    function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
        let oldStartIdx = 0 // 旧头索引
        let newStartIdx = 0 // 新头索引
        let oldEndIdx = oldCh.length - 1 // 旧尾索引
        let newEndIdx = newCh.length - 1 // 新尾索引
        let oldStartVnode = oldCh[0] // oldVnode的第一个child
        let oldEndVnode = oldCh[oldEndIdx] // oldVnode的最后一个child
        let newStartVnode = newCh[0] // newVnode的第一个child
        let newEndVnode = newCh[newEndIdx] // newVnode的最后一个child
        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
        // 如果oldStartVnode和oldEndVnode重合,并且新的也都重合了,证明diff完了,循环结束
        while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
          // 如果oldVnode的第一个child不存在
          if (isUndef(oldStartVnode)) {
            // oldStart索引右移
            oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
          // 如果oldVnode的最后一个child不存在
          } else if (isUndef(oldEndVnode)) {
            // oldEnd索引左移
            oldEndVnode = oldCh[--oldEndIdx]
          // oldStartVnode和newStartVnode是同一个节点
          } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // patch oldStartVnode和newStartVnode, 索引左移,继续循环
            patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
            oldStartVnode = oldCh[++oldStartIdx]
            newStartVnode = newCh[++newStartIdx]
          // oldEndVnode和newEndVnode是同一个节点
          } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // patch oldEndVnode和newEndVnode,索引右移,继续循环
            patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
            oldEndVnode = oldCh[--oldEndIdx]
            newEndVnode = newCh[--newEndIdx]
          // oldStartVnode和newEndVnode是同一个节点
          } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            // patch oldStartVnode和newEndVnode
            patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
            // 如果removeOnly是false,则将oldStartVnode.eml移动到oldEndVnode.elm之后
            canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
            // oldStart索引右移,newEnd索引左移
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
          // 如果oldEndVnode和newStartVnode是同一个节点
          } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            // patch oldEndVnode和newStartVnode
            patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
            // 如果removeOnly是false,则将oldEndVnode.elm移动到oldStartVnode.elm之前
            canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
            // oldEnd索引左移,newStart索引右移
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
          // 如果都不匹配
          } else {
            if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            // 尝试在oldChildren中寻找和newStartVnode的具有相同的key的Vnode
            idxInOld = isDef(newStartVnode.key)
              ? oldKeyToIdx[newStartVnode.key]
              : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
            // 如果未找到,说明newStartVnode是一个新的节点
            if (isUndef(idxInOld)) { // New element
              // 创建一个新Vnode
              createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
            // 如果找到了和newStartVnodej具有相同的key的Vnode,叫vnodeToMove
            } else {
              vnodeToMove = oldCh[idxInOld]
              /* istanbul ignore if */
              if (process.env.NODE_ENV !== &#39;production&#39; && !vnodeToMove) {
                warn(
                  &#39;It seems there are duplicate keys that is causing an update error. &#39; +
                  &#39;Make sure each v-for item has a unique key.&#39;
                )
              }
              // 比较两个具有相同的key的新节点是否是同一个节点
              //不设key,newCh和oldCh只会进行头尾两端的相互比较,设key后,除了头尾两端的比较外,还会从用key生成的对象oldKeyToIdx中查找匹配的节点,所以为节点设置key可以更高效的利用dom。
              if (sameVnode(vnodeToMove, newStartVnode)) {
                // patch vnodeToMove和newStartVnode
                patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
                // 清除
                oldCh[idxInOld] = undefined
                // 如果removeOnly是false,则将找到的和newStartVnodej具有相同的key的Vnode,叫vnodeToMove.elm
                // 移动到oldStartVnode.elm之前
                canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
              // 如果key相同,但是节点不相同,则创建一个新的节点
              } else {
                // same key but different element. treat as new element
                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
              }
            }
            // 右移
            newStartVnode = newCh[++newStartIdx]
          }
        }
    로그인 후 복사

    while循环主要处理了以下五种情景:

    • 当新老 VNode 节点的 start 相同时,直接 patchVnode ,同时新老 VNode 节点的开始索引都加 1
    • 当新老 VNode 节点的 end相同时,同样直接 patchVnode ,同时新老 VNode 节点的结束索引都减 1
    • 当老 VNode 节点的 start 和新 VNode 节点的 end 相同时,这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1
    • 当老 VNode 节点的 end 和新 VNode 节点的 start 相同时,这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1
    • 如果都不满足以上四种情形,那说明没有相同的节点可以复用,则会分为以下两种情况:
      • 从旧的 VNodekey 值,对应 index 序列为 value 值的哈希表中找到与 newStartVnode 一致 key 的旧的 VNode 节点,再进行patchVnode,同时将这个真实 dom移动到 oldStartVnode 对应的真实 dom 的前面
      • 调用 createElm 创建一个新的 dom 节点放到当前 newStartIdx 的位置

    小结

    • 当数据发生改变时,订阅者watcher就会调用patch给真实的DOM打补丁
    • 通过isSameVnode进行判断,相同则调用patchVnode方法
    • patchVnode做了以下操作:
      • 找到对应的真实dom,称为el
      • 如果都有都有文本节点且不相等,将el文本节点设置为Vnode的文本节点
      • 如果oldVnode有子节点而VNode没有,则删除el子节点
      • 如果oldVnode没有子节点而VNode有,则将VNode的子节点真实化后添加到el
      • 如果两者都有子节点,则执行updateChildren函数比较子节点
    • updateChildren主要做了以下操作:
      • 设置新旧VNode的头尾指针
      • 新旧头尾指针进行比较,循环向中间靠拢,根据情况调用patchVnode进行patch重复流程、调用createElem创建一个新节点,从哈希表寻找 key一致的VNode 节点再分情况操作

    (学习视频分享:web前端开发编程基础视频

    위 내용은 vue diff 알고리즘을 이해하시나요? 원리분석의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

관련 라벨:
원천:vue3js
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
최신 이슈
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿