手动实现vue-diff

264 阅读7分钟

项目git

文章大致目录:

  • 虚拟dom是什么
  • 如何创建虚拟dom
  • 虚拟dom如何渲染成真实的dom
  • 虚拟dom如何patch
  • 虚拟dom的优势
  • vue中key的到底有什么用,为什么最好不是index
  • vue中diff算法的实现

1.virtual DOM是什么

  • 在virtual DOM没有提出之前,我们操作的都是真实的dom,当我们每次进行dom查询的时,几乎需要遍历整颗dom树,以及dom操作引起浏览器的重绘和重排,这都十分影响性能。
  • 因此用js模拟一颗dom树(在我看来,虚拟dom就是一个描述真实dom的对象),放在浏览器内存中.当你要变更时,虚拟dom使用diff算法进行新旧虚拟dom的比较,将变更放到变更队列中,反应到实际的dom树,减少了dom操作.
  • 虚拟DOM将DOM树转换成一个JS对象树,diff算法逐层比较,删除,添加操作,但是,如果有多个相同的元素,可能会浪费性能,所以,react和vue-for引入key值进行区分,便于复用。

2. 如何创建虚拟dom

解析传入的参数,将参数分类,输出统一的格式

// h.js
    import { vnode } from './vnode'
    
    
    export default function creatElement(type, props, ...children) {
      let key;
      if (props.key) {
        key = props.key    // 一般属性中的key不会传给子,因此单独设为虚拟dom的一个属性
        delete props.key
      }
      children = children.map(child => {
        if  (typeof child === 'string') {
          return vnode(null,null,null,null, child)
        } else {
          return child
        }
      })
      return vnode(type, key, props, children)
    }
    
// vnode.js
    /*
        @type:节点类型
        @props: 节点属性
        @children 子节点集合
        @key: 节点的索引
        @text: 文本
    */
    export function vnode(type, key, props, children, text) {
      return {
        type, 
        key, 
        props, 
        children, 
        text
      }
    }
// index.js 测试
import { h, render } from './vdom/index'
const vnode = h('div', {id: 'diff', a: 1, style: {color: '#ab4d63'}}, h('span', { style: {color: '#969696'}}, 'vue-'), 'diff')

3.虚拟dom渲染成真实的dom

// patch.js
/*
  将生成的真实dom挂在到容器中
**/
export function render(vnode, container) {
  const ele = createDomByVnode(vnode)
  container.appendChild(ele)
}
/*
  生成真实dom
**/
function createDomByVnode(vnode) {
  const { type, key, props, children = [], text} = vnode
  if (type) {
    // 标签
    vnode.domElement = document.createElement(type)
    updateProperties(vnode)
  } else {
    // 文本
    vnode.domElement = document.createTextNode(text)
  }
  // children的处理
  children && children.forEach(child => {
    return render(child, vnode.domElement)
  });
  return vnode.domElement // 在vnode中创建一个属性,映射真正的dom
}

/**
 * 更新属性
 * **/
function updateProperties(vnode, oldProps = {}) {
   // 属性暂时只分析到style
  const domElement = vnode.domElement
  const props = vnode.props
  // 老有, 新没有,删除属性
  for(let oldPropsName in oldProps) {
    if (!props[oldPropsName]) {
      domElement.removeAttribute(oldPropsName)
    }
  }
  // 针对styles, 老有,新没有,应该将style的对应属性置为空
  const newStyle = vnode.props.style
  const oldStyle = props.style
  for(let k in oldStyle) {
    if(!newStyle[k]) {
      domElement.style[k] = ''
    }
  }
  // 老没有,新有,添加属性
  for(let newPropsName in props) {
    if (newPropsName === 'style') {
      const styleObj = props.style
      for(let i in styleObj) {
        domElement.style[i] = styleObj[i]
      }
    } else {
      domElement.setAttribute(newPropsName, props[newPropsName])
    }
  }
}
// index.js 测试
import { h, render } from './vdom/index'
const vnode = h('div', {id: 'diff', a: 1, style: {color: '#ab4d63'}}, h('span', { style: {color: '#969696'}}, 'vue-'), 'diff')

render(vnode, app)

启动项目,打开localhost:8080,dom已然渲染出来~~

使用一张流程图总结这个过程

4. 虚拟dom如何patch(更新)

/*比对更新属性
*/

export function patch(newVnode, oldVnode) { //geng

  // 标签不相同相同复用
  if (newVnode.type !== oldVnode.type) {
    return oldVnode.domElement.parentNode.replaceChild(createDomByVnode(newVnode), oldVnode.domElement)
  }
  // 文本
  if (newVnode.text !== oldVnode.text) {
    return oldVnode.domElement.textContent = newVnode.text
  }
  // 是标签,并且类型相同,根据新节点的属性更新旧节点
  const domElement = newVnode.domElement = oldVnode.domElement //节点复用
  // 更新属性
  updateProperties(newVnode, oldVnode.props)
  // 外层更新后,继续更新children
  const newChildren = newVnode.children
  const oldChildren = oldVnode.children
  /** 有三种情况
   * newChildren: 有, oldChildren:有 // 最复杂,比较,diff算法的核心
   * newChildren: 有, oldChildren:无 // 直接添加
   * newChildren: 无, oldChildren:有 //直接删除
  */
  if(newChildren.length > 0 && oldChildren.length > 0) {
    updeteChild(domElement, newChildren, oldChildren)
  } else if (newChildren.length > 0){
    // 新的有,直接循环添加
    for(let i = 0; i < newChildren.length; i++) {
      domElement.appendChild(createDomByVnode(newChildren[i]))
    }

  } else if (oldChildren.length > 0) {
    // 老的有,直接删除
    oldVnode.domElement.innerHTML = ''
  }
}
function oldmap(arr) {
  return arr.reduce((acc, [item, index]) => {
    const {key} = item
    key ? acc[key] = index : null
    return acc
  }, {})
}
/** 
 * parent, 外层节点,方便操作内部的节点
 * newChildren新虚拟dom的儿子
 * oldChildren老虚拟dom的儿子
*/
function updeteChild(parent, newChildren, oldChildren) {
  // 采用列表比对, 会对常见的dom操作做优化:前后追加,正序和倒序
  // 定义头指针和尾指针
  let newStartIndex = 0
  let newStart = newChildren[0] // 新的开始虚拟节点
  let newEndIndex = newChildren.length - 1
  let newEnd = newChildren[newEndIndex]

  let oldStartIndex = 0
  let oldStart = oldChildren[0] // 新的开始虚拟节点
  let oldEndIndex = oldChildren.length - 1
  let oldEnd = oldChildren[newEndIndex]
  let oldChildrenMap = oldmap(oldChildren)

  /* 情况:
  * 新的开始 == 老的开始
  * 新的开始 == 老的尾
  * 老的开始 == 新的尾
  * 老的尾 == 新的开始
  * 以上四种情况都不是
  */
  // 谁先满足就结束循环
  while(newStartIndex <= newEndIndex && oldStartIndex <= oldEndIndex) {
    if (!oldStart) {
      // 排除undefined的情况
      oldStart = oldChildren[++oldStartIndex]
    } else if(!oldEnd) {
      oldEnd = oldChildren[--oldEndIndex]
    } else if(isSameNode(newStart, oldStart)) {
      // 新的开始 == 老的开始,头指针后移,更新属性
      patch(newStart, oldStart)
      newStart = newChildren[++newStartIndex]
      oldStart = oldChildren[++oldStartIndex]
    } else if (isSameNode(newEnd, oldEnd)) {
      // 新的尾 == 老的尾,尾指针前移,更新属性
      patch(newEnd, oldEnd)
      newEnd = newChildren[--newEndIndex]
      oldEnd = oldChildren[--oldEndIndex]
    } else if(isSameNode(newStart, oldEnd)) {
       // 新的开始 == 老的尾, 新的头指针后移, 老的尾指针前移
       patch(newStart, oldEnd)
       parent.insertBefore(oldEnd.domElement, oldStart.domElement)
       newStart = newChildren[++newStartIndex]
       oldEnd = oldChildren[--oldEndIndex]
     } else if(isSameNode(newEnd, oldStart)) {
      // 新的尾 == 老的开始, 新的尾指针前移, 老的头指针后移
      patch(newEnd, oldStart)
      parent.insertBefore(oldStart.domElement, oldEnd.domElement.nextSiblings)
      newEnd = newChildren[--newEndIndex]
      oldStart = oldChildren[++oldStartIndex]
    } else {
      // 都不一样,则要建立一个老children中key和元素的映射,遍历新的每一个,如果在老的存在就复用,不存在就创建
      const index = oldChildrenMap[newStart.key]
      if (index) {
        // 复用
        patch(newStart, oldChildren(index))
        parent.insertBefore(oldChildren[index].domElement, oldStartIndex.domElement)
        oldChildren[index] = null
        
      } else {
        // 创建
        parent.insertBefore(createDomByVnode(newStart), oldStart.domElement)
      }
      newStart = newChildren[++newStartIndex]
    }
  }
  // 循环之后,新的如果有剩余
  if (newStartIndex <= newEndIndex) {
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // 如果遍历之后, i+1个元素有值则是向前追加元素, 否则是向后插入元素
      const beforeElement = newChildren[newEndIndex + 1] == null ? null : newChildren[newEndIndex + 1].domElement
      beforeElement.domElement.insertBefore(createDomByVnode(creanewChildren[i]), beforeElement)
    }
  }
  // 如果老的有剩余,则删除
  if (oldIndex <= oldEndIndex) {
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      // 删除
      if (oldChildred[i].domElement) {
        parent.removeChild(oldChildred[i].domElement)
      }
    }
  }
}

总结整个流程:

5. 虚拟dom的优劣

很多时候手工优化dom确实会比virtual dom效率高,对于比较简单的dom结构用手工优化没有问题,但当页面结构很庞大,结构很复杂时,手工优化会花去大量时间,而且可维护性也不高,不能保证每个人都有手工优化的能力。至此,virtual dom的解决方案应运而生,virtual dom很多时候都不是最优的操作,但它具有普适性,在效率、可维护性之间达平衡。

  • 虚拟DOM具有批处理和高效的Diff算法,最终表现在DOM上的修改只是变更的部分,可以保证非常高效的渲染,优化性能.
  • 但首次渲染大量DOM时,由于多了一层虚拟DOM的计算,会比直接操作dom要慢。

6. vue中key的到底有什么用,为什么最好不是index

diff算法的思想

  • 如果节点类型不同,直接干掉前面的节点,再创建并插入新的节点,不会再比较这个节点以后的子节点了。

  • 如果节点类型相同,则会重新设置该节点的属性,从而实现节点的更新

为什么加key

情景1:

没加key之前,Diff算法默认把C更新成F,D更新成C,E更新成D,最后再插入E:

加了key之后:

因此 key的作用主要是为了高效的更新虚拟DOM,因为如果只是位置发生了变化,就可以通过移动操作调整位置,而不是去做创建和删除的操作了

为什么不用index作为key

  • 1)index作为key,其实就等于不加key
  • 2)index作为key,只适用于不依赖子组件状态或临时 DOM 状态 (例如:表单输入值) 的列表渲染输出(这是vue官网的说明)

在网上找了个例子:

  • 假设v-for渲染[k1, k2, k3],将key设为index,目的是删除第一个
  • 如下图所示,复用1和2,其实删除的是第三个

7.vue中diff算法的实现

找了一张图

参考文献