笔记Vue源码解析之虚拟DOM和diff算法
Posted ThinkerWing
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了笔记Vue源码解析之虚拟DOM和diff算法相关的知识,希望对你有一定的参考价值。
笔记简介
本文为尚硅谷视频学习笔记,参考博客学习速度更快,跟着视频记录笔记加深印象及补充视频中讲师所讲到的一些知识点,扩充了部分,加上遇到的问题及解决方案。
视频链接
https://www.bilibili.com/video/BV1v5411H7gZ?p=3
博客参考链接
https://blog.csdn.net/weixin_44972008/article/details/115620198
snabbdom库
https://github.com/snabbdom/snabbdom/tree/master/src
git笔记https://gitee.com/thinkerwing/study/tree/master/vue2/%E8%99%9A%E6%8B%9FDOM%E5%92%8Cdiff%E7%AE%97%E6%B3%95/study-snabbdom
注意可能会遇到的问题
1.可能是端口占用的情况导致的404
2.可能是缓存的情况导致新写入的逻辑无法执行,sources中查看
3.如果没有key的话,容易被判断成同一节点,key的作用主要是为了高效的更新虚拟DOM,通过源码学习能更深的感受到这一点的重要性
diff算法和虚拟DOM简介
diff算法
diff算法可以进行精细化比对,实现最小量更新。h3 和 ul 没有变化就可以不用动,只插入span和雪碧。
虚拟DOM
diff算法是发生在虚拟 DOM 上的
DOM如何变为虚拟DOM属于模板编译原理范畴,mustache
snabbdom简介和测试环境搭建
npm install -S snabbdom
npm i -D webpack@5 webpack-cli@3 webpack-dev-server@3
配置webpack.config.js,参考官网配置
https://www.webpackjs.com/
下面附上几个配置
- src/index.js
- www/index.html
- webpack.config.js
src/index.js
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// Init patch function with chosen modules
classModule, // makes it easy to toggle classes
propsModule, // for setting properties on DOM elements
styleModule, // handles styling on elements with support for animations
eventListenersModule, // attaches event listeners
]);
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: function () { } } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// Patch into empty DOM element – this modifies the DOM as a side effect
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: function () { } } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// Second `patch` invocation
patch(vnode, newVnode); // Snabbdom efficiently updates the old view to the new state
www/index.html
<!DOCTYPE html>
<html lang="en">
<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">
<title>Document</title>
</head>
<body>
<div id="container"></div>
<script src="/xuni/bundle.js"></script>
</body>
</html>
webpack.config.js
const path = require('path')
module.exports = {
// webpack5 不用配置mode
// 入口
entry: "./src/index.js",
// 出口
output: {
// 虚拟打包路径,文件夹不会真正生成,而是在8080端口虚拟生成
publicPath: "xuni",
// 打包出来的文件名
filename: "bundle.js",
},
// 配置webpack-dev-server
devServer: {
// 静态根目录
contentBase: 'www',
// 端口号
port: 8081,
},
};
更改package.json中script配置,通过npm run test 启动
"scripts": {
"test": "webpack-dev-server"
},
启动如果遇到404要注意是否端口占用
cmd 查找80端口占用情况
netstat -ano|findstr "80"
taskkill/pid 117884 -t -f // PID为117884的进程被杀掉
如果删不掉,可以考虑更换一下端口
虚拟 DOM 和 h 函数
- 研究1:虚拟 DOM 如何被渲染函数(h函数)产生?
我们要手写h函数 - 研究2:diff算法原理?
我们要手写diff算法 - 研究3:虚拟 DOM 如何通过diff变为真正的 DOM 的
事实上,虚拟 DOM 变回真正的 DOM,是涵盖在diff算法里面的
h函数用来产生虚拟节点(vnode)
虚拟节点vnode的属性
{
children: undefined// 子元素 数组
data: {} // 属性、样式、key
elm: undefined // 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
key: // 唯一标识
sel: "div" // 选择器
text: "我是一个盒子" // 文本内容
}
使用h函数 创建虚拟节点
// 创建虚拟节点
var myVnode1 = h('a', { props: { href: 'https://www.baidu.com' } }, 'baidu')
console.log(myVnode1)
使用patch函数 将虚拟节点上DOM树
一个容器只能让一个虚拟节点上树,除非有内嵌的
// 创建patch函数
const patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule,
]);
// 创建虚拟节点
var myVnode1 = h(
"a", {
props: {
href: "https://www.baidu.com",
target: "_blank"
}
},
"baidu"
);
// 一个容器只能让一个虚拟节点上树
const myVnode2 = h('div', {class: {'box': true}},'我是一个盒子')
// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode2);
h函数嵌套使用,得到虚拟DOM树(重要)
一定要套h函数,并且里面可以继续嵌套
const myVnode3 = h('ul', [
h('li', '苹果'),
h('li', '香蕉'),
h('li', [
h('div', '西瓜籽'),
]),
h('li', '番茄'),
])
手写h函数
- 看源码的TS版代码,然后仿写JS代码
h.js
import vnode from "./vnode";
/**
* 产生虚拟DOM树,返回的一个对象
* 低配版本的h函数,这个函数必须接受三个参数,缺一不可
* 调用只有三种形态 文字、数组、h函数
* ① h('div', {}, '文字')
* ② h('div', {}, [])
* ③ h('div', {}, h())
*/
export default function (sel, data, c) {
// 检查参数个数
if (arguments.length !== 3) {
throw new Error("h函数必须传入3个参数");
}
// 检查第参数c的类型
if (typeof c === "string" || typeof c === "number") {
// 说明现在调用h函数是形态1
return vnode(sel, data, undefined, c, undefined);
} else if (Array.isArray(c)) {
// 说明现在调用h函数是形态2 数组
let children = [];
// 遍历 c 数组,收集children
for (let item of c) {
// 检查c[i]必须是一个对象,如果不满足
if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
throw new Error("传入的数组参数中有项不是h函数");
}
// 不用执行item, 因为测试语句中已经有了执行,此时只要收集数组中的每一个对象
children.push(item);
}
//循环结束了,就说明children收集完毕了,此时可以返回虚拟节点了,它有children属性
return vnode(sel, data, children, undefined, undefined);
} else if (typeof c === "object" && c.hasOwnProperty("sel")) {
// 说明现在调用h函数是形态3 即,传入的c是唯一的children,不用执行c
let children = [c];
return vnode(sel, data, children, undefined, undefined);
} else {
throw new Error("传入的第三个参数类型不对");
}
}
vnode.js
/**
* 产生虚拟节点
* 将传入的参数组合成对象返回
* @param {string} sel 选择器
* @param {object} data 属性、样式
* @param {Array} children 子元素
* @param {string|number} text 文本内容
* @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
* @returns
*/
// 函数的功能非常简单,就是把传入的5个参数组合成对象返回
export default function(sel, data, children, text, elm) {
const key = data.key;
return { sel, data, children, text, elm, key };
}
index.js
import h from "./mysnabbdom/h";
const myVnode1 = h("div", {}, [
h("p", {}, "文字"),
h("p", {}, []),
h("p", {}, h('span', {}, '呵呵')),
]);
console.log(myVnode1);
感受diff算法
1. 最小量更新,key很关键。key是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点。
2.只有是同一个虚拟节点才进行精细化比较,否则就是暴力删除旧的、插入新的。
延伸问题:如何定义是同一个虚拟节点?答:选择器相同且key相同
3.只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的
下面通过三个案例来对比
// 创建虚拟节点
var vnode1 = h(
"ul", {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D')
]
);
// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, vnode1);
var vnode2 = h(
"ul", {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D'),
h('li', {}, 'E')
]
);
// 点击按钮时, 将vnode1变为vnode2
btn.onclick = function() {
patch(vnode1, vnode2)
}
用案例测试最小量更新,点击按钮,并没有刷新变回B。
再用一个新的案例测试前面插入E,全部都会更新,因为原来的A变成了E,B变成了A
var vnode2 = h(
"ul", {}, [
h('li', {}, 'E'),
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, 'C'),
h('li', {}, 'D')
]
);
再来第三个测试
// 创建虚拟节点
var vnode1 = h(
"ul", {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]
);
// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, vnode1);
var vnode2 = h(
"ul", {}, [
h('li', { key: 'E' }, 'E'),
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'D' }, 'D')
]
);
只进行同层比较,不会跨层比较
diff处理新旧节点不是同一节点时
旧节点的key要和新节点的key相同且旧节点的选择器要和新节点的选择器相同
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key;
const isSameIs = vnode1.data?.is === vnode2.data?.is;
const isSameSel = vnode1.sel === vnode2.sel;
return isSameSel && isSameKey && isSameIs;
}
源码中创建子节点,需要递归
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
手写上树
- patch方法是虚拟dom核心中的核心。在VNode(虚拟节点)改变后和初始化前都会调用。
- patch的本质是将新旧vnode进行比较,创建、删除或者更新DOM节点/组件实例。
了解一下递归的过程
这一层调用下一层一直往下调,遇见文本节点,一层一层往回调。
index.js
import h from './mysnabbdom/h.js'
import patch from './mysnabbdom/patch.js'
const myVnode1 = h(
"ul", {}, [
h('li', {}, 'A'),
h('li', {}, 'B'),
h('li', {}, [
h('div', {}, [
h('ol', {}, [
h('li', {}, 'C1'),
h('li', {}, 'C2'),
h('li', {}, 'C3'),
])
])
]),
h('li', {}, 'D')
]
);
const container = document.getElementById('container')
patch(container, myVnode1)
patch.js
import vnode from './vnode';
import createElement from './createElement';
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数是 DOM节点 还是 虚拟节点
if (oldVnode.sel == '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装成虚拟节点
oldVnode = vnode(
oldVnode.tagName.toLowerCase(), // sel
{}, // data
[], // children
undefined, // text
oldVnode // elm
);
}
// 判断 oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log("是同一个节点,需要精细化比较");
} else {
console.log("不是同一个节点,暴力插入新节点,删除旧节点");
let newVnodeElm = createElement(newVnode);
let oldVnodeElm = oldVnode.elm;
// 以oldVnodeElm为标杆
if (oldVnodeElm.parentNode && newVnodeElm) {
// 判断newVnodeElm是存在的,插入到老节点之前
oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
}
// 删除老节点
oldVnodeElm.parentNode.removeChild(oldVnodeElm);
}
}
createElement.js
// 真正创建节点。将vnode虚拟节点创建为DOM,是孤儿节点,不进行插入
export default function createElement(vnode) {
console.log('目的是把虚拟节点', vnode, '真正变为DOM');
// 根据虚拟节点sel选择器属性 创建一个DOM节点,这个节点现在是孤儿节点
let domNode = document.createElement(vnode.sel);
// 判断是有子节点还是有文本
if ( vnode.text !== "" && (vnode.children === undefined || vnode.children.length === 0)
) {
// 说明内部是文本
domNode.innerText = vnode.text;
// 这里不上树,因为设置内部文字就相当于上树
} else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
// 说明内部是子节点,需要递归创建节点
for (let i = 0; i < vnode.children.length; i++) {
// 得到当前这个children
let ch = vnode.children[i]
// 创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,
// 但是还没上树,是一个孤儿节点
console.log(ch);
let chDOM = createElement(ch)
// n+1层就可以被n层创建回来
// 上树
domNode.appendChild(chDOM)
}
}
// 补充虚拟节点的elm属性
vnode.elm = domNode;
// 返回domNode 和 vnode.elm 是一样的 引用类型值内存中同一个对象,elm的属性是一个纯DOM对象
return domNode;
}
测试结果:
尝试书写diff更新子节点
代码
index.js
import h from './mysnabbdom/h.js'
import patch from './mysnabbdom/patch.js'
const myVnode1 = h(
"ul", {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'C' }, 'C')
]
);
// 得到盒子和按钮
const container = document.getElementById('container')
const btn = document.getElementById('btn')
// 第一次上树
patch(container, myVnode1)
// 新节点
const myVnode2 = h(
"ul", {}, [
h('li', { key: 'A' }, 'A'),
h('li', { key: 'B' }, 'B'),
h('li', { key: 'D' }, 'D'),
h('li', { key: 'D' }, 'E'),
h('li', { key: 'C' }, 'C'),
h('li', { key: 'C' }, 'F'),
h('li', { key: 'G' }, 'G'),
]
)
btn.onclick = function() {
patch(myVnode1, myVnode2)
}
patch.js
import vnode from './vnode';
import createElement from './createElement';
import patchVnode from './patchVnode.js';
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数是 DOM节点 还是 虚拟节点
if (oldVnode.sel == '' || oldVnode.sel === undefined) {
// 传入的第一个参数是DOM节点,此时要包装成虚拟节点
oldVnode = vnode(
oldVnode.tagName.toLowerCase(), // sel
{}, // data
[], // children
undefined, // text
oldVnode // elm
);
}
// 判断 oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
console.log('是同一个节点');
patchVnode(oldVnode, newVnode)
} else {
console.log("不是同一个节点,暴力插入新节点,删除旧节点");
let newVnodeElm = createElement(newVnode);
let oldVnodeElm = oldVnode.elm;
// 以oldVnodeElm为标杆
if (oldVnodeElm.parentNode && newVnodeElm) {
// 判断newVnodeElm是存在的,插入到老节点之前
oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
}
// 删除老节点
oldVnodeElm.parentNode.removeChild(oldVnodeElm);
}
}
patchVNode.js
import createElement from "./createElement";
export default function patchVnode(oldVnode, newVnode) {
console.log("是同一个节点,需要精细化比较");
// 判断新旧vnode是否是同一个对象
if (oldVnode === newVnode) return;
// 判断新的vnode有没有text属性
if (newVnode.text !== undefined && newVnode.children == undefined || newVnode.
children.length == 0) {
console.log('新vnode有text属性');
if (newVnode.text !== oldVnode.text) {
// 如果新虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可。
// 如果老的elm中是children,那么也会立即消失。
oldVnode.elm.innerText = newVnode.text
}
} else {
// 新vnode没有text属性
console.log('新vnode没有text属性');
// 判断老的有没有children
if (oldVnode.children != undefined && oldVnode.children.length > 0) {
// 老的有children,此时就是最复杂的情况。就是新老都有children
// 所有未处理的节点的开头
let un = 0
for (let i = 0; i < newVnode.children.length; i++) {
let ch = newVnode.children[i]
// 再次遍历,看看oldVnode中有没有节点和它是same的
let isExist = false
for (let j = 0; j < oldVnode.children.length; j++) {
if (oldVnode.children[j].sel == ch.sel && oldVnode.children[j].key == ch.key) {
isExist = true
}
}
if (!isExist) {
console.log('ch', ch, i);
let dom = createElement(ch)
ch.elm = dom
if (un < oldVnode.children.length) {
oldVnode.elm.insertBefore(dom, oldVnode.children[un].elm)
} else {
oldVnode.elm.appendChild(dom)
}
} else {
// 让处理的节点指针下移
un ++
}
}
} else {
// 老的没有children,新的有children
// 清空老的节点的内容
oldVnode.elm.innerHTML = ''
// 遍历新的vnode的子节点,创建DOM,上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
测试
以上是关于笔记Vue源码解析之虚拟DOM和diff算法的主要内容,如果未能解决你的问题,请参考以下文章
面试中的网红Vue源码解析之虚拟DOM,你知多少呢?深入解读diff算法
面试中的网红Vue源码解析之虚拟DOM,你知多少呢?深入解读diff算法