那些年我们一起学习过的dom diff

692 阅读8分钟

写在前面

最近在整理vue知识的时候,就深入了去学习了dom diff算法,对于我前端刚刚满一年的菜鸟来说,学习源码是一件很困难的事情,但是为了以后迎娶白富美,所以现在必须努力

前言

1.虚拟DOM是什么?

所谓的虚拟DOM就是用JS去按照DOM结构来实现的树形结构对象,也可以叫做DOM对象。

2. diff的比较方式?

在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。算法也是采用了先序遍历

dom diff流程又是怎么样的呢

dom diff的实现需要分为下面的4个步骤,废话不多说,让我们来实现一个简版的dom diff吧!

  • 用JavaScript对象模拟DOM
  • 虚拟DOM转成真实DOM并插入页面中
  • 两棵虚拟DOM树的差异,得到差异对象
  • 异对象应用到真正的DOM树上

项目目录结构

先发个图,来看一下整个目录结构是什么样子的 这个目录结构是用create-react-app脚手架直接生成的,也是为了方便编译调试

// 全局安装
npm i create-react-app -g
// 生成项目
create-react-app dom-diff
// 进入项目目录
cd dom-diff
// 编译
npm run start

创建虚拟DOM

首先实现一下如何创建虚拟DOM,看代码:

// element.js

//  虚拟DOM元素类
class Element {
    constructor(type, props, children) {
        this.type = type;
        this.props = props;
        this.children = children;
    }
}
// 创建虚拟DOM,返回虚拟节点(object)
function createElement(type, props, children) {
    return new Element(type, props, children);
}

export {
    Element,
    createElement
}

写好了我们下面把createElement引入到index.js来看看结果吧

完成第一步,调用createElement方法把JavaScript对象模拟DOM

import {createElement} from './element';

let vertualDom1 = createElement('ul', { class: 'list' }, [    createElement('li', { class: 'item' }, ['1']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item ' }, ['3'])
  ]);
  let vertualDom2 = createElement('ul', { class: 'list-group' }, [    createElement('li', { class: 'item' }, ['3']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item item3' }, ['1']),
  ]);

  console.log('vertualDom1',vertualDom1)

createElement方法也是vue用来创建虚拟DOM的方法,接收三个参数,分别是type,props和children

参数分析

  • type: 指定元素的标签类型,如'li', 'div', 'a'等
  • props: 表示指定元素身上的属性,如class, style, 自定义属性等
  • children: 表示指定元素是否有子节点,参数以数组的形式传入

下面来看一下打印出来的虚拟DOM,如下图

完成第二步 虚拟DOM转成真实DOM并插入页面中

// element.js

class Element {
    // 省略
}

function createElement() {
    // 省略
}

// render方法可以将虚拟DOM转化成真实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 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;
  }
}

// 将元素插入到页面内
function renderDom(el, target) {
    target.appendChild(el);
}

export { createElement, render, renderDom, Element };

下面我们在index.js去调用render和renderDom方法吧

// index.js

// 引入createElement、render和renderDom方法
import { createElement, render, renderDom } from './element';

let vertualDom1 = createElement('ul', { class: 'list' }, [
    createElement('li', { class: 'item' }, ['1']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item ' }, ['3'])
  ]);
  let vertualDom2 = createElement('ul', { class: 'list-group' }, [
    createElement('li', { class: 'item' }, ['3']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item item3' }, ['1']),
  ]);

console.log(vertualDom1);


let el = render(virtualDom); // 渲染虚拟DOM得到真实的DOM结构
console.log(el);
// 直接将DOM添加到页面内
renderDom(el, document.getElementById('root'));

下图为打印后的结果:

实现第三步 DOM-diff比较策略

// diff.js
const ATTRS = "ATTRS";
const TEXT = "TEXT";
const REMOVE = "REMOVE";
const REPLACE = "REPLACE";
let Index = 0;

function diff(oldTree, newTree) {
  let patchers = {};
  let index = 0; //  这个index表示是第几层的dom
  walk(oldTree, newTree, index, patchers);
  return patchers;
}

//  判断是否是字符串类型
function isString(node) {
  return Object.prototype.toString.call(node) === "[object String]";
}

function diffAttr(oldAttrs, newAttrs) {
  let patch = {};
  //  判断老的属性和新的属性的关系
  for (let key in oldAttrs) {
    if (oldAttrs[key] !== newAttrs[key]) {
      patch[key] = newAttrs[key]; //  newAttrs[key]有可能为undefined
    }
  }
  // 老节点没有新节点的属性
  for (let key in newAttrs) {
    if (!oldAttrs.hasOwnProperty(key)) {
      patch[key] = newAttrs[key];
    }
  }

  return patch;
}

function diffChildren(oldChildren, newChildren, patches) {
  // 比较老的第一个和新的第一个
  oldChildren.forEach((child, idx) => {
    // 索引不应该是index了 ------------------
    // index 每次传递给waklk时 index是递增的,所有的都基于一个序号来实现
    walk(child,newChildren[idx],++Index,patches)
  });
}

//  index被私有化到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.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;

比较规则

  • 新的DOM节点不存在{type: 'REMOVE', index}
  • 文本的变化{type: 'TEXT', text: 1}
  • 当节点类型相同时,去看一下属性是否相同,产生一个属性的补丁包{type: 'ATTR', attr: {class: 'list-group'}}
  • 节点类型不相同,直接采用替换模式{type: 'REPLACE', newNode}

walk方法都做了什么?

每个元素都有一个补丁,所以需要创建一个放当前补丁的数组如果没有新节点的话,就直接将type为REMOVE的类型放到当前补丁里

if (!newNode) {
    current.push({ type: 'REMOVE', index });
}

如果新老节点是文本的话,判断一下文本是否一致,再指定类型TEXT并把新节点放到当前补丁

else if (isString(oldNode) && isString(newNode)) {
  if (oldNode !== newNode) {
      current.push({ type: 'TEXT', text: newNode });
  }
}

如果新老节点的类型相同,那么就来比较一下他们的属性props,通过diffAttr函数来实现

  • 去比较新老Attr是否相同
  • 把newAttr的键值对赋给patch对象上并返回此对象

然后如果有子节点的话就再比较一下子节点的不同,再调一次walk

遍历oldChildren,然后递归调用walk再通过child和newChildren[index]去diff

else if (oldNode.type === newNode.type) {
    // 比较属性是否有更改
    let attr = diffAttr(oldNode.props, newNode.props);
    if (Object.keys(attr).length > 0) {
        current.push({ type: 'ATTR', attr });
    }

    // 如果有子节点,遍历子节点
    diffChildren(oldNode.children, newNode.children, patches);
}

上面三个如果都没有发生的话,那就表示节点单纯的被替换了,type为REPLACE,直接用newNode替换即可

else {
      current.push({ type: 'REPLACE', newNode});
  }

当前补丁里确实有值的情况,就将对应的补丁放进大补丁包里

  if (current.length > 0) {
      // 将元素和补丁对应起来,放到大补丁包中
      patches[index] = current;
  }

下面我们把diff方法引入index.js文件来看一下结果吧

import {createElement,render,renderDom} from './element';
import diff from './diff'

let vertualDom1 = createElement('ul', { class: 'list' }, [
    createElement('li', { class: 'item' }, ['1']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item ' }, ['3'])
  ]);
  let vertualDom2 = createElement('ul', { class: 'list-group' }, [
    createElement('li', { class: 'item' }, ['3']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item item3' }, ['1']),
  ]);

  console.log('vertualDom1',vertualDom1)

  let el = render(vertualDom1);
  console.log('el',el)

  let patches = diff(vertualDom1,vertualDom2);
  console.log('patches',patches);

  renderDom(el, window.root);

如图:

实现第四步 最后我们来完成patch方法吧dom diff的结果补丁到真实的dom里面去

//  patch.js
import {Element,render} from './element';
let allPathes;
let index = 0; // 默认哪个需要打补丁
function patch(node,patches) {
  allPathes = patches;
  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;
  }
}
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);
        node.parentNode.replaceChild(newNode,node);
        break;
      case 'REMOVE':
        node.parentNode.removeChild(node);
        break;
      default:
        break;
    }
  });
}
export default patch;

patch做了什么?

  • 用一个变量来得到传递过来的所有补丁allPatches

  • patch方法接收两个参数(node, patches)

    1.在方法内部调用walk方法,给某个元素打上补丁

  • walk方法里获取所有的子节点

    1.给子节点也进行先序深度优先遍历,递归walk

    2.如果当前的补丁是存在的,那么就对其打补丁(doPatch)

  • doPatch打补丁方法会根据传递的patches进行遍历,判断补丁的类型来进行不同的操作

    1.属性ATTR for in去遍历attrs对象,当前的key值如果存在,就直接设置属性setAttr; 如果不存在对应的key值那就直接删除这个key键的属性

    2.文字TEXT 直接将补丁的text赋值给node节点的textContent即可

    3.替换REPLACE 新节点替换老节点,需要先判断新节点是不是Element的实例,是的话调用render方法渲染新节点;不是的话就表明新节点是个文本节点,直接创建一个文本节点就OK了。

    4.删除REMOVE 直接调用父级的removeChild方法删除该节点

最后让我们测试一下吧

    import {createElement,render,renderDom} from './element';
import diff from './diff'
import patch from './patch'

let vertualDom1 = createElement('ul', { class: 'list' }, [
    createElement('li', { class: 'item' }, ['1']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item ' }, ['3'])
  ]);
  let vertualDom2 = createElement('ul', { class: 'list-group' }, [
    createElement('li', { class: 'item' }, ['3']),
    createElement('li', { class: 'item' }, ['2']),
    createElement('li', { class: 'item item3' }, ['1']),
  ]);

  console.log('vertualDom1',vertualDom1)

  let el = render(vertualDom1);
  console.log('el',el)
  renderDom(el, window.root);
  let patches = diff(vertualDom1,vertualDom2);
  console.log('patches',patches);

// 给元素打补丁 重新更新视图
  patch(el, patches);

如图:

总结

我们已经把一个简单的dom diff已经撸出来了(小撸怡情,大撸伤身,强撸灰飞烟灭),不容易呀,最后附上整个dom diff的流程图

如果想看整个项目代码的话,可以通过github.com/xiehaitao02… 地址去下载