美文网首页
虚拟DOM的实现

虚拟DOM的实现

作者: zdxhxh | 来源:发表于2019-10-31 12:34 被阅读0次

实现虚拟DOM

原文来自:https://www.jianshu.com/p/af0b398602bc

例如一个真实的DOM节点

<div id="real-container">
  <p class="my-class">Real Dom</p>
</div>

用JS模拟

{
  tagName : 'div',
  props : {},
  children : [
    {
      tagName : 'p',
      props : { 
        class : 'my-class'
      },
      key : '',
      count : 0 
    }
  ],
  key : '',
  count : 0 // 子元素数量(不是后代元素)
}

这样就模拟出了一个虚拟dom树结构

虚拟DOM

我们再把构造函数写出来

function Element(type, props, children) {
  // 构造函数的安全模式
  if (!(this instanceof Element)) {
    return new Element(type, props, children)
  }
  this.type = type
  this.props = props || {}
  this.children = children || []
  this.key = props ? props.key : undefined
  // 子元素个数 用于先序遍历时记录的index
  let count = 0;
  this.children.forEach(child => {
    if (child instanceof Element) {
      count += child.count
    }
    count++
  })
  this.count = count
}

实际上,上面这种函数,可以直接生成一颗树。

const oldTree = Element('div', { id: 'virtual-root' }, [
  Element('h1', {}, ['Virtual DOM']),
  Element('h1', {}, ['un unpdate']),
  Element('ul', {}, [
    Element('li', { class: 'item' }, ['Iteme 1']),
    Element('li', { class: 'item' }, ['this node will be remove']),
    Element('li', { class: 'item' }, ['Iteme 3']),
  ]),
])

此外,它还为这个对象添加render方法,渲染成真实dom

Element.prototype.render = function () {
  const el = document.createElement(this.type)
  const props = this.props
  for (const propName in props) {
    el.setAttribute(propName, props[propName])
  }
  this.children.forEach(child => {
    const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child)
    el.append(childEl)
  })
  return el
}

上面已经初步完成了创建虚拟DOM并映射成真实DOM,这样的更新都可以先反应到虚拟DOM上,需要使用臭名昭著的diff算法.

听说两颗树完全比较时间复杂度为 O(n)^3 ,但是我们知道它们的比较方式有三种,一种是真实树与虚拟树比较、一种是组件与组件间比较,一种是节点与节点的比较。

diff算法流程是这样的 : 新树与旧树比较、创建补丁patches->对真实树进行打补丁

步骤一: 新树与旧树比较创建补丁

function diff(oldTree, newTree) {
  // 声明变量patches用来存放补丁的对象
  let patches = []
  // 初始索引为0
  let index = 0
  dfsWalk(oldTree, newTree, index, patches)
  return patches;
}

function isString(data) { 
  return typeof data === 'string'
}

function dfsWalk(oldNode, newNode, index, patches) {
  // 每个元素都有一个补丁
  let current = []
  // judge1 : 如果新节点不存在啊
  if (!newNode) {     
    current.push({ type: 'REMOVE', index });
  
  // judge2 : 判断是否为文本节点

  } else if ( isString(oldNode) && isString(newNode) ) {  
    // 判断文本是否一致
    if (oldNode !== newNode) {
      current.push({
        type: 'TEXT',
        text: newNode
      })
    }
  
  // judge3 : 节点类型一致(即节点存在),比较他的props是否修改

  } else if (oldNode.type === newNode.type) {  
    // 比较属性是否有更改
    let attr = diffAttr(oldNode, newNode)
    if (Object.keys(attr).length > 0) {
      current.push({ type: 'ATTR', attr });
    }
    // 递归子节点,默认为空,所以不用做判断
    diffChildren(oldNode.children, newNode.children, index, patches);
  
  // finally judge : 如123都不符合,则说明节点被替换了

  } else { 
    // 说明节点被替换了
    current.push({ type: 'REPLACE', newNode });
  }
  if (current.length) {
    // 如果小补丁存在, 则放入大补丁中
    patches[index] = current;
  }
}

function diffChildren(oldChildren, newChildren, index, patches) {
  let leftNode = null
  let currentNodeIndex = index
  // 比较老的第一个和新的第一个
  oldChildren.forEach((child, i) => {
    let newChlid = newChildren[i]
    // 计算节点的index
    if (leftNode && leftNode.count) {
      currentNodeIndex = currentNodeIndex + leftNode.count + 1
    } else {
      currentNodeIndex = currentNodeIndex + 1
    }
    dfsWalk(child, newChlid, currentNodeIndex, patches);
    leftNode = child
  });
}

/**
 * 返回新旧节点的attribute的差异并集
 * @param {Element} oldNode 
 * @param {Element} newNode 
 */
function diffAttr(oldNode, newNode) {
  const oldProps = oldNode.props || {},
    newProps = newNode.props
  const propsPatches = {}
  // 比较旧新两颗树的attr
  for (let key in oldNode) {
    if (newProps[key] !== oldProps[key]) {
      // 如果其中的新树的Props不等于旧树的props 
      // 将不同的attr赋值给propsPatches
      propsPatches[key] = newProps[key]
    }
  }
  // 比较新树是否增加了attr
  for (let key in newProps) {
    if (!oldProps.hasOwnProperty(key)) {
      propsPatches[key] = newProps[key]
    }
  }
  // 分析完毕,返回有差异的props
  // 包括 : 原有props值改变、新树增加新props(这个东西又叫补丁)
  return propsPatches
}

首先,你要知道,patches补丁包是以二维数组形式存在的,它所对于的索引,即节点(oldTree)的遍历顺序,而根据 dfsWalk 方法可知,它的遍历是先序遍历,即它会遍历最深处的子节点,如下图所示

image.png

来讲一下diff比较算法

  • 创建当前节点的补丁包current,以下判断情况使用if-else
  • 首先新节点是否存在,不存在即代表被移除了, 补丁包存放 REMOVE 类型对象
  • 判断两个节点是否为字符串(文本节点),如果字符串改变了,则向补丁包添加 TEXT 类型对象
  • 判断两个节点是否为同一类型,同类型比较节点的属性,若改变,则向补丁包添加 ATTR 类型
  • 以上三种都不符合,说明节点被替换了,则向补丁包添加 REPLACE 类型
  • 返回补丁包,数据结构如下
     patches : [
       1 : [{type: "REPLACE", newNode: {}}]
     ]
    

步骤二、打补丁

因为一颗树的JS对象与render出来的真正DOM树的信息,结构是一样的,而且顺序也是一致,所以我们也对真正的DOM树进行深度遍历,根据patches对象的type来使用不同策略进行DOM的更新。

function run(node,patches) {
  let current = patches[index++]
  let childNodes = node.childNodes
  childNodes.forEach(child => run(child,patches))
  if (current) {
    doPatch(node, current) // 打补丁
  }
}

let index // 使用一个全局变量记录当前遍历节点index
function patch(node, patches) {
  index = 0
  run(node,patches)
}

以上代码是path的入口,因为我们要记录节点的索引,并且真实DOM没有虚拟DOM的count属性的,所以我们只能通过一个全局变量记录索引的值,每访问一i个节点就++,以下是打补丁的方法。

function doPatch(node, patches) {
  // 遍历所有打过的补丁
  patches.forEach(patch => {
    switch (patch.type) {
      case 'ATTR': {
        for (let key in patch.attr) {
          let value = patch.attr[key]
          if (value) {
            setAtrr(node, key, value)
          } else {
            node.removeAttribute(key)
          }
        }
        break;
      }

      case 'TEXT': {
        node.textContent = patch.text;
        break;
      }
      case 'REPLACE': {
        let newNode = patch.newNode
        newNode = (newNode instanceof Element) ? newNode.render() : document.createTextNode(newNode)
        node.parentNode.replaceChild(newNode, node)
        break;
      }
      case 'REMOVE': {
        node.parentNode.removeChild(node);
        break;
      }
      default:
        break
    }
  })
}


会出现四种情况:

  • ATTR,即属性发生了变化,使用DOM.setAttribute方法
  • TEXT,即说明文本节点发生变化,使用DOM.textContent方法
  • REPCACE,则使用DOM.replaceChild
  • REMOVE,则使用DOM.removeChild(node)

当使用完这个函数后,就可以将DOM树更新了。

唯一不足的是: 上面方法不能处理节点增加的情况,不过这对于新手也应对diff有一定了解了。

Virtual DOM 算法主要是实现上面步骤的三个函数:element,diff,patch。

总结: 实现diff需要四步 :

  • 用JS数据结构,模拟DOM,插入文档中
  • 当状态变更时,重新生产一颗对象树,然后新旧树比较,记录差异,生成补丁(patches)
  • 将差异应用到真实dom中,完成

但是实际上,组件化开发过程中,diff的新旧树只会在组件下,所以肯定是不会进行从根节点遍历的。s

相关文章

网友评论

      本文标题:虚拟DOM的实现

      本文链接:https://www.haomeiwen.com/subject/qrzzvctx.html