VUE3虚拟DOM一(结构篇)

97 阅读10分钟

今天这一篇分享下vue3的虚拟dom

本篇的相关的解析,我们先用文字描述逻辑,然后再转换成代码实现

其实VDOM的核心是更新,这一篇我们主要看下更新的算法,其他的顺带的讲一讲。

虚拟DOM是什么?

const VNode = {
  type: 'div',
  el:"真实DOM引用",
  key:'',
  props: {
    id: 'app'
  },
  children: [
    '你好啊!',
    {
      type: 'p',
      children: '这是p标签'
    },
    {
      type: 'span'
    }
  ]
}

这就是虚拟DOM,用一个对象来描述真实Dom,而没有真实Dom上那么多我们不需要的属性。 平时我们看调用render都是renderer.render,这里renderer是做跨平台用。

接下来我们抛开跨平台,我们来讲下更新虚拟dom都有哪些事情要做:

  1. 首先我们需要有一个render函数用来做整个应用的入口,用来是更新还是卸载,及给根元素加个属性存vnode方便做patch更新对比用(只有根html节点存了)。
  2. 我们需要有一个patch函数,来对比新旧VNode来进行打补丁更新,更新分三种情况,挂载,卸载,对比更新。
  3. 剩下就是unmount卸载函数,mountElement挂载函数,patchElement对比更新(patchChildren更新子节点 这里是重点 会涉及到更新算法)

render函数实现

render函数接收两个参数,一个是vnode,一个是要往哪个元素上添加的 html元素,实现如下

  1. 先判断vnode有没有,有vnode时,如果旧的有oldvnode有那就是更新,没有那就是新增。
  2. 如果vnode没有,而oldvnode旧的有,代表上一次有渲染,而新的一轮vnode不存在,证明要卸载。


function render(vnode, container) {
  if (vnode) {
    // 如果有vnode情况下 container._vnode有是更新 没有是新增
    patch(container._vnode, vnode, container)
  } else {
    if (container._vnode) {
      // 新的vnode没有  旧的有证明 新一轮调用 旧的被卸载了  所以执行卸载
      unmount(container._vnode)
    }
  }
  container._vode = vnode
}

const VNode = {
  type: 'div',
  key:'1',
  props: {
    id: 'app1'
  },
  children: [
    '你好啊!',
    {
      type: 'p',
      children: '这是p标签'
    },
    {
      type: 'span'
    }
  ]
}
render(VNode,document.getElementById('app'))

patch函数实现

  1. 首先它有4个参数,n1是旧的vnode,n2是新的vnode,container是父容器(插入时插入到这个下面),anchor是锚点元素(插入到它之前,如果是null那就是最后)。
  2. 走到这个函数里n2一定是存在的,n2不存在那前面就执行卸载操作不会走这里了。
  3. 首先看看新旧vnode的type是不是一致的,如果不一致一证明从底子就变了,所以需要把旧的n1卸载。
  4. 在这里我们根据n2新的vnode的type来决定对应不同的处理操作。
  5. 如果type是字符串证明是普通html元素,然后n1老的不存在,那直接挂载(n2一定存在),如果都存在那就是更新。
  6. 如果type是text纯文本,那children就是字符串,如果n1老的不存在,那就直接插入文本,如果都存在然后不一样那就更新。
  7. 如果type是空节点Fragment,Fragment 本身并不会渲染任何内容,只处理 Fragment 的子节点就够了,n1不存在,直接n2.children挂载,如果都存在对比n1.children和n2.children更新。
  8. 如果type是组件,n1不存在那就直接挂载n2组件,否则组件对比更新。
/**
 *
 * @param n1 老的vnode
 * @param n2 新的vnode
 * @param container 父容器
 * @param anchor 锚点元素  一般是插入它之前
 * 它负责了 更新 和 挂载 和 卸载
 */
function patch(n1, n2, container, anchor) {
  // 走这个函数n2 一定是有的
  if (n1 && n1.type !== n2.type) {
    // n1和n2 type不是同一个 从底子就变了
    unmount(n1) //卸载
    n1 = null
  }
  const { type } = n2
  if (typeof type === 'string') {
    // 正常的元素标签
    if (!n1) {
      // 老的没有 新的有 这种情况直接挂载n2元素
      mountElement(n2, container, anchor) //把n2 插入到 容器container下的  锚点anchor之前
    } else {
      // 新旧都有 对比更新
      patchElement(n1, n2)
    }
  } else if (typeof type === Text) {
    if (!n1) {
      // 调用 createText 函数创建文本节点 新的vnode存储el
      const el = (n2.el = createText(n2.children)) // children纯文本
      insert(el, container) //直接添加
    } else {
      const el = (n2.el = n1.el) // 新的vnode存储el
      if (n2.children !== n1.children) {
        // children纯文本
        // 调用 setText 函数更新文本节点的内容
        setText(el, n2.children)
      }
    }
  } else if (typeof type === Fragment) {
    // 由于 Fragment 本身并不会渲染任何内容,所以渲染器只会渲染 Fragment 的子节点
    if (!n1) {
      // 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
      n2.children.forEach(c => patch(null, c, container))
    } else {
      // 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
      patchChildren(n1, n2, container) //对比更新子集
    }
  } else if (typeof type === 'Object' || typeof type === 'function') {
    //type 是对象 --> 有状态组件  type 是函数 --> 函数式组件
    if (!n1) {
      //如果n1不存在 n2存在 代表老的没有 新的存在那就是挂载
      mountComponent(n2, container, anchor)
    } else {
      //都存在就进行对比更新
      patchComponent(n1, n2, anchor)
    }
  }
}


//虚拟 DOM 描述更多类型的真实 DOM 这种情况下会出现文本节点,所以上面patch也处理了 text节点
<div><!-- 注释节点 -->我是文本节点</div>

const Text = Symbol()
const newTextVNode = {
    // 描述文本节点 
    type: Text,
    children: '我是文本节点'
}

// 注释节点的 type 标识 
const Comment = Symbol()
const newVNode = {
    // 描述注释节点 
    type: Comment,
    children: '注释节点'
}

const vnode = {
    type:'div',
    children:[
        newVNode,
        newTextVNode
    ]
}

unmount函数实现

  1. vnode如果是空元素,空元素时是只会渲染它的子节点,所以我们直接卸载子节点。

  2. vnode如果是组件,如果是keepAlive的组件这个放在后面和编译器一起讲。

  3. 普通组件直接卸载,正常的组件vnode.component是 模拟的组件实例,subTree是组件render返回的vnode,渲染器渲染组件时添加的一系列属性。

  4. 元素标签的话直接卸载,过渡组件化的时候讲。

function unmount(vnode) {
  if (vnode.type === Fragment) {
    // 空标签 不渲染 渲染的使它的子集 所以把子集直接卸载
    vnode.children.forEach((c) => unmount(c))
    return
  } else if (typeof vnode.type === 'object') {
    // 是组件
    if (vnode.shouldKeepAlive) {
      //处理keepalive 这块vnode 编译器加渲染器渲染组件时   后续会将 这里纯dom不用关注
      vnode.keepAliveInstance._deActivate(vnode)
    } else {
      // 正常的组件 vnode.component是 模拟的组件实例,subTree是组件render返回的vnode,渲染器渲染组件时添加的一系列属性
      unmount(vnode.component.subTree)
    }
    return
  }
  // vnode.type是元素标签 找他的父级 从他父级卸载
  const parent = vnode.el.parentNode
  if (parent) {
    const performRemove = () => parent.removeChild(vnode.el)

    //通过 vnode.transition 处理过渡
    const needTransition = vnode.transition
    if (needTransition) {
      // xxx
    } else {
      //直接卸载元素
      performRemove()
    }
  }
}

mountElement实现

  1. 挂载时主要区分 children是字符串还是数组,如果是字符串那证明有文本子节点,是数组证明有多个子元素。
  2. 根据type创建元素标签el
  3. 如果是字符串直接el设置内容然后挂载el到指定容器上。
  4. 如果是数组调用patch给el添加子元素之后再把el挂载到指定元素上。
function mountElement(vnode, container, anchor) {
  const el = (vnode.el = createElement(vnode.type))
  // 挂载子节点,首先判断 children 的类型
  // 如果是字符串类型,说明是文本子节点
  if (typeof vnode.children === 'string') {
    setElementText(el, vnode.children)
  } else if (Array.isArray(vnode.children)) {
    // 如果是数组,说明是多个子节点
    vnode.children.forEach(child => {
      patch(null, child, el)
    })
  }
  if (vnode.props) {
    for (const key in vnode.props) {
      patchProps(el, key, null, vnode.props[key])
    }
  }
  insert(el, container, anchor)
}

/**
 *
 * @param el 要插入的元素
 * @param parent 插入的父级元素
 * @param anchor 锚点元素 插入到它之前 为null时插入到父级最后
 */
function insert(el, parent, anchor = null) {
  parent.insertBefore(el, anchor)
}

patchElement实现

  1. 这个是更新html标签的函数,到这个函数里标签一定是一样的,所以它的实现主要是更新属性props。
  2. 这里主要就是先循环新的props看和旧的对应值是否一致,不一致就更新。
  3. 然后再循环旧的props,如果有的key在新的里不存在了,证明被删除了,那就删掉该属性。
  4. 最后调用patchChildren,更新子节点是对一个元素进行打补丁的最后 一步操作。我们将它封装到 patchChildren 函数中,更新子集我们就使用patchChildren。
/**
 * 更新html元素
 * @param {*} n1 旧vnode 
 * @param {*} n2 新vnode
 */
function patchElement(n1,n2){
    const el = n2.el = n1.el;
    const oldProps = n1.props;
    const newProps = n2.props;
    // 更新props
    for(const key in newProps){
        if(newProps[key] !== oldProps[key]){
            patchProps(el,key,oldProps[key],newProps[key]);
        }
    }
    // 清楚不存在的props
    for(const key in oldProps){
        if(!(key in newProps)){
            patchProps(el,key,oldProps[key],null)
        }
        
    }

    // 更新内容children

    patchChildren(n1,n2,el)
}

这里我们看到了patchProps,它更新属性时对事件做了特殊处理优化,就是内部伪造处理函数invoker绑定,然后元素el上存储了实际函数最后执行,更新也是更新el上的存储,这样就可以防止频繁的解绑和绑定。 代码类似这样

el._vei={
    onClick:(e)=>{ el._vei.onClick.value.forEach(fn => fn(e)或者el._vei.onClick.value(e))}
}

el._vei.onClick.value = 数组事件集合或者单一事件回调函数

el.addEventListener(name, el._vei.onClick)
//就是把函数存储到el上,然后模拟了一个el._vei.onClick点击事件绑定,每次就更新el._vei.onClick.value,防止频繁绑定事件

1 patchProps(el, key, prevValue, nextValue) { 
       if (/^on/.test(key)) { 
         const invokers = el._vei || (el._vei = {}) 
         let invoker = invokers[key] 
         const name = key.slice(2).toLowerCase() 
         if (nextValue) { 
           if (!invoker) { 
             invoker = el._vei[key] = (e) => { 
               // 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数 
               if (Array.isArray(invoker.value)) { 
                 invoker.value.forEach(fn => fn(e)) 
               } else { 
                 // 否则直接作为函数调用 
                 invoker.value(e) 
               } 
             } 
             invoker.value = nextValue 
             el.addEventListener(name, invoker) 
           } else { 
             invoker.value = nextValue 
           } 
         } else if (invoker) { 
           el.removeEventListener(name, invoker) 
         } 
       } else if (key === 'class') { 
         // 省略部分代码 
       } else if (shouldSetAsProps(el, key, nextValue)) { 
         // 省略部分代码 
       } else { 
         // 省略部分代码 
       } 
 }

patchChildren实现

  1. 对比更新其实就是children的三种情况,它是字符串,它是数组,它不存在,然后新旧vnode.children都有这三种情况,我们把他们过一下就可以。
  2. 因为是渲染新vnode.children,所以我们以新的children为主,还是老样子n1是旧,n2是新。
  3. n2.children是字符串时,n1.children是数组那就卸载,否则直接用n2.children替换n1.children.
  4. n2.children是数组时,n1.children是数组那就对比更新(重点算法,后面补充),否则容器清空再挂载n2.children.
  5. n2.children不存在,n1.children是数组那就卸载,是字符串那就清空容器,要是n1.children不存在那就什么都不用做。
/**
 * 对比子节点更新 
 * @param {*} n1 老的vnode
 * @param {*} n2 新的vnode
 * @param {*} container 正在被打补丁的 DOM 元素 el 
 */
function patchChildren(n1, n2, container) {
    //判断新子节点的类型是否是文本子节点
    if (typeof n2.children === 'string') {
        // 老节点有三种情况 一组子节点children数组 没有子节点 文本子节点
        if (Array.isArray(n1.children)) {
            // 老的直接卸载掉 新的就一个字符串 老的都不一样了
            n1.children.forEach(function (child) { unmount(child) })
        }

        // 将新的文本节点内容设置给容器元素 这个操作可以直接包含上面所有了
        setElementText(container, n2.children)
    } else if (Array.isArray(n2.children)) {
        // 说明新子节点是一组子节点 
        if (Array.isArray(n1.children)) {
            // 新旧都是一个数组 这里需要核心算法 对比新旧子节点
            // 这里临时写一个 最简陋的处理 后面写主要的算法补充这里

            //卸载旧的
            n1.children.forEach(child => { unmount(child) })
            //挂载新的
            n2.children.forEach(child => { patch(null, child, container) })
        } else {
            // 这会老子节点要么是字符串 要么不存在 
            // 无论那种情况 我们只需要把容器清空 然后挂载新的这组子节点就够了
            setElementText(container, '')
            n2.children.forEach(child => { patch(null, child, container) })

        }
    } else { //到这里说明新节点不存在,新节点和老的一样也是三种情况 字符串 数组前面都处理了
        //这里处理新节点不存在 
        if (Array.isArray(n1.children)) {
            //老的是数组
            n1.children.forEach(function (child) { unmount(child) })
        } else if (typeof n1.children === 'string') {
            // 老的是字符串 给他清空
            setElementText(container, '')
        }

        // 如果也没有旧子节点,那么什么都不需要做
    }
}

到这里基础的虚拟dom树的同级比对的顺序和逻辑就完了,下一篇算法篇我们来,具体的看新旧VNode都有children子集时,涉及到常见的几种算法。