【VUE】手写一个VUE虚拟dom diff算法

背景

  • 这个写起来有点麻烦,难度还行,一步步总结一下。

流程

一、 准备工作

  • 先建个html和一个js文件。让html引入这个js文件。

二、渲染vnode到页面

const root = document.querySelector('#root');


const vnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)

mount(vnode,root)
  • 我们拿到一个vnode后需要通过h函数做成虚拟dom对象,然后通过amount函数挂在到真实dom即可。
  • 所以需要写一下h函数和mount函数。

h函数

const VNODE_TYPE = 'VNODE_TYPE';
const hasOwnProperty = Object.prototype.hasOwnProperty
function convertVnode(type,props={},key,children=[],text,domElement) {
    return {
        _type:VNODE_TYPE,
        type,props,key,children,text,domElement
    }
}
function h(type,attr,...children) {
    let key;
    let props={}
    if(attr.key){
        key = attr.key;
    }
    for(let attName  in attr){
        if(hasOwnProperty.call(attr,attName)&&attName!='key'){
            props[attName]=attr[attName]
        }
    }
    return convertVnode(type,props,key,children.map((child)=>{
        return  typeof child =='string'||typeof child =='number'?
        convertVnode(undefined,undefined,undefined,undefined,child):child
    }))
}
  • 功能很简单,就是把传来的vnode的属性分解一下,做成一个对象,这个对象有个_type标记,然后上面各种属性。
  • 这里用hasOwnProperty是可以就看它自己的属性,并且能防止有重名属性导致hasOwnProperty不工作。

mount函数


function updateAttr(vnode,oldprops=[]) {
    let newprops = vnode.props
    for(let attr in newprops){
        if(attr === 'style'){
            let styleObj = newprops[attr]
            for(let styleName in styleObj){//styleObj样式对象
                vnode.domElement.style[styleName]=styleObj[styleName]
            }
        }else{//一般样式直接弄
            vnode.domElement[attr]=newprops[attr]
        }
    }
}


function creatRealDom(vnode) {
    let {type,children}=vnode
    if(type){
        let dom = vnode.domElement=document.createElement(type)
        updateAttr(vnode)
        if(Array.isArray(children)){
            children.forEach((child)=>dom.appendChild(creatRealDom(child)))
        }
    }else{
        vnode.domElement = document.createTextNode(vnode.text)
    }
    return vnode.domElement      
}


function mount(vnode,container) {
    let realDom = creatRealDom(vnode)
    container.appendChild(realDom)
}

  • 通过creatRealDom函数把真实dom节点放在vnode的domElement属性上,通过updateAttr更新属性,如果还有子元素,那么把子元素创建真实dom,同时加入其真实dom的子元素。
  • 这段也没太难的地方,主要就是靠细心和有思路即可写出来。
  • 然后打开浏览器可以看一下是否是个不同底色的列表,如果是那就成功了。

三、用新vnode替换老vnode

  • 首先,把数据改成一个新的vnode一个老的vnode。先做成如果节点不一样,整个全换掉。
const oldVnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)
const newVnode = h('div',{},'xx')
  • 然后改一下挂载逻辑,挂在在dom上后进行用patch打补丁修改以前dom
  • 为了看出区别,用settimeout延时这个函数。
mount(oldVnode,root)
setTimeout(() => {
    patch(oldVnode,newVnode)
}, 3000);

patch函数

function  patch(oldVnode,newVnode) {
    //如果类型不一样,直接替换
    if(newVnode.type!==oldVnode.type){//
        return oldVnode.domElement.parentNode.replaceChild(creatRealDom(newVnode),oldVnode.domElement)
    }
}
  • 这里替换操作需要注意,是拿到它父亲的真实dom节点,然后替换儿子。
  • 一开始我这有个地方没缓过来,以为还需要修改oldVnode上属性。其实是不需要修改的,因为你需要生成的虚拟节点已经就是newVnode传来了,再把oldVnode上修改毫无意义。就是如果这2类型不一样,替换就完了,替换后老虚拟节点相当于独立于真实dom外。如果类型一样,这2个虚拟节点实际指的是一个dom,无论更改哪个都会影响视图。
  • 所以这时候可以打开浏览器看看是不是成功替换了。
  • 然后需要做下一个节点类型一样,文本不一样,更新属性,并能去查找儿子不一样的地方。
  • 测试例子改成如下:
const newVnode = h('ul', { id: 'container' },
    h('li', { style: { backgroundColor: '#AA0000' }, key: 'E' }, 'E1'),
    h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B1'),
    h('li', { style: { backgroundColor: '#110000' }, key: 'f' }, 's1'),
    h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D1'),
    h('li', { style: { backgroundColor: '#770000' }, key: 'A' }, 'A1'),
); 
const oldVnode = h('ul', { id: 'container' },
h('li', { style: { backgroundColor: '#110000' }, key: 'A' }, 'A'),
h('li', { style: { backgroundColor: '#440000' }, key: 'B' }, 'B'),
h('li', { style: { backgroundColor: '#770000' }, key: 'C' }, 'C'),
h('li', { style: { backgroundColor: '#AA0000' }, key: 'D' }, 'D'),
)
  • 父节点和子节点一样,但是属性和text不一样。
  • 我们需要对patch函数增加逻辑和更新属性函数增加逻辑。
function  patch(oldVnode,newVnode) {
    //如果类型不一样,直接替换
    if(newVnode.type!==oldVnode.type){
        return oldVnode.domElement.parentNode.replaceChild(creatRealDom(newVnode),oldVnode.domElement)
    }
    //节点类型一样,文本赋值,第三个属性不是儿子就是文本,是文本返回
    if(newVnode.text!==undefined){
        return oldVnode.domElement.textContent=newVnode.text
    }
    //节点类型一样,更新属性
    let domElement = newVnode.domElement = oldVnode.domElement//先让newvnode上能操作dom
    updateAttr(newVnode,oldVnode.props)
    //节点类型一样,查找儿子
    let oldChildren = oldVnode.children
    let newChildren = newVnode.children
    //分三种情况考虑
    //老的有儿子,新的没儿子,删除老的
    if(oldChildren.length>0 && newChildren.length>0){
        updateChildren(domElement,newChildren,oldChildren)
    }else if(oldChildren.length>0){//如果不是2个都大于0 那么就是老的有儿子新的没儿子
        domElement.innerHTML=''//改内部html即可删除儿子
    }else if(newChildren.length>0){//新的有老的没有
        for(let i=0;i<newChildren.length;i++){
            domElement.appendChild(creatRealDom(newChildren[i]))
        }
    }
}

function updateAttr(vnode,oldprops=[]) {
    let oldStyle = oldprops.style ||{}
    let newStyle = vnode.props.style ||{}
    for(let attr in oldStyle){//循环老的,如果老的有新的没有,删除
        if(!newStyle[attr]){
            vnode.domElement.style[attr]=''
        }
    }
    for(let attrName in oldprops){//除style属性 老的有新的没有删除
        if(!vnode.props[attrName]){
            delete vnode.domElement[attrName]
        }
    }
    let newprops = vnode.props
    for(let attr in newprops){
        if(attr === 'style'){
            let styleObj = newprops[attr]
            for(let styleName in styleObj){
                vnode.domElement.style[styleName]=styleObj[styleName]
            }
        }else{//一般样式直接弄
            vnode.domElement[attr]=newprops[attr]
        }
    }
}
  • 更新属性套用前面的方法,先把老的有新的没有的属性全删掉,再统一添加。
  • 最重要的就是两个孩子都大于0的updateChildren方法了。

updateChildren

function isSameNode(oldNode,newNode) {//key和type都一样
   return oldNode.key === newNode.key && oldNode.type === newNode.type;
}
function createKeyToIndexMap(children) {
    let map = {};
    for (let i = 0; i < children.length; i++) {
        let key = children[i].key;
        if (key) {
            map[key] = i;
        }
    }//遍历一遍老节点获取索引和key
    return map;
}
function updateChildren(parentDomElement,newChildren,oldChildren) {
    //如果都有,先对新旧的前后进行相互比较。
    //做开始结束指针
    let oldStartIndex = 0
    let oldStartNode  = oldChildren[0]
    let newStartIndex = 0
    let newStartNode = newChildren[0]
    let oldEndIndex = oldChildren.length-1
    let oldEndNode = oldChildren[oldChildren.length-1]
    let newEndIndex = newChildren.length-1
    let newEndNode = newChildren[newChildren.length-1]
    //这是个递归,所以停止条件就是2个指针都完全重合。
    //循环逻辑下,一方走完就出while,不然没法比。
    while(oldStartIndex<=oldEndIndex&&newStartIndex<=newEndIndex){
        if(!oldStartNode){//如果取不到,说明这个节点被移动了,获取下一个
            oldStartNode=oldChildren[++oldStartIndex]
        }else if(!oldEndNode){
            oldEndNode = oldChildren[--oldEndIndex]
        }
        //头比头,如果相同,那么递归Patch,加到dom上,更新索引和节点
        else if(isSameNode(oldStartNode,newStartNode)){
            patch(oldStartNode,newStartNode)
            oldStartNode = oldChildren[++oldStartIndex];
            newStartNode = newChildren[++newStartIndex];
        }else if(isSameNode(oldEndNode,newEndNode)){
            patch(oldEndNode,newEndNode)
            oldEndNode = oldChildren[--oldEndIndex]
            newEndNode = newChildren[--newEndIndex]
        }else if(isSameNode(oldStartNode,newEndNode)){//头和尾
            patch(oldStartNode,newEndNode)
            //移动位置,把老的头移到老的尾,然后end索引少一
            parentDomElement.insertBefore(oldStartNode.domElement,oldEndNode.domElement.nextSibiling)
            oldStartNode = oldChildren[++oldStartIndex]
            newEndNode  = newChildren[--newEndIndex]
        }else if(isSameNode(oldEndNode,newStartNode)){
            patch(oldEndNode,newStartNode)
            //把老尾移动到老头
            parentDomElement.insertBefore(oldEndNode.domElement,oldStartNode.domElement)
            newStartNode = newChildren[++newStartIndex]
            oldEndNode   = oldChildren[--oldEndIndex]
        }else{//全都对不上,使用key寻找
            let oldIndexByKey = createKeyToIndexMap(oldChildren)[newStartNode.key]
            if(oldIndexByKey==null){//找不到
                parentDomElement.insertBefore(creatRealDom(newStartNode),oldStartNode.domElement)
            }else{//如果找到了,那么就把老的索引那个移动到最前面。
                let oldMoveNode = oldChildren[oldIndexByKey]
                if(oldMoveNode.type!==newStartNode.type){//类型不一样按找不到处理
                    parentDomElement.insertBefore(creatRealDom(newStartNode),oldStartNode.domElement)
                }else{
                    patch(oldMoveNode,newStartNode)
                    oldChildren[oldIndexByKey]=undefined//移动后让节点变成空
                    parentDomElement.insertBefore(oldMoveNode,oldStartNode.domElement)
                }
            }
            newStartNode = newChildren[++newStartIndex]
        }
    }
    if(newStartIndex<=newEndIndex){//老走完 新没走完
        let beforeDOMElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement;
        for(let i = newStartIndex;i<=newEndIndex;i++){
            parentDomElement.insertBefore(creatRealDom(newChildren[i]),beforeDOMElement)
        }
    }
    if(oldStartIndex<=oldEndIndex){//新走完,老没走完
        for(let i = oldStartIndex;i<=oldEndIndex;i++){
            parentDomElement.removeChild(oldChildren[i].domElement)
        }
    }
}
  • 这里更新孩子走一个while循环,如果其中一对指针走完那么判断有没有多的或者少的节点。
  • 在while循环里主要就是判断头和头,尾和尾,以及头和尾,尾和头四种情况是否相同,判断是根据type和key是否一样。
  • 如果相同,那么把新的vdom更新老的vdom,移动索引,这里需要注意头和尾或者尾和头相等的需要移动老节点,这样才能保证顺序不会错。而insertbefore算纯粹移动,会干掉以前元素,最适合干这事。
  • 如果四种情况都不符合,那就根据键值索引去比较老的Key和新的key相同元素,如果type也相同,那么就patch,不同,那么就创建真实dom插入前头。
  • 另外还有几个容易逻辑遗漏的地方:
  • 因为前面四种情况都是移动头或者尾,如果不符合四种情况就会移动中间,这样导致指针为空,所以while循环里要进行个判断指针为空的情况。
  • 还有就是老的走完,新的没走完时的插入位置不是固定的,通过查找走完前索引来找到插入位置。
  • 最后打开浏览器试验下,列表按顺序排列即ok。
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

业火之理

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值