vue源码之虚拟DOM和diff算法
vue源码之虚拟DOM和diff算法
虚拟DOM和diff算法
snabbdom 库
snabbdom是著名的虚拟dom库,是diff算法的最早,vue中借鉴了snabbdom;
github 地址 snabbdom
注意:git上的snabbdom是用ts写的,git并不支持编译好的js版本;
建议从npm中下载 npm i snabbdom -D
需要搭建webpack和webpack-dev-server 开发环境,不用安装任何loader
注意:必须安装webpack@5 不能安装webpack@4 因为webpack@4 没有读取身份证中exports 的能力,建议使用如下版本:
npm i webpack@5 webpack-cli@3 webpack-dev-server@3 -D // -D 开发时依赖 -S 生产时依赖
然后再写webpack.config.js文件
步骤如下:
- 首先
npm init
- 安装
npm i snabbdom -D
- 安装
npm i webpack@5 webpack-cli@3 webpack-dev-server@3 -D
- 新建webpack.config.js文件
// https://webpack.docschina.org
const path = require('path');
module.exports = {
// 入口
entry: './src/index.js',
// 出口
output: {
// 虚拟打包文件 就是说文件夹不会真正的生成,而是在8080端口虚拟生成
publicPath:'xuni',
// 打包出来的文件名
filename: 'bundle.js',
},
devServer:{
// 端口号
port:8080,
// 静态资源文件夹
contentBase:'www'
}
};
- 新建src和index.js
- 新建www和index.html
虚拟DOM :用javascript对象描述DOM的层次结构,DOM中的一切属性都在虚拟DOM中对应的属性;
diff 是发生在虚拟DOM上的 ,新虚拟DOM 和 老虚拟DOM进行 diff(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM上;
DOM变为虚拟DOM,属于模板编译原理;
1、虚拟DOM如何被渲染函数产生?h函数
2、diff算法原理;
3、虚拟DOM如何通过diff 变为真正的DOM;
h函数用来产生 虚拟节点vnode
h('a', { props:{ 'http://www.baidu.com' }}, '百度')
// 第一个参数 代表标签的名字 第二个参数:对象props properties 属性的意思 第三个参数
将得到这样的虚拟节点
{ "sel", "a", "data":{ props: {'http://www.baidu.com'}}, "test":"百度" }
它真正的DOM的节点:
<a href="http://www.baidu.com">百度</a>
一个虚拟节点有哪些属性?
{
children: undefined,
data: {},
elm: undefined,
key: undefined,
sel: "div",
text: "我是一个盒子"
}
import { init } from 'snabbdom/init';
import { classModule } from 'snabbdom/modules/class';
import { propsModule } from 'snabbdom/modules/props';
import { styleModule } from 'snabbdom/modules/style';
import { eventListenersModule } from 'snabbdom/modules/eventlisteners';
import { h } from 'snabbdom/h';
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
// 创建虚拟节点
var myVnode = h('a', { props: {href:'http://www.baidu.com' }}, "百度")
console.log(myVnode);
// 让虚拟节点上树
const container = document.getElementById('container')
patch(container, myVnode)
import { init } from 'snabbdom/init';
import { classModule } from 'snabbdom/modules/class';
import { propsModule } from 'snabbdom/modules/props';
import { styleModule } from 'snabbdom/modules/style';
import { eventListenersModule } from 'snabbdom/modules/eventlisteners';
import { h } from 'snabbdom/h';
// 创建出patch函数
const patch = init([classModule, propsModule, styleModule, eventListenersModule])
// 创建虚拟节点
var myVnode1 = h('a', { props: {href:'https://www.baidu.com' }}, "百度")
// console.log(myVnode1);
const myVnode2 = h('div', {}, '我是一个div盒子')
// const myVnode2 = h('div', '我是一个div盒子')
// 嵌套使用 h 函数
const myVnode3 = h('ul', {}, [
h('li',{}, '牛奶') ,
h('li',{}, '豆浆') ,
h('li',{}, '油条') ,
// 当有一个子元素,可以直接写,两个需要数组
h('li', h('div','包子')) ,
])
console.log(myVnode3 )
// 得到这样的虚拟DOM
/**
* {
* "sel": "ul",
* "data": {},
* "children": [
* {"sel": "li","text":"牛奶"},
* {"sel": "li","text":"豆浆"},
* {"sel": "li","text":"油条"}
* ]
* }
*/
// 让虚拟节点上树
const container = document.getElementById('container')
patch(container, myVnode3)
手写h函数
先看源码中 vonde.ts
:
h.ts
中:
实现下面三个参数:
h('div', {}, [])
h('div', {}, '文字')
h('div', {}, h())
1、新建vnode.js文件
// 参考源码中VNode
// 函数的功能非常简单,就是把传入的5 个参数组合对象返回
export default function(sel, data, children, text, elm){
return {
sel, data, children, text, elm
}
}
2、新建myh.js文件(简易版)
import vnode from './vnode';
// 编写一个简单的h函数,这个函数必须接受3个参数,缺一不可,重载能力弱
// 调用必须是下面三种之一
/**
*
* 第一种:h('div', {}, '文字')
* 第二种:h('div', {}, [])
* 第三种:h('div', {}, h())
*
*/
export default function (sel, data, c) {
// 检查参数的个数
if (arguments.length !== 3) throw new Error('对不起, h函数必须传入3个参数,低配的h函数')
// 检查c 的类型
if (typeof c == 'string' || typeof c == 'number') {
// 说明调用的是第一种
return vnode(sel, data, undefined, c, undefined)
} else if (Array.isArray(c)) {
// 说明调用的是第二种
let children = []
// 编写 c
for (let i = 0; i < c.length; i++) {
// 检查c[i] 返回必须是个对象
if (!(typeof c[i] == 'object' && c[i].hasOwnProperty('sel'))) {
throw new Error('传入的数组参数不是有项不是h函数')
}
// 这里不用执行c[i], 因为测试语句中已经有了执行调用 c[i]()
// 此时只需要收集好
children.push(c[i])
}
// 循环结束,就说明children收集完毕 ,此时可以返回虚拟节点,它有children属性
return vnode(sel, data, children, undefined, undefined)
} else if (typeof c == 'object' && c.hasOwnProperty('sel')) {
// 说明调用的是第三种
// 即 传入的c是唯一的children 不用执行c ,因为测试中已经执行
let children = [c]
return vnode(sel, data, children, undefined, undefined)
} else {
throw new Error('传入第三个参数不正确')
}
}
3、在index.js中引入使用
import myh from './myh'
// var myVnode1 = myh('div', {}, '文字')
var myVnode1 = myh('div', {}, [
myh('div', {}, 'niuniu'),
myh('div', {}, 'fqniu'),
myh('div', {}, 'niufq'),
myh('div', {}, myh('div',{},'包子')),
])
console.log(myVnode1);
diff 算法心得
1、最小量的更新,当然key最重要,key是唯一节点,是为了告诉diff算法,在更改前后他们是同一个dom节点 ;
2、只有是同一个虚拟节点,才进行精细比较,否则就删除旧的,插入新的延伸问题,如何定义同一个虚拟节点,选择器相同且key相同 ;
3、只进行同层比较,不会进行跨层比较,即使是同一片虚拟节点,但是跨层了,精细化比较不 diff 你,不是删除旧的,然后插入新的 ;
diff处理新旧节点不是同一个节点时
创建节点时,所有子节点需要递归创建出来的
新建index .js文件
import myh from './myh';
import patch from './patch';
const myVnode1 = myh('h1', {}, '你好');
const container = document.getElementById('container');
patch(container, myVnode1)
新建patch .js文件
import vnode from './vnode';
import createElement from './createElement';
import patchVnode from './patchVNode';
export default function (oldVnode, newVnode) {
// 判断传入的第一个参数,是DOM节点,还是虚拟节点
if (oldVnode.sel == '' || oldVnode.sel == undefined) {
// 说明是DOM节点,此时包装为虚拟节点
oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode)
}
// console.log(oldVnode);
// 判断 oldVnode 和 newVnode 是否为同一个节点
if (oldVnode.key == newVnode.key && oldVnode.sel == newVnode.sel) {
console.log('是同一个节点');
patchVnode(oldVnode, newVnode);
} else {
console.log('不是同一个节点,暴力插入新的,删除旧的');
let newVnodeElm = createElement(newVnode);
// 插入到老节点之前
if (oldVnode.elm.children && newVnodeElm) {
oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm)
}
// 删除老节点
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
新建createElement .js文件(简易版)
// 真正创建节点 将vnode 创建为 DOM 是孤儿节点,不进行插入
export default function createElement(vnode) {
// console.log('目的是 把虚拟节点 ,', vnode, '插入到标杆或者说基准', pivot, '之前');
// 创建一个DOM节点,这个节点是孤儿节点
let domNode = document.createElement(vnode.sel)
// 有子节点还是文本???
if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) {
// 他内部是文字
domNode.innerText = vnode.text;
// // 将孤儿节点上树,让标杆节点的父元素调用insertBefore方法,将孤儿节点插入到标签节点之前
// pivot.parentNode.insertBefore(domNode, pivot)
} 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];
console.log(ch);
// 创建出他的D欧美,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点
let chDOM = createElement(ch)
// 上树
domNode.appendChild(chDOM)
}
}
// 补充elm属性
vnode.elm = domNode;
// 返回elm, elm属性是纯DOM对象
return vnode.elm;
}
新建 patchVnode .js文件
import createElement from "./createElement";
import updateChildren from './updateChildren';
export default function patchVnode(oldVnode, newVnode) {
// 判断新旧节点是否是同一个对象
if (oldVnode === newVnode) return;
if (newVnode.text !== undefined && (newVnode.children == undefined) || newVnode.children.length == 0) {
// 新vnode有text属性
console.log('新vnode有text属性');
if (newVnode.text != oldVnode.text) {
// 如果新虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入elm中即可,如果老的elm是children那么也会立即消失掉
oldVnode.elm.innerText = newVnode.text;
}
} else {
// 新vnode没有text属性, 有children
console.log('新vnode没有text属性');
// 判断老的有没有children
if (oldVnode.children != undefined && oldVnode.children.length > 0) {
console.log(1);
// 老的有children ,新的有children, 此时最为复杂,就是新老都有children
updateChildren(oldVnode.elm,oldVnode.children,newVnode.children);
} else {
console.log(2);
// 老的没有children,新的有children
// 清空老的节点的内容
oldVnode.elm.innerHTML = '';
// 遍历新的vnode的节点, 创建DOM 循环上树
for (let i = 0; i < newVnode.children.length; i++) {
let dom = createElement(newVnode.children);
oldVnode.elm.appendChild(dom);
}
}
}
}
diff算法的子节点更新优化策略
四种命中查找(命中一种就不再进行命中判断,当然如果都没有命中,则需要循环寻找):
1、新前与旧前;
2、新后与旧后;
3、新后与旧前;
4、新前与旧后;
需要四个指针:旧前、旧后、新前、新后;
1、新增:如果是旧节点先循环完毕,说明新节点中有要插入的节点;
2、删除:如果是新节点先循环完毕,如果老节点中还有剩余节点,说明他们是要删除的节点;
当3新后和旧前命中时,此时要移动节点,移动新前指向的这个节点到老节点的 旧后的后面
当4新前与旧后命中时,此时要移动节点,移动到新前指向的这个节点到老节点的 旧前的前面
创建updateChildren.js
import pathVnode from './patchVNode';
import createElement from './createElement'
import patchVnode from './patchVNode';
// 用于判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
return a.sel == b.sel && a.key == b.key;
}
export default function updataChildren(parentElm, oldCh, newCh) {
console.log('updateChildren');
console.log(oldCh, newCh);
// 旧前
let oldStartIdx = 0;
// 新前
let newStartIdx = 0;
// 旧后
let oldEndIdx = oldCh.length - 1;
// 新后
let newEndIdx = newCh.length - 1;
// 旧前节点
let oldStartVNnode = oldCh[0];
// 旧后节点
let oldEndVNnode = oldCh[oldEndIdx];
// 新前节点
let newStartVNnode = newCh[0];
// 新后节点
let newEndVNnode = newCh[newEndIdx];
// console.log(oldStartIdx, newEndIdx);
let keyMap = null;
// 开始while循环
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 首先判断略过加undefined 标记的东西
if (oldStartVNnode == null || oldCh[oldStartIdx] == undefined) {
oldStartVNnode = oldCh[++oldStartIdx]
} else if (oldEndVNnode == null || oldCh[oldEndIdx] == undefined) {
oldEndVNnode = oldCh[--oldEndIdx]
} else if (newStartVNnode == null || newCh[newStartIdx] == undefined) {
newStartVNnode = newCh[++oldStartIdx]
} else if (newEndVNnode == null || newCh[newEndIdx] == undefined) {
newEndVNnode = newdCh[--newEndIdx]
}
if (checkSameVnode(oldStartVNnode, newStartVNnode)) {
console.log('1新前和旧前');
pathVnode(oldStartVNnode, newStartVNnode);
oldStartVNnode = oldCh[++oldStartIdx];
newStartVNnode = newCh[++newStartIdx];
} else if (checkSameVnode(oldEndVNnode, newEndVNnode)) {
console.log('2新后和旧后');
pathVnode(oldEndVNnode, newEndVNnode);
oldEndVNnode = oldCh[--oldEndIdx];
newEndVNnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldStartVNnode, newEndVNnode)) {
console.log('3新后和旧前');
pathVnode(oldStartVNnode, newEndVNnode);
// 插入 当3新后和旧前命中时,此时要移动节点,移动新前指向的这个节点到老节点的 旧后的后面
parentElm.insertBefore(oldStartVNnode.elm, oldEndVNnode.elm.nextSibling);
oldStartVNnode = oldCh[++oldStartIdx];
newEndVNnode = newCh[--newEndIdx];
} else if (checkSameVnode(oldEndVNnode, newStartVNnode)) {
console.log('4新前和旧后');
pathVnode(oldEndVNnode, newStartVNnode);
// 当4新前与旧后命中时,此时要移动节点,移动到新前指向的这个节点到老节点的 旧前的前面
parentElm.insertBefore(oldEndVNnode.elm, oldStartVNnode.elm.nextSibling);
oldEndVNnode = oldCh[--oldEndIdx];
newStartVNnode = newCh[++newStartIdx];
} else {
// 都没有找到的情况 四种都没有命中的情况
// 寻找keyMap
if (!keyMap) {
keyMap = {};
// 从oldStartIndx开始,到oldEndIdx结束,创建keyMap映射对象
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
const key = oldCh[i].key;
if (key != undefined) {
keyMap[key] = i
}
}
}
// console.log(keyMap);
// 寻找当前这项(newStartIdx) 这项在keyMap中的映射的位置序号
const idxInOld = keyMap(newStartVNnode.key)
console.log(idxInOld);
if (idxInOld == undefined) {
// 判断 如果idxInOld 是undefined 表示他是全新的项
// 被加入的项是newStartVnode 不是真正的DOM节点
parentElm.insertBefore(createElement(newStartVNnode),oldStartVNnode.elm)
} else {
// 如果不是undefined 不是全新的项,而是要移动
const elmToMove = oldCh[idxInOld];
if (elmToMove.elm.nodeType == 1) {
patchVnode(elmToMove, newStartVNnode)
// 把这项设置为undefined,表示我已经处理完这项了
oldCh[idxInOld] = undefined;
// 移动,调用insertBefore也可以实现移动
parentElm.insertBefore(elmToMove.elm, oldStartVNnode.elm)
}
}
// 指针下移,只移动新的头
newStartVNnode = newCh[++newStartIdx]
}
}
// 继续看下有没有剩余,循环结束 start还是比old小
if (newStartIdx <= newEndIdx) {
console.log('new还有剩余节点没有处理,要把所有剩余的节点,都要插入到oldStartIdx值之前');
// 插入的标杆
// const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
// console.log(before);
// 新增
for (let i = newStartIdx; i <= newEndIdx; i++) {
// insertBefore 这个方法可以自动识别null,如果是null就会自动排到队尾去,和appendChild一致
// newCh[i]现在还没有真正的DOM,所以要调用 createElement() 函数变为DOM
parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx])
}
} else if (oldStartIdx <= oldEndIdx) {
console.log('old还有剩余节点没有处理');
// 批量删除oldStart和oldEnd之间的项
// 删除 只能是内部的
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if(oldCh[i]){
parentElm.removeChild(oldCh[i].elm)
}
}
}
}