Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

青灯夜游
リリース: 2022-03-08 19:48:56
転載
2376 人が閲覧しました

この記事は、Vue を学習し、仮想 DOM と diff アルゴリズムを徹底的に理解するのに役立ちます。皆様のお役に立てれば幸いです。

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

この記事の主な目的は、誰もが仮想 DOM と diff アルゴリズムを真に徹底的に理解できるようにすることです。彼らの最下層を自分たちでノックアウトできるかどうかは私たち次第です! レンダリング関数 (h 関数) (手書きの h 関数) によって仮想 DOM が生成される方法 から、 diff アルゴリズムの原理 (手書きの差分アルゴリズム) 、そして最後に 仮想 DOM は diff を通じて変更されます 実 DOM の場合 (実際、仮想 DOM から実 DOM への変換は diff アルゴリズムによってカバーされます). 誰もが理解しやすくするために、記事にはより多くのポイントと内容が含まれる場合があります。内容は比較的長いです。気長にお待ちください。詳しく見て、最後に を押していただければ幸いです。 ! !

さて、早速、仮想 DOM と diff アルゴリズムを真に完全にマスターできるように、正式に記事のトピックに入りましょう。以下では、仮想 DOM と diff アルゴリズムを段階的に実装します。 [関連する推奨事項: vuejs ビデオ チュートリアル ]

仮想 DOM と diff アルゴリズムを簡単に紹介します

まず簡単な例を使用してみましょうVirtual DOMdiff アルゴリズム : たとえば、住宅計画があり、この住宅計画に対して次の変換を行う必要があります。

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

実際、これはオリジナルとの違いを見つけることができる間違い探しゲームに相当します。以下では、相違点を丸で囲みました。

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

さて、どのような変換を行う必要があるかはすでにわかっていますが、どのように変換を行うのでしょうか?最も愚かな方法は、すべて取り壊して、再度建て直すことですが、実際には、取り壊して建て直すことは絶対にありません。これでは、あまりにも非効率的で、あまりにも費用がかかります。変換は確かに完了しましたが、これは最小限の更新ではないため、必要なのは diff です。

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

では、diff とは何でしょうか。実際、diff はコンピュータの最小更新を表すアルゴリズムであり、洗練された比較を実行し、最小量で更新します。このようにすると、コストが比較的小さく、高価ではなく、比較的最適化されていることがわかります。そのため、Vue の最下層に対応することが非常に重要です。
わかりました。Vue に戻ります。上記のフロア プランは、Vue の DOM ノードに相当します。これらのノードを変更 (追加、削除、調整) してから、 最小限の量で DOM を更新する必要があります。 、これによりパフォーマンスのオーバーヘッドが回避されます。

// 原先DOM
<div class="box">
        <h2>标题</h2>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
    </div>
ログイン後にコピー
// 修改后的DOM
<div class="box">
        <h2>标题</h2>
        <span>青峰</span>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
            <li>4</li>
        </ul>
    </div>
ログイン後にコピー

ここでは、diff アルゴリズムを使用して洗練された比較を実行し、更新量を最小限に抑えることができます。 上記で diff が何であるかを理解しましたが、仮想 DOM が何であるかを簡単に理解しましょう。

<div class="box">
        <h2>标题</h2>
        <ul>
            <li>1</li>
            <li>2</li>
            <li>3</li>
        </ul>
    </div>
ログイン後にコピー
{
    sel: "div",
    elm: undefined, // 表示虚拟节点还没有上树
    key: undefined, // 唯一标识
    data: {
        class: { "box" : true}
    },
    children: [
        {
            sel: "h2",
            data: {},
            text: "标题"
        },
        {
            sel: "ul",
            data: {},
            children: [
                { sel: li, data: {}, text: "1"},
                { sel: li, data: {}, text: "2"},
                { sel: li, data: {}, text: "3"}
            ]
        }
    ]
}
ログイン後にコピー

観察すると、virtual DOM は、Contains 内の JavsScript オブジェクト であることがわかります。 sel selectordata datatext text contentchildren subtag など、一度に 1 つのレイヤーずつネストされます。これは 仮想 DOM 構造 を表します。仮想 DOM を処理する方法は、実際の DOM を処理するよりも簡単で効率的であるため、diff アルゴリズム 仮想 DOM Up で発生します。 。
: diff アルゴリズムは仮想 DOM 上で発生します。

为什么需要 Virtual DOM(虚拟DOM)

  • 首先,我们都知道,在前端性能优化的一个秘诀就是尽可能的减少DOM的操作,不仅仅是DOM相对较慢,更是因为变动DOM会造成浏览器的回流和重绘,这些都会降低性能,因此,我们需要虚拟DOM,在patch(比较新旧虚拟DOM更新去更新视图)过程中尽可能的一次性将差异更新到DOM中,这样就保证了DOM不会出现了性能很差的情况。
  • 其次,使用 虚拟DOM 改变了当前的状态不需要立即去更新DOM,而是对更新的内容进行更新,对于没有改变的内容不做任何处理,通过前后两次差异进行比较
  • 最后,也是Virtual DOM 最初的目的,就是更好的跨平台,比如node.js就没有DOM,如果想实现 SSR(服务端渲染),那么一个方式就是借助Virtual DOM,因为 Virtual DOM本身是 JavaScript对象。

h函数(创建虚拟DOM)

作用:h函数 主要用来产生 虚拟节点(vnode)
第一个参数:标签名字、组件的选项对象、函数
第二个参数:标签对应的属性 (可选)
第三个参数:子级虚拟节点,字符串或者是数组形式

 h(&#39;a&#39;,{ props: {href: &#39;http://www.baidu.com&#39;}, &#39;百度&#39;})
ログイン後にコピー

上面的h函数对应的虚拟节点为:

{ sel: &#39;a&#39;, data: { props: {href: &#39;http://www.baidu.com&#39;}}, text: "百度"}
ログイン後にコピー

真正的DOM节点为:

<a href = "http://www.baidu.com">百度</a>
ログイン後にコピー

我们还可以嵌套的使用h函数,比如:

h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;1&#39;),
    h(&#39;li&#39;, {}, &#39;2&#39;),
    h(&#39;li&#39;, {}, &#39;3&#39;),
])
ログイン後にコピー

嵌套使用h函数,就会生成一个虚拟DOM树。

{
            sel: "ul",
            elm: undefined,
            key: undefined,
            data: {},
            children: [
                { sel: li, elm: undefined, key: undefined, data: {}, text: "1"},
                { sel: li, elm: undefined, key: undefined, data: {}, text: "2"},
                { sel: li, elm: undefined, key: undefined, data: {}, text: "3"}
            ]
        }
ログイン後にコピー

好了,上面我们已经知道了h函数是怎么使用的了,下面我们手写一个阉割版的h函数。

手写 h函数

我们手写的这个函数只考虑三种情况(带三个参数),分别如下:

情况①:h(&#39;div&#39;, {}, &#39;文字&#39;)
情况②:h(&#39;div&#39;, {}, [])
情况③:h(&#39;div&#39;, {}, h())
ログイン後にコピー

在手写h函数之前,我们需要声明一个函数,用来创建虚拟节点

// vnode.js 返回虚拟节点
export default function(sel, data, children, text, elm) {
    // sel 选择器 、data 属性、children 子节点、text 文本内容、elm 虚拟节点绑定的真实 DOM 节点
    const key = data.key
    return {
        sel,
        data,
        children,
        text,
        elm,
        key
    }
}
ログイン後にコピー

声明好vnode函数之后,我们正式来手写h函数,思路如下:

  • 判断第三个参数是否是字符串或者是数字。如果是字符串或数字,直接返回 vnode

  • 判断第三个参数是否是一个数组。声明一个数组,用来存储子节点,需要遍历数组,这里需要判断每一项是否是一个对象(因为 vnode 返回一个对象并且一定会有sel属性)但是不需要执行每一项,因为在数组里面已经执行了h函数。其实,并不是函数递归进行调用(自己调自己),而是一层一层的嵌套

  • 判断都三个参数是否是一个对象。直接将这个对象赋值给 children,并会返回 vnode

// h.js h函数
import vnode from "./vnode";
// 情况①:h(&#39;div&#39;, {}, &#39;文字&#39;)
// 情况②:h(&#39;div&#39;, {}, [])
// 情况③:h(&#39;div&#39;, {}, h())
export default function (sel, data, c) {
    // 判断是否传入三个参数
    if (arguments.length !== 3) 
        throw Error(&#39;传入的参数必须是三个参数&#39;) 
    // 判断c的类型 
    if (typeof c === &#39;string&#39; || typeof c === &#39;number&#39;) {
        // 情况①
        return vnode(sel, data, undefined, c, undefined)
    } else if(Array.isArray(c)) {
        // 情况②
        // 遍历
        let children = []
        for(let i = 0; i < c.length; i++) {
            // 子元素必须是h函数
            if (!(typeof c[i] === &#39;object&#39; && c[i].hasOwnProperty(&#39;sel&#39;)))
                throw Error(&#39;数组中有一项不是h函数&#39;)
            // 收集子节点 不需要执行 因为数组里面已经执行h函数来
            children.push(c[i])
        }
        return vnode(sel, data, children, undefined, undefined)
    } else if (typeof c === &#39;object&#39; && c.hasOwnProperty(&#39;sel&#39;)) {
        // 直接将子节点放到children中
        let children = [c]
        return vnode(sel, data, children, undefined, undefined)
    } else {
        throw Error(&#39;传入的参数格式不对&#39;)
    }
}
ログイン後にコピー

通过上面的代码,我们已经实现了一个简单 h函数 的基本功能。

感受 diff 算法

在讲解 diff算法 之前,我们先来感受一下 diff算法 的强大之处。先利用 snabbdom 简单来举一个例子。

import {
    init,
    classModule,
    propsModule,
    styleModule,
    eventListenersModule,
    h,
} from "snabbdom";
//创建出patch函数
const patch = init([
    classModule, 
    propsModule, 
    styleModule, 
    eventListenersModule, 
]);
//让虚拟节点上树
const container = document.getElementById("container");
const btn = document.getElementById("btn");
//创建虚拟节点
const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;)
])
patch(container, myVnode1)
const myVnode2 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;),
    h(&#39;li&#39;, {}, &#39;E&#39;),
])
btn.addEventListener(&#39;click&#39;, () => {
    // 上树
    patch(myVnode1,myVnode2)
})
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

当我们点接改变DOM的时候,发现会新增一个 li标签 内容为 E,单单的点击事件,我们很难看出,是将 旧的虚拟DOM 全部替换掉 新的虚拟DOM,然后再渲染成 真实DOM,还是直接在 旧的虚拟DOM 上直接在后面添加一个节点,所以,在这里我们可以巧妙的打开测试工具,直接将标签内容进行修改,如果点击之后是全部拆除,那么标签的内容就会发生改变,若内容没有发生改变,则是将最后添加的。

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

点击改变 DOM 结构:

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

果然,之前修改的内容没有发生变化,这一点,就可以验证了是进行了 diff算法精细化的比较,以最小量进行更新。 那么问题就来了,如果我在前面添加一个节点呢?是不是也是像在最后添加一样,直接在前面添加一个节点。我们不妨也来试一试看看效果:

...
const container = document.getElementById("container");
const btn = document.getElementById("btn");
//创建虚拟节点
const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;)
])
patch(container, myVnode1)
const myVnode2 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;E&#39;),  // 将E移至前面
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;),
])
btn.addEventListener(&#39;click&#39;, () => {
    // 上树
    patch(myVnode1,myVnode2)
})
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

点击改变 DOM 结构

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

哦豁!!跟我们想的不一样,你会发现,里面的文本内容全部发生了变化,也就是说将之前的 DOM 全部拆除,然后将新的重新上树。这时候,你是不是在怀疑其实 diff算法 没有那么强大,但是你这样想就大错特错了,回想一下在学习 Vue 的过程中,在遍历DOM节点 的时候,是不是特别的强调要写上key唯一标识符,此时,key在这里就发挥了它的作用。 我们带上key再来看一下效果:

...
const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
    h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
    h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
    h(&#39;li&#39;, { key: "D" }, &#39;D&#39;)
])
patch(container, myVnode1)
const myVnode2 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, { key: "E" }, &#39;E&#39;),
    h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
    h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
    h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
    h(&#39;li&#39;, { key: "D" }, &#39;D&#39;),
])
...
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

点击改变 DOM 结构

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

看到上面的结果,此时此刻,你是不是恍然大悟了,顿时知道了key在循环当中有什么作用了吧。我们可以推出的结论一就是: key是当前节点的唯一标识,告诉 diff算法,在更改前后它们是同一个 DOM节点

当我们修改父节点,此时新旧虚拟DOM的父节点不是同一个节点,继续来观察一下 diff算法是如何分析的

const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
    h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
    h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
    h(&#39;li&#39;, { key: "D" }, &#39;D&#39;)
])
patch(container, myVnode1)
const myVnode2 = h(&#39;ol&#39;, {}, [
    h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
    h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
    h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
    h(&#39;li&#39;, { key: "D" }, &#39;D&#39;),
])
ログイン後にコピー

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

点接改变 DOM结构

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

你会发现,这里将旧节点进行了全部的拆除,然后重新将新节点上树。我们可以推出的结论二就是:
只有是同一个虚拟节点diff算法 才进行精细化比较,否则就是暴力删除旧的、插入新的。判断同一个虚拟节点的依据:选择器(sel)相同且key相同。

那么如果是同一个虚拟节点,但是子节点里面不是同一层在比较的呢?

const myVnode1 = h(&#39;div&#39;, {}, [
    h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
    h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
    h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
    h(&#39;li&#39;, { key: "D" }, &#39;D&#39;)
])
patch(container, myVnode1)
const myVnode2 = h(&#39;div&#39;, {}, h(&#39;section&#39;, {},
    [
        h(&#39;li&#39;, { key: "A" }, &#39;A&#39;),
        h(&#39;li&#39;, { key: "B" }, &#39;B&#39;),
        h(&#39;li&#39;, { key: "C" }, &#39;C&#39;),
        h(&#39;li&#39;, { key: "D" }, &#39;D&#39;),
    ]
))
ログイン後にコピー

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

点击改变DOM结构

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

你会发现,此时DOM结构同多了一层 section标签 包裹着,然后,文本的内容也发生了变化,所以我们可以推出结论三
diff算法 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,不进行精细化比较,而是暴力删除旧的、然后插入新的。

综上,我们得出diff算法的三个结论:

  • key 是当前节点的唯一标识,告诉 diff算法,在更改前后它们是同一个 DOM节点

  • 只有是同一个虚拟节点diff算法 才进行精细化比较,否则就是暴力删除旧的、插入新的。判断同一个虚拟节点的依据:选择器(sel)相同 key相同

  • diff算法 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,不进行精细化比较,而是暴力删除旧的、然后插入新的。

看到这里,相信你已经对 diff算法 已经有了很大的收获了。

patch 函数

patch函数 的主要作用就是:判断是否是同一个节点类型,是就在进行精细化对比,不是就进行暴力删除,插入新的
我们在可以简单的画出patch函数现在的主要流程图如下:

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

// patch.js    patch函数
import vnode from "./vnode";
import sameVnode from "./sameVnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
    // 判断oldVnode是否是虚拟节点
    if (oldVnode.sel == &#39;&#39; || oldVnode.sel == undefined) {
        // console.log(&#39;不是虚拟节点&#39;);
        // 创建虚拟DOM
        oldVnode = emptyNodeAt(oldVnode)
    }
    // 判断是否是同一个节点
    if (sameNode(oldVnode, newVnode)) {
        console.log(&#39;是同一个节点&#39;);
    } else {
        // 暴力删除旧节点,插入新的节点
        // 传入两个参数,创建的节点 插入到指定标杆的位置
        createElement(newVnode, oldVnode.elm)
    }
}
// 创建虚拟DOM
function emptyNodeAt (elm) {
    return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm)
}
ログイン後にコピー
ログイン後にコピー

在进行上DOM上树之前,我们需要了解一下DOM中的insertBefore()方法、appendChild()方法,因为,只有你真正的知道它们两者的用法,才会让你在下面手写上树的时候更加的清晰。

appendChild()方法

appendChild() 方法:可以向节点的子节点列表的末尾添加新的子节点。比如:appendChild(newchild)。
注意: appendChild()方法是在父节点中的子节点的末尾添加新的节点。(相对于父节点来说)。

<body>
    <div class="box">
        <span>青峰</span>
        <ul>
            <li>1</li>
            <li>2</li>
        </ul>
    </div>
    <script>
        const box = document.querySelector(&#39;.box&#39;)
        const appendDom = document.createElement(&#39;div&#39;)
        appendDom.style.backgroundColor = &#39;blue&#39;
        appendDom.style.height = 100 + &#39;px&#39;
        appendDom.style.width = 100 + &#39;px&#39;
        // 在box里面的末尾追加一个div
        box.appendChild(appendDom)
    </script>
</body>
ログイン後にコピー

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

你会发现,创建的div是嵌套在box里面的,div 属于 box 的子节点,box 是 div 的子节点。

insertBefore()方法

insertBefore() 方法:可在已有的字节点前中插入一个新的子节点。比如:insertBefore(newchild,rechild)。
注意: insertBefore()方法是在已有的节点前添加新的节点。(相对于子节点来说的)。

<body>
    <div class="box">
        <span>青峰</span>
        <ul>
            <li>1</li>
            <li>2</li>
        </ul>
    </div>
    <script>
        const box = document.querySelector(&#39;.box&#39;)
        const insertDom = document.createElement(&#39;p&#39;)
        insertDom.innerText = &#39;我是insertDOM&#39;
        // 在body中 box前面添加新的节点
        box.parentNode.insertBefore(insertDom, box)
    </script>
</body>
ログイン後にコピー

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

我们发现,box 和 div 是同一层的,属于兄弟节点。

处理不同节点

sameVnode 函数

作用:比较两个节点是否是同一个节点

// sameVnode.js
export default function sameVnode(oldVnode, newVnode) {
    return (oldVnode.data ? oldVnode.data.key : undefined) === (newVnode.data ? newVnode.data.key : undefined) && oldVnode.sel == newVnode.sel
}
ログイン後にコピー

手写第一次上树

理解了上面的 appendChild()方法insertBefore()方法之后,我们正式开始让 真实DOM 上树,渲染页面。

// patch.js    patch函数
import vnode from "./vnode";
import sameVnode from "./sameVnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
    // 判断oldVnode是否是虚拟节点
    if (oldVnode.sel == &#39;&#39; || oldVnode.sel == undefined) {
        // console.log(&#39;不是虚拟节点&#39;);
        // 创建虚拟DOM
        oldVnode = emptyNodeAt(oldVnode)
    }
    // 判断是否是同一个节点
    if (sameNode(oldVnode, newVnode)) {
        console.log(&#39;是同一个节点&#39;);
    } else {
        // 暴力删除旧节点,插入新的节点
        // 传入两个参数,创建的节点 插入到指定标杆的位置
        createElement(newVnode, oldVnode.elm)
    }
}
// 创建虚拟DOM
function emptyNodeAt (elm) {
    return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm)
}
ログイン後にコピー
ログイン後にコピー

上面我们已经明确的知道,patch的作用就是判断是否是同一个节点,所以,我们需要声明一个createElement函数,用来创建真实DOM。

createElement 函数

createElement主要用来 创建子节点的真实DOM。

// createElement.js
export default function createElement(vnode,pivot) {
    // 创建上树的节点
    let domNode = document.createElement(vnode.sel)
    // 判断有文本内容还是子节点
    if (vnode.text != &#39;&#39; && (vnode.children == undefined || vnode.children.length == 0)) {
        // 文本内容 直接赋值
        domNode.innerText = vnode.text
        // 上树 往body上添加节点
        // insertBefore() 方法:可在已有的字节点前中插入一个新的子节点。相对于子节点来说的
        pivot.parentNode.insertBefore(domNode, pivot)
    } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
        // 有子节点
    }
}
ログイン後にコピー
// index.js
import patch from "./mysnabbdom/patch";
import h from &#39;./mysnabbdom/h&#39;
const container = document.getElementById("container");
//创建虚拟节点
const myVnode1 = h(&#39;h1&#39;, {}, &#39;文字&#39;)
patch(container, myVnode1)
ログイン後にコピー

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

我们已经将创建的真实DOM成功的渲染到页面上去了,但是这只是实现了最简单的一种情况,那就是 h函数 第三个参数是字符串的情况,所以,当第三个参数是一个数组的时候,是无法进行上树的,下面我们需要将 createElement函数 再进一步的优化,实现递归上树。

递归创建子节点

我们发现,在第一次上树的时候,createElement函数 有两个参数,分别是:newVnode (新的虚拟DOM),标杆(用来上树插入到某个节点的位置),在createElement内部 我们是使用 insertBefore()方法 进行上树的,使用这个方法我们需要知道已有的节点是哪一个,当然,当有 text(第三个参数是字符串或数字)的时候,我们是可以找到要插入的位置的,但是当有 children(子节点)的时候,我们是无法确定标杆的位置的,所以,我们要将上树的工作放到 patch函数 中,即 createElement函数只负责创建节点

// index.js
import patch from "./mysnabbdom/patch";
import h from &#39;./mysnabbdom/h&#39;
const container = document.getElementById("container");
//创建虚拟节点
const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;)
])
patch(container, myVnode1)
ログイン後にコピー
// patch.js
import vnode from "./vnode";
import sameVnode from "./sameVnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
    // 判断oldVnode是否是虚拟节点
    if (oldVnode.sel == &#39;&#39; || oldVnode.sel == undefined) {
        // console.log(&#39;不是虚拟节点&#39;);
        // 创建虚拟DOM
        oldVnode = emptyNodeAt(oldVnode)
    }
    // 判断是否是同一个节点
    if (sameNode(oldVnode, newVnode)) {
        console.log(&#39;是同一个节点&#39;);
    } else {
        // 暴力删除旧节点,插入新的节点
        // 传入参数为创建的虚拟DOM节点  返回以一个真实DOM
        let newVnodeElm = createElement(newVnode)
        console.log(newVnodeElm);
        // oldVnode.elm.parentNode 为body 在body中 在旧节点的前面添加新的节点
        if (oldVnode.elm.parentNode && oldVnode.elm) {
            oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
        }
        // 删除老节点
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}
// 创建虚拟DOM
function emptyNodeAt (elm) {
    return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm)
}
ログイン後にコピー

完善 createElement 函数

//  createElement.js只负责创建真正节点 
export default function createElement(vnode) {
    // 创建上树的节点
    let domNode = document.createElement(vnode.sel)
    // 判断有文本内容还是子节点
    if (vnode.text != &#39;&#39; && (vnode.children == undefined || vnode.children.length == 0)) {
        // 文本内容 直接赋值
        domNode.innerText = vnode.text
        // 上树 往body上添加节点
        // insertBefore() 方法:可在已有的字节点前中插入一个新的子节点。相对于子节点来说的
    } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
        // 有子节点
        for(let i = 0; i < vnode.children.length; i++) {
            // console.log(vnode.children[i]);
            let ch = vnode.children[i]
            // 进行递归 一旦调用createElement意味着 创建了DOM 并且elm属性指向了创建好的DOM
            let chDom = createElement(ch)
            // 添加节点 使用appendChild 因为遍历下一个之前 上一个真实DOM(这里的domVnode)已经生成了 所以可以使用appendChild
            domNode.appendChild(chDom)
        }
    }
    vnode.elm = domNode
    return vnode.elm
}
ログイン後にコピー

经过上面的分析,我们已经完成了对createElem函数的完善,可能你对这个递归有点不了解,那么大概捋一下进行的过程:

  • 首先,一开始的这个 新的虚拟DOM的sel 属性为 ul,创建的真实DOM节点为 ul,执行 createElement函数 发现,新的虚拟DOM里面有children属性,children 属性里面又包含 h函数。
  • 其次,进入到for循环中,拿到 children 中的第一项,然后再次 调用crateElement函数 创建真实DOM,上面第一次调用createElement的时候已经创建了ul,执行完第一项返回创建的虚拟DOM,然后使用 appendChild方法()追加到 ul中,依次类推,执行后面的数组项。
  • 最后,将创建好的 所有真实DOM 返回出去,在 patch函数 中上树。

执行上面的代码,测试结果如下:

1Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

完美!我们成功的将递归子节点完成了,无论嵌套多少层,我们都可以通过递归将子节点渲染到页面上。

前面,我们实现了不是同一个节点的时候,进行删除旧节点和插入新节点的操作,下面,我们来实现是相同节点时的相关操作,这也是文章中最重要的部分,diff算法 就包含在其中!!!

处理相同节点

上面的 patch函数 流程图中,我们已经处理了不同节点的时候,进行暴力删除旧的节点,然后插入新的节点,现在我们进行处理相同节点的时候,进行精细化的比较,继续完善 patch函数 的主流程图:

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

看到上面的流程图,你可能会有点疑惑,为什么不在 newVnode 是否有 Text属性 中继续判断 oldVnode 是否有 children 属性而是直接判断两者之间的 Text 是否相同,这里需要提及一个知识点,当我们进行 DOM操作的时候,文本内容替换DOM的时候,会自动将DOM结构全部销毁掉,innerText改变了,DOM结构也会随之被销毁,所以这里可以不用判断 oldVnode 是否存在 children 属性,如果插入DOM节点,此时的Text内容并不会被销毁掉,所以我们需要手动的删除。这也是为什么在流程图后面,我们添加 newVnode 的children 的时候需要将 oldVnode 的 Text 手动删除,而将 newVnode 的 Text 直接赋值oldVnode.elm.innerText 的原因。
知道上面流程图是如何工作了,我们继续来书写patch函数中是同一个节点的代码。

// patch.js
import vnode from "./vnode";
import sameVnode from "./sameVnode";
import createElement from "./createElement";
export default function (oldVnode, newVnode) {
    // 判断oldVnode是否是虚拟节点
    if (oldVnode.sel == &#39;&#39; || oldVnode.sel == undefined) {
        // console.log(&#39;不是虚拟节点&#39;);
        // 创建虚拟DOM
        oldVnode = emptyNodeAt(oldVnode)
    }
    // 判断是否是同一个节点
    if (sameNode(oldVnode, newVnode)) {
        console.log(&#39;是同一个节点&#39;);
        // 是否是同一个对象
        if (oldVnode === newVnode) return
        // newVnode是否有text
        if (newVnode.text && (newVnode.children == undefined || newVnode.children.length == 0)) {
            // 判断newVnode和oldVnode的text是否相同
            if (!(newVnode.text === oldVnode.text)) {
                // 直接将text赋值给oldVnode.elm.innerText 这里会自动销毁oldVnode的cjildred的DOM结构
                oldVnode.elm.innerText = newVnode.text
            }
            // 意味着newVnode有children
        } else {
            // oldVnode是否有children属性
            if (oldVnode.children != undefined && oldVnode.children.length > 0) {
                // oldVnode有children属性
            } else {
                // oldVnode没有children属性
                // 手动删除 oldVnode的text
                oldVnode.elm.innerText = &#39;&#39;
                // 遍历
                for (let i = 0; i < newVnode.children.length; i++) {
                    let dom = createElement(newVnode.children[i])
                    // 追加到oldvnode.elm中
                    oldVnode.elm.appendChild(dom)
                }
            }
        }
    } else {
        // 暴力删除旧节点,插入新的节点
        // 传入参数为创建的虚拟DOM节点  返回以一个真实DOM
        let newVnodeElm = createElement(newVnode)
        console.log(newVnodeElm);
        // oldVnode.elm.parentNode 为body 在body中 在旧节点的前面添加新的节点
        if (oldVnode.elm.parentNode && oldVnode.elm) {
            oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
        }
        // 删除老节点
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)
    }
}
// 创建虚拟DOM
function emptyNodeAt(elm) {
    return vnode(elm.tagName.toLowerCase(), {}, [], undefined, elm)
}
ログイン後にコピー
....
//创建虚拟节点
const myVnode1 = h(&#39;ul&#39;, {}, &#39;oldVnode有text&#39;)

patch(container, myVnode1)
const myVnode2 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;)
])
btn.addEventListener(&#39;click&#39;, () => {
    patch(myVnode1, myVnode2)
})
ログイン後にコピー

oldVnode 有 tex属性 和 newVnode 有 children属性 的效果如下:

2Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

2Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

...
//创建虚拟节点
const myVnode1 = h(&#39;ul&#39;, {}, [
    h(&#39;li&#39;, {}, &#39;A&#39;),
    h(&#39;li&#39;, {}, &#39;B&#39;),
    h(&#39;li&#39;, {}, &#39;C&#39;),
    h(&#39;li&#39;, {}, &#39;D&#39;)
])

patch(container, myVnode1)
const myVnode2 = h(&#39;ul&#39;, {}, &#39;newVnode 有text&#39;)
btn.addEventListener(&#39;click&#39;, () => {
    patch(myVnode1, myVnode2)
})
ログイン後にコピー

oldVode 有children属性 和 newVnode 有 text属性 的效果如下:

2Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

2Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

完美!现在我们就只差最后diff算了。

patchVnode 函数

在patch函数中,我们需要将同同一节点的比较分成一个单独的模块patchVnode函数,方便我们在diff算法中进行递归运算。

patchVnode函数的主要作用就是:

  • 判断newVnodeoldVnode是否指向同一个对象,如果是,那么直接return

  • 如果他们都有text并且不相等 或者 oldVnode有子节点而newVnode没有,那么将oldVnode.elm的文本节点设置为newVnode的文本节点。

  • 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到oldVnode.elm后面,然后删除 oldVnode.elmtext

  • 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要

// patchVnode.js
export default function patchVnode(oldVnode, newVnode) {
    // 是否是同一个对象
    if (oldVnode === newVnode) return
    // newVnode是否有text
    if (newVnode.text && (newVnode.children == undefined || newVnode.children.length == 0)) {
        // 判断newVnode和oldVnode的text是否相同
        if (!(newVnode.text === oldVnode.text)) {
            // 直接将text赋值给oldVnode.elm.innerText 这里会自动销毁oldVnode的cjildred的DOM结构
            oldVnode.elm.innerText = newVnode.text
        }
        //说明 newVnode有 children
    } else {
        // oldVnode是否有children属性
        if (oldVnode.children != undefined && oldVnode.children.length > 0) {
            // oldVnode有children属性
        } else {
            // oldVnode没有children属性
            // 手动删除 oldVnode的text
            oldVnode.elm.innerText = &#39;&#39;
            // 遍历
            for (let i = 0; i < newVnode.children.length; i++) {
                let dom = createElement(newVnode.children[i])
                // 追加到oldvnode.elm中
                oldVnode.elm.appendChild(dom)
            }
        }
    }
}
ログイン後にコピー

diff算法

精细化比较:diff算法 四种优化策略这里使用双指针的形式进行diff算法的比较,分别是旧前、旧后、新前、新后指针,(前指针往下移动,后指针往上移动

四种优化策略:(命中:key 和 sel 都要相同)

  • ①、新前与旧前
  • ②、新后与旧后
  • ③、新后与旧前
  • ④、新前与旧后

注意: 当只有第一种不命中的时候才会采取第二种,依次类推,如果四种都不命中,则需要通过循环来查找。 命中指针才会移动,否则不移动。

①、新前与旧前
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する 如果就旧节点先循环完毕,说明需要新节点中有需要插入的节点

②、新后与旧后
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

如果新节点先循环完毕,旧节点还有剩余节点,说明旧节点中有需要删除的节点。

多删除情况:Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する当只有情况①命中,剩下三种都没有命中,则需要进行循环遍历,找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。删除的节点为旧前与旧后之间(包含旧前、旧后)。

③、新后与旧前
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する③新后与旧前命中的时候,此时要移动节点,移动 新后指向的节点到旧节点的 旧后的后面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined

④、新前与旧后
ログイン後にコピー

Vue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解する

④新前与旧后命中的时候,此时要移动节点,移动新前指向的这个节点到旧节点的 旧前的前面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined

好了,上面通过动态讲解的四种命中方式之后,动态gif图片有水印,看着可能不是很舒服,但当然能够理解是最重要的,那么我们开始手写 diff算法 的代码。

updateChildren 函数

updateChildren()方法 主要作用就是进行精细化比较,然后更新子节点。这里代码比较多,需要耐心的阅读。

import createElement from "./createElement";
import patchVnode from "./patchVnode";
import sameVnode from "./sameVnode";
export default function updateChildren(parentElm, oldCh, newCh) {
    //parentElm 父节点位置 用来移动节点 oldCh旧节点children newCh新节点children
    // console.log(parentElm, oldCh, newCh);
    // 旧前
    let oldStartIndex = 0
    // 旧后
    let oldEndIndex = oldCh.length - 1
    // 新前
    let newStartIndex = 0
    // 旧后
    let newEndIndex = newCh.length - 1
    // 旧前节点
    let oldStartVnode = oldCh[0]
    // 旧后节点
    let oldEndVnode = oldCh[oldEndIndex]
    // 新前节点
    let newStartVnode = newCh[0]
    // 新后节点
    let newEndVnode = newCh[newEndIndex]
    // 存储mapkey
    let keyMap
    // 循环 条件 旧前 <= 旧后 && 新前 <= 新后
    while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
        // 首先需要判断是否已经处理过了 
        if (oldCh[oldStartIndex] == undefined) {
            oldStartVnode = oldCh[++oldStartIndex]
        } else if (oldCh[oldStartIndex] == undefined) {
            oldEndVnode = oldCh[--oldEndIndex]
        } else if (newCh[newStartIndex] == undefined) {
            newStartVnode = newCh[++newStartIndex]
        } else if (newCh[newEndIndex] == undefined) {
            newEndVnode = newCh[--newEndIndex]
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            // ①、新前与旧前命中
            console.log(&#39;①、新前与旧前命中&#39;);
            //调用 patchVnode 对比两个节点的 对象 文本 children
            patchVnode(oldStartVnode, newStartVnode)
            // 指针下移改变节点
            oldStartVnode = oldCh[++oldStartIndex]
            newStartVnode = newCh[++newStartIndex]

        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            // ②、新后与旧后命中
            console.log(&#39;②、新后与旧后命中&#39;);
            //调用 patchVnode 对比两个节点的 对象 文本 children
            patchVnode(oldStartVnode, newStartVnode)
            // 指针下移并改变节点
            oldEndVnode = oldCh[--oldEndIndex]
            newEndVnode = newCh[--newEndIndex]
        } else if (sameVnode(oldStartVnode, newEndVnode)) {
            // ③、新后与旧前命中
            patchVnode(oldStartVnode, newEndVnode)
            console.log(newEndVnode);
            // 移动节点 当③新后与旧前命中的时候,此时要移动节点,
            // 移动 新后(旧前两者指向的是同一节点) 指向的节点到旧节点的 旧后的后面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
            // 在上面动画中 命中③是在旧节点的后面插入的 所以使用nextSibling
            // 指针下移并改变节点
            oldStartVnode = oldCh[++oldStartIndex]
            newEndVnode = newCh[--newEndIndex]
        } else if (sameVnode(oldEndVnode, newStartVnode)) {
            // ④、新前与旧后命中
            patchVnode(oldEndVnode, newStartVnode)
            // 移动节点
            // 当`④新前与旧后`命中的时候,此时要移动节点,移动`新前(旧后指向同一个节点)`指向的这个节点到旧节点的 `旧前的前面`,
            //并且找到`旧节点中对应的节点`,然后在`旧的虚拟节点中将这个节点设置为undefined`
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
            //指针下移并改变节点
            oldEndVnode = oldCh[--oldEndIndex]
            newStartVnode = newCh[++newStartIndex]
        } else {
            // 四种都没有命中
            console.log(11);
            //kepMap作为缓存不用每次遍历对象
            if (!keyMap) {
                keyMap = {}
                // 遍历旧的节点
                for (let i = oldStartIndex; i <= oldEndIndex; i++) {
                    // 获取旧节点的key
                    const key = oldCh[i].data.key
                    if (key != undefined) {
                        //key不为空 并且将key存放到keyMap对象中
                        keyMap[key] = i
                    }
                }
            }
            // 取出newCh中的的key 并找出在keyMap中的位置 并映射到oldCh中
            const oldIndex = keyMap[newStartVnode.key]
            if (oldIndex == undefined) {
                // 新增
                console.log(111);
                parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
            } else {
                // 移动位置
                // 取出需要移动的项
                const elmToMove = oldCh[oldIndex]
                // 判断是选择器是否一样
                patchVnode(elmToMove, newStartVnode)
                // 标记已经处理过了
                oldCh[oldIndex] = undefined
                // 移动节点 移动到旧前前面 因为旧前与旧后之间要被删除
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
            }
            // 只移动新的节点
            newStartVnode = newCh[++newStartIndex]
        }
    }
    //循环结束 还有剩余节点没处理
    if (newStartIndex <= newEndIndex) {
        //说明 新节点还有未处理的节点,意味着需要添加节点
        console.log(&#39;新增节点&#39;);
        // 创建标杆 
        console.log(newCh[newEndIndex + 1]);
        // 节点插入的标杆位置 官方源码的写法 但是我们写代码新的虚拟节点中,elm设置了undefined 所以这里永远都是会在后面插入 小bug
        // let before = newCh[newEndIndex + 1] == null ? null : newCh[newEndIndex + 1].elm
        // 若不想出现bug 可以在插入节点中直接oldCh[oldStartIndex].elm 但是也会出现不一样的bug 所以重在学习思路
        for (let i = newStartIndex; i <= newEndIndex; i++) {
            // 插入节点 因为旧节点遍历完之后 新节点还有剩余节点 这里需要使用crateElement函数新建一个真实DOM节点
            // insertBefore会自动识别null
            parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIndex].elm)
        }
    } else if (oldStartIndex <= oldEndIndex) {
        //说明旧节点还有剩余节点还没有处理 意味着需要删除节点
        console.log(&#39;删除节点&#39;);
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            if(oldCh[i]) parentElm.removeChild(oldCh[i].elm)
        }
    }
}
ログイン後にコピー

好了,以上就是 Vue2中 虚拟DO M和 diff算法 的阉割版代码,可能上面代码中有些许bug存在,但是这并不会影响你对diff算法的理解,只有你细心品味,肯定会有所收获的!!!  最后淡淡我自己对虚拟DOM和diff算法的理解

我对Vue中虚拟DOM和diff算法的理解

在Javascript中,渲染 真实DOM 的开销是非常大的,比如我们修改了某个数据,如果直接渲染到 真实DOM,会引起整个 DOM树 的 回流和重绘。那么有没有可能实现只更新我们修改的那一小块DOM而不会引起整个DOM更新?此时我们就需要先根据 真实DOM 生成 虚拟DOM ,当 虚拟DOM 某个节点的数据改变后会生成一个 新的Vnode,然后 新的Vnode旧的Vnodde 进行比较,发现有不一样的地方就直接修改到 真实DOM 上,然后使 旧的Vnode 的值变成 新的Vnode

diff算法 的过程就是 patch函数 的调用,比较新旧节点,一边比较一边给 真实的DOM 打补丁。在采用 diff算法 比较新旧节点的时候,只会进行同层级的比较。在 patch方法 中,首先比较新旧虚拟节点是否是同一个节点,如果不是同一个节点,那么就会将旧的节点删除掉,插入新的虚拟节点,然后再使用 createElement函数 创建 真实DOM,渲染到真实的 DOM树。如果是同一个节点,使用 patchVnode函数 比较新旧节点,包括属性更新、文本更新、子节点更新,新旧节点均有子节点,则需要进行 diff算法,调用updateChildren方法,如果新节点没有文本内容而旧节点有文本内容,则需要将旧节点的文本删除,然后再增加子节点,如果新节点有文本内容,则直接替换旧节点的文本内容。

updateChildren方法 将新旧节点的子节点都提取出来,然后使用的是 双指针 的方式进行四种优化策略循环比较。分别是:①、新前与旧前比较  ②、新后与旧后比较  ③、新后与旧前比较  ④、新前与旧后比较。如果四种优化策略方法均没有命中,则会进行遍历方法进行比较(源码中使用了Map对象进行了缓存,加快了比较的速率),如果设置了 key,就会使用key进行比较,找到当前的新节点的子节点在 Map 中的映射位置,如果不存在,则需要添加节点存在则需要移动节点。最后,循环结束之后,如果新节点还有剩余节点,则说明需要添加节点,如果旧节点还有剩余节点,则说明需要删除节点

以上,就是我对Vue2中的 虚拟DOM 和 diff算法 的理解,希望读完这篇文章对你理解Vue2中的虚拟DOM和diff算法有所帮助!!最后希望各位大佬能够给个赞!!!!

(学習ビデオ共有: vuejs チュートリアル Web フロントエンド )

以上がVue の仮想 DOM と Diff アルゴリズムを 1 つの記事で徹底的に理解するの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

関連ラベル:
ソース:juejin.cn
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
最新の問題
人気のチュートリアル
詳細>
関連するチュートリアル
人気のおすすめ
最新のコース
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート