手写Diff算法

当内容即dom tree发生变化时,如何基于Virtual DOM来进行局部刷新渲染,而不是整棵树都重新渲染=>最小程度话的操作dom,而不是推倒重来。

一、环境

本次的代码实现,用到了ES6,所以需要使用babel,具体如下:

package.json:

{
  "scripts": {
    "cleanbuild": "rm ./public/vm_bundle.js && babel ./src/index.js --out-file ./public/vm_bundle.js",
    "build": "babel ./src/index.js -o ./public/vm_bundle.js",
    "clean": "rm ./public/vm_bundle.js"
  },
  "devDependencies": {
    "@babel/cli": "^7.14.3",
    "@babel/core": "^7.14.3",
    "@babel/preset-env": "^7.14.2",
    "babel-plugin-transform-react-jsx": "^6.24.1"
  }
}

.babelrc:

{
  "presets": ["@babel/preset-env"],
  "plugins": [
    [
      "transform-react-jsx",
      {
        "pragma": "h" //默认是React.createElement
      }
    ]
  ]
}

二、前期准备:

实现步骤:

  • state 变化,生成新的 Virtual DOM;
  • 比较新 Virtual DOM 与之前 Virtual DOM 的差异;
  • 生成差异对象(patch);
  • 遍历差异对象并更新 DOM;

差异对象如下:

{
    type,
    vdom,
    props: [{
        type,
        key,
        value
    }],
    children
}

type说明:

  • DOM 元素对应的变化类型:新建、删除、替换和更新。
  • props 变化的类型 :更新和删除。
const TYPES = {
  CREATE: "create", //新增加的节点
  DELETE: "delete", //要删除的属性或节点
  UPDATE: "update", //节点类型一致但是children或props不一致、新增属性或属性变化
  REPLACE: "replace", //节点类型不一致、文本节点(number/string))内容不一致
};

三、代码实现

1、view函数:修改上一篇view层,每3秒,新增一个 div 块元素,并修改根元素的属性(data-number):

let preVdom; //上一次渲染的vdom

//状态对象
const state = {  
  number: 0,
};

//jsx结构
function view() {
  return (
    <div data-number={state.number}>
      Hello World!
      {[...Array(state.number).keys()].map((id) => {
        return (
          <div id={`div${id}`} data-idx={id}>
            {"->"}
            {id}
          </div>
        );
      })}
    </div>
  );
}

//真实dom插入页面
function renderDom(container) {
  var vdom = view();
  if (!preVdom) {
    container.appendChild(createElement(vdom));
  } else {
    patch(diff(preVdom, vdom), container);
  }

  preVdom = vdom;
  setTimeout(() => {
    state.number += 1;
    renderDom(container);
  }, 3000);
}
renderDom(document.getElementById("root"));

2、diff算法--简洁版(未考虑性能优化)

const TYPES = {
  CREATE: "create", //新增加的节点
  DELETE: "delete", //要删除的属性或节点
  UPDATE: "update", //节点类型一致但是children或props不一致、新增属性或属性变化
  REPLACE: "replace", //节点类型不一致、文本节点(number/string))内容不一致
};

/**
 * diff渲染前后的vdom,递归找出差异对象
 * @param preVdom  上一次渲染的vdom
 * @param postVdom  本次渲染的vdom
 *
 * 1. diff根元素;
 * 2. diff根元素的props;
 * 3. diff根元素的children;
 * 4. 每个child的diff又是调用diff方法
 */
function diff(preVdom, postVdom) {
  //preVdom不存在,postVdom存在=》插入新增的节点
  if (preVdom === undefined) {
    return {
      type: TYPES.CREATE,
      vdom: postVdom,
    };
  }

  //preVdom存在,postVdom不存在=》删掉旧节点
  if (postVdom === undefined) {
    return {
      type: TYPES.DELETE,
    };
  }

  //文本节点(number/string))内容不一致、节点类型不同
  if (
    typeof preVdom !== typeof postVdom ||
    preVdom.tag !== postVdom.tag ||
    ((typeof preVdom === "number" || typeof preVdom === "string") &&
      preVdom !== preVdom)
  ) {
    return {
      type: TYPES.REPLACE,
      vdom: postVdom,
    };
  }

  //节点类型相同:对比该节点的属性、children
  if (preVdom.tag) {
    //非文本节点,才有属性和children
    const propsPatches = difProps(preVdom.props, postVdom.props);
    const childrenPatches = diffChildren(preVdom.children, postVdom.children);

    //属性或children不同
    if (propsPatches.length > 0 || childrenPatches > 0) {
      return {
        type: TYPES.UPDATE,
        props: propsPatches,
        children: childrenPatches,
      };
    }
  }
}

function difProps(preProps, postProps) {
  const patches = [];

  const props = { ...preProps, ...postProps }; // 合并属性,相同key的属性后者覆盖前者
  Object.keys(props).forEach((key) => {
    const preP = preProps[key];
    const postP = postProps[key];

    //新属性中已没有该属性=》删掉的属性
    if (postP === undefined) {
      patches.push({
        pType: TYPES.DELETE,
        key,
      });
    }

    //新增的属性或者属性值变化了
    if (preP === undefined || preP !== postP) {
      patches.push({
        pType: TYPES.UPDATE,
        key,
        value: postP,
      });
    }
  });

  return patches;
}

function diffChildren(preChildren, postChildren) {
  const patches = [];
  const len = Math.max(preChildren.length, postChildren.length);

  let i;
  for (i = 0; i < len; i++) {
    const diffPatch = diff(preChildren[i], postChildren[i]);
    if (diffPatch) {
      diffPatch["idx"] = i;
      patches.push(diffPatch);
    }
  }

  return patches;
}

3、差异(patch)更新

/**
 * diff后的结果 映射到 真实DOM上
 * @param patches diff后的差异对象
 * @param parent  容器dom节点
 */
function patch(patches, parent, cid = 0) {
  if (!patches || Object.keys(patches).length < 1) {
    return;
  }

  const len = parent.childNodes.length;
  const child = cid >= len ? null : parent.childNodes[cid];
  switch (patches.type) {
    case TYPES.CREATE:
      return parent.appendChild(createElement(patches.vdom));
    case TYPES.DELETE:
      return parent.removeChild(child);
    case TYPES.REPLACE:
      return parent.replaceChild(createElement(patches.vdom), child);
    case TYPES.UPDATE:
      patchProps(child, patches.props);
      patchChilren(child, patches.children);
      break;
  }
}

function patchProps(element, propsPatches = []) {
  if (!propsPatches || propsPatches.length < 1) {
    return;
  }

  propsPatches.forEach((patch) => {
    if (patch.type === TYPES.DELETE) {
      element.removeAttribute(patch.key);
    } else if (patch.type === TYPES.UPDATE) {
      element.setAttribute(patch.key, patch.value);
    }
  });
}

function patchChilren(parent, patchChildren = []) {
  if (!patchChildren || patchChildren.length < 1) {
    return;
  }

  patchChildren.forEach((patchChild) => {
    patch(patchChild, parent, patch.idx);
  });
}

四、简易版diff分析

1、通过diff获取了新、老 Virtual DOM的差异,然后再对现有DOM进行打补丁=》既然,都找出了差异,何不直接修改DOM,还要再多此一举呢?

优化diff,去掉patch=》diff和patch合并

上面renderDom修改如下:

 // patch(diff(preVdom, vdom), container);
    diff(preVdom, vdom, container);

删掉patch相关方法,内容合并到diff上,修改后的diff如下:

function diff(preVdom, postVdom, container, cid = 0) {
  const len = container.childNodes.length;
  const child = cid >= len ? null : container.childNodes[cid];

  //preVdom不存在,postVdom存在=》插入新增的节点
  if (preVdom === undefined) {
    // return {
    //   type: TYPES.CREATE,
    //   vdom: postVdom,
    // };
    return container.appendChild(createElement(postVdom));
  }

  //preVdom存在,postVdom不存在=》删掉旧节点
  if (postVdom === undefined) {
    // return {
    //   type: TYPES.DELETE,
    // };
    return container.removeChild(child);
  }

  //文本节点(number/string))内容不一致、节点类型不同
  if (
    typeof preVdom !== typeof postVdom ||
    preVdom.tag !== postVdom.tag ||
    ((typeof preVdom === "number" || typeof preVdom === "string") &&
      preVdom !== preVdom)
  ) {
    // return {
    //   type: TYPES.REPLACE,
    //   vdom: postVdom,
    // };
    return container.replaceChild(createElement(postVdom), child);
  }

  //节点类型相同:对比该节点的属性、children
  if (preVdom.tag) {
    //非文本节点,才有属性和children
    // const propsPatches = difProps(preVdom.props, postVdom.props);
    // const childrenPatches = diffChildren(preVdom.children, postVdom.children);
    //属性或children不同
    // if (propsPatches.length > 0 || childrenPatches > 0) {
    //   return {
    //     type: TYPES.UPDATE,
    //     props: propsPatches,
    //     children: childrenPatches,
    //   };
    // }
    difProps(preVdom.props, postVdom.props, child);
    diffChildren(preVdom.children, postVdom.children, child);
  }
}

function difProps(preProps, postProps, element) {
  // const patches = [];
  const props = { ...preProps, ...postProps }; // 合并属性,相同key的属性后者覆盖前者
  Object.keys(props).forEach((key) => {
    const preP = preProps[key];
    const postP = postProps[key];

    //新属性中已没有该属性=》删掉的属性
    if (postP === undefined) {
      // patches.push({
      //   pType: TYPES.DELETE,
      //   key,
      // });
      element.removeAttribute(key);
    }

    //新增的属性或者属性值变化了
    if (preP === undefined || preP !== postP) {
      // patches.push({
      //   pType: TYPES.UPDATE,
      //   key,
      //   value: postP,
      // });
      element.setAttribute(key, postP);
    }
  });
  // return patches;
}

function diffChildren(preChildren, postChildren, parent) {
  // const patches = [];
  const len = Math.max(preChildren.length, postChildren.length);

  let i;
  for (i = 0; i < len; i++) {
    // const diffPatch = diff(preChildren[i], postChildren[i]);
    // if (diffPatch) {
    //   diffPatch["idx"] = i;
    //   patches.push(diffPatch);
    // }
    diff(preChildren[i], postChildren[i], parent, i);
  }
  // return patches;
}

2、每次都会保存上一次的Virtual DOM,与新的Virtual DOM比较,再转成真实的dom树=》何不将Virtual DOM与真实dom树直接关联,diff差异,然后直接更新呢?

关联真实dom树,直接更新

上面renderDom修改如下:

function renderDom(container) {
  // var vdom = view();
  // if (!preVdom) {
  //   container.appendChild(createElement(vdom));
  // } else {
  //   diff(preVdom, vdom, container);
  // }
  // preVdom = vdom;
  diff(view(), container);

  setTimeout(() => {
    state.number += 1;
    renderDom(container);
  }, 3000);
}

virtual dom中setProps修改:把属性挂载到元素上element["props"] = props保存起来,用于vdom和真实dom对比使用

function setProps(element, props) {
  for (let key in props) {
    if (props.hasOwnProperty(key)) {
      element.setAttribute(key, props[key]);
    }
  }

  // 保存当前的属性,之后用于新VirtualDOM的属性比较
  element["props"] = props;  
}

diff、diffProps和diffChildren方法修改如下: 

function diff(vdom, container, cid = 0) {
  const len = container.childNodes.length;
  const child = cid >= len ? undefined : container.childNodes[cid]; //节点不存在一定是undefined,因为child === undefined用的是全等

  //preVdom不存在,postVdom存在=》插入新增的节点
  if (child === undefined) {
    // return {
    //   type: TYPES.CREATE,
    //   vdom: postVdom,
    // };
    return container.appendChild(createElement(vdom));
  }

  //preVdom存在,postVdom不存在=》删掉旧节点
  if (vdom === undefined) {
    // return {
    //   type: TYPES.DELETE,
    // };
    return container.removeChild(child);
  }

  //文本节点(number/string))内容不一致、节点类型不同
  if (
    !isEqual(vdom, child)
    // typeof preVdom !== typeof postVdom ||
    // preVdom.tag !== postVdom.tag ||
    // ((typeof preVdom === "number" || typeof preVdom === "string") &&
    //   preVdom !== preVdom)
  ) {
    // return {
    //   type: TYPES.REPLACE,
    //   vdom: postVdom,
    // };
    return container.replaceChild(createElement(vdom), child);
  }

  //节点类型相同:对比该节点的属性、children
  // if (preVdom.tag) {
  //非文本节点,才有属性和children
  // const propsPatches = difProps(preVdom.props, postVdom.props);
  // const childrenPatches = diffChildren(preVdom.children, postVdom.children);
  //属性或children不同
  // if (propsPatches.length > 0 || childrenPatches > 0) {
  //   return {
  //     type: TYPES.UPDATE,
  //     props: propsPatches,
  //     children: childrenPatches,
  //   };
  // }
  // }

  //child.nodeType === Node.ELEMENT_NODE ie中没有Node对象,所以用数值
  //排除文本节点,文本节点没有props和children
  if (child.nodeType === 1) {
    difProps(vdom.props, child);
    diffChildren(vdom.children, child);
  }
}

function difProps(vdomProps, element) {
  // const patches = [];
  const oldProps = element["props"] || {};
  const newProps = {};

  const props = { ...oldProps, ...vdomProps }; // 合并属性,相同key的属性后者覆盖前者
  Object.keys(props).forEach((key) => {
    const preP = oldProps[key];
    const postP = vdomProps[key];

    //新属性中已没有该属性=》删掉的属性
    if (postP === undefined) {
      // patches.push({
      //   pType: TYPES.DELETE,
      //   key,
      // });

      return element.removeAttribute(key);
    }

    //新增的属性或者属性值变化了
    if (preP === undefined || preP !== postP) {
      // patches.push({
      //   pType: TYPES.UPDATE,
      //   key,
      //   value: postP,
      // });
      element.setAttribute(key, postP);
    }

    newProps[key] = props[key];
  });

  element["props"] = newProps; //更新挂载到element上的props
  // return patches;
}

function diffChildren(vdomChildren, parent) {
  // const patches = [];
  const oldChilrenNodes = parent.childNodes;
  const len = Math.max(oldChilrenNodes.length, vdomChildren.length); //还是要计算新旧节点最大的children树,即使旧的节点children多,vdomChildren[i]就是undefined,即要删掉的节点

  let i;
  for (i = 0; i < len; i++) {
    // const diffPatch = diff(preChildren[i], postChildren[i]);
    // if (diffPatch) {
    //   diffPatch["idx"] = i;
    //   patches.push(diffPatch);
    // }
    diff(vdomChildren[i], parent, i);
  }
  // return patches;
}

diff中判断节点类型前后是否变化,之前对比的是vdom,现在对比的是vdom和真实的dom节点,方法如下:

/**
 * 判断新的vdom和原先的dom节点类型是否相同
 * @param vdom 虚拟dom
 * @param element 真实dom节点
 */
function isEqual(vdom, element) {
  const eType = element.nodeType;
  const vType = typeof vdom;

  //文本节点
  //3-Node.TEXT_NODE,IE没有Node对象
  //注意:文本节点中的数字通过element.nodeValue获取的内容 类型是string,而vdom若为数字则类型为number,所以使用===会导致含有数字文本的节点即使前后没变化都会更新,像是本案例每3s插入一条新的div,但是原先插入的节点因为含有数字文本,导致每次插入之前插入的div需要全更新=》vdom转换为string
  if (
    eType === 3 &&
    (vType === "string" || "number") &&
    element.nodeValue === String(vdom)
  ) {
    return true;
  }

  //1-Node.ELEMENT_NODE,IE没有Node对象
  if (eType === 1 && element.tagName.toLowerCase() === vdom.tag.toLowerCase()) {
    return true;
  }

  return false;
}

注意:上面element.nodeValue === String(vdom)中vdom要转换成string,要么就不要用===全等,因为element.nodeValue获取的内容 类型是string,而vdom若为数字则类型为number,不转换,运行代码可以看到:每隔3秒插入一个新的div,之前所有的div都会更新,因为数字文本前后不等,如下:

    

五、diff算法优化(待更新)

在react里,渲染数组元素的时候,会提醒加上key这个属性,那么key是用来做什么的呢?

key的作用:

看过react源码的都知道,在react里,key的作用主要就是复用之前的节点,没有key的话,数组就要每次全部删除然后重新创建,开销就非常大。key就是暗示某些元素的稳定性。

在子元素列表末尾新增元素时,更新开销比较小:React 会先匹配两个 <li>first</li> 对应的树,然后匹配第二个元素 <li>second</li> 对应的树,最后插入第三个元素的 <li>third</li> 树。

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

但是新增元素插入到表头,那么更新开销会比较大:React 并不会意识到应该保留 <li>Duke</li> 和 <li>Villanova</li>,而是会重建每一个子元素=》产生性能问题

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

为了解决上述问题,React 引入了 key 属性。有了key,react就知道:和旧树中相同的key 只是发生了位移,旧树中不存在的key元素为新增元素,需要插入dom。

//待更新。。。

猜你喜欢

转载自blog.csdn.net/CamilleZJ/article/details/117025862