环境准备
- 下载 && 安装 && 启动
sh
git clone https://github.com/liaohui5/vue-diff-demo
cd vue-diff-demo
npm install
npm run dev
# 打开浏览器访问: http://localhost:9527
- 目录结构
sh
.
├── LICENSE
├── index.html
├── js
│ ├── diff.js # diff 比对算法主要功能实现
│ ├── index.js # 导入并使用 diff/patch/vnode 查看效果
│ ├── patch.js # 给真实dom打补丁(算法比对后的结果)
│ └── vnode.js # 类似 vue render 的 h 函数, 创建虚拟节点, 将虚拟节点转真实节点等功能
├── package-lock.json
└── package.json
virtual DOM & real DOM
vDOM : virtual DOM - 虚拟DOM(描述真实DOM的一个对象)
vNode : virtual Node - 虚拟节点(描述真实节点的一个对象)
vNodeUid : virtual Node index - 虚拟节点遍历时标记 VNode 的标记
rDOM : real DOM - 真实的 HTMLElement 元素
rNode : real Node - 真实的 HTMLElement 节点
rNodeUid : real Node index - 遍历真实的 HTMLElement节点 的标记
patchPackMap : virtual Node patch - 虚拟节点补丁Map
源码实现
https://github.com/liaohui5/vue-diff-demo
测试步骤
- createVNode(h) 函数类似 Vue 的 h 函数, 传入3个参数(标签名, 属性集合, 子节点)
- createVNode(h) 函数执行后返回一个 VirtualDOM
- createRNode 函数可以把 VirtualDOM 转化为真实的 DOM 元素
- mount 函数可以把 创建出来的 DOM 元素挂载页面指定的元素中
模拟内容更新后: 比较新老VirtualDOM差异, 然后给rNode打上补丁
- diff: 比较新老VirtualDOM差异获得 patches
- patch: 根据获得的 patches 去更新 realDOM, 更新UI
html
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-cn">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>Vue Diff 算法简单实现</title>
</head>
<body>
<div id="app"></div>
<!-- javascript -->
<script type="module" src="./js/index.js"></script>
</body>
</html>
js
// js/index.js
import { h, render, mount } from "./vnode";
import { diff } from "./diff";
import { patch } from "./patch";
// 修改前的 virtualDOM
const oldVDom = h(
"ul",
{
class: "list",
style: "width: 300px; height: 300px; border: 1px solid #f00; color: #555;",
},
[
h("li", { "data-index": 0, class: "item " }, [
h("p", { class: "text" }, ["第一个列表项"]),
]),
h("li", { "data-index": 1, class: "item" }, [
h("p", { class: "text" }, [
h("span", { class: "title" }, ["第二个列表项"]),
]),
]),
h("li", { "data-index": 2, class: "item" }, ["第三个列表项"]),
]
);
// 模拟修改内容后的 virtualDOM
const newVDom = h(
"ul",
{
class: "list-wrapper",
style: "width: 300px; height: 300px; border: 1px solid #555; color:#f00;",
},
[
h("li", { "data-index": 0, class: "item active" }, [
h("p", { class: "text" }, ["第一个列表项"]),
]),
h("li", { "data-index": 1, class: "item" }, [
h("p", { class: "text" }, ["这是第二个li, 内容被替换了"]),
]),
h("li", { "data-index": 2, class: "item" }, [
"这是第3个被修改后的列表项内容",
]),
]
);
// 根据老节点渲染出真实的DOM
const realDOM = render(oldVDom);
// 将真实dom挂载到页面元素中
mount(realDOM, document.getElementById("app"));
// newVDom模拟数据更新的过程 -> 对比新老VDom -> 获得补丁包
const patchMap = diff(oldVDom, newVDom);
console.log('🥧[patchMap]:', patchMap);
// 给真实节点打补丁
patch(realDOM, patchMap);
js
// js/vnode.js
"use strict";
/**
* 虚拟节点
*/
export class VNode {
constructor(type, props, children = []) {
this.type = type;
this.props = props;
this.children = children;
}
}
/**
* 处理html标签元素的属性 id="xxx" style="xxx" class="xxx"
* @param {HTMLElement} element 真实DOM元素
* @param {Object} attr 标签上的属性
* @param {String} val 标签属性的值
*/
export function setAttrs(element, attr, val) {
switch (attr) {
case "value":
if (["INPUT", "TEXTAREA"].includes(element.tagName)) {
element.value = val;
} else {
element.setAttribute(attr, val);
}
break;
case "style":
element.style.cssText = val;
break;
default:
element.setAttribute(attr, val);
break;
}
}
/**
* 把虚拟dom变成真实的dom元素
* @param {VNode} vDom 虚拟DOM
* @return {HTMLElement} el 真实的DOM元素
*/
export const render = createRNode;
export function createRNode(vDom) {
const { type, props, children } = vDom;
const el = document.createElement(type);
// 处理属性
Object.keys(props).forEach((key) => {
setAttrs(el, key, props[key]);
});
// 处理子节点(元素节点/文本节点)
if (!Array.isArray(children)) {
return el;
}
for (const child of children) {
let childElement;
if (child instanceof VNode) {
childElement = createRNode(child);
} else {
childElement = document.createTextNode(child);
}
el.append(childElement);
}
return el;
}
/**
* 创建虚拟节点
* @param {String} type 标签名
* @param {Object} props 属性集合
* @param {Array} children 子节点
*/
export const h = createVNode;
export function createVNode(type, props, children) {
return new VNode(type, props, children);
}
/**
* 将真实节点挂载到页面中
*/
export function mount(el, container) {
container.append(el);
}
js
// js/diff.js
"use strict";
/*
const patchPackMap = {
0: [
{
type: PatchPack.ATTR, // 代表一个更新属性的补丁包
value: { style: 'xxx', class: 'xxx', id: 'xxx' }
}
],
1: [
{
type: PatchPack.TEXT, // 代表一个标签内部文本的补丁包
value: '这是更新后要显示的文本'
}
],
2: [
{
type: PatchPack.REPLACE, // 代表一个替换节点的补丁包
value: rNode
}
],
2: [
{
type: PatchPack.REMOVE, // 代表一个移除节点的补丁包
value: index
}
],
};
*/
import { PatchPack } from "./patch";
const { ATTR, TEXT, REPLACE, REMOVE } = PatchPack;
// 新老节点对比后的差异补丁包
const patchPackMap = new Map();
let vNodeUid = 0;
// 对比新老虚拟节点
export function diff(oldVNode, newVNode) {
let index = 0;
vNodeWalk(oldVNode, newVNode, index);
return patchPackMap;
}
// 深度遍历对比
function vNodeWalk(oldVNode, newVNode, index) {
const patchSet = new Set();
if (!newVNode) {
// 1. 如果新节点不存在, 就添加一个移除节点的补丁包
patchSet.add(new PatchPack(REMOVE, index));
} else if (typeof newVNode === "string" && typeof oldVNode === "string") {
// 2. 如果新节点和老节点都是文本,
// 并且文本内容不相等, 就直接修改老的节点的文本内容
if (newVNode !== oldVNode) {
patchSet.add(new PatchPack(TEXT, newVNode));
}
} else if (oldVNode.type === newVNode.type) {
// 3. 如果新老节点标签名相等, 对比新老节点所有的属性差异
// 获取新老节点所有的属性补丁包
const attrPatches = attrsWalk(oldVNode.props, newVNode.props);
if (Object.keys(attrPatches).length > 0) {
patchSet.add(new PatchPack(ATTR, attrPatches));
}
// 对比元素节点的所有子节点
childrenWalk(oldVNode.children, newVNode.children);
} else {
// 4. 如果上面的3种情况,那么节点一定发生了变化,
// 那么就需要替换原来的节点, 添加一个替换补丁包
patchSet.add(new PatchPack(REPLACE, newVNode));
}
patchSet.size > 0 && patchPackMap.set(index, patchSet);
}
// 对比新老节点的所有属性, 获取属性补丁包
function attrsWalk(oldProps, newProps) {
const attrPatch = {};
// 节点属性修改的处理: 新老节点的属性不一致
Object.keys(oldProps).forEach((key) => {
if (oldProps[key] !== newProps[key]) {
attrPatch[key] = newProps[key];
}
});
// 节点属性添加的处理: 新老节点属性个数不一致
Object.keys(newProps).forEach((key) => {
if (!oldProps.hasOwnProperty(key)) {
attrPatch[key] = newProps[key];
}
});
return attrPatch;
}
// 对比元素节点的所有子节点
// TODO: 如果新节点比老节点length要长,
// 那么如果有新增加的节点就遍历不到
function childrenWalk(oldChildren, newChildren) {
oldChildren.forEach((child, i) => {
vNodeWalk(child, newChildren[i], ++vNodeUid);
});
}
js
// js/patch.js
"use strict";
import { VNode, setAttrs } from "./vnode";
/**
* 补丁包
*/
export class PatchPack {
static ATTR = "ATTR";
static TEXT = "TEXT";
static REPLACE = "REPLACE";
static REMOVE = "REMOVE";
constructor(type, value) {
if (!PatchPack[type]) {
throw new TypeError("[PatchPack] unknown PatchPack type");
}
this.type = type;
this.value = value;
}
}
let finalPatchMap = null;
let rNodeUid = 0;
/**
* 给真实节点打补丁包
* @param {HTMLElement} rDom 真实的DOM元素
* @param {Map} patchMap 补丁包
*/
export function patch(rDom, patchMap) {
finalPatchMap = patchMap;
rNodeWalk(rDom);
}
/**
* 递归遍历所有的真实节点(深度优先), 更新补丁包记录的内容
* @param {HTMLElement} rNode 真实的DOM元素
*/
function rNodeWalk(rNode) {
const rNodePatch = finalPatchMap.get(rNodeUid++); // Set
const childNodes = Array.from(rNode.childNodes);
// 如果有子节点: 递归的给子节点打补丁
if (childNodes.length > 0) {
childNodes.forEach((child) => {
rNodeWalk(child);
});
}
// 如果当前节点有补丁包 patch 需要更新
rNodePatch && patchAction(rNode, rNodePatch);
}
/**
* 更新补丁包记录的内容到真实的dom,让UI更新
* @param {HTMLElement} rNode 真实的DOM元素
* @param {PatchPack} patches 补丁包
*/
function patchAction(rNode, patches) {
for (const { type, value } of patches) {
switch (type) {
case PatchPack.ATTR:
Object.keys(value).forEach((key) => {
setAttrs(rNode, key, value[key]);
});
break;
case PatchPack.TEXT:
rNode.textContent = value;
break;
case PatchPack.REPLACE:
// 新的节点是文本节点还是元素节点
const newRNode = rNode instanceof VNode
? document.createElement(value.type)
: document.createTextNode(value);
rNode.parentNode.replaceChild(newRNode, rNode);
break;
case PatchPack.REMOVE:
rNode.remove();
break;
default:
console.error("[patch] unknown PatchPack type");
break;
}
}
}