目标
什么是虚拟DOM
可以看到真实dom非常多,创建成本很高
通过sel描述标签,text描述文本
创建虚拟dom比真实dom成本小很多
总结:虚拟dom就是普通的js对象用来描述真实dom
为什么使用虚拟DOM
模板引擎问题:数据变化后页面无法获取上一次的状态只好删除元素后重新创建
所以就出现了虚拟dom
虚拟dom项目,发现新增后也没有闪烁问题
排序也没有闪烁,因为没有新增闪出,只改变了位置,所以比JQ性能好。
git上virtualDom的描述
总结:
虚拟DOM的作用和虚拟DOM库
先看看纯Dom操作
尝试虚拟dom
1创建虚拟dom对象,也就是js普通对象,点击按钮然后对比前后dom进行更新。如果这样会比纯dom操作的性能低一些,所以不是任何时候使用虚拟dom都是最好的,只有在视图较复杂的情况下才会提高性能。
ssr渲染把虚拟dom转换成普通的html,也可以通过特殊手动转换成原生引用或小程序(rn weex uniapp都使用了虚拟dom)。
因为只有200行所以比看vue的源码轻松得多。
此库功能强大,拓展性强。
创建项目
导入Snabbdom
没有值是因为,import的原因,但官方文档没有写,可以通过源码找原因
发现没有默认导出,导出了三个
需要import {} 才能导入
疑问:为什么require可以直接导入 import不行呢? 答:因为COMJS中所有模块都会导出一个对象所有可以直接导入,ESM中如果没有使用default导出就需要 { name , name2 }
代码演示
模块
Snabbdom源码解析
这里了解核心即可
tonode.js是把dom转换成vnode
研究这三个核心文件即可
h函数
vue中的h函数是增强过的,实现了组件机制,snabdom中没有。
h函数的实现
最后返回
必备快捷键
看源码快捷键
快速跳回is
快速定位变量2
vnode
导入文件
定义属性
vnode的方法返回了的 必须是继承vnode接口
children和text互斥有text就不能有children。
elm:把真dom存储到elm。
key:优化,vnode转换真实dom的时候会看到。
patch的整体过程分析
patch(打补丁)
updatechildren是最复杂的核心部分
通过源码体验过程
init
调用init时返回了patch,所以要先了解init。
大致是先导入了需要的模块,然后定义属性和函数。
最后导出了成员。
3
init函数有俩个参数。
都是些dom操作。
通过第二个参数,把虚拟dom转换成真dom。
最后返回了patch函数(函数返回函数属于高阶函数)
使用高阶函数的好处是,init的时候已经传入需要的参数了,patch函数就不需要传入对应的参数。
而且patch访问内部函数的时候,这里产生了闭包
接下来看看循环
moduls是统一的规范
hooks中的钩子函数和这里的modules是一致的。
patch
首次渲染需要真实dom
把真实dom转换成虚拟节点
判断是否同节点
如果是相同节点,执行patchVnode对比,对比内容是否有变化,但不会重新创建这个对应的dom提升效率。
这里获取父元素是为了待会创建dom后挂载到父元素上。
返回父节点
创建vnode对应dom
这里是插到div#app之后,因为div后一般是换行文本节点,removeVnodes结束后首次渲染就结束了,后面触发对应钩子函数
这个队列里存储的是具有insert钩子函数的新vnode节点,这个队列中的元素是createElm中添加的。注意这里的insert是data中获取的,也就是用户传递过来的,那么这样可以做一些相应的操作,例如获取新插入到dom树上的dom的行高。(这里!表示推断是否有值,有就执行,没有就跳过,这样就不用写大量if判断了)
最后在变量post函数
最后返回新vnode节点,作为下次操作老节点处理
调试patch
用之前的代码
打断点
第一个是 执行下一个断点 跳过函数执行f10 进入函数执行 f11
H函数中没传入模块所以这里的insertedVnodeQueue没有,
f11 进入判断isVnode分支,这里传入的是div不是虚拟节点,
有sel就认为是虚拟的。
,这里没有所以进入转换虚拟dom函数 emptyNodeat
把elm真实dom,作为vnode函数第五个参数元素的值,这里没传data所以key为undefined。
f11 判断是否相同节点
此时因为传入的不是相同的div所以进入
创建真dom渲染到页面上。并且把旧的从界面上移除,插入elm老节点的旁边(api.nextSibling)
执行完这行代码后页面渲染能看到东西了。
此时还没有执行删除 之前的dom还有
执行删除后没有了
因为现在没有传入任何钩子函数 这里patch首次执行过程就演示结束了
createElm
但不渲染到页面上(不挂载到dom树上),在下面方法中才渲染
createElm过程开始
第一过程如果init有值就执行init钩子,这里是创建真实dom前方便用户对vnode再次修改例如样式等,然后把处理完后的vnode.data重新赋值给data
第二过程
转换前定义了俩个变量
如果sel等于感叹号会创建注释节点,不等于则创建对应dom元素,如果为空创建文本节点(这里的三个判断)
往下就判断是否是数组,children和text互斥
如果是就遍历子节点,如果子节点有的话,继续调用createElm
如果是text 就判断text是否是原始值(只有string和number类型才直接显示页面上),满足条件就调用,createTextNode
最后如果有hook函数就执行,然后又判断了是否有insert,有就push,到这,第三过程就处理结束,最后返回dom
回到patch函数中
createElm结束后,执行 插入和移除dom方法,然后循环队列
总结
createElm调试
可以传入data,带处理函数,init是创建dom之前执行,create是创建dom完毕后执行,是可以拿到值的。
打断点
这里的vnode是新传入的vnode对象
这里打印的值应该是undefined 因为dom还没创建。
需要注意的是,这里init执行后又重新给data赋值了,这是因为,在init中用户可能修改了data,重新赋值会好。
这里继续调试就好了,上一节有做过分析笔记。
目前的操作都是内存中进行的,因为还没挂载到dom树上。
返回vnode后执行插入方法。
再往后就是用户传入的模块hook了
addVnodes和removeVnodes
批量添加和移除对应的vnoodes对应dom元素
分析内部实现 1 父级,2旧节点,3开始节点位置4结束节点位置。
真正删除的函数,这里的listeners代表remove函数的个数+1,也就是remove都执行完后执行
addvnodes
把vnode对应元素插入before之前 ,vnodes是添加的节点,后面是开始和结束索引,可以指定vnode中的那些节点插入到parentElm中,最后是insertedVnodeQueue,存储刚刚创建有insert钩子函数的vnode节点。
patchVnode
对比新旧vnode
第一部分,触发俩个函数 prepatch 和update
第二部分 if开始, 真正对比俩个vnode差异的地方,当找到差异地方后立即更新dom。
第三部分,最后触发postpatch
- 第一过程分析:
1, 获取prepatch后立即执行,是对比俩个vnode前执行的。
2,定义三个属性,把旧节点的elm赋值给新节点的elm(为啥??),后分别获取新旧节点的子节点。
3,判断新旧节点是否相同节点,如果是就没必要比较,return
4,如果不是,循环执行cbs中的update,然后获取用户传入的date中的update执行,后执行是因为用户修改vnode的数据data后可以覆盖模块中update的数据。
总结:这里核心是判断俩节点是否相同,相同就返回,这里不管节点是否相同一定会触发prepatch函数,只有不相同的时候才触发update函数
- 第二过程分析:
1,如果没有text属性,会继续比较子节点(text和children是互斥的),先看else里的,if分支比较复杂。
2,老节点text有值并且不等于新的text(一样的话就不做任何dom操作),进一步判断是否有子节点,如果老节点有就删除,没有就直接插入,注意这里数据变化后,没有创建新dom元素,重用之前的dom只是设置了text。
3,分析刚刚跳过的if分支代码,判断新旧是否有子节点,有并且,不相同调用updatechildren,这里代码最麻烦最后再看。
4第二个分支中判断新节点是否有子节点,在判断老节点是否有text,有就清空,然后调用addvnodes添加新旧节点。
5,第三判断是否老节点有没有,有就移除。
6,第四判断,老节点是否有text,有就清空对应elm的text。
总结:这里的核心就是判断新老节点和文本属性的差异,然后更新真实dom。
最后第三部分,触发postpatch,当对比更新dom完成会触发用户传递的钩子。此函数可以获取最新dom数据。类似vue的updated函数。到此更新完毕。
补充:
调试patchVnode
那么会转换成vnode
然后通过俩个节点的key判断是否相同节点
首次渲染肯定进入else分支,转换vnode成真实dom渲染
渲染dom到页面
f8到下一个断点
f11进入函数内部
此时key是一样的 都是undefined
进入patchvnode
获取新老节点,判断内存地址是否相同
**总结:**patchvonode执行过程就是判断新的虚拟节点是否发生了变化,如果text属性和字节点变化了才更新dom。
下面介绍如果子节点变化的情况
updateChildren整体分析
为什么用diff算法?
操作dom是很耗性能的,需要重排重绘,例如大量数据的列表,如果操作dom会重新渲染列表,但用虚拟dom时,当数据变化后不直接操作dom,只会JS对象描述真实dom,数据变化后作对比,然后找到比变化的位置,从而提高性能。
比较笨的方法,对比每一个节点
snabdom中,只比较同级,不同就直接删除创建。
- 1.新开和旧开
1.用sameVnode判断是否同节点
2.是就patchVnode继续对比内部差异
3.更新到真实dom
4.移动索引对比第二个节点
把第二个节点作为开始节点比较,如果开始节点不是sameVnode会从后往前比较,比较旧的结束节点和新的结束节点是否sameVnode。
如果是调用patchVnode,比较完成后移动索引(到倒数第二个),作为结束节点,继续调用sameVnode,是相同就patchVnode比较差异更新dom,(注意这里是sameVnode的话会重用之前就节点的dom元素,patchVnode中会对比差异然后把差异更新到重用的dom上),如果text或childre相同是不会dom操作的,不相同只需更旧dom上即可不会新创建,这就是虚拟dom如何提高效率的核心(如果sameVnode就重用旧的dom)。
前俩种对比就说完了。
-
2 第三种情况-旧开始节点和新结束节点
1,sameVnode,是就触发patchVnode继续对比,内部差异更新完成后,把旧的元素移动到最后(旧的最后),因为这里旧开始和新结束相同,
移动完后更新索引,移动到旧2的位置,新的会移动到前面(新5)
这是第三种情况,和前俩种不一样的是,这里会移动元素。 -
3.第四种情况
旧结束节点和新开始结束节点的比较
和刚刚差不多,sameVnode,如果是patchVnode,更新完后把旧移动到旧的最前面
如果上述四种都不满足,说明开始和结束节点都不相同,
这个时候就在旧数组中依次查找是否有相同的新节点
过程:首先遍历新开始节点,在旧数组中查找是否有相同key值的旧节点,如果没有找到,就说明是新节点,此时要创建新的dom元素,并插入到旧的最前面
如果找到具有相同的key值节点,判断sel是否相同,不相同就新建dom,插入到最前面的位置,情况2,如果sel和key相同,找到的这个相同节点会赋值给elmToMove这个变量,然后调用patchVnode对比差异更新后放入最前
到这里整个同级别的比较就结束了。
循环结束后的情况
情况1.老节点个数少于新节点个数,老节点先遍历完,新节点剩余()
情况2.老的大于新的,老节点剩余
情况1.答案
比较过程
先比较旧和新开始节点,如果相同,后移继续比较
3不相同后开始节点的比较结束了,开始比较结束节点,如果相同就索引往前移动,
如果不相同,结束节点比较结束,进行其他操作
发现新节点中有三个剩余节点,那么这些剩余的就是需要新增的dom元素,最后插入老节点元素数组中的末尾
那么这种情况旧开始索引大于旧结束索引,第一种情况看完了。
- 情况2答案
老节点个数大于新节点个数
过程: 还是差不多,先比较开始节点,相同就后移索引,
如果不同(4开始不同)就结束开始节点的比较,开始比较结束节点
先拿到旧节点的结束节点 然后再拿新节点的结束节点
如果相同,索引往前,不相同就停止比较结束节点。
最后执行其他操作,此时老节点剩余俩个节点,这俩个节点在新节点数组中不存在,所以这俩个对应的dom需要从dom树移除,这种情况新节点开始的索引大于旧节点开始的索引
updateChildren
源码分析
- 第一部分,定义变量
- 第二部分,循环
对比同级,循环条件是都没有对比完成
1先判断新旧节点开始的值是否为null,如果是相同节点处理完后,会对新旧节点重新赋值,此时赋值可能会null
判断完后会进行四种情况的判断
如果不相同,会对比旧的结束和新的开始,然后把旧的elm移动到旧的elm后面,然后再改变对应索引
如果这俩不是相同节点,那么会比较旧结束和新开始,如果一样就把旧的elm结束移动到旧的开始节点之前,然后改变索引
到这 四种情况就结束了
遍历新节点,用新节点key,在老节点中找到相同key的节点,如果找到了就移动对应dom到合适的位置。
首先判断是否是undefined,这个对象存储老节点的key,值是老节点的索引,根据key找索引,
没有就初始化(创建方法循环老节点,拿到对应key作为键,索引作为值,返回map)
拿到新节点的key去找旧节点index存到idxInOld变量中,然后判断是否存在(因为新的节点在老的节点中不存在例如新插入的值可能 有不存在的情况),
所以会判断是否有值,没有就代表没有对应元素,创建对应dom插入到旧的开始前。新的节点处理完后,新的索引++,获取下一个作为开始节点。
上面是找不到的情况。
找到的话,取老节点赋值给elmToMove,2比较sel如果不一样,创建新元素插入老节点开始前
sel相同的情况,触发patchVnode对比更新,然后把老节点对应索引设置为undefined,因为这个节点被处理过了,然后移动老dom元素到老dom开始之前,最后又继续索引++
到此对比同级节点就结束了。
收尾工作,确保新旧节点个数相同都比较完了,如果其中一个为true那么代表某数据没处理完。
情况1,老节点先完成。
情况2,新节点先完成的情况。
参数34记录剩余节点的位置
那么到这里updateChildren就看完了,这就是diff的核心
调试updateChildren
断点
f11继续断点
此时的key是undefined sel都是ul,那么sameVnode就认为是同节点。
进入patchVnode
对比新旧节点是否有值,有值进入下一个分支对比是否相同节点,因为新节点是新建的,此时不相等触发updatechildren
进入updatechildren
while这里判断是否为null,首次可以跳过,因为可以获取到值,只有后面赋值的时候可能会出现null的情况,所以这里断点到第一次调用sameVnode位置
此时新旧开始节点的sel和key都是相同的,f10进入if分支,触发patchVnode对比差异,并更新dom,进入patchVnode
对比第一个子节点 打个断点
此时vnode.text有值,进入 else if 分支
进入elseif后判断旧节点text和新的是否相同
此时都是相同的 不会执行任何操作。
patchV对比结束,触发postPatch
继续回到(执行完后)patchVnode 获取下一个节点 ++index,此时第二个节点作为了下次循环的开始节点。
继续跳到设置的断点 sameVnode中
相同进入patchVnode,有值进入elseif,此时值不相等,判断oldch,此时为undefined,更新dom(注意setTextContent执行完后页面才更新数据)
执行postpatch 执行完后又回到patchVnode 索引++
此时指向第三个节点,第三节点作为开始节点循环。
下次循环又进入sameVnode判断
和刚才一样
此时新老节点的个数是相同的,updatechildren执行结束
调试结束
以上是没有设置key的情况
调试带key的情况
有key的相同的情况会交互,不会重新创建节点,例如 key是 旧b 和新b 虽然顺序对应不上,但遇到key一样的会对换位置。
然后updatechildren内 isUndef打断点
旧节点
新节点
进入下一个循环,点击下一个断点直接到sameVnode
不同的情况
注意,因为这里key不一样,这里有和不设置key不一样的地方,sameVnode失败 进入下一个sameVnode 比较老结束和新开始是否相同
此时老是c 新是b,不相同
继续进入第三个分支 老开始和新结束对比
此时相同进入patchVnode,进入下一轮updatechildren,此时只有text有,而且相同 不做任何操作
这就是不设置key不一样的地方,如果没有key那么key都相同,会重新设置文本内容
对比完跳出patchVnode,执行api插入老开始节点对应的dom元素插到老结束节点后
实时更新,执行代码完后立即更新,老节点上修改 。
第三轮
key和text都一样 不做任何操作。
收尾工作,因为新老节点个数都相同不做任何工作。
Key 的意义
vue中的key和dsnabom中的是一样的,都是在vnode中比较是否相同节点,不设置key最大程度重用元素(有问题)
问题演示
不带key
这里出问题了选中的是1单更新后变成100
updatechildren对比:新开和旧开都一样所以重用了,继续对比text发现不一样更新text
(没有key或者key一样的时候都有这种错误)
带key情况
问题解决
updatechildren对比新旧节点,发现key不一样老是1 新是100,key不同重新创建li
总之给相同父元素的子元素添加唯一key 不然渲染错误
todo 调试这段代码
snabdom了解即可,为后续vue源码做准备。
注意 这里新节点 视频位置换了一下,单key相同
进入patchVnode
判断是否定义,判断内存地址是否一样,此时不一样进入updateChildren
这里新旧节点的key是相同的,节点相同进入patchVnode
发现这里的过程和之前没设置key是一样的。
索引++对比下一个节点
注意此时执行不一样了,不是相同节点了,不进入patchVnode
对比老的结束节点和新的结束节点
这里会一直对比到最后一个节点
key都不相同
继续对比
这里的key是相同的,进入patchVnode
判断是否有值后 对比新旧的text,这里相同 不做任何事情 结束。
执行完了后交换了位置
这里把老的开始节点 移动到了旧的结束节点。
继续指向下一个元素
旧的开始节点 微博 和新的开始节点 微博 他们的key是一样的 进入patchVnode,不更新dom,所以性能比之前好。
所有节点对比完毕。
另一种带key的位置优化情况
当新旧开始节点 四种情况都不满足的时候
使用新开始节点的key 在老节点数组中获取对应节点的索引,如何取得对应老节点
如果找到对应的老节点后
而且老节点的sel是相同的,执行patchVnode后 进行移动,不会调用createElm方法创建 对应对应元素,减少dom渲染过程
所以这里也是key性能提升的位置
这里和vue的for是一样的 带key的情况,vue内部就是使用了这段代码优化的,去避免重复渲染相同项
模块源码
定义接口
initHOOk后create触发时可以给dom注册样式或事件
modules
导出钩子
上面是新节点情况
看看旧节点的属性情况