VirtualDOM与DomDiff浅谈

什么是VirtualDOM

virtual dom(虚拟DOM),也就是虚拟节点。它通过JS的Object对象模拟DOM中的节点,然后再通过特定的render方法将其渲染成真实的DOM节点。

我们可以通过creatElement的API来创建虚拟DOM。我们模拟的虚拟DOM主要包括三部分:元素类型、元素属性、虚拟子节点,其中虚拟子节点又是通过creatElement模拟出来的具有那三个主要部分的虚拟节点。



为什么引入虚拟DOM

JQuery横霸天下的时候,我们都知道频繁操作大量DOM会产生巨大的性能损耗,因为操作DOM会引起页面的回流或者重绘,操作大量dom也很费时,请看下面的代码,操作很简单,创建一个空的div标签,循环遍历其中的属性并将其拼打印出来

var div = document.createElement('div')
    var item ,result = ''
    for (item in div) {
      result += ' | ' + item
    }
    console.log(result)
复制代码


这么多的属性,更何况这还只是一级属性,可见直接操作大量dom属性所带来的损耗也是很不乐观的,而且JavaScript操作DOM进行重绘整个视图层的时候也是相当消耗性能的,如何规避这些问题呢?SO,虚拟DOM就可以帮助我们解决上面的问题。通过虚拟DOM我们不需要操作真实DOM,只需要操作JavaScript对象模拟出来的节点,通过对这些虚拟节点进行修改,修改以后经过diff算法得出一些需要修改的最小单位,再将这些最小单位的视图进行更新。这样做减少了很多不必要的DOM操作,大大提高了性能。“使用虚拟DOM会更快”这句话并不一定适用于所有场景。例如:一个页面就有一个按钮,点击一下,数字加一,那肯定是直接操作DOM更快。使用虚拟DOM无非白白增加了计算量和代码量。即使是复杂情况,浏览器也会对我们的DOM操作进行优化,大部分浏览器会根据我们操作的时间和次数进行批量处理,所以直接操作DOM也未必很慢。大家应该根据具体场景对症下药。

什么是DomDiff

dom diff则是通过JS层面的计算,返回一个patch对象,即补丁对象,在通过特定的操作解析patch对象,完成页面的重新渲染。

DomDiff是进行虚拟节点Element的对比,并返回一个pathchs对象,即补丁对象,用来存储两个节点改变的地方,最后用pathchs记录的内容去局部更新Dom。


DomDiff的三种优化策略


第一种更新的时候只比较平级虚拟节点,依次进行比较并不会跨级比较,比较差异部分,生成对应补丁包,根据补丁包改变的内容更新差异的dom

第二种更新的时候平级比较两个虚拟DOM,当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。如果该删除的节点之下有子节点,那么这些子节点也会被完全删除,它们也不会用于后面的比较。这样只需要对树进行一次遍历,便能完成整个DOM树的比较。新增的节点,会对应的创建一个新的节点

第三种更新的时候平级比较两个虚拟DOM,如果只是一层变了,互换了位置,那么它会复用此虚拟节点,把对应的位置互换一下即可,这个是通过给对应元素添加的key不同来实现的

两个树如果完全比较的话需要时间复杂度为O(n^3),如果对O(n^3)不太清楚的话建议去网上搜索资料。而在Diff算法中因为考虑效率的问题,只会对同层级元素比较,时间复杂度则为O(n),也就是深度遍历,并比较同层级的节点。

实现DomDiff的思路

domDiff的差异算法采用先序深度优先遍历,其主要分为以下四步:

  1. 用JS对象模拟真实DOM
  2. 把此虚拟DOM转换成真实DOM插入页面中
  3. 如果有事件发生修改了虚拟DOM,会比较两棵虚拟DOM树的差异,得到差异对象,即补丁包
  4. 把差异对象应用到真正的DOM树上
以平级删除节点为例,流程如下图所示:


先把真实DOM映射为虚拟DOM,这个虚拟DOM代表着真实DOM,当虚拟DOM变化时,例如上图,它的第三个p和第二个p中的son2被删除了,这个时候我们会根据前后的变化计算出一个差异对象patches。根据索引我们可以找到那个节点生成对应索引元素的补丁包patches。patches记录了改变的内容,这里type是remove,代表删除。根据patches中每一项的索引去对应的位置修改老的DOM节点,完成差异部分的更新。

代码实现

首先,我们创建element.js,创建虚拟Dom元素类Element,createElement用于虚拟节点的创建,setAttr用于属性的挂载,render方法可以把虚拟Dom转换成真实Dom,renderDom将元素插入页面内

代码如下:

// 虚拟DOM元素的类
class Element {
  constructor(type, props, children) {
    this.type = type;
    this.props = props;
    this.children = children;
  }
}
// 设置属性
function setAttr(node,key,value){
  switch (key) {
    case 'value': // node是一个input或者textarea
      if(node.tagName.toUpperCase() === 'INPUT'|| node.tagName.toUpperCase()=== 'TEXTAREA'){
        node.value = value;
      }else{
        node.setAttribute(key,value);
      }
      break;
    case 'style':
      node.style.cssText = value;
      break;
    default:
      node.setAttribute(key, value);
      break;
  }
}
// 返回虚拟节点的 返回object的
function createElement(type, props, children) {
  return new Element(type, props, children);
};
// render方法可以将vnode转化成真实dom
function render(eleObj) {
  let el = document.createElement(eleObj.type);
  for(let key in eleObj.props){
     // 设置属性的方法
    setAttr(el, key, eleObj.props[key]);
  }
  // 遍历儿子 如果是虚拟dom继续渲染,不是就代表的是文本节点
  eleObj.children.forEach(child=>{
    child = (child instanceof Element)?render(child):document.createTextNode(child);
    el.appendChild(child);
  })
  return el;
}
// 将元素插入到页面内
function renderDom(el,target) {
  target.appendChild(el);
}
export { createElement, render, Element, renderDom}复制代码

其次,我们创建一个index.js文件,创建旧虚拟Dom节点vertualDom1,并把它渲染到页面上,创建新虚拟Dom节点vertualDom2,代表更新后的vertualDom1,导入diff,根据diff算法计算出补丁对象,导入patch,给变化的元素打补丁,更新视图

代码如下:

import { createElement, render,renderDom} from './element';
import diff from './diff';
import patch from './patch';
//旧虚拟Dom树
let vertualDom1 = createElement('ul', { class: 'list' }, [
  createElement('li', { class: 'item' }, ['1']),
  createElement('li', { class: 'item' }, ['2'])
]);
//新虚拟Dom树
let vertualDom2 = createElement('ul', { class: 'list-group' }, [
  createElement('li', { class: 'item' }, ['2']),
  createElement('li', { class: 'item' }, ['1']),
  createElement('li', { class: 'item' }, ['1']),
]);

// 如果平级元素有互换 那会导致冲洗渲染
// 新增节点也不会被更新
// index

//将虚拟dom转化成真实dom渲染到页面上
let el = render(vertualDom1);
renderDom(el, window.root);

//diff 入口,比较新旧两棵树的差异,构建出差异的补丁包
let patches = diff(vertualDom1, vertualDom2)

//找到对应的真实dom,给它打补丁,进行部分渲染更新视图
patch(el, patches);
复制代码

再其次,就是核心部分diff算法了,采用先序深度优先遍历算法在两棵虚拟Dom树平级位置做比较,同时用补丁的形式记录需要更新的内容。

type不一致直接替换当前节点以及当前节点下的子节点; 如果两个父节点一致,则从左往后遍历子节点,若子节点一致,遍历子节点下的子节点,依次递归。


补丁包定义规则:
1. 节点类型相同,属性不同(type: 'ATTRS', attrs)
2. 新的节点不存在,被删除了 (type: 'REMOVE', index: xxxx)
3. 节点类型不同/新增 (type: 'REPLACE', newNode)
4. 仅仅是文本变化(type: 'TEXT', text)
复制代码

我们创建diff.js ,代码如下:

//oldTree:旧虚拟Dom树,newTree:新虚拟Dom树,diff 入口,比较新旧两棵树的差异
function diff(oldTree,newTree) {
  let patches = {}//补丁包,记录节点差异
  let index = 0;//当前所在树的第几层
  // 递归树 比较后的结果放到补丁包种
  walk(oldTree,newTree,index,patches);
  return patches;//将差异对象返回
}
//判断两个节点的属性差异
function diffAttr(oldAttrs,newAttrs) {
    let patch = {};//记录差异
    // 判断老的属性种和新的属性的关系
    for(let key in oldAttrs){
      if(oldAttrs[key] !== newAttrs[key]){//二者不相等代表属性更新了
        patch[key] = newAttrs[key]; // 有可能时undefined,将更新的新属性存入patch
      }
    }
    for(let key in newAttrs){
      // 老节点没有新节点的属性,进行添加到patch中
      if(!oldAttrs.hasOwnProperty(key)){
        patch[key] = newAttrs[key];
      }
    }
    return patch;//将差异返回
}
//常量代表差异的类型
const ATTRS = 'ATTRS';//属性
const TEXT = 'TEXT';//文本
const REMOVE = 'REMOVE';//删除
const REPLACE = 'REPLACE'//替换
let Index = 0;//基于原有的一个递增序号来遍历
//如果有子节点,判断孩子节点,调用walk
function diffChildren(oldChildren,newChildren,patches) {
  // 比较老的第一个和新的第一个
  oldChildren.forEach((child,idx) => {
    // 索引不应该是index了 ------------------
    // index 每次传递给waklk时 index是递增的,所有的人都基于一个序号来实现
    walk(child, newChildren[idx], ++Index,patches);
  });
}
//判断内容是不是字符串
function isString(node) {
  return Object.prototype.toString.call(node) === '[object String]';
}
// 递归树,记录旧虚拟节点、新虚拟节点的差异,把差异放到补丁包currentPatch中,根据索引标识了哪些节点被替换、删除、新增、文本更新了、还//是属性更新了,inde被私有化到了walk作用域内。
function walk(oldNode,newNode,index,patches) {
  let currentPatch = []; // 每个元素都有一个补丁对象,存放当前层的差异对比
  if(!newNode){//如果节点不存在,彻底删除此节点包括其下的所有子节点
    currentPatch.push({ type: REMOVE, index })
  }else if(isString(oldNode)&&isString(newNode)){ // 判断文本是否一致
    if(oldNode !== newNode){//如果发现文本不同,currentPatch会记录一个差异
      currentPatch.push({ type: TEXT,text:newNode});
    }
  }else if(oldNode.type === newNode.type){//如果发现两个节点一样 则去判断节点是属性是否一样,并记录下来
    // 比较属性是否有更改
    let attrs = diffAttr(oldNode.props,newNode.props);
    if(Object.keys(attrs).length>0){//有属性差异则把差异记录下来
      currentPatch.push({ type: ATTRS,attrs})
    }
    // 如果有儿子节点 遍历儿子
    diffChildren(oldNode.children,newNode.children,patches);
  }else{
    // 说明节点不一样,比如类型不同等,直接进行替换
    currentPatch.push({ type: REPLACE, newNode });
  }
  if(currentPatch.length>0){ // 当前元素确实有补丁
    // 将元素和补丁对应起来 放到大补丁包中
    patches[index] = currentPatch;
  }
}
export default diff;复制代码

最后,创建patch.js, 遍历补丁对象,将补丁的差异对象与真实DOM节点对应起来,并将差异更新到真实DOM节点中,更新页面的DOM节点,更新视图。

代码如下:

import {Element,render} from './element';
let allPathes;//不用传来传去,直接公用这个大补丁包
let index = 0; // 默认哪个需要打补丁
function patch(node,patches) {
  allPathes = patches;//把补丁包赋给allPathes
  //递归树,给元素打补丁
  walk(node);
  // 给某个元素打补丁
}
//递归树,
function walk(node) {
  //根据索引一层层取对应元素的补丁差异对象
  let currentPatch = allPathes[index++];
  let childNodes = node.childNodes;//拿出子节点
  childNodes.forEach(child =>walk(child));//深度先序遍历,如果有子节点,递归调用
  if(currentPatch){//补丁存在,给对应元素打对应差异补丁,加补丁是后序的,先给子节点打再给父节点打补丁
    doPatch(node, currentPatch);
  }
}
//挂载属性
function setAttr(node, key, value) {
  switch (key) {
    case 'value': // node是一个input或者textarea
      if (node.tagName.toUpperCase() === 'INPUT' || node.tagName.toUpperCase() === 'TEXTAREA') {
        node.value = value;
      } else {
        node.setAttribute(key, value);
      }
      break;
    case 'style':
      node.style.cssText = value;
      break;
    default:
      node.setAttribute(key, value);
      break;
  }
}
//给对应元素打对应差异补丁,加补丁是后序的,先给子节点打再给父节点打补丁,根据patch.type类型不同,进行不同的操作function doPatch(node,patches) {
  patches.forEach(patch=>{
    switch (patch.type) {
      case 'ATTRS'://类型相同,属性不同,更新属性
        for(let key in patch.attrs){
          let value = patch.attrs[key];
          if(value){
            setAttr(node, key, value);
          }else{
            node.removeAttribute(key);
          }
        }
        break;
      case 'TEXT'://仅仅是文本不同,更新文本
        node.textContent = patch.text;
        break;
      case 'REPLACE'://类型不同/新增进行替换
        let newNode = (patch.newNode instanceof Element) ? render(patch.newNode) : document.createTextNode(patch.newNode);//newNode是元素,render渲染真实元素,否则是文本,创建文本
        node.parentNode.replaceChild(newNode,node);//替换
        break;
      case 'REMOVE'://删除节点
        node.parentNode.removeChild(node);
        break;
      default:
        break;
    }
  });
}
export default patch;复制代码

上面模拟的VirtualDOM与DomDiff没有解决1)平级元素如果有呼唤会导致重新渲染2)新增节点也不会被更新的问题等等,这只是diff算法的一个简易实现,还存在一些复杂情况处理的情况以及还有很多算法上面优化的方案,这里让大家大概了解了diff算法的原理。

总结,Virture Dom与DOM Diff算法简化了UI开发的复杂度,也优化了大量操作DOM所带来的性能损耗。通过虚拟DOM,我们不需要操作真实DOM,只需要操作JavaScript对象模拟出来的节点,通过对这些虚拟节点进行修改,修改以后经过diff算法得出一些需要修改的最小单位,再将这些最小单位的视图进行更新。它的出现无疑具有重要的意义,值得学习研究!

如有笔误或者其他实现不对的地方,还望大家指出,谢谢!


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值