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

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

當數據更新的時候,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 屬性就更新 dom 的 text 即可。

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