本篇文章带大家学习一下vue,彻底的弄懂 虚拟dom 和 diff算法,希望对大家有所帮助!
本文章主要的目的就是让大家:真正的、彻底的弄懂 虚拟dom 和 diff算法,那么何为真正、彻底的弄懂呢?就是我们自己要把它们的底层动手敲出来!从 虚拟dom如何被渲染函数(h函数)产生(手写h函数),到 diff算法原理(手写diff算法)、最后 虚拟dom如何通过diff变为真正的dom的(事实上,虚拟dom变回真正的dom,是涵盖在diff算法里面的),为了方便大家去理解,可能文章涉及的点比较多,内容比较长,希望大家耐心细品,最后希望各位大佬点一个赞!!!。
好了,废话不多说,正式进入文章主题,让你真正的、彻底掌握 虚拟dom 和 diff算法。下面,我们一步步来实现虚拟dom和diff算法。【相关推荐:vuejs视频教程】
简单介绍一下 虚拟dom 和 diff算法先用一个简单的例子来说一下 虚拟dom 和 diff算法:比如有一个户型图,现在我们需要对这个户型图进行下面的改造,
其实,这个就是相当于一个进行找茬的游戏,让我们找出与原来的不同之处。下面,我已经将不同之处圈了出来,
现在,我们已经知道了要进行哪些改造了,但是,我们该如何进行改造呢?最笨的方法就是全部拆了再重新建一次,但是,在我们实际中肯定不会进行拆除再新建,这样效率太低了,而且代价太昂贵。确实是完成了改造,但是,这不是一个最小量的更新,所以我们想要的是 diff,
那么diff是什么呢?其实,diff 在我们计算机中就是代表着最小量更新的一个算法,会进行精细化对比,以最小量去更新。这样你就会发现,它的代价比较小,也不会昂贵,也会比较优化,所以对应在我们 vue底层中是非常关键的。
好了,现在回归到我们的vue中,上面的户型图中就相当于vue中的 dom节点,我们需要对这些节点进行改造(增删调),然后以最小量去更新dom,这样就会避免我们性能上面的开销。
// 原先dom<div class="box"> <h2>标题</h2> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> </div>
// 修改后的dom<div class="box"> <h2>标题</h2> <span>青峰</span> <ul> <li>1</li> <li>2</li> <li>3</li> <li>4</li> </ul> </div>
在这里,我们就可以利用 diff算法进行精细化对比,实现最小量更新。上面我们了解了什么是diff,下面再来简单了解一下什么是虚拟dom,
<div class="box"> <h2>标题</h2> <ul> <li>1</li> <li>2</li> <li>3</li> </ul> </div>
{ sel: "div", elm: undefined, // 表示虚拟节点还没有上树 key: undefined, // 唯一标识 data: { class: { "box" : true} }, children: [ { sel: "h2", data: {}, text: "标题" }, { sel: "ul", data: {}, children: [ { sel: li, data: {}, text: "1"}, { sel: li, data: {}, text: "2"}, { sel: li, data: {}, text: "3"} ] } ]}
通过观察可以发现,虚拟dom 是一个 javsscript对象,里面包含 sel选择器,data数据,text文本内容,children子标签等等,一层嵌套一层。这样就表达了一个 虚拟dom结构,处理 虚拟dom 的方式总比处理 真实的dom 要简单并且高效,所以 diff算法 是发生在 虚拟dom 上的。
注意:diff算法 是发生在 虚拟dom 上的。
为什么需要 virtual dom(虚拟dom)首先,我们都知道,在前端性能优化的一个秘诀就是尽可能的减少dom的操作,不仅仅是dom相对较慢,更是因为变动dom会造成浏览器的回流和重绘,这些都会降低性能,因此,我们需要虚拟dom,在patch(比较新旧虚拟dom更新去更新视图)过程中尽可能的一次性将差异更新到dom中,这样就保证了dom不会出现了性能很差的情况。其次,使用 虚拟dom 改变了当前的状态不需要立即去更新dom,而是对更新的内容进行更新,对于没有改变的内容不做任何处理,通过前后两次差异进行比较。最后,也是virtual dom 最初的目的,就是更好的跨平台,比如node.js就没有dom,如果想实现 ssr(服务端渲染),那么一个方式就是借助virtual dom,因为 virtual dom本身是 javascript对象。h函数(创建虚拟dom)作用:h函数 主要用来产生 虚拟节点(vnode)
第一个参数:标签名字、组件的选项对象、函数
第二个参数:标签对应的属性 (可选)
第三个参数:子级虚拟节点,字符串或者是数组形式
h('a',{ props: {href: 'http://www.baidu.com'}, '百度'})
上面的h函数对应的虚拟节点为:
{ sel: 'a', data: { props: {href: 'http://www.baidu.com'}}, text: "百度"}
真正的dom节点为:
<a href = "http://www.baidu.com">百度</a>
我们还可以嵌套的使用h函数,比如:
h('ul', {}, [ h('li', {}, '1'), h('li', {}, '2'), h('li', {}, '3'),])
嵌套使用h函数,就会生成一个虚拟dom树。
{ sel: "ul", elm: undefined, key: undefined, data: {}, children: [ { sel: li, elm: undefined, key: undefined, data: {}, text: "1"}, { sel: li, elm: undefined, key: undefined, data: {}, text: "2"}, { sel: li, elm: undefined, key: undefined, data: {}, text: "3"} ] }
好了,上面我们已经知道了h函数是怎么使用的了,下面我们手写一个阉割版的h函数。
手写 h函数我们手写的这个函数只考虑三种情况(带三个参数),分别如下:
情况①:h('div', {}, '文字')情况②:h('div', {}, [])情况③:h('div', {}, h())
在手写h函数之前,我们需要声明一个函数,用来创建虚拟节点
// vnode.js 返回虚拟节点export default function(sel, data, children, text, elm) { // sel 选择器 、data 属性、children 子节点、text 文本内容、elm 虚拟节点绑定的真实 dom 节点 const key = data.key return { sel, data, children, text, elm, key }}
声明好vnode函数之后,我们正式来手写h函数,思路如下:
判断第三个参数是否是字符串或者是数字。如果是字符串或数字,直接返回 vnode
判断第三个参数是否是一个数组。声明一个数组,用来存储子节点,需要遍历数组,这里需要判断每一项是否是一个对象(因为 vnode 返回一个对象并且一定会有sel属性)但是不需要执行每一项,因为在数组里面已经执行了h函数。其实,并不是函数递归进行调用(自己调自己),而是一层一层的嵌套
判断都三个参数是否是一个对象。直接将这个对象赋值给 children,并会返回 vnode
// h.js h函数import vnode from "./vnode";// 情况①:h('div', {}, '文字')// 情况②:h('div', {}, [])// 情况③:h('div', {}, h())export default function (sel, data, c) { // 判断是否传入三个参数 if (arguments.length !== 3) throw error('传入的参数必须是三个参数') // 判断c的类型 if (typeof c === 'string' || typeof c === 'number') { // 情况① return vnode(sel, data, undefined, c, undefined) } else if(array.isarray(c)) { // 情况② // 遍历 let children = [] for(let i = 0; i < c.length; i++) { // 子元素必须是h函数 if (!(typeof c[i] === 'object' && c[i].hasownproperty('sel'))) throw error('数组中有一项不是h函数') // 收集子节点 不需要执行 因为数组里面已经执行h函数来 children.push(c[i]) } return vnode(sel, data, children, undefined, undefined) } else if (typeof c === 'object' && c.hasownproperty('sel')) { // 直接将子节点放到children中 let children = [c] return vnode(sel, data, children, undefined, undefined) } else { throw error('传入的参数格式不对') }}
通过上面的代码,我们已经实现了一个简单 h函数 的基本功能。
感受 diff 算法在讲解 diff算法 之前,我们先来感受一下 diff算法 的强大之处。先利用 snabbdom 简单来举一个例子。
import { init, classmodule, propsmodule, stylemodule, eventlistenersmodule, h,} from "snabbdom";//创建出patch函数const patch = init([ classmodule, propsmodule, stylemodule, eventlistenersmodule, ]);//让虚拟节点上树const container = document.getelementbyid("container");const btn = document.getelementbyid("btn");//创建虚拟节点const myvnode1 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd')])patch(container, myvnode1)const myvnode2 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd'), h('li', {}, 'e'),])btn.addeventlistener('click', () => { // 上树 patch(myvnode1,myvnode2)})
当我们点接改变dom的时候,发现会新增一个 li标签 内容为 e,单单的点击事件,我们很难看出,是将 旧的虚拟dom 全部替换掉 新的虚拟dom,然后再渲染成 真实dom,还是直接在 旧的虚拟dom 上直接在后面添加一个节点,所以,在这里我们可以巧妙的打开测试工具,直接将标签内容进行修改,如果点击之后是全部拆除,那么标签的内容就会发生改变,若内容没有发生改变,则是将最后添加的。
点击改变 dom 结构:
果然,之前修改的内容没有发生变化,这一点,就可以验证了是进行了 diff算法精细化的比较,以最小量进行更新。那么问题就来了,如果我在前面添加一个节点呢?是不是也是像在最后添加一样,直接在前面添加一个节点。我们不妨也来试一试看看效果:
...const container = document.getelementbyid("container");const btn = document.getelementbyid("btn");//创建虚拟节点const myvnode1 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd')])patch(container, myvnode1)const myvnode2 = h('ul', {}, [ h('li', {}, 'e'), // 将e移至前面 h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd'),])btn.addeventlistener('click', () => { // 上树 patch(myvnode1,myvnode2)})
点击改变 dom 结构
哦豁!!跟我们想的不一样,你会发现,里面的文本内容全部发生了变化,也就是说将之前的 dom 全部拆除,然后将新的重新上树。这时候,你是不是在怀疑其实 diff算法 没有那么强大,但是你这样想就大错特错了,回想一下在学习 vue 的过程中,在遍历dom节点 的时候,是不是特别的强调要写上key唯一标识符,此时,key在这里就发挥了它的作用。 我们带上key再来看一下效果:
...const myvnode1 = h('ul', {}, [ h('li', { key: "a" }, 'a'), h('li', { key: "b" }, 'b'), h('li', { key: "c" }, 'c'), h('li', { key: "d" }, 'd')])patch(container, myvnode1)const myvnode2 = 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'),])...
点击改变 dom 结构
看到上面的结果,此时此刻,你是不是恍然大悟了,顿时知道了key在循环当中有什么作用了吧。我们可以推出的结论一就是:key是当前节点的唯一标识,告诉 diff算法,在更改前后它们是同一个 dom节点。
当我们修改父节点,此时新旧虚拟dom的父节点不是同一个节点,继续来观察一下 diff算法是如何分析的
const myvnode1 = h('ul', {}, [ h('li', { key: "a" }, 'a'), h('li', { key: "b" }, 'b'), h('li', { key: "c" }, 'c'), h('li', { key: "d" }, 'd')])patch(container, myvnode1)const myvnode2 = h('ol', {}, [ h('li', { key: "a" }, 'a'), h('li', { key: "b" }, 'b'), h('li', { key: "c" }, 'c'), h('li', { key: "d" }, 'd'),])
点接改变 dom结构
你会发现,这里将旧节点进行了全部的拆除,然后重新将新节点上树。我们可以推出的结论二就是:
只有是同一个虚拟节点,diff算法 才进行精细化比较,否则就是暴力删除旧的、插入新的。判断同一个虚拟节点的依据:选择器(sel)相同且key相同。
那么如果是同一个虚拟节点,但是子节点里面不是同一层在比较的呢?
const myvnode1 = h('div', {}, [ h('li', { key: "a" }, 'a'), h('li', { key: "b" }, 'b'), h('li', { key: "c" }, 'c'), h('li', { key: "d" }, 'd')])patch(container, myvnode1)const myvnode2 = h('div', {}, h('section', {}, [ h('li', { key: "a" }, 'a'), h('li', { key: "b" }, 'b'), h('li', { key: "c" }, 'c'), h('li', { key: "d" }, 'd'), ]))
点击改变dom结构
你会发现,此时dom结构同多了一层 section标签 包裹着,然后,文本的内容也发生了变化,所以我们可以推出结论三:
diff算法 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,不进行精细化比较,而是暴力删除旧的、然后插入新的。
综上,我们得出diff算法的三个结论:
key 是当前节点的唯一标识,告诉 diff算法,在更改前后它们是同一个 dom节点。
只有是同一个虚拟节点,diff算法 才进行精细化比较,否则就是暴力删除旧的、插入新的。判断同一个虚拟节点的依据:选择器(sel)相同 且 key相同。
diff算法 只进行同层比较,不会进行跨层比较。即使是同一片虚拟节点,但是跨层了,不进行精细化比较,而是暴力删除旧的、然后插入新的。
看到这里,相信你已经对 diff算法 已经有了很大的收获了。
patch 函数patch函数 的主要作用就是:判断是否是同一个节点类型,是就在进行精细化对比,不是就进行暴力删除,插入新的。
我们在可以简单的画出patch函数现在的主要流程图如下:
// patch.js patch函数import vnode from "./vnode";import samevnode from "./samevnode";import createelement from "./createelement";export default function (oldvnode, newvnode) { // 判断oldvnode是否是虚拟节点 if (oldvnode.sel == '' || oldvnode.sel == undefined) { // console.log('不是虚拟节点'); // 创建虚拟dom oldvnode = emptynodeat(oldvnode) } // 判断是否是同一个节点 if (samenode(oldvnode, newvnode)) { console.log('是同一个节点'); } else { // 暴力删除旧节点,插入新的节点 // 传入两个参数,创建的节点 插入到指定标杆的位置 createelement(newvnode, oldvnode.elm) }}// 创建虚拟domfunction emptynodeat (elm) { return vnode(elm.tagname.tolowercase(), {}, [], undefined, elm)}
在进行上dom上树之前,我们需要了解一下dom中的insertbefore()方法、appendchild()方法,因为,只有你真正的知道它们两者的用法,才会让你在下面手写上树的时候更加的清晰。
appendchild()方法
appendchild() 方法:可以向节点的子节点列表的末尾添加新的子节点。比如:appendchild(newchild)。
注意: appendchild()方法是在父节点中的子节点的末尾添加新的节点。(相对于父节点来说)。
<body> <div class="box"> <span>青峰</span> <ul> <li>1</li> <li>2</li> </ul> </div> <script> const box = document.queryselector('.box') const appenddom = document.createelement('div') appenddom.style.backgroundcolor = 'blue' appenddom.style.height = 100 + 'px' appenddom.style.width = 100 + 'px' // 在box里面的末尾追加一个div box.appendchild(appenddom) </script></body>
你会发现,创建的div是嵌套在box里面的,div 属于 box 的子节点,box 是 div 的子节点。
insertbefore()方法
insertbefore() 方法:可在已有的字节点前中插入一个新的子节点。比如:insertbefore(newchild,rechild)。
注意: insertbefore()方法是在已有的节点前添加新的节点。(相对于子节点来说的)。
<body> <div class="box"> <span>青峰</span> <ul> <li>1</li> <li>2</li> </ul> </div> <script> const box = document.queryselector('.box') const insertdom = document.createelement('p') insertdom.innertext = '我是insertdom' // 在body中 box前面添加新的节点 box.parentnode.insertbefore(insertdom, box) </script></body>
我们发现,box 和 div 是同一层的,属于兄弟节点。
处理不同节点
samevnode 函数
作用:比较两个节点是否是同一个节点
// samevnode.jsexport default function samevnode(oldvnode, newvnode) { return (oldvnode.data ? oldvnode.data.key : undefined) === (newvnode.data ? newvnode.data.key : undefined) && oldvnode.sel == newvnode.sel}
手写第一次上树
理解了上面的 appendchild()方法、insertbefore()方法之后,我们正式开始让 真实dom 上树,渲染页面。
// patch.js patch函数import vnode from "./vnode";import samevnode from "./samevnode";import createelement from "./createelement";export default function (oldvnode, newvnode) { // 判断oldvnode是否是虚拟节点 if (oldvnode.sel == '' || oldvnode.sel == undefined) { // console.log('不是虚拟节点'); // 创建虚拟dom oldvnode = emptynodeat(oldvnode) } // 判断是否是同一个节点 if (samenode(oldvnode, newvnode)) { console.log('是同一个节点'); } else { // 暴力删除旧节点,插入新的节点 // 传入两个参数,创建的节点 插入到指定标杆的位置 createelement(newvnode, oldvnode.elm) }}// 创建虚拟domfunction emptynodeat (elm) { return vnode(elm.tagname.tolowercase(), {}, [], undefined, elm)}
上面我们已经明确的知道,patch的作用就是判断是否是同一个节点,所以,我们需要声明一个createelement函数,用来创建真实dom。
createelement 函数
createelement主要用来 创建子节点的真实dom。
// createelement.jsexport default function createelement(vnode,pivot) { // 创建上树的节点 let domnode = document.createelement(vnode.sel) // 判断有文本内容还是子节点 if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) { // 文本内容 直接赋值 domnode.innertext = vnode.text // 上树 往body上添加节点 // insertbefore() 方法:可在已有的字节点前中插入一个新的子节点。相对于子节点来说的 pivot.parentnode.insertbefore(domnode, pivot) } else if (array.isarray(vnode.children) && vnode.children.length > 0) { // 有子节点 }}
// index.jsimport patch from "./mysnabbdom/patch";import h from './mysnabbdom/h'const container = document.getelementbyid("container");//创建虚拟节点const myvnode1 = h('h1', {}, '文字')patch(container, myvnode1)
我们已经将创建的真实dom成功的渲染到页面上去了,但是这只是实现了最简单的一种情况,那就是 h函数 第三个参数是字符串的情况,所以,当第三个参数是一个数组的时候,是无法进行上树的,下面我们需要将 createelement函数 再进一步的优化,实现递归上树。
递归创建子节点
我们发现,在第一次上树的时候,createelement函数 有两个参数,分别是:newvnode (新的虚拟dom),标杆(用来上树插入到某个节点的位置),在createelement内部 我们是使用 insertbefore()方法 进行上树的,使用这个方法我们需要知道已有的节点是哪一个,当然,当有 text(第三个参数是字符串或数字)的时候,我们是可以找到要插入的位置的,但是当有 children(子节点)的时候,我们是无法确定标杆的位置的,所以,我们要将上树的工作放到 patch函数 中,即 createelement函数 就只负责创建节点。
// index.jsimport patch from "./mysnabbdom/patch";import h from './mysnabbdom/h'const container = document.getelementbyid("container");//创建虚拟节点const myvnode1 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd')])patch(container, myvnode1)
// patch.jsimport vnode from "./vnode";import samevnode from "./samevnode";import createelement from "./createelement";export default function (oldvnode, newvnode) { // 判断oldvnode是否是虚拟节点 if (oldvnode.sel == '' || oldvnode.sel == undefined) { // console.log('不是虚拟节点'); // 创建虚拟dom oldvnode = emptynodeat(oldvnode) } // 判断是否是同一个节点 if (samenode(oldvnode, newvnode)) { console.log('是同一个节点'); } else { // 暴力删除旧节点,插入新的节点 // 传入参数为创建的虚拟dom节点 返回以一个真实dom let newvnodeelm = createelement(newvnode) console.log(newvnodeelm); // oldvnode.elm.parentnode 为body 在body中 在旧节点的前面添加新的节点 if (oldvnode.elm.parentnode && oldvnode.elm) { oldvnode.elm.parentnode.insertbefore(newvnodeelm, oldvnode.elm) } // 删除老节点 oldvnode.elm.parentnode.removechild(oldvnode.elm) }}// 创建虚拟domfunction emptynodeat (elm) { return vnode(elm.tagname.tolowercase(), {}, [], undefined, elm)}
完善 createelement 函数
// createelement.js只负责创建真正节点 export default function createelement(vnode) { // 创建上树的节点 let domnode = document.createelement(vnode.sel) // 判断有文本内容还是子节点 if (vnode.text != '' && (vnode.children == undefined || vnode.children.length == 0)) { // 文本内容 直接赋值 domnode.innertext = vnode.text // 上树 往body上添加节点 // insertbefore() 方法:可在已有的字节点前中插入一个新的子节点。相对于子节点来说的 } else if (array.isarray(vnode.children) && vnode.children.length > 0) { // 有子节点 for(let i = 0; i < vnode.children.length; i++) { // console.log(vnode.children[i]); let ch = vnode.children[i] // 进行递归 一旦调用createelement意味着 创建了dom 并且elm属性指向了创建好的dom let chdom = createelement(ch) // 添加节点 使用appendchild 因为遍历下一个之前 上一个真实dom(这里的domvnode)已经生成了 所以可以使用appendchild domnode.appendchild(chdom) } } vnode.elm = domnode return vnode.elm}
经过上面的分析,我们已经完成了对createelem函数的完善,可能你对这个递归有点不了解,那么大概捋一下进行的过程:
首先,一开始的这个 新的虚拟dom的sel 属性为 ul,创建的真实dom节点为 ul,执行 createelement函数 发现,新的虚拟dom里面有children属性,children 属性里面又包含 h函数。其次,进入到for循环中,拿到 children 中的第一项,然后再次 调用crateelement函数 创建真实dom,上面第一次调用createelement的时候已经创建了ul,执行完第一项返回创建的虚拟dom,然后使用 appendchild方法()追加到 ul中,依次类推,执行后面的数组项。最后,将创建好的 所有真实dom 返回出去,在 patch函数 中上树。执行上面的代码,测试结果如下:
完美!我们成功的将递归子节点完成了,无论嵌套多少层,我们都可以通过递归将子节点渲染到页面上。
前面,我们实现了不是同一个节点的时候,进行删除旧节点和插入新节点的操作,下面,我们来实现是相同节点时的相关操作,这也是文章中最重要的部分,diff算法 就包含在其中!!!
处理相同节点
上面的 patch函数 流程图中,我们已经处理了不同节点的时候,进行暴力删除旧的节点,然后插入新的节点,现在我们进行处理相同节点的时候,进行精细化的比较,继续完善 patch函数 的主流程图:
看到上面的流程图,你可能会有点疑惑,为什么不在 newvnode 是否有 text属性 中继续判断 oldvnode 是否有 children 属性而是直接判断两者之间的 text 是否相同,这里需要提及一个知识点,当我们进行 dom操作的时候,文本内容替换dom的时候,会自动将dom结构全部销毁掉,innertext改变了,dom结构也会随之被销毁,所以这里可以不用判断 oldvnode 是否存在 children 属性,如果插入dom节点,此时的text内容并不会被销毁掉,所以我们需要手动的删除。这也是为什么在流程图后面,我们添加 newvnode 的children 的时候需要将 oldvnode 的 text 手动删除,而将 newvnode 的 text 直接赋值给oldvnode.elm.innertext 的原因。
知道上面流程图是如何工作了,我们继续来书写patch函数中是同一个节点的代码。
// patch.jsimport vnode from "./vnode";import samevnode from "./samevnode";import createelement from "./createelement";export default function (oldvnode, newvnode) { // 判断oldvnode是否是虚拟节点 if (oldvnode.sel == '' || oldvnode.sel == undefined) { // console.log('不是虚拟节点'); // 创建虚拟dom oldvnode = emptynodeat(oldvnode) } // 判断是否是同一个节点 if (samenode(oldvnode, newvnode)) { console.log('是同一个节点'); // 是否是同一个对象 if (oldvnode === newvnode) return // newvnode是否有text if (newvnode.text && (newvnode.children == undefined || newvnode.children.length == 0)) { // 判断newvnode和oldvnode的text是否相同 if (!(newvnode.text === oldvnode.text)) { // 直接将text赋值给oldvnode.elm.innertext 这里会自动销毁oldvnode的cjildred的dom结构 oldvnode.elm.innertext = newvnode.text } // 意味着newvnode有children } else { // oldvnode是否有children属性 if (oldvnode.children != undefined && oldvnode.children.length > 0) { // oldvnode有children属性 } else { // oldvnode没有children属性 // 手动删除 oldvnode的text oldvnode.elm.innertext = '' // 遍历 for (let i = 0; i < newvnode.children.length; i++) { let dom = createelement(newvnode.children[i]) // 追加到oldvnode.elm中 oldvnode.elm.appendchild(dom) } } } } else { // 暴力删除旧节点,插入新的节点 // 传入参数为创建的虚拟dom节点 返回以一个真实dom let newvnodeelm = createelement(newvnode) console.log(newvnodeelm); // oldvnode.elm.parentnode 为body 在body中 在旧节点的前面添加新的节点 if (oldvnode.elm.parentnode && oldvnode.elm) { oldvnode.elm.parentnode.insertbefore(newvnodeelm, oldvnode.elm) } // 删除老节点 oldvnode.elm.parentnode.removechild(oldvnode.elm) }}// 创建虚拟domfunction emptynodeat(elm) { return vnode(elm.tagname.tolowercase(), {}, [], undefined, elm)}
....//创建虚拟节点const myvnode1 = h('ul', {}, 'oldvnode有text')patch(container, myvnode1)const myvnode2 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd')])btn.addeventlistener('click', () => { patch(myvnode1, myvnode2)})
oldvnode 有 tex属性 和 newvnode 有 children属性 的效果如下:
...//创建虚拟节点const myvnode1 = h('ul', {}, [ h('li', {}, 'a'), h('li', {}, 'b'), h('li', {}, 'c'), h('li', {}, 'd')])patch(container, myvnode1)const myvnode2 = h('ul', {}, 'newvnode 有text')btn.addeventlistener('click', () => { patch(myvnode1, myvnode2)})
oldvode 有children属性 和 newvnode 有 text属性 的效果如下:
完美!现在我们就只差最后diff算了。
patchvnode 函数
在patch函数中,我们需要将同同一节点的比较分成一个单独的模块patchvnode函数,方便我们在diff算法中进行递归运算。
patchvnode函数的主要作用就是:
判断newvnode和oldvnode是否指向同一个对象,如果是,那么直接return
如果他们都有text并且不相等 或者 oldvnode有子节点而newvnode没有,那么将oldvnode.elm的文本节点设置为newvnode的文本节点。
如果oldvnode没有子节点而newvnode有,则将newvnode的子节点真实化之后添加到oldvnode.elm后面,然后删除 oldvnode.elm 的 text
如果两者都有子节点,则执行updatechildren函数比较子节点,这一步很重要
// patchvnode.jsexport default function patchvnode(oldvnode, newvnode) { // 是否是同一个对象 if (oldvnode === newvnode) return // newvnode是否有text if (newvnode.text && (newvnode.children == undefined || newvnode.children.length == 0)) { // 判断newvnode和oldvnode的text是否相同 if (!(newvnode.text === oldvnode.text)) { // 直接将text赋值给oldvnode.elm.innertext 这里会自动销毁oldvnode的cjildred的dom结构 oldvnode.elm.innertext = newvnode.text } //说明 newvnode有 children } else { // oldvnode是否有children属性 if (oldvnode.children != undefined && oldvnode.children.length > 0) { // oldvnode有children属性 } else { // oldvnode没有children属性 // 手动删除 oldvnode的text oldvnode.elm.innertext = '' // 遍历 for (let i = 0; i < newvnode.children.length; i++) { let dom = createelement(newvnode.children[i]) // 追加到oldvnode.elm中 oldvnode.elm.appendchild(dom) } } }}
diff算法
精细化比较:diff算法 四种优化策略这里使用双指针的形式进行diff算法的比较,分别是旧前、旧后、新前、新后指针,(前指针往下移动,后指针往上移动)
四种优化策略:(命中:key 和 sel 都要相同)
①、新前与旧前②、新后与旧后③、新后与旧前④、新前与旧后注意: 当只有第一种不命中的时候才会采取第二种,依次类推,如果四种都不命中,则需要通过循环来查找。命中指针才会移动,否则不移动。
①、新前与旧前
如果就旧节点先循环完毕,说明需要新节点中有需要插入的节点。
②、新后与旧后
如果新节点先循环完毕,旧节点还有剩余节点,说明旧节点中有需要删除的节点。
多删除情况:当只有情况①命中,剩下三种都没有命中,则需要进行循环遍历,找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。删除的节点为旧前与旧后之间(包含旧前、旧后)。
③、新后与旧前
当③新后与旧前命中的时候,此时要移动节点,移动 新后指向的节点到旧节点的 旧后的后面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。
④、新前与旧后
当④新前与旧后命中的时候,此时要移动节点,移动新前指向的这个节点到旧节点的 旧前的前面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。
好了,上面通过动态讲解的四种命中方式之后,动态gif图片有水印,看着可能不是很舒服,但当然能够理解是最重要的,那么我们开始手写 diff算法 的代码。
updatechildren 函数
updatechildren()方法 主要作用就是进行精细化比较,然后更新子节点。这里代码比较多,需要耐心的阅读。
import createelement from "./createelement";import patchvnode from "./patchvnode";import samevnode from "./samevnode";export default function updatechildren(parentelm, oldch, newch) { //parentelm 父节点位置 用来移动节点 oldch旧节点children newch新节点children // console.log(parentelm, oldch, newch); // 旧前 let oldstartindex = 0 // 旧后 let oldendindex = oldch.length - 1 // 新前 let newstartindex = 0 // 旧后 let newendindex = newch.length - 1 // 旧前节点 let oldstartvnode = oldch[0] // 旧后节点 let oldendvnode = oldch[oldendindex] // 新前节点 let newstartvnode = newch[0] // 新后节点 let newendvnode = newch[newendindex] // 存储mapkey let keymap // 循环 条件 旧前 <= 旧后 && 新前 <= 新后 while (oldstartindex <= oldendindex && newstartindex <= newendindex) { // 首先需要判断是否已经处理过了 if (oldch[oldstartindex] == undefined) { oldstartvnode = oldch[++oldstartindex] } else if (oldch[oldstartindex] == undefined) { oldendvnode = oldch[--oldendindex] } else if (newch[newstartindex] == undefined) { newstartvnode = newch[++newstartindex] } else if (newch[newendindex] == undefined) { newendvnode = newch[--newendindex] } else if (samevnode(oldstartvnode, newstartvnode)) { // ①、新前与旧前命中 console.log('①、新前与旧前命中'); //调用 patchvnode 对比两个节点的 对象 文本 children patchvnode(oldstartvnode, newstartvnode) // 指针下移改变节点 oldstartvnode = oldch[++oldstartindex] newstartvnode = newch[++newstartindex] } else if (samevnode(oldendvnode, newendvnode)) { // ②、新后与旧后命中 console.log('②、新后与旧后命中'); //调用 patchvnode 对比两个节点的 对象 文本 children patchvnode(oldstartvnode, newstartvnode) // 指针下移并改变节点 oldendvnode = oldch[--oldendindex] newendvnode = newch[--newendindex] } else if (samevnode(oldstartvnode, newendvnode)) { // ③、新后与旧前命中 patchvnode(oldstartvnode, newendvnode) console.log(newendvnode); // 移动节点 当③新后与旧前命中的时候,此时要移动节点, // 移动 新后(旧前两者指向的是同一节点) 指向的节点到旧节点的 旧后的后面,并且找到旧节点中对应的节点,然后在旧的虚拟节点中将这个节点设置为undefined。 parentelm.insertbefore(oldstartvnode.elm, oldendvnode.elm.nextsibling) // 在上面动画中 命中③是在旧节点的后面插入的 所以使用nextsibling // 指针下移并改变节点 oldstartvnode = oldch[++oldstartindex] newendvnode = newch[--newendindex] } else if (samevnode(oldendvnode, newstartvnode)) { // ④、新前与旧后命中 patchvnode(oldendvnode, newstartvnode) // 移动节点 // 当`④新前与旧后`命中的时候,此时要移动节点,移动`新前(旧后指向同一个节点)`指向的这个节点到旧节点的 `旧前的前面`, //并且找到`旧节点中对应的节点`,然后在`旧的虚拟节点中将这个节点设置为undefined` parentelm.insertbefore(oldendvnode.elm, oldstartvnode.elm) //指针下移并改变节点 oldendvnode = oldch[--oldendindex] newstartvnode = newch[++newstartindex] } else { // 四种都没有命中 console.log(11); //kepmap作为缓存不用每次遍历对象 if (!keymap) { keymap = {} // 遍历旧的节点 for (let i = oldstartindex; i <= oldendindex; i++) { // 获取旧节点的key const key = oldch[i].data.key if (key != undefined) { //key不为空 并且将key存放到keymap对象中 keymap[key] = i } } } // 取出newch中的的key 并找出在keymap中的位置 并映射到oldch中 const oldindex = keymap[newstartvnode.key] if (oldindex == undefined) { // 新增 console.log(111); parentelm.insertbefore(createelement(newstartvnode),oldstartvnode.elm) } else { // 移动位置 // 取出需要移动的项 const elmtomove = oldch[oldindex] // 判断是选择器是否一样 patchvnode(elmtomove, newstartvnode) // 标记已经处理过了 oldch[oldindex] = undefined // 移动节点 移动到旧前前面 因为旧前与旧后之间要被删除 parentelm.insertbefore(elmtomove.elm, oldstartvnode.elm) } // 只移动新的节点 newstartvnode = newch[++newstartindex] } } //循环结束 还有剩余节点没处理 if (newstartindex <= newendindex) { //说明 新节点还有未处理的节点,意味着需要添加节点 console.log('新增节点'); // 创建标杆 console.log(newch[newendindex + 1]); // 节点插入的标杆位置 官方源码的写法 但是我们写代码新的虚拟节点中,elm设置了undefined 所以这里永远都是会在后面插入 小bug // let before = newch[newendindex + 1] == null ? null : newch[newendindex + 1].elm // 若不想出现bug 可以在插入节点中直接oldch[oldstartindex].elm 但是也会出现不一样的bug 所以重在学习思路 for (let i = newstartindex; i <= newendindex; i++) { // 插入节点 因为旧节点遍历完之后 新节点还有剩余节点 这里需要使用crateelement函数新建一个真实dom节点 // insertbefore会自动识别null parentelm.insertbefore(createelement(newch[i]), oldch[oldstartindex].elm) } } else if (oldstartindex <= oldendindex) { //说明旧节点还有剩余节点还没有处理 意味着需要删除节点 console.log('删除节点'); for (let i = oldstartindex; i <= oldendindex; i++) { if(oldch[i]) parentelm.removechild(oldch[i].elm) } }}
好了,以上就是 vue2中 虚拟do m和 diff算法 的阉割版代码,可能上面代码中有些许bug存在,但是这并不会影响你对diff算法的理解,只有你细心品味,肯定会有所收获的!!! 最后淡淡我自己对虚拟dom和diff算法的理解
我对vue中虚拟dom和diff算法的理解在javascript中,渲染 真实dom 的开销是非常大的,比如我们修改了某个数据,如果直接渲染到 真实dom,会引起整个 dom树 的 回流和重绘。那么有没有可能实现只更新我们修改的那一小块dom而不会引起整个dom更新?此时我们就需要先根据 真实dom 生成 虚拟dom ,当 虚拟dom 某个节点的数据改变后会生成一个 新的vnode,然后 新的vnode 和 旧的vnodde 进行比较,发现有不一样的地方就直接修改到 真实dom 上,然后使 旧的vnode 的值变成 新的vnode。
diff算法 的过程就是 patch函数 的调用,比较新旧节点,一边比较一边给 真实的dom 打补丁。在采用 diff算法 比较新旧节点的时候,只会进行同层级的比较。在 patch方法 中,首先比较新旧虚拟节点是否是同一个节点,如果不是同一个节点,那么就会将旧的节点删除掉,插入新的虚拟节点,然后再使用 createelement函数 创建 真实dom,渲染到真实的 dom树。如果是同一个节点,使用 patchvnode函数 比较新旧节点,包括属性更新、文本更新、子节点更新,新旧节点均有子节点,则需要进行 diff算法,调用updatechildren方法,如果新节点没有文本内容而旧节点有文本内容,则需要将旧节点的文本删除,然后再增加子节点,如果新节点有文本内容,则直接替换旧节点的文本内容。
updatechildren方法 将新旧节点的子节点都提取出来,然后使用的是 双指针 的方式进行四种优化策略循环比较。分别是:①、新前与旧前比较 ②、新后与旧后比较 ③、新后与旧前比较 ④、新前与旧后比较。如果四种优化策略方法均没有命中,则会进行遍历方法进行比较(源码中使用了map对象进行了缓存,加快了比较的速率),如果设置了 key,就会使用key进行比较,找到当前的新节点的子节点在 map 中的映射位置,如果不存在,则需要添加节点,存在则需要移动节点。最后,循环结束之后,如果新节点还有剩余节点,则说明需要添加节点,如果旧节点还有剩余节点,则说明需要删除节点。
以上,就是我对vue2中的 虚拟dom 和 diff算法 的理解,希望读完这篇文章对你理解vue2中的虚拟dom和diff算法有所帮助!!最后希望各位大佬能够给个赞!!!!
(学习视频分享:vuejs教程、web前端)
以上就是一文彻底的弄懂vue中的虚拟dom和 diff 算法的详细内容。