一、什么是虚拟dom
虚拟DOM 其实就是一棵以 JavaScript 对象 (VNode 节点) 作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。
//真实dom
<div id="app"> hello world</div>
//虚拟dom
{ tagName:'div', attrs:{id:'app' },children:[ 'hello world']}
二、为什么要使用虚拟dom
1、减少直接操作 dom 次数,从而提高程序性能
js 层面上,DOM 的操作并不慢。DOM 操作慢是慢在浏览器渲染的过程里,改变一行数据就要全部重新渲染,在大多数情况下虚拟 DOM 比 DOM 快,是因为需要更新的 DOM 节点要比原生 DOM 操作更新的节点少,浏览器重绘的时间更短。而且虚拟 DOM 的优势不在于单次的操作,用对比的算法,它可以将多次操作合并成一次操作,在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。
真实的dom上有一堆的属性和方法,直接操作DOM的话性能会变得很慢。频繁操作虚拟DOM不存在性能问题,等数据全部更新完之后只会去更新真实dom树需要更新改变的地方
直接操作真的的DOM每操作一次就会导致一次重绘和回流。使用虚拟dom,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制,从而提高性能!
2、跨平台
它的本质就是一个 JavaScript 对象,并不依赖真实平台环境,所以使它具有了跨平台的能力。它在浏览器上可以变成 DOM,在其他平台里也可以变成相应的渲染对象。同一VNode 节点可以渲染成不同平台上的对应的内容,比如:渲染在浏览器是 dom 元素节点,渲染在 Native( iOS、Android) 变为对应的控件、可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等 、渲染到 WebGL 中等等。
Vue3 中允许开发者基于 VNode 实现自定义渲染器(renderer),以便于针对不同平台进行渲染
三、DOM diff
snabbdom一个虚拟 DOM 库,专注提供简单、模块性的体验,以及强大的功能和性能。
1、snabbdom基本使用
//安装
npm i snabbdom -D
//使用
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
const patch = init([
// 通过传入模块初始化 patch 函数
classModule, // 开启 classes 功能
propsModule, // 支持传入 props,允许在dom上设置属性
styleModule, // 支持内联样式同时支持动画
eventListenersModule, // 添加事件监听
]);
// 创建虚拟节点
const myVnode=h('div',{props:{href:"https:/'/baidu.com"}, on: { click: someFn },style:{color:'red'}},'我是替换后的内容')
function someFn(){
console.log(myVnode);
}
//让虚拟节点上树
const container=document.getElementById('container')
patch(container,myVnode)
//获取到的虚拟节点的内容为下面的对象
//让虚拟节点上树
const container=document.getElementById('container')
patch(container,myVnode1)
//再次调用 `patch`
const myVnode2=h('div',{},'test2')
patch(myVnode1,myVnode2)
2、核心算法
h函数:把传入的节点信息转化为虚拟dom
//使用形式
h('div')
h('div','文字’)
h('div',{},'文字’)
h('div',{},[])
h('div',{},h())
//嵌套使用,得到有children的虚拟节点
h('ul',{},[
h('li',{},'牛奶‘),
h('li',{},'咖啡‘)
])
patch函数:把虚拟dom转化为真实dom
// 调用h函数
h('a',{props:{href:'https://baidu.com'}},'点击跳转')
//产生虚拟节点
{"sel":"a","data":{"props":{"href":"https://baidu.com"}},"text":"点击跳转"}
// 真正的dom节点
<a href="https://baidu.com">点击跳转</a>
四、手写diff算法
①、key 节点的唯一标识,使节点最小量更新
②、只有同一个虚拟节点才会进行精细化比较(老节点的标签和新节点的标签相同且新老节点的key相同)
③、只进行同层比较,不会进行跨层比较
1、实现h函数
vnode.js
把传入的js参数组合成对象返回
//{
// children: undefined,//子元素
// data: props: {href: "https:/'/baidu.com"}},//属性&属性值对象
// elm: undefined,//元素对应的真正的元素节点
// key: undefined,//唯一标示
// sel: "a",//选择器
// text: "test",//内容文本
//}
export default function(sel,data,children,text,elm){
let key=data?.key??undefined
return {sel,data,children,text,elm,key}
}
h.js
低配版,只支持三个参数的情况
h('div',{},'文字’)
h('div',{},[])
h('div',{},h())
import vNode from './vnode'
// 将内容转化为虚拟节点
function h(sel,data,c){
if(arguments.length!==3){
throw new Error ('参数有误')
}else if(typeof c!=='object'){
return vNode(sel,data,undefined,c,undefined)
}else if(Array.isArray(c)){
//检查c[i]中必须是一个对象
let children=[]
for(let i=0;i<c.length;i++){
if(!(typeof c[i]==='object'&&c[i].hasOwnProperty('sel'))){
throw new Error ('传入数组参数中有项不是h函数')
}else{
children.push(c[i])
}
}
return vNode(sel,data,children,undefined,undefined)
}else if(typeof c==='object'&&c.hasOwnProperty('sel')){
let children=[c]
return vNode(sel,data,children,undefined,undefined)
}else{
throw new Error ('第三个参数有误')
}
}
export default h
2、patch函数
createElement.js
真正创建节点,将vnode创建为dom,孤儿节点不进行插入
//创建节点,将vnode转化为dom插入到pivot元素之前
export default function createElement (vnode) {
//创建一个节点
let domNode = document.createElement(vnode.sel);
//判断子节点还是文字内容
if (vnode.text && (!vnode.children || !vnode.children.length)) {
domNode.innerText = vnode.text
} else if (Array.isArray(vnode.children) && vnode.children.length) {
// 如果是一个数组,包含了子节点,递归处理
for(let i=0;i<vnode.children.length;i++){
let ch=vnode.children[i]
let chDom=createElement(ch)
domNode.appendChild(chDom)
}
}
vnode.elm=domNode
return vnode.elm
}
pathVnode.js
对比节点
// 1、新节点是文本属性,使用innerText直接替换老节点
// 2、老节点是文本属性,直接用新节点的内容替换
// 3、新老节点都不是文本属性,diff判断最小量更新
import createElement from './createElement';
import updateChildren from './updateChildren';
export default function (oldVnode, newVnode) {
if (newVnode.text && (!newVnode.children || !newVnode.children.length)) {
//场景1
if (newVnode.text !== oldVnode.text) {
oldVnode.elm.innerText = newVnode.text
}
} else if (oldVnode.text && (!oldVnode.children || !oldVnode.children.length)) {
//场景2
//清空老节点的文字
oldVnode.elm.innerHtml = ''
for (let i; i < newVnode.children.length; i++) {
let ch = newVnode.children[i]
let newVnodeElm = createElement(ch)
//把新节点塞入老节点中
oldVnode.elm.appendChild(newVnodeElm)
}
} else if (newVnode.children && newVnode.children.length && oldVnode.children && oldVnode.children.length) {
//场景3
let parentElm = oldVnode.elm
let oldCh = oldVnode.children
let onewCh = newVnode.children
updateChildren(parentElm,oldCh,onewCh)
}
}
updateChildren.js
diff对比新旧节点
import pathVnode from "./pathVnode"
import createElement from "./createElement"
//判断两个节点是否是同一个节点
function checkSomeVnode (vnode1, vnode2) {
return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
export default function (parentElm, oldCh, newCh) {
// 新前 旧前
// 新后 旧后
// 新后 旧前
// 新前 旧后
// 命中其中一个就停止,如果都没有命中遍历查找
//旧前 位置
let oldStartIdx = 0
//旧前 节点
let oldStartVnode = oldCh[0]
//旧后 位置
let oldEndIdx = oldCh.length - 1
//旧后 节点
let oldEndVnode = oldCh[oldEndIdx]
//新前 位置
let newStartIdx = 0
//新前 节点
let newStartVnode = newCh[0]
//新后 位置
let newEndIdx = newCh.length - 1
//新后 节点
let newEndVnode = newCh[newEndIdx]
let keyMap = null
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if(oldStartVnode==null||oldCh[oldStartIdx]===undefined){
oldStartVnode=oldCh[++oldStartIdx]
}else if(oldEndVnode==null||oldCh[oldEndIdx]===undefined){
oldEndVnode = oldCh[--oldEndIdx]
}else if(newStartVnode===null||newCh[newStartIdx]===undefined){
newStartVnode = newCh[++newStartIdx]
}else if(newEndVnode===null||newCh[newEndIdx]===undefined){
newEndVnode = newCh[--newEndIdx]
}else if (checkSomeVnode(oldStartVnode, newStartVnode)) {
//判断新前和新后是否匹配
//对比同一个虚拟节点
console.log('新前和旧前中');
pathVnode(oldStartVnode, newStartVnode)
//后移 新前和旧前的指针,重新赋值节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (checkSomeVnode(oldEndVnode, newEndVnode)) {
//对比新后和旧后
console.log('新后和旧后命中');
pathVnode(oldEndVnode, newEndVnode)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSomeVnode(oldStartVnode, newEndVnode)) {
//对比新后和旧前
console.log('新后和旧前命中');
pathVnode(oldStartVnode, newEndVnode)
//只要插入一个已经在dom树上的节点就会被移动
parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (checkSomeVnode(oldEndVnode, newStartVnode)) {
//对比新前和旧后,节点移动到旧后节点之后
console.log('新前和旧后命中');
pathVnode(oldEndVnode, newStartVnode)
parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
//四种情况都没有命中的情况
console.log('四种情况都没有命中');
if (!keyMap) {
//循环存储key值对应的下标
keyMap = {}
for (let i = 0; i <= oldEndIdx; i++) {
const key = oldCh[i].key
if (key !== undefined) {
keyMap[key] = i
}
}
}
// 找到当前这项在keymap中的位置序号
let idxInOld = keyMap[newStartVnode.key]
if (idxInOld) {
//当该项在旧的虚拟列表中存在
const elmToMove = oldCh[idxInOld]
pathVnode(elmToMove, newStartVnode)
// 把该项移动到旧的虚拟列表的对应位置
oldCh[idxInOld] = undefined;
parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm)
}else{
//当该项在旧的虚拟列表中不存在,创建节点并插入
parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx]
}
}
console.log('newStartIdx',newStartIdx,'newEndIdx',newEndIdx)
//插入节点的情况
if (newStartIdx <= newEndIdx) {
// console.log('还有新的节点没有处理')
for (let i = newStartIdx; i <= newEndIdx; i++) {
console.log('oldCh[oldStartIdx]',oldCh[oldStartIdx])
parentElm.insertBefore(createElement(newCh[i]),oldCh[oldStartIdx]?.elm)
}
} else if (oldStartIdx <= oldEndIdx) {
//需要删除的场景
for (let i = oldStartIdx; i <= oldEndIdx; i++) {
if (oldCh[i]) {
parentElm.removeChild(oldCh[i].elm)
}
}
}
}
patch.js
将新旧两个节点对比后更新dom
import vNode from './vnode'
import createElement from './createElement';
import patchVnode from './pathVnode';
//判断两个节点是否是同一个节点
function isSomeVnode (vnode1, vnode2) {
return vnode1.sel === vnode2.sel && vnode1.key === vnode2.key
}
//
export default function (oldVnode, newVnode) {
let _oldVnode = oldVnode;
//判断老节点是虚拟节点还是真实dom节点
if (!_oldVnode.sel) {
//oldVnode为真实dom节点,需要包装为虚拟节点
_oldVnode = vNode(oldVnode.tagName.toLowerCase(0), {}, [], undefined, oldVnode)
}
//判断新老节点是否是同一个节点
if (isSomeVnode(_oldVnode, newVnode)) {
//同一个节点,需要进行精细化比较
if (_oldVnode === newVnode) {
return
}
// 1、新节点是文本属性,使用innerText直接替换老节点
// 2、老节点是文本属性,直接用新节点的内容替换
// 3、新老节点都不是文本属性,diff判断最小量更新
patchVnode(_oldVnode, newVnode)
} else {
//直接暴力更新
let newVnodeElm = createElement(newVnode)
//把新节点插入到老节点之前
_oldVnode.elm.parentNode.insertBefore(newVnodeElm, _oldVnode.elm)
//删除老节点
_oldVnode.elm.parentNode.removeChild(_oldVnode.elm)
}
}
五、diff 算法中的key的作用
没有key或者key是索引index时,会采用就地更新原则,在顺序位置上同一个索引就会被认为是同一个元素,正常情况下效率确实会比较高,但是当顺序更改的时候,就会出现索引不一样,不必要的元素也要更新内容和属性。
六、vue2和vue3中diff的实现及区别
可以看这个大神的文章https://juejin.cn/post/7010594233253888013#heading-9