目录
本文章是在学习b站 尚硅谷 的视频后总结的,主要是讲解了snabbdom的基本原理与diff算法的实现原理。
1、文章结构
1.1snabbdom简介
snabbdom是著名的虚拟Dom的库,也是diff算法的鼻祖
1.2项目所涉及的模块及各模块功能
在本文章中会手写一个简易版的snabbdom,下面是所涉及的模块以及各部分的功能
2、手写一个简易版的snabbdom
2.1、vnode函数
功能:生成虚拟Dom对象,返回的对象中主要有5个属性,分别是:
sel //选择器
data //虚拟dom的属性
children //虚拟dom的子元素
text //虚拟dom的文本
elm //虚拟dom对应的真实的dom节点
key //虚拟dom的唯一标识
vnode函数代码
/**
*vnode的主要功能是返回传入参数的值
*
* @export
* @param {string} sel 节点类型
* @param {object} data 节点属性
* @param {Array} children 节点的子节点
* @param {String|number} text 节点的文本内容
* @param {object} elm 判断节点有没有上树
* @return {*}
*/
export default function(sel,data,children,text,elm){
let key = data.key
return {
sel,
data,
children,
text,
elm,
key
}
}
2.2、h函数
功能:h函数主要是根据传入参数的不同进行if判断,调用vnode函数,产生不同的虚拟节点对象
h函数的代码
import vnode from "./vnode";
// 低配版的h函数,重载功能较弱
/**
*调用的时候的参数形态
h('div',{},'文字')
h('div',{},[])
h('div',{},h())
* @export
* @param {*} sel
* @param {*} data
* @param {*} c
*/
export default function(sel,data,c){
// 首先进行传入参数的个数,个数不符合,给出错误信息
if(arguments.length!=3){
throw new Error('低配版的h函数,请输入三个参数!')
}
// 处理第一种情况,也就是没有子节点,节点只有文本内容
if(typeof c=='string'||typeof c=='number'){
return vnode(sel,data,undefined,c,undefined)
}
// 判断传入的参数是数组,并且数组的子元素必须是h函数的返回值
if(Array.isArray(c)){
let children=[]
for(let i=0;i<c.length;i++){
if(!(typeof c[i]=='object'&&c[i].hasOwnProperty('sel'))){
throw new Error('传入的不是h函数!')
}
// 如果是h函数,就收集所有的h函数,作为节点的子元素
children.push(c[i])
}
return vnode(sel,data,children,undefined,undefined)
}
// 第三种情况,c是h函数
else if(typeof c=='object'&&c.hasOwnProperty('sel')){
let children=[c]
return vnode(sel,data,children,undefined,undefined)
}else{
throw new Error('传入的第三个参数类型不对')
}
}
由于是简易版的snabbdom(不考虑又有子元素又有文本的情况,两者只能有其中的一个),这里只考虑三种参数传入的情况:
h函数调用时参数形态:
h('div',{},'文字')
h('div',{},[ ])
h('div',{},h())
第一种情况:
如果第三个参数时文字, 说明虚拟Dom只有文本没有children,因此返回的是
return vnode(sel,data,undefined,c,undefined)
所对应的虚拟Dom对象是:
{
sel:sel,
data:data,
children:undefined,
text:c,
elm:undefined
}
第二种情况;
第三个参数传入的是数组,说明没有text,只有children,因此返回
return vnode(sel,data,children,undefined,undefined)
所对应的虚拟dom对象是:
{
sel:sel,
data:data,
children:children
text:undefined
elm:undefined
}
第三种情况:
第三个参数传入的是一个h函数,但是h函数的返回值是vnode函数,而vnode函数的返回值是一个虚拟Dom对象,因此可以把第三种情况看作是虚拟Dom节点只有一个子元素的情况,因此返回的也是与第二种情况相同的值
return vnode(sel,data,children,undefined,undefined)
所对应的虚拟dom对象是:
{
sel:sel,
data:data,
children:children
text:undefined
elm:undefined
}
2.3、patch函数
功能:将虚拟Dom上树(上树:就是将虚拟Dom转变为真实的Dom节点,并插入到dom树中)
patch函数思路图
patch函数代码
import vnode from './vnode'
import createElement from './createElement';
import patchVnode from './patchVnode';
export default function(oldVnode,newVnode){
// 判断传入的第一个参数,是真实节点还是虚拟节点
if(oldVnode.sel==''||oldVnode.sel==undefined){
// 如果是真是节点,则封装成为虚拟节点
oldVnode=vnode(oldVnode.tagName.toLowerCase(),{},[],undefined,oldVnode)
}
// 判断新老节点是不是同一个节点
if(oldVnode.key===newVnode.key&&oldVnode.sel===newVnode.sel){
console.log('是同一个节点');
patchVnode(oldVnode,newVnode)
}else{
console.log('不是同一个节点,删除老节点,重建新节点');
let newVnodeElm=createElement(newVnode)
if(newVnodeElm&&oldVnode.elm.parentNode){
// 插入到老节点之前
oldVnode.elm.parentNode.insertBefore(newVnodeElm,oldVnode.elm)
}
oldVnode.elm.parentNode.removeChild(oldVnode.elm)
}
}
patch函数讲解
当patch函数被调用时,首先会判断oldVnode是不是虚拟节点,如果不是虚拟节点,就转化为虚拟节点,前面已经说过,vnode函数会返回一个虚拟Dom节点,因此当判断oldVnode不是虚拟节点,会调用vnode函数将oldVnode转化为虚拟Dom。
其次,当oldVnode确定为虚拟节点时,就会判断更新前后的新老节点是不是同一个节点,那又如何判断是不是同一个节点呢?
方法:选择器(sel)相同与key相同
①如果是同一个节点,则进行精细化比较
②如果不是同一个节点,则去除旧的节点,插入新的节点
最后,根据上面两种情况,对其进行不同的处理。
2.4、createElement函数
功能 :在patch函数中,回调用这个函数,主要用于”更新前后不是同一个节点,则去除旧的节点,插入新的节点“的情况,createElement函数主要将虚拟Dom节点变为真实Dom节点
createElement函数代码
/**
*
*真正创建节点,将vnode创建为Dom,插入到pivot这个元素之前
* @export
* @param {*} vnode
* @param {*} pivot
*/
export default function createElement(vnode){
// console.log('目的是将虚拟节点',vnode);
// 创建一个DOM节点,这个节点还是孤儿节点
const domNode=document.createElement(vnode.sel)
// 第一种情况:只有文本内容,没有子元素
if(vnode.text!=''&&(vnode.children==undefined||vnode.children.length==0)){
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]
// console.log(ch);
// 为每一个子元素创建真正的DOM元素
let chDOM=createElement(ch)
// console.log(chDOM);
// 添加进上一层的子元素
domNode.appendChild(chDOM)
}
}
// 补充elm属性
vnode.elm=domNode
return vnode.elm
}
createElement函数讲解
前面说过了,这是一个简易版的snabbdom,因此只有两种虚拟Dom的情况,一种是text有值children没值,一种是children有值,text没值
①当text有值时,只需要将text的值赋值给新创建的真实的dom(domNode)的innerText就好了
if(vnode.text!=''&&(vnode.children==undefined||vnode.children.length==0)){
domNode.innerText=vnode.text;
②当children有值时,需要遍历children中的每一个元素,将其转变为真实的Dom节点,再利用appendChild()函数插入的domNode中
else if(Array.isArray(vnode.children)&&vnode.children.length>0){
for(let i=0;i<vnode.children.length;i++){
// 获取每一个子元素
let ch=vnode.children[i]
// console.log(ch);
// 为每一个子元素创建真正的DOM元素
let chDOM=createElement(ch)
// console.log(chDOM);
// 添加进上一层的子元素
domNode.appendChild(chDOM)
}
}
最后 ,将新创建的真实Dom(domNode)赋值给newVnode的elm属性(前面说过,elm属性的值是真实的Dom,前面的elm都没有赋值,所以都是undefined,在这里才开始赋值,将自身新创建的真实Dom赋值给它)
2.5、patchVnode函数
功能:在patch函数,会调用这个函数,主要用来处理更新前后是同一个节点的情况
patchVnode函数的代码
import createElement from "./createElement"
import updateChildren from "./updateChildren"
/**
*虚拟节点比较,主要有三种情况,精细化比较,父节点不同的情况
*
* @export
* @param {*} oldVnode
* @param {*} newVnode
*/
export default function patchVnode(oldVnode,newVnode){
// 当新老节点相同时(指的是内存中的储存完全相同)
if(oldVnode === newVnode) return
// 当oldVnode有text或者children,newVnode也有text的情况
if(newVnode.text!=undefined&&(newVnode.children==undefined||newVnode.children.length==0)){
if(newVnode.text!=oldVnode.text){
oldVnode.elm.innerText=newVnode.text
}else{
return
}
}else{
if(oldVnode.children!=undefined&&oldVnode.children.length>0){
// oldVnode有children,新的也有children
updateChildren(oldVnode.elm,oldVnode.children,newVnode.children)
}else{
// 老的没有,新的有children
oldVnode.elm.innerText=''
for(let i=0;i<newVnode.children.length;i++){
let dom=createElement(newVnode.children[i])
oldVnode.elm.appendChild(dom)
}
}
}
}
patchVnode函数讲解
patchVnode函数主要是在新老节点的key与sel属性都相同的情况下进行
主要处理四种情况:
1、当新老节点在内存中完全相同,则什么都不干,直接返回
2、当newVnode有text时,无论oldVnode有text或者children,newVnode中的text都会覆盖oldVnode中的text或者children,所以可以当成一种情况
3、当newVnode有children,oldVnode也有children的时候,这时候会用一个updateChildren()方法进行更新(下面会讲解到这个函数,这个函数是diff算法的核心)
4、当newVnode有children,oldVnode没有children的时候,也当作一种情况处理
2.6、updateChildren函数
功能:updateChildren函数是对newVnode有children,oldVnode也有children情况的处理(在是同一个节点的前提下)
updateChilldren函数代码
import createElement from "./createElement";
import patchVnode from "./patchVnode";
// 判断是否是同一个节点
function checkSameVnode(oldVnode,newVnode){
return oldVnode.sel==newVnode.sel&&oldVnode.key==newVnode.key
}
/**
*
*
* @export
* @param {*} parentElm
* @param {*} oldCh
* @param {*} newCh
*/
export default function updateChildren(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 oldStartVnode=oldCh[0]
// 旧后节点
let oldEndVnode=oldCh[oldEndIdx]
// 新前节点
let newStartVnode=newCh[0]
// 新后节点
let newEndVnode=newCh[newEndIdx]
let keyMap=null
// 开始循环
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx){
if(checkSameVnode(oldStartVnode,newStartVnode)){
console.log('新前与旧前命中');
console.log(oldStartVnode,newStartVnode);
// 是同一个节点,通过patchVnode函数对他进行比较,然后指针下移
patchVnode(oldStartVnode,newStartVnode)
oldStartVnode=oldCh[++oldStartIdx]
newStartVnode=newCh[++newStartIdx]
}else if(checkSameVnode(oldEndVnode,newEndVnode)){
console.log('新后与旧后命中');
patchVnode(oldEndVnode,newEndVnode)
oldEndVnode=oldCh[--oldEndIdx]
newEndVnode=newCh[--newEndIdx]
}else if(checkSameVnode(oldStartVnode,newEndVnode)){
console.log('新后与旧前命中');
// 当新后与旧前命中的时候,此时要移动节点,移动旧前指向的节点到旧后的后面
parentElm.insetBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
patchVnode(oldStartVnode,newEndVnode)
oldStartIdx=oldCh[++oldStartIdx]
newEndVnode=newCh[--newEndIdx]
}else if(checkSameVnode(newStartVnode,oldEndVnode)){
console.log('新前与旧后命中');
// 当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面
parentElm.insetBefore(oldEndVnode.elm,oldStartVnode.elm)
patchVnode(newStartVnode,oldEndVnode)
oldEndVnode=oldCh[--oldEndIdx]
newStartVnode=newCh[++newStartIdx]
}else{
// 四种都没有命中
// 寻找key的map
if(!keyMap){
keyMap={}
for(let i=oldStartIdx;i<=oldEndIdx;i++){
const key=oldCh[i].key
if(key!=undefined){
keyMap[key]=i
}
}
}
console.log(keyMap);
// 寻找当前项,这项在keyMap中的映射序号
const idxInOld=keyMap[newStartVnode.key]
console.log(idxInOld);
if(idxInOld==undefined){
// 说明该项是全新的项
// 在oldStartVnode前面加入
parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
}else{
// 如果不是undefined,说明该项已经存在,而是需要移动
const elmToMove=oldCh[idxInOld]
if(elmToMove.elm.nodeType==1){
patchVnode(elmToMove,newStartVnode)
// 把这项设置成undefined,表示已经处理完了
oldCh[idxInOld]=undefined
// 移动到oldStartVnode之前
parentElm.insertBefore(elmToMove,oldStartVnode.elm)
}
}
newStartVnode=newCh[++newStartIdx]
}
}
// 继续看看,有没有剩余节点
if(newStartIdx<=newEndIdx){
console.log('new还有剩余节点没有处理');
const before =oldCh[oldEndIdx+1]==null?null:oldCh[oldEndIdx+1].elm
for(let i=newStartIdx;i<=newEndIdx;i++){
// 调用insertBefore方法可以自动识别null,如果是null,会自动排到队尾,和appendChild一致
parentElm.insertBefore(createElement(newCh[i]),before)
}
}else if(oldStartIdx<=oldEndIdx){
console.log('old还有剩余节点没有处理');
for(let i=oldStartIdx;i<=oldEndIdx;i++){
parentElm.removeChild(oldCh[i].elm)
}
}
}
updateChildren函数讲解
updateChildren函数是对newVnode有children,oldVnode也有children情况的处理
先看一下图示:
解释一下:
首先会有四个指针,新前,旧前,新后,旧后
有四种情况:
1、新前与旧前
2、新后与旧后
3、新后与旧前
4、新前与旧后
有一个循环,当命中一种情况后,将不会命中其他种情况,而且是按顺序命中,首先命中1情况,1情况不命中,才开始2情况,2情况不命中,再开始3情况,以此类推...,最后还有一个所有情况都不命中的情况下,会进行对应处理。
当循环结束后,还有oldCh有剩余与newCh有剩余的情况
因此总的来说,有7种情况:
①新前与旧前命中
②新后与旧后命中
③新后与旧前命中
④新前与旧后命中
⑤前面四种都不命中的情况
⑥循环结束后oldCh有剩余的情况
⑦循环结束后newCh有剩余
第一种情况 (新前与旧前命中)
if(checkSameVnode(oldStartVnode,newStartVnode)){
console.log('新前与旧前命中');
console.log(oldStartVnode,newStartVnode);
// 是同一个节点,通过patchVnode函数对他进行比较,然后指针下移
patchVnode(oldStartVnode,newStartVnode)
oldStartVnode=oldCh[++oldStartIdx]
newStartVnode=newCh[++newStartIdx]
通过patchVnode将oldStartVnode与newStartVnode进行比较,再将 oldStartVnode与newStartVnode下移
第二种情况(新后与旧后命中)
lse if(checkSameVnode(oldEndVnode,newEndVnode)){
console.log('新后与旧后命中');
patchVnode(oldEndVnode,newEndVnode)
oldEndVnode=oldCh[--oldEndIdx]
newEndVnode=newCh[--newEndIdx]
通过patchVnode将oldEndVnode与newEndVnode进行比较,再将 oldEndVnode与newEndVnode上移
第三种情况(新后与旧前命中)
else if(checkSameVnode(oldStartVnode,newEndVnode)){
console.log('新后与旧前命中');
// 当新后与旧前命中的时候,此时要移动节点,移动旧前指向的节点到旧后的后面
parentElm.insetBefore(oldStartVnode.elm,oldEndVnode.elm.nextSibling)
patchVnode(oldStartVnode,newEndVnode)
oldStartIdx=oldCh[++oldStartIdx]
newEndVnode=newCh[--newEndIdx]
当新后与旧前命中的时候,此时要移动节点,移动旧后指向的节点到旧前的前面,通过patchVnode将oldStartVnode与newEndVnode进行比较,再将oldStartVnode下移,而newEndVnode上移
第四种情况(新前与旧后命中)
else if(checkSameVnode(newStartVnode,oldEndVnode)){
console.log('新前与旧后命中');
// 当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面
parentElm.insetBefore(oldEndVnode.elm,oldStartVnode.elm)
patchVnode(newStartVnode,oldEndVnode)
oldEndVnode=oldCh[--oldEndIdx]
newStartVnode=newCh[++newStartIdx]
当新后与旧前命中的时候,此时要移动节点,移动旧后指向到节点到旧前的前面, 通过patchVnode将newStartVnode与oldEndVnode进行比较,再将newStartVnode下移,而oldEndVnode上移
第五种情况(前四种都不命中的情况)
else{
// 四种都没有命中
// 寻找key的map
if(!keyMap){
keyMap={}
for(let i=oldStartIdx;i<=oldEndIdx;i++){
const key=oldCh[i].key
if(key!=undefined){
keyMap[key]=i
}
}
}
console.log(keyMap);
// 寻找当前项,这项在keyMap中的映射序号
const idxInOld=keyMap[newStartVnode.key]
console.log(idxInOld);
if(idxInOld==undefined){
// 说明该项是全新的项
// 在oldStartVnode前面加入
parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
}else{
// 如果不是undefined,说明该项已经存在,而是需要移动
const elmToMove=oldCh[idxInOld]
if(elmToMove.elm.nodeType==1){
patchVnode(elmToMove,newStartVnode)
// 把这项设置成undefined,表示已经处理完了
oldCh[idxInOld]=undefined
// 移动到oldStartVnode之前
parentElm.insertBefore(elmToMove,oldStartVnode.elm)
}
}
newStartVnode=newCh[++newStartIdx]
}
在这里有设置一个对象keyMap,keyMap的作用是将旧节点(oldCh)未被扫描的子元素进行存储,在keyMap中:
键:' key属性 ' 值 :子元素的下标
在新节点(newCh)中根据key在keyMap中查找当前项,用idxInOld来存储,若 idxInOld为undefined,则说明当前项在oldCh是不存在的,则在oldCh直接插入该项就好,若inxInOLd不为undefined,则说明oldCh本来就存在该项,这是就需要进行最小量更新。
①idxInOld为undefined
if(idxInOld==undefined){
// 说明该项是全新的项
// 在oldStartVnode前面加入
parentElm.insertBefore(createElement(newStartVnode),oldStartVnode.elm)
② idxInOld不为undefined
else{
// 如果不是undefined,说明该项已经存在,而是需要移动
const elmToMove=oldCh[idxInOld]
if(elmToMove.elm.nodeType==1){
patchVnode(elmToMove,newStartVnode)
// 把这项设置成undefined,表示已经处理完了
oldCh[idxInOld]=undefined
// 移动到oldStartVnode之前
parentElm.insertBefore(elmToMove,oldStartVnode.elm)
}
}
第六种情况(循环结束后oldCh有剩余的情况)
else if(oldStartIdx<=oldEndIdx){
console.log('old还有剩余节点没有处理');
for(let i=oldStartIdx;i<=oldEndIdx;i++){
parentElm.removeChild(oldCh[i].elm)
}
}
直接将oldStartIdx与oldEndIdx之间的元素从oldCh中移除就好
第七种情况 (循环结束后newCh有剩余)
if(newStartIdx<=newEndIdx){
console.log('new还有剩余节点没有处理');
const before =oldCh[oldEndIdx+1]==null?null:oldCh[oldEndIdx+1].elm
for(let i=newStartIdx;i<=newEndIdx;i++){
// 调用insertBefore方法可以自动识别null,如果是null,会自动排到队尾,和appendChild一致
parentElm.insertBefore(createElement(newCh[i]),before)
}
直接将oldStartIdx与oldEndIdx之间的元素插入到oldCh最后就行