Vue2 剝絲抽繭 - 虛擬 dom 之更新

虛擬 dom 簡介虛擬 dom 之綁定事件 中我們將虛擬 dom 轉換爲了真實 dom 的結構,介紹了 domclassstyle 、綁定事件的過程。

當數據更新的時候,vue 會重新觸發 render ,此時會通過新的 vdom來更新視圖。

新的 vdom 結構可能發生改變,就涉及到 dom 的新建、刪除和移動,這篇文章先假設更新的 dom 結構沒有變化,我們來過一下整體更新的過程。

dom 結構

不管是虛擬 dom,還是真實 dom,都可以看成一個樹結構。

對應的 render 函數如下:

render(createElement) {
  return createElement(
    "div",
    [
      createElement("div"[
        createElement("div"{}"left"),
        "hello",
      ]),
      createElement("span"{}"right"),
    ]
  );
},

生成的 vnode 如下:

{
    "tag""div",
    "children"[
        {
            "tag""div",
            "children"[
                {
                    "tag""div",
                    "children"[
                        {
                            "text""left",
                        }
                    ],
                },
                {
                    "text""hello",
                }
            ],
        },
        {
            "tag""span",
            "data"{},
            "children"[
                {
                    "text""right",
                }
            ],
        }
    ],
}

渲染的 dom 如下:

假設新的 vnode 結構沒有改變,只是 text 進行了更新:

{
    "tag""div",
    "children"[
        {
            "tag""div",
            "children"[
                {
                    "tag""div",
                    "children"[
                        {
                            "text""leftupdate",
                        }
                    ],
                },
                {
                    "text""hello",
                }
            ],
        },
        {
            "tag""span",
            "data"{},
            "children"[
                {
                    "text""rightupdate",
                }
            ],
        }
    ],
}

我們只需要同時遍歷這兩個 vdom ,如果有 tag 屬性就遞歸它們的 children ,如果只有 text 屬性就更新 domtext 即可。

function patchVnode (
oldVnode,
 vnode,
) {
  const elm = vnode.elm = oldVnode.elm // 拿到對應的 dom
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isUndef(vnode.text)) { // 如果沒有 text 屬性,遞歸遍歷 children
    for(let i = 0; i < oldch.length; i++) {
      patchVnode(oldch[i], ch[i])
    }
    // 如果有 text 屬性,說明是 text 節點
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text) // 更新 text
  }
}

上邊就是更新的核心邏輯了,本質上就是對樹的一個深度優先遍歷,下邊我們繼續完善一些細節。

引入響應式

爲了測試數據更新自動更新頁面,相比於 Vue2 剝絲抽繭 - 虛擬 dom 之綁定事件 的測試程序,我們將上一篇章介紹的 響應式系統 引入,當點擊的時候我們修改 data 的數據,然後自動觸發頁面的 update

import * as nodeOps from "./node-ops";
import modules from "./modules";
import { createPatchFunction } from "./patch";
import { createElement } from "./create-element";
import { observe } from "./observer/reactive";
import Watcher from "./observer/watcher";
const options = {
    el: "#root",
    data: {
        selected: 1,
    },
    render(createElement) {
        const vnode = createElement(
            "div",
            {
                on: {
                    click: () ={
                        this.selected = 3;
                    },
                },
            },
            [
                createElement("div"[
                    createElement("div"{}, this.selected + "left"), // 使用 data 數據
                    "hello",
                ]),
                createElement("span"{}"right"),
            ]
        );
        return vnode;
    },
};

const _render = function () {
    const vnode = options.render.call(options.data, createElement);
    return vnode;
};
let $el = document.querySelector(options.el);

const __patch__ = createPatchFunction({ nodeOps, modules });
const _update = (vnode) ={
    $el = __patch__($el, vnode);
};

observe(options.data); // 將數據變爲響應式

new Watcher(options.data, () => _update(_render())); // 創建 Watcher

這樣當我們點擊頁面的時候,頁面就會自動刷新了,selected 的值從 1 變成了 3

但因爲我們並沒有寫更新 dom 的代碼,此時相當於是用新的 vnode 生成了新 dom 然後直接代替了原 dom

在創建 dom 代碼打個斷點來看一下:

下邊來完善下當 vnode 結構不變情況下 dom 的更新代碼。

更新代碼

看一下我們原來的 _update 方法:

const _update = (vnode) ={
    $el = __patch__($el, vnode);
};

因爲之前第一次創建 dom 的時候還沒有舊的 vdom,所以我們直接傳了 $el ,但當第二次更新的時候已經有了 oldvode ,我們第一個參數應該把舊的 vnode 傳入。

const vm = {};
vm.$el = document.querySelector(options.el);
const _update = (vnode) ={
    const prevVnode = vm._vnode;
    vm._vnode = vnode;
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
        // initial render
        vm.$el = __patch__(vm.$el, vnode);
    } else {
        // updates
        vm.$el = __patch__(prevVnode, vnode);
    }
};

上邊模擬一個 vm 對象,將 $el 掛到 vm 對象中,同時用 vm._vnode 存儲 vnode ,這樣下一次更新的時候 vm._vnode 就代表的是舊的 vnode 了。

接下來完善 createPatchFunction 返回的 __patch__ 方法:

return function patch(oldVnode, vnode) {
  const isRealElement = isDef(oldVnode.nodeType);
  if (!isRealElement && sameVnode(oldVnode, vnode)) {
    // 通過新舊 vnode 進行更新
    patchVnode(oldVnode, vnode);
  } else {
    // vnode 發生改變或者是第一次渲染
    if (isRealElement) {
      // either not server-rendered, or hydration failed.
      // create an empty node and replace it
      oldVnode = emptyNodeAt(oldVnode);
    }
    // replacing existing element
    const oldElm = oldVnode.elm;
    const parentElm = nodeOps.parentNode(oldElm);

    // create new node
    createElm(vnode, parentElm, nodeOps.nextSibling(oldElm));

    removeVnodes([oldVnode], 0, 0);
  }

  return vnode.elm;
};

上邊的 else 分支中的代碼是 虛擬 dom 之綁定事件 中我們介紹的邏輯。

if 中判斷它不是真實 dom 並且當前的 vnode 沒有改變,然後就調用 pathVnode 方法來更新 dom

其中的 sameVnode 我們僅簡單判斷:

// vue 源碼中的 sameVnode 判斷的比較多,這裏我們僅簡單理解爲 key、tag 一致,並且 data 屬性還存在即可
function sameVnode(a, b) {
    return (
        a.key === b.key && a.tag === b.tag && isDef(a.data) === isDef(b.data)
    );
}

接着看一下 patchVnode 的實現:

function isPatchable(vnode) {
  return isDef(vnode.tag);
}
function patchVnode(oldVnode, vnode) {
  if (oldVnode === vnode) {
    return;
  }

  const elm = (vnode.elm = oldVnode.elm);
  const oldCh = oldVnode.children;
  const ch = vnode.children;
  const data = vnode.data;
  if (isDef(data) && isPatchable(vnode)) {
    for (i = 0; i < cbs.update.length; ++i)
      cbs.update[i](oldVnode, vnode);
  }
  if (isUndef(vnode.text)) { // 不是 text 節點 更新children
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch) updateChildren(elm, oldCh, ch);
    } else if (isDef(oldVnode.text)) {
      // 更新成了空字符
      nodeOps.setTextContent(elm, "");
    }
  } else if (oldVnode.text !== vnode.text) {
    // text 節點
    nodeOps.setTextContent(elm, vnode.text);
  }
}

這就是文章最開始講的那段邏輯了,不是 text 節點就更新 children,如果是 text 節點就直接更新 dom 的文本內容。

除此之外,創建 dom 的時候在  虛擬 dom 之綁定事件  我們調用了 cbs.create ,這裏我們調用 cbs.update 來更新 dom 的屬性。

因爲這篇文章我們只考慮 dom 整個結構沒有發生變化的情況,所以我們 updateChilden 簡單的實現爲一個循環即可。

function updateChildren(elm, oldCh, ch) {
  for (let i = 0; i < oldCh.length; i++) {
    patchVnode(oldCh[i], ch[i]);
  }
}

以上就是 dom 更新的整個過程了。

測試

import * as nodeOps from "./node-ops";
import modules from "./modules";
import { createPatchFunction } from "./patch";
import { createElement } from "./create-element";
import { observe } from "./observer/reactive";
import Watcher from "./observer/watcher";
const options = {
    el: "#root",
    data: {
        selected: 1,
    },
    render(createElement) {
        const vnode = createElement(
            "div",
            {
                on: {
                    click: () ={
                        this.selected = 3;
                    },
                },
            },
            [
                createElement("div"[
                    createElement("div"{}, this.selected + "left"),
                    "hello",
                ]),
                createElement("span"{}"right"),
            ]
        );
        return vnode;
    },
};

const _render = function () {
    const vnode = options.render.call(options.data, createElement);
    return vnode;
};

const __patch__ = createPatchFunction({ nodeOps, modules });

const vm = {};
vm.$el = document.querySelector(options.el);
const _update = (vnode) ={
    const prevVnode = vm._vnode;
    vm._vnode = vnode;
    // Vue.prototype.__patch__ is injected in entry points
    // based on the rendering backend used.
    if (!prevVnode) {
        // initial render
        vm.$el = __patch__(vm.$el, vnode);
    } else {
        // updates
        vm.$el = __patch__(prevVnode, vnode);
    }
};

observe(options.data);

new Watcher(options.data, () => _update(_render()));

視圖肯定會更新,我們來看一下是刪除原有 dom 插入新 dom ,還是直接在原有 dom 上進行的更新:

可以看到代碼走到了我們的 patchVnode 中,複用了原有 dom 進行更新。

這篇文章主要是加深對虛擬 dom 結構的瞭解,然後通過深度優先遍歷對虛擬 dom 樹進行遍歷,因爲我們假設了 dom 樹的結構沒有發生變化,所以遍歷過程中直接進行節點的更新即可。

如果 dom 樹發生了變化,爲了儘可能的複用原有 dom ,就會涉及到 diff 算法了,接下來幾篇文章會講到。

本文相應源碼詳見網站:vue.windliang.wang

windliang 前端,生活,成長

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/m9Xmtvw-TCLwfFn_qbU6wA