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