虚拟dom理解和简单diff算法实现

发布于:2023-01-14 ⋅ 阅读:(244) ⋅ 点赞:(0)

什么是虚拟DOM

简单说就是用js对象来模拟DOM结构

<template>
    <div id="app" class="container">
        <h1>asherchen</h1>
    </div>
</template>

在vue中,在template中所有的内容都会通过编译器转换为虚拟DOM结构

{
  tag:'div',
  props:{ id:'app', class:'container' },
  children: [
    { tag: 'h1', children:'asherchen' }
  ]
}
  • tag用来描述标签名称,tag:'div’就是描述了一个 < div > 标签
  • props用来描述标签的属性,事件等内容
  • children用来描述标签的子节点

为什么需要虚拟dom

命令式编程与声明式编程对比

从编程范式来看,目前视图层框架通常分为命令式和声明式

命令式编程:更强调做事的过程
声明式编程:更强调做事的结果

//命令式编程
const div = document.querySelector('#app')
div.innerText = 'hello world'
div.addEventListener('click', () => { alert('ok') })
//类似:
//弟弟去拿个杯子
//弟弟去帮我接点水
//弟弟去帮我拿一个吸管
//弟弟帮我把吸管插上
//声明式编程
<div @click ="() => alert('ok')">hello world</div>
//类似
//弟弟我要一杯带吸管的水

可以看出声明式编程对开发人员更友好

BUT:我们先抛出一个结论:声明式的代码不优于命令式代码性能
在这里插入图片描述

如何让声明式编程性能更极致呢

对于框架,最优的更新性能需要找到前后差异并只更新变化的地方
当我们把直接修改的性能消耗定义为A,把性能消耗差距定义为B。
命令式更新性能消耗:A
声明式更新性能消耗:A+B
框架设计者要做的就是:在保持可维护性的前提下,让性能损失最小化
diff算法的出现就是为了最小化找出差异这一步的性能消耗而出现的。

js更新和dom更新元素比较

我们来看一下当页面渲染很多内容时,通过DOM直接更新和通过js进行更新的区别

	//使用DOM进行更新
	let box = document.getElementById("box");
	console.time('time');
	for (let i = 0; i < 10000; i++) {
		box.innerHtml = i;
	}
	console.timeEnd('time')
	

	//使用js进行更新
	console.time('time2')
	let num = 0;
	for(let i = 0;i<10000;i++){
		num = i
	}
	box.innerHTML += num;
	console.timeEnd("time2")

处理时间结果
在这里插入图片描述

可以得出结论,直接操作DOM更新在不如js操作更新快
innerHTML性能:HTML字符串拼接的计算量+innerHTML的DOM计算量

接下来我们来看虚拟DOM在更新时的性能

虚拟DOM innerHTML
纯JavaScript运算 创建新的JavaScript对象+Diff 渲染HTML字符串
DOM运算 必要的DOM更新 销毁所有的旧DOM+新建所有的新DOM

在更新页面时,虚拟DOM在js层面的运算要比创建页面多处一个diff的性能消耗,但是是属于js层面的运算,不会产生数量级的差异,DOM层面时,虚拟DOM只在更新页面时更新必要的元素,但是innerhtml需要全部销毁更新,虚拟DOM的优势就出来了

虚拟DOM如何转换真实DOM

renderer渲染器

在这里插入图片描述
假设我们有如下虚拟DOM

const vnode = {
	tag/type: 'div',
	props: {
		onClick: () => alert('hello')class:'red'
	},
	children: 'click me'
}

我们可以编写一个渲染器,把虚拟DOM转换为真实DOM

function renderer(vnode, container){
	//根据传入的vnode.tag创建对应DOM元素
	const el = document.createElement(vnode.tag)
	//遍历vnode.props将属性,事件添加到DOM元素
	for(const key in vnode.props){
		//如果是事件
		if(/^on/.test(key)){
			el.addEventListener(
				//onClick ---> click
				key.substr(2).toLowerCase(),
				//处理函数
				vnode.props[key]
			)else{
				el.setAttribute(key, vnode.props[key])
			}
		}
	}
	
	//如果vnode.chlidren是string,说明他是文本子节点
	if(typeof vnode.children === 'string'){
		//创建子节点并添加vnode.children
		el.appendChild(document.createTextNode(vnode.children))
	}else if(Array.isArray(vnode.children)){ //如果为true则说明还有嵌套元素,需要递归调用
	//递归的调用renderer函数
	vnode.children.forEach(child =>renderer(child,el))
	}
	container.appendChild(el)
}
  • vnode:虚拟DOM对象
  • container: 一个真实的DOM元素,作为挂载点

调用render函数

renderer(vnode, document.body)

结果
在这里插入图片描述
点击事件
在这里插入图片描述

diff算法

对于渲染器来说,它需要更精确的找到vnode对象变更点,并且只更新变更的内容,于是diff算法出现了

如果新老节点不是同一个节点名称,暴力删除旧的,创建新的节点
只能同级比较,不能跨层比较
如果要提升性能,一定要加入key, key是唯一标识,

path

通过path来找出不同,避免每次执行渲染器都进行重新渲染

n1为null为了后续挂载的正确执行,我猜是这里需要释放内存,查询资料解释为:

  • 如果定义的变量在将来用于保存对象,那么最好将该变量初始化为null,而不是其他值。换句话说,只要意在保存对象的变量还没有真正保存对象,就应该明确地让该变量保存null值,这样有助于进一步区分null和undefined。
  • 当一个数据不再需要使用时,我们最好通过将其值设置为null来释放其引用,这个做法叫做解除引用。不过解除一个值的引用并不意味着自动回收改值所占用的内存。解除引用的真正作用是让值脱离执行环境,以便垃圾收集器在下次运行时将其回收。解除引用还有助于消除有可能出现的循环引用的情况。这一做法适用于大多数全局变量和全局对象的属性,局部变量会在它们离开执行环境时(函数执行完时)自动被解除引用。
 const patch = (n1, n2, container) => {
        if (n1 === n2) return;
        //判断n1n2描述的内容是否相同,如果不同则删除节点
        if (n1 && n1.type !== n2.type) {
            unmount(n1);//删除老的
            n1 = null
        }

        //如果n1与n2描述的内容相同
        const { type } = n2

        if (typeof type === "string") {
            if (!n1) {
                // 不存在旧数组则直接渲染
                mountElement(n2, container)
            } else {
                //更新
                patchElement(n1, n2)
            }
        } else if (typeof type == 'object') {
            //如果是object则说明描述的是组件
        } else if (typeof type === 'xxx') {
            // 如果是其它类型vnode
        }
    }
    function unmount(vnode){
		const parent = vnode.el.parentNode
		if(parent){
		parent.removeChild(vnode.el)
		}
	}

更新方式

节点数相同
在这里插入图片描述

新子节点多
在这里插入图片描述
旧子节点多
在这里插入图片描述

 function patchChildren(n1,n2,container){
            if(typeof n2.children ==='string'){
                //省略
            }else if(Array.isArray(n2.children)){
                const oldChildren =n1.children
                const newChildren = n2.children
                //旧的节点长度
                const oldLen = oldChildren.length
                //新的节点长度
                const newLen = newChildren.length
                //判断哪个节点长度更短
                const commonLength = Max.min(oldLen, newLen)
                // 遍历commonLength次
                for(let i = 0; i<commonLength; i++){
                    patch(oldChildren[i], newChildren[i], container)
                }
                
                if(oldLen>newLen){
                    //如果oldLen>newLen 说明需要删除节点
                    for(let i = commonLength; i<oldLen;i++){
                        unmount(oldChildren[i])
                    }
                }else if(newLen>oldLen){
                    //如果newLen>oldLen 说明需要挂载节点
                    for(let i = commonLength; i<newLen; i++){
                        patch(null, newChildren[i],container)
                    }
                }else{
                    省略
                }
            }
        }

上面的代码有一个问题,我们无法判断是否可复用

改进方法

加入key值
在这里插入图片描述

lastIndex用来存储寻找具有相同key值的节点的过程中,遇到最大的索引值

在这里插入图片描述
在这里插入图片描述

  • 取第一个新子节点p-3,key为3,在旧子节点中寻找key为3的子节点,发现可以找到,并且该子节点在旧的子节点中索引为2,因为2>0,所以不移动DOM,将lastIndex值变为2
  • 取第二个新子节点 p-1, key为1,在旧子节点中寻找key为1的子节点,发现可以找到,并且该子节点在旧的子节点中索引为1,因为1<2,所以p-1对应的真实节点需要移动
  • 取第二个新子节点 p-2, key为2,在旧子节点中寻找key为2的子节点,发现可以找到,并且该子节点在旧的子节点中索引为0,因为0<2,所以p-2对应的真实节点需要移动。
function patchChildren(n1, n2, container) {
        if (typeof n2.children === 'string') {
            //省略
        } else if (Array.isArray(n2.children)) {
            const oldChildren = n1.children
            const newChildren = n2.children

            let lastIndex = 0
            for (let i = 0; i < newChildren.length; i++) {
                const newVnode = newChildren[i]
                let j = 0//?
                for (j; j < oldChildren.length; j++) {
                    const oldVNode = oldChildren[j]
                    //如果找到了具有相同key的两个节点,说明可以复用,需要用patch进行函数更新
                    if (newVnode.key === oldVNode.key) {
                        patch(oldVNode, newVnode, container)
                        if (j < lastIndex) {
                            // 说明需要移动newVNode对应的真实DOM
                            // 先获取newVNode前一个vnode,即prevVNode
                            const prevVNnode = newChildren[i - 1]
                            //如果prevVNode不存在说明是第一个节点,他不需要移动
                            if(prevVNnode){
                            	//我们需要将newVnode对应的真实DOM欲动刀prevVnode所对应的真实DOM后面
                            	//获取preVNode下一个兄弟节点,并将其作为锚点
                                const anchor = prevVNnode.el.nextSibling
                                调用insert方法将newVNode对应的真实DOM的后面
                                insert(newVnode.el, container, anchor)
                            }
                        }else{
                            lastIndex = j
                        }
                        break
                    }
                }
            }
        } else {
            //
        }
    }
    insert(el, parent, anchor = null){
        parent.insertBefore(el,anchor)
    }

但这不是最佳算法,更好的方式是将3拿到最上面去,一次解决问题,具体算法后面讲

添加节点

思路:当节点在旧的子节点遍历一圈没有发现可以复用的节点时,会被看作新增节点,观察节点在新的一组子节点中的位置,并按位置进行挂载

  function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
      //省略
    } else if (Array.isArray(n2.children)) {
      const oldChildren = n1.children
      const newChildren = n2.children
      let lastIndex = 0;
      for (let i = 0; i < newChildren.length; i++) {
        const newVnode = newChildren[i]
        let j = 0//?
        // 在第一层循环中定义变量 find,代表是否在旧的一层子节点中找到可复用节点,
        //初始为false 代表没找到
        let find = false

        for (j; j < oldChildren.length; j++) {
          const oldVNode = oldChildren[j]

          if (newVnode.key === oldVNode.key) {
            // 找到可复用节点find变成true
            find= true
            patch(oldVNode, newVnode, container)
            if (j < lastIndex) {
              const prevVNnode = newChildren[i - 1]
              if (prevVNnode) {
                const anchor = prevVNnode.el.nextSibling
                insert(newVnode.el, container, anchor)
              }
            } else {
              lastIndex = j
            }
            break
          }
        }
        //运行到这里说明没有找到可复用节点,需要挂载
        if(!find){
          //为了将节点挂载到正确位置上,我们需要先获取锚点元素
          // 首先获取当前newVNode前一个vnode节点
          const prevVNnode = newChildren[i-1]
          let anchor = null
          if(prevVNnode){
            //如果有前一个vnode节点,则使用他的下一个兄弟节点作为锚点元素
            anchor = prevVNnode.el.nextSibling
          }else {
            anchor = container.firstChild
          }
          patch(null,newVnode,container,anchor)

        }
      }
    } else {
      //
    }
  }

此时path需要接收四个函数

  function path(n1, n2, container, anchor) {
     	if (n1 === n2) return;
        //判断n1n2描述的内容是否相同,如果不同则删除节点
        if (n1 && n1.type !== n2.type) {
            unmount(n1);//删除老的
            n1 = null
        }

        //如果n1与n2描述的内容相同
        const { type } = n2

    if (typeof type === "string") {
      if (!n1) {
        mountElement(n2, container, anchor)
      } else {
        patchElement(n1, n2)
      }
    } else if (type === "Text") {
      //省略
    } else if (type === Fragment) {

    }
  }
  

  function mountElement(vnode, container, anchor){
    insert(el,container, anchor)
  }

删除节点

思路:当基本的更新结束时,我们需要遍历旧的一组子节点,去新的一组子节点中找到具有相同key值的节点,如果找不到,就说明应当删除节点

  function patchChildren(n1, n2, cintainer, anchor) {
    if (typeof n2.children === "string") {
      //省略
    } else if (Array.isArray(n2.children)) {
      const oldChildren = n1.children
      const newChildren = n2.children

      let lastIndex = 0
      for (let i = 0; i < newChildren.length; i++) {
        //省略更新部分代码
      }

      //新增删除部分代码
      for (let i = 0; i < oldChildren.length; i++) {
        const oldVNode = oldChildren[i]
        //拿旧子节点oldVNode去新的一组子节点中寻找具有相同key值的节点
        const has = newChildren.find(
          vnode => vnode.key === oldVNode.key
        )
        if(!has){
          unmount(oldVNode)
        }
      }
    }else{
      //省略
    }
  }

以上就是一个简单diff算法的实现,后续会讲解双端diff算法等知识

本文含有隐藏内容,请 开通VIP 后查看