Vue的虚拟DOM与diff算法
真实DOM与虚拟DOM:
虚拟DOM:用JS对象描述DOM的层次结构。真实DOM中的一切属性都在虚拟DOM中有对应的属性
diff核心:精细化对比、最小量更新
新虚拟DOM和老虚拟DOM进行diff(精细化比较),算出如何最小量更新,然后反映在真实DOM上
真实DOM => 模版编译 => 虚拟DOM
1、h函数(产生虚拟节点VNode)调用h得到虚拟节点
//调用: h('a', { props: { href: 'http://www.baidu.com' }}, '百度'); //得到虚拟DOM: { "sel": "a", "data": { props: { href: 'http://www.baidu.com' } }, "text": "百度" } //表示的真实DOM: <a href="http://www.baidu.com">百度</a>
2、虚拟节点所包含的属性
{ children: undefined data: {}
//elm为虚拟节点所对应的真实节点,若为undefined则是没有上DOM树 elm: undefined
//key唯一标识 key: undefined sel: "div" text: "我是一个盒子" }
3、简单的虚拟节点上树流程
//创建patch函数,模块采用snabbdom const patch = init([ classModule, propsModule, styleModule, eventListenersModule, ]); //创建虚拟节点 var firstVNode = h("a", { props: { href: "http://www.baidu.com" } }, "百度"); console.log(firstVNode); //虚拟节点上树 const container = document.getElementById("container"); patch(container, firstVNode);
4、h函数的嵌套使用
//嵌套h函数 const thirdVNode = h("ul", {}, [ h("li", {}, "牛奶"), h("li", {}, [ h("p", {}, "雪碧"), h("p", {}, "芬达") ]), h("li", {}, "可乐"), ]); //得到虚拟DOM { "sel": "ul", "data": {}, "children": [ { "sel": "li", "text": "牛奶" }, { "sel": "li", { "children": [ { "sel": "p", "text": "雪碧" }, { "sel": "p", "text": "芬达" }, ] } }, { "sel": "li", "text": "可乐" } ] }
5、h函数实现
h函数的一些重要点(snabbdom为例)
- h函数返回一个vnode
- vnode函数将接收的参数返回成对象
- 同理,内部children也不断创建vnode节点
- h函数,如果是js类型下则需要模拟重载(函数的方法名相同,但参数不同,返回的类型不同),判断参数的含义;ts版直接重载
- 手写简单的h函数
//以下vnode函数只返回对象,不做diff处理 //这里只考虑接受三个参数,弱重载 // 形态 h('div', {}, '文字') // 形态 h('div', {}, []) // 形态 h('div', {}, h()) export default function (sel, data, c) { if (arguments.length != 3) { throw new Error("请输入三个参数"); } //检查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]必须是对象,其实在这就是考虑是h函数 if (!(typeof c[i] == "object" && c[i].hasOwnProperty("sel"))) { throw new Error("传入的数组参数中,需要为h函数"); } //收集好数组的h函数,整理children children.push(c[i]); } return vnode(sel, data, children, undefined, undefined); } //如果是h函数,即对象,且有sel属性 else if (typeof c == "object" && c.hasOwnProperty("sel")) { //虽然他只有一个children,但是为了统一,还是要把他放到数组里 let children =[c] return vnode(sel, data, children, undefined, undefined); } else { throw new Error("请传入正确类型的参数"); } }
6、diff算法
1. key是唯一标识,diff算法对比更新DOM的时候看的就是key,而不是内容。如果没有key,当在前排插入节点的时候,全部节点将会重排重绘(涉及元素的几何更新时,叫重排。而只涉及样式更新而不涉及几何更新时,叫重绘。对于两者来说,重排必定引起重绘,但是重绘并不一定引起重排)
2、只有是同一个虚拟节点下(父级标签相同且key相同),才进行精细化比较 ,否则就是暴力删除旧的、插入新的。比如原来是ul,下面是li,然后改成了ol下面还是li,且ol和ul的key相同,其也会全部更新
3、只进行同层比较,不会跨层比较,否则就是暴力删除旧的、插入新的。。即本来children只有一层,新节点有两层children,diff直接全部更新children
4、diff遇到不一样的也不是直接拆了重建,比如他会判断是不是只是移动位置,从而交换位置,而不是删除再新增
7、diff算法的实现:
1、patch函数
- patch首先初始化节点,当老节点不是虚拟节点的时候(第一次使用的时候),先让他变成虚拟节点(和老师的比,新版多了文档片段的转换)
- 再判断新老节点是否为同一节点,如果是则精细化diff,如果不是则直接暴力拆除,递归创建子节点
- 判断方式:sel、is和key相同(和老师的比,新版多了data内的is)
- 手写patch函数(包含createElm)
//patch函数 import createElm from "./createElm"; import vnode from "./vnode"; export default function (oldVNode, newVNode) { //判断老节点是否为vnode if (oldVNode.sel == "" || oldVNode.sel == undefined) { //DOM节点要包装成虚拟节点 oldVNode = vnode( oldVNode.tagName.toLowerCase(), {}, [], undefined, oldVNode ); } //判断old和new是不是同一个节点 if (oldVNode.key == newVNode.key && oldVNode.sel == newVNode.sel) { //同一节点精细比较 } else { //不是则删除旧的,上新的 let newVNodeElm = createElm(newVNode); //当返回的值存在,且oldVNode存在parentNode,毕竟手写的,防报错 if (oldVNode.elm.parentNode && newVNodeElm) { oldVNode.elm.parentNode.insertBefore(newVNodeElm, oldVNode.elm); //删除老节点 oldVNode.elm.parentNode.removeChild(oldVNode.elm); } } } //createElm函数 export default function createElement(vnode) { let domNode = document.createElement(vnode.sel); //由于是简单的diff,所以这里就不考虑又传文字又传children的情况 if ( vnode.text != "" && (vnode.children == undefined || vnode.children.length == 0) ) { //文本情况,不要做太多的事情,让patch做 domNode.innerText = vnode.text; } else if (Array.isArray(vnode.children) && vnode.children.length > 0) { //递归创建子节点 //在这步之前是把标杆拿出去了,不然不好递归 for (let i = 0; i < vnode.children.length; i++) { let ch = vnode.children[i]; //开启递归,直到没有children let chDOM = createElement(ch); //给父亲添加孩子生成dom树 //又因为子元素没有标杆,不能用insertBefore,所以直接append domNode.appendChild(chDOM); } } //帮其上树,补充elm vnode.elm = domNode; //返回这个真实的dom节点 return vnode.elm; }
2、精细化diff(这里都只考虑:没有text就有children)
- 手写精细化第一部分patch
if (oldVNode.key == newVNode.key && oldVNode.sel == newVNode.sel) { //同一节点精细比较 //先判断两点在内存中是否为同一对象 if (oldVNode == newVNode) { return; } if ( newVNode.text != "" && (newVNode.children == undefined || newVNode.children.length == 0) ) { //命中,newvnode有text无children //如果text不同就覆盖,相同则不用操作 if (newVNode.text != oldVNode.text) { oldVNode.elm.innerText = newVNode.text; } } else { //newVNode没有text,则有children //判断oldVNode有没有children if (oldVNode.children != undefined && oldVNode.children.length > 0) { //老的也有children,此时情况最复杂,要递归 //这里为第二部分,放在下面 // patchVNode(); } else { //老的没有children,则有text //清空text并追加children oldVNode.elm.innerText = ""; for (let i = 0; i < newVNode.children.length; i++) { let dom = createElm(newVNode.children[i]); oldVNode.elm.appendChild(dom); } } } }
- 精细化比较第二部分(五角星部分)
首先分析,新、老节点都有children,新节点相比于老节点,有增、删、移、更新内容四种情况。
如果单纯的手写,需要考虑的内容太多,且四种情况容易杂糅。因此引出diff算法
diff的子节点更新策略:
四种命中查找(指针依次往下走,且规则按顺序判断执行,命中其中一种,就不会往下判断):
① 新前与旧前相同
② 新后与旧后相同
③ 新后与旧前相同 (此种发生了,涉及移动节点,那么新后指向的节点(其实移旧前也可以),移动到旧后之后)
④ 新前与旧后相同 (此种发生了,涉及移动节点,那么新前指向的节点,移动到旧前之前)
若都没有命中,需要通过循环寻找老节点内是否有相同,有则移动新前,且将老节点中相同的节点undefined(Vue)
先定下循环的规则:while(新前<=新后&&旧前<=旧后)
新增的逻辑:当旧节点先完成循环,则新前和新后的中间节点为需要新增的节点
删除的逻辑:当新节点先完成循环,则旧前和旧后的中间节点为需要删除的节点
多删(位置多变)的逻辑:当四个都不命中,则循环旧节点,找到与新前相同的,Vue中将其命为undefined,然后新前指针往前,继续循环至循环结束
- 命中3时:新前指向的节点,移动到旧后之后,新后指针上移,旧前指针节点undefined后下移。
这里要注意,是没处理的旧后的后面,按顺序插入(如图B、C),若已经有添加过的(如图老节点的A),那要在添加过的之前添加(也就是旧后的后面)
- 命中4时:新前指向的节点,移动到旧前之前,新前指针下移,旧后指针节点undefined后上移,同理,注意插入顺序
import createElm from "./createElm"; import patchVNode from "./patchVNode"; //判断是否同节点 function sameVNode(a, b) { return a.sel == b.sel && a.key == b.key; } export default function updateChildren(parentElm, oldCh, newCh) { //旧前指针 let oldStartIdx = 0; //新前指针 let newStartIdx = 0; //旧后指针 let oldEndIdx = oldCh.length - 1; //新后指针 let newEndIdx = newCh.length - 1; //旧前节点 let oldStartVNode = oldCh[0]; //旧后节点 let oldEndVNode = oldCh[oldEndIdx]; //新前节点 let newStartVNode = newCh[0]; //新后节点 let newEndVNode = newCh[newEndIdx]; //创建keyMap帮助快速查找key let keyMap = null; //diff的循环规则 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { //这里得先判断oldStart节点是否被设置成了undefined,如果是就跳过 if (oldStartVNode == undefined) { oldStartVNode = oldCh[++oldStartIdx]; } else if (oldEndVNode == undefined) { oldEndVNode = oldCh[--oldEndIdx]; } //新前旧前 else if (sameVNode(oldStartVNode, newStartVNode)) { console.log("新前旧前"); patchVNode(newStartVNode, oldStartVNode); oldStartVNode = oldCh[++oldStartIdx]; newStartVNode = newCh[++newStartIdx]; } //新后旧后 else if (sameVNode(newEndVNode, oldEndVNode)) { console.log("新后旧后"); patchVNode(newEndVNode, oldEndVNode); oldEndVNode = oldCh[--oldEndIdx]; newEndVNode = newCh[--newEndIdx]; } //新后旧前 else if (sameVNode(newEndVNode, oldStartVNode)) { console.log("新后旧前"); patchVNode(newEndVNode, oldStartVNode); //insert会自动将另一个li删除,相当于移动 //如果nextSibing为null则insertBefore会将其插到最后 parentElm.insertBefore(oldStartVNode.elm, oldEndVNode.elm.nextSibling); oldStartVNode = oldCh[++oldStartIdx]; newEndVNode = newCh[--newEndIdx]; } //新前旧后 else if (sameVNode(newStartVNode, oldEndVNode)) { console.log("新前旧后"); patchVNode(newStartVNode, oldEndVNode); parentElm.insertBefore(oldEndVNode.elm, oldStartVNode.elm); oldEndVNode = oldCh[--oldEndIdx]; newStartVNode = newCh[++newStartIdx]; } //都未命中 else { //如果没有keyMap,先创建keyMap if (!keyMap) { keyMap = {}; for (let i = oldStartIdx; i <= oldEndIdx; i++) { const key = oldCh[i].key; if (key != undefined) { keyMap[key] = i; } } } //寻找没有命中的项,新的项(这里newStartVNode指的就是新节点所指的 旧节点可能没有的新项) //用map找得更快 const idxInOlds = keyMap[newStartVNode.key]; console.log(idxInOlds); //idxInOlds如果没找到,那就是undefined if (idxInOlds == undefined) { //新项,不存在的新项,放在oldstart前 parentElm.insertBefore(createElm(newStartVNode), oldStartVNode.elm); } else { //存在项,需要移动 const elmToMove = oldCh[idxInOlds]; patchVNode(newStartVNode, elmToMove); //标记undefined,表示已处理该项 oldCh[idxInOlds] = undefined; //移动 console.log(elmToMove); parentElm.insertBefore(elmToMove.elm, oldStartVNode.elm); } newStartVNode = newCh[++newStartIdx]; } } //看新节点有无剩余,若有,则为新增的节点 if (newStartIdx <= newEndIdx) { console.log("新增节点"); //添加标杆,这里标杆使用新节点是因为现在处理的是剩余的节点,所要插入要依照新节点的顺序 const before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm; for (let i = newStartIdx; i <= newEndIdx; i++) { parentElm.insertBefore(createElm(newCh[i]), before); } } //看旧节点有无剩余,若有,则为删除的节点 else if (oldStartIdx <= oldEndIdx) { console.log("删除节点"); console.log(oldCh); for (let i = oldStartIdx; i <= oldEndIdx; i++) { if (oldCh[i]) { parentElm.removeChild(oldCh[i].elm); } } } }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!