- 发布于
 
浅谈 Virtual Dom
- Authors
 
- Name
 - 田中原
 
浅谈 Virtual Dom
抛开vue、react中的virtual dom,来聊下我们怎么去实现一个差不多还能凑合的 virtual dom。
思考下virtual dom应该做什么事情
- 先要有能造出一颗树的工具,这颗能用 js 描述出来的树
 - 有一个转换方法,把那颗树转换为真实的dom
 - 再有一个对比机制,找出新旧差异
 - 把新旧差异再更新到真实dom中
 
来一颗树
这颗树长啥样啊?上面的叶子是啥样的?
叶子还能是啥,element 呗?
嗯...
先来一个能表示唯一标示的属性呗
key?
差不多这意思吧...,再来个类型?
type?
嗯好,元素上还有一堆属性呢...
那就 attrs?
好low… 还可能有一堆孩子
children 咯
so...
{
  key: 'emmmm.....',
  type: 'div',
  attrs: {
    class: 'container',
    'data-type': 'string'
  },
  children: [
    {
      key: 'hkask',
      type: 'h2',
      attrs: {},
      text: 'virtual dom'
    },
    {
      key: 'sdashdjk',
      type: 'div',
      text: '一会被移除1'
    },
    {
      key: 'hfkjahfk',
      type: 'span',
      arrts: {},
      text: '什么是virtual dom啊'
    },
    {
      key: 'sdashdjk2133',
      type: 'div',
      text: '一会被移除2'
    },
  ]
}
说明
比如有课树的顺序是: 1、3、5、7
新的树可以是:1、3、4、5、7
但不会是 1、5、3、7
一旦插入到整颗树中,前后顺序不会再发生变化
转换
假树有了,那就变成真的:
注意:这里有text属性并且text不为空,就表示该节点为文本节点,有文本节点就不考虑其children属性
function render(element) {
  // 先根据type创建元素
  const el = document.createElement(element.type)
  // 再添加 元素属性
  for (let attr in element.arrts) {
    // 这里不考虑 input 和 textarea 的value属性情况了
    element.setAttribute(attr, element.arrts[attr])
  }
  // 判断子元素是不是文本类型的
  if (element.text) {
    el.appendChild(document.createTextNode(element.text))
  } else {
    // 遍历子元素
    element.children &&
      element.children.forEach((val) => {
        el.appendChild(render(val))
      })
  }
  element.$el = el
  return el
}
这里会给 element 属性增加一个 $el 属性,这样整个dom树就在虚拟树上关联起来了。
dom diff
比如在vue中,data 属性中有变化,重新生成了一个新的树
那就开始diff一下,那 diff 啥了?
- 先比较根节点
 - 比较 key 、比较 type 、比较 attrs、比较children
 - 比较 children 中的 key 、type、attrs 、children
 - 然后循环...
 
这里比较的方案是 snabbdom 中的简版, 但思路是一样的
在 snabbdom 中先比较同层级的元素,然后比较相同元素的子元素。
比较根节点
function patch(oldNode, newNode) {
  if (sameNode(oldNode, newNode)) {
    // 类型一致 比较里面的内容
    patchNode(oldNode, newNode)
  } else {
    // 不一致删除旧的, 添加新的dom
    const parentEl = oldNode.$el.parentElement
    parentEl.replaceChild(render(newNode), oldNode.$el)
  }
  return newNode
}
比较根节点中的具体细节
function patchNode(oldNode, newNode) {
  newNode.$el = oldNode.$el
  // 判断属性是否需要更新
  if (newNode.text) {
    // 更新文本节点
    if (newNode.text !== oldNode.text) newNode.$el.innerHTML = ''
    newNode.$el.appendChild(document.createTextNode(newNode.text))
    return
  }
  // 如果都有子节点
  if (oldNode.children && newNode.children) {
    // 比较子节点
    updateChildren(newNode.$el, oldNode.children, newNode.children)
  } else if (oldNode.children) {
    // 如果旧的有 新的没有 删除旧的子节点
    newNode.$el.innerHTML = ''
  } else if (newNode.children) {
    // 如果新的有 旧的没有 添加新的子节点
    newNode.children.forEach((val) => {
      newNode.appendChild(render(element))
    })
  }
}
比较子节点
在snabbdom中还有各种
事件,比如元素添加、更新、删除之类的。这里简版实现 不存在这些...
这里逻辑比较绕…
snabbdom 中算法还是比较牛逼的… 新旧前后交叉比较
大概逻辑:
- 旧的第一个 和 新的第一个比较
 - 如果不匹配 旧的第一个 和 新的最后一个比较
 - 如果不匹配 旧的最后一个 和 新的最后一个比较
 - 如果不匹配 旧的最后一个 和 新的第一个比较
 - 如果还不匹配,查找新的第一个的key 在 旧的children中的位置
 - 然后比较 类型
 - 如果没找到 说明新的第一个 是新增加的
 
function updateChildren(parentElement, oldCh, newCh) {
  let oldStartIndex = 0,
    newStartIndex = 0,
    oldEndIndex = oldCh.length - 1,
    newEndIdnex = newCh.length - 1
  let oldStartNode = oldCh[0],
    newStartNode = newCh[0]
  let oldEndNode = oldCh[oldEndIndex],
    newEndNode = newCh[newEndIdnex]
  let oldKeyMap = ''
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIdnex) {
    if (!oldStartNode) {
      oldStartNode = oldCh[++oldStartIndex]
    } else if (!newStartNode) {
      newStartNode = newCh[++newStartIndex]
    } else if (!oldEndNode) {
      oldEndNode = oldCh[--oldEndIndex]
    } else if (!newEndNode) {
      newEndNode = newCh[--newEndIdnex]
    } else if (sameNode(oldStartNode, newStartNode)) {
      patchNode(oldStartNode, newStartNode)
      oldStartNode = oldCh[++oldStartIndex]
      newStartNode = newCh[++newStartIndex]
    } else if (sameNode(oldEndNode, newEndNode)) {
      patchNode(oldEndNode, newEndNode)
      oldEndNode = oldCh[--oldEndIndex]
      newEndNode = newCh[--newEndIdnex]
    } else if (sameNode(oldStartNode, newEndNode)) {
      patchNode(oldStartNode, newEndNode)
      oldStartNode = oldCh[++oldStartIndex]
      newEndNode = newCh[--newEndIdnex]
    } else if (sameNode(oldEndNode, newStartNode)) {
      patchNode(oldEndNode, newStartNode)
      oldEndNode = oldCh[--oldEndIndex]
      newStartNode = newCh[++newStartIndex]
    } else {
      // 如果交叉比较都不一样, 根据 key 找到元素对应的位置
      if (!oldKeyMap) {
        oldKeyMap = createOldKeyMap(oldCh, oldStartIndex, oldEndIndex)
      }
      // 看看 newStartNode 的 key 在老节点树中能否找到
      let oldKeyIndex = oldKeyMap[newStartNode.key]
      if (oldKeyIndex !== undefined) {
        // 找到了
        let currentOldNode = oldCh[oldKeyIndex]
        if (currentOldNode.type !== newStartNode.type) {
          // 类型不一致 把新的节点 插入到 旧的前面
          parentElement.insertBefore(render(newStartNode), oldStartNode.$el)
        } else {
          // 类型一直 比较其他内容,并删除旧节点
          patchNode(currentOldNode, newStartNode)
          oldCh[oldKeyIndex] = undefined
        }
        newStartNode = newCh[++newStartIndex]
      } else {
        // 没有找到 说明是新增的,在插入 oldStartNode 前插入
        console.log(oldStartNode.$el)
        parentElement.insertBefore(render(newStartNode), oldStartNode.$el)
        newStartNode = newCh[++newStartIndex]
      }
    }
  }
  if (oldStartIndex <= oldEndIndex || newStartIndex <= newEndIdnex) {
    // 有一个树遍历完了,另一个没有遍历完的情况
    if (oldStartIndex > oldEndIndex) {
      // 旧的树遍历完了,新的没遍历完 添加中间的
      addNodes(parentElement, newCh[newEndIdnex + 1].$el, newStartIndex, newEndIdnex)
    } else {
      // 新的遍历完了,移除旧的树
      removeNodes(parentElement, oldCh, oldStartIndex, oldEndIndex)
    }
  }
}