React系列 —- virtualdom diff算法实现分析

React系列

React系列 —- 简单模拟语法(一)
React系列 —- Jsx, 合成事件与Refs(二)
React系列 —- virtualdom diff算法实现分析(三)
React系列 —- 从Mixin到HOC再到HOOKS(四)
React系列 —- createElement, ReactElement与Component部分源码解析(五)
React系列 —- 从使用React了解Css的各种使用方案(六)
React系列 —- 从零构建状态管理及Redux源码解析(七)
React系列 —- 扩展状态管理功能及Redux源码解析(八)

完整代码可查看virtualdom-diff

渲染DOM

经历过PHP模板开发或者JQuery的洗礼的人都知道,它们实现重新渲染采用最简单粗暴的办法就是重新构建DOM替换旧DOM,问题也很明显

  • 性能消耗高
  • 无法保存状态(聚焦,滚动等)

我们先看看创建一个元素所包含的实例属性有多少个

  1. const div = document.createElement('div');
  2. let num = 0;
  3. for (let k in div) {
  4. num++;
  5. }
  6. console.log(num); // 241

然后浏览器根据CSS规则查找匹配节点,计算合并样式布局,为了避免重新计算一般浏览器会保存这些数据.但这是整个过程下来依然会耗费大量的内存和 CPU 资源.

Virtual DOM

实际也是操作Dom树进行渲染更新,但是它只是针对修改部分进行局部渲染,将影响降到最低,虽然实现方式各有不同,但是大体步骤如下:

  1. 用Javascript对象结构描述Dom树结构,然后用它来构建真正的Dom树插入文档
  2. 当状态发生改变之后,重新构造新的Javascript对象结构和旧的作对比得出差异
  3. 针对差异之处进行重新构建更新视图

无非就是利用Js做一层映射比较,操作简单并且速度远远高于直接比较Dom树

基础工具函数

无非就是一些类型判断,循环遍历的简化函数

  1. function type(obj) {
  2. return Object.prototype.toString.call(obj).replace(/\[object\s|\]/g, "");
  3. }
  4. function isArray(list) {
  5. return type(list) === "Array";
  6. }
  7. function isObject(obj) {
  8. return type(obj) === "Object";
  9. }
  10. function isString(str) {
  11. return type(str) === "String";
  12. }
  13. function isNotEmptyObj(obj) {
  14. return isObject(obj) && JSON.stringify(obj) != "{}";
  15. }
  16. function objForEach(obj, fn) {
  17. isNotEmptyObj(obj) && Object.keys(obj).forEach(fn);
  18. }
  19. function aryForEach(ary, fn) {
  20. ary.length && ary.forEach(fn);
  21. }
  22. function setAttr(node, key, value) {
  23. switch (key) {
  24. case "style":
  25. node.style.cssText = value;
  26. break;
  27. case "value":
  28. var tagName = node.tagName || "";
  29. tagName = tagName.toLowerCase();
  30. if (tagName === "input" || tagName === "textarea") {
  31. node.value = value;
  32. } else {
  33. // if it is not a input or textarea, use `setAttribute` to set
  34. node.setAttribute(key, value);
  35. }
  36. break;
  37. default:
  38. node.setAttribute(key, value);
  39. break;
  40. }
  41. }
  42. function toArray(data) {
  43. if (!data) {
  44. return [];
  45. }
  46. const ary = [];
  47. aryForEach(data, item => {
  48. ary.push(item);
  49. });
  50. return ary;
  51. }
  52. export {
  53. isArray,
  54. isObject,
  55. isString,
  56. isNotEmptyObj,
  57. objForEach,
  58. aryForEach,
  59. setAttr,
  60. toArray
  61. };

相关代码可以查看util.js

Javascript对象结构描述

我之前讲JSX的时候举过这么个例子,然后我们就以这个来实现效果吧

  1. <div className="num" index={1}>
  2. <span>123456</span>
  3. </div>
  4. "use strict";
  5. React.createElement("div", {
  6. className: "num",
  7. index: 1
  8. }, React.createElement("span", null, "123456"));

创建一个Element类负责将Javascript对象结构转换为Dom树结构

  1. import {
  2. isObject,
  3. isString,
  4. isArray,
  5. isNotEmptyObj,
  6. objForEach,
  7. aryForEach
  8. } from "./util";
  9. import { NOKEY } from "./common";
  10. class Element {
  11. constructor(tagName, props, children) {
  12. // 解析参数
  13. this.tagName = tagName;
  14. // 字段处理,可省略参数
  15. this.props = isObject(props) ? props : {};
  16. this.children =
  17. children ||
  18. (!isNotEmptyObj(this.props) &&
  19. ((isString(props) && [props]) || (isArray(props) && props))) ||
  20. [];
  21. // 无论void后的表达式是什么,void操作符都会返回undefined
  22. this.key = props ? props.key : void NOKEY;
  23. // 计算节点数
  24. let count = 0;
  25. aryForEach(this.children, (item, index) => {
  26. if (item instanceof Element) {
  27. count += item.count;
  28. } else {
  29. this.children[index] = "" + item;
  30. }
  31. count++;
  32. });
  33. this.count = count;
  34. }
  35. render() {
  36. // 根据tagName构建
  37. const dom = document.createElement(this.tagName);
  38. // 设置props
  39. objForEach(this.props, propName =>
  40. dom.setAttribute(propName, this.props[propName])
  41. );
  42. // 渲染children
  43. aryForEach(this.children, child => {
  44. const childDom =
  45. child instanceof Element
  46. ? child.render() // 如果子节点也是虚拟DOM,递归构建DOM节点
  47. : document.createTextNode(child); // 如果字符串,只构建文本节点
  48. dom.appendChild(childDom);
  49. });
  50. return dom;
  51. }
  52. }
  53. // 改变传参方式,免去手动实例化
  54. export default function CreateElement(tagName, props, children) {
  55. return new Element( tagName, props, children );
  56. }

新建示例,调用方式如下

  1. // 1. 构建虚拟DOM
  2. const tree = createElement("div", { id: "root" }, [
  3. createElement("h1", { style: "color: blue" }, ["Tittle1"]),
  4. createElement("p", ["Hello, virtual-dom"]),
  5. createElement("ul", [
  6. createElement("li", { key: 1 }, ["li1"]),
  7. createElement("li", { key: 2 }, ["li2"]),
  8. createElement("li", { key: 3 }, ["li3"]),
  9. createElement("li", { key: 4 }, ["li4"])
  10. ])
  11. ]);
  12. // 2. 通过虚拟DOM构建真正的DOM
  13. const root = tree.render();
  14. document.body.appendChild(root);

运行之后能正常得出结果了,那么第一步骤算是完成了,具体还有更多不同类型标签,对应事件状态先略过.

界面如图

Javascript结构如图

结构原型如下

相关代码可以查看element.js

diff算法

这是整个实现里面最关键的一步,因为这决定了计算的速度和操作Dom的数量

我们创建新的Dom树作对比

  1. // 3. 生成新的虚拟DOM
  2. const newTree = createElement("div", { id: "container" }, [
  3. createElement("h1", { style: "color: red" }, ["Title2"]),
  4. createElement("h3", ["Hello, virtual-dom"]),
  5. createElement("ul", [
  6. createElement("li", { key: 3 }, ["li3"]),
  7. createElement("li", { key: 1 }, ["li1"]),
  8. createElement("li", { key: 2 }, ["li2"]),
  9. createElement("li", { key: 5 }, ["li5"])
  10. ])
  11. ]);

Javascript结构如图

tree diff

传统 diff 算法的复杂度为 O(n^3),但是一般Dom跨层级的情况是非常少见的。所以React 只针对同层级Dom节点做比较,将 O(n^3) 复杂度的问题转换成 O(n) 复杂度的问题。

比较大的问题就是当节点跨层级移动并不会进行移动而是直接替换整个节点,所以切记这点性能问题

component diff

  • 某个组件发生变化,会导致自其从上往下整体替换
  • 同一类型组件会进行Virtual DOM进行比较
  • React提供了一个shouldComponentUpdate决定是否更新

尽可能将动态组件往底层节点迁移,有利于提高性能

element diff

元素操作无非就是几种,我们定义几个类型做状态标记

  1. const REPLACE = "replace";
  2. const REORDER = "reorder";
  3. const PROPS = "props";
  4. const TEXT = "text";
  5. const NOKEY = "no_key"
  6. export {
  7. REPLACE,
  8. REORDER,
  9. PROPS,
  10. TEXT,
  11. NOKEY
  12. }

其中NOKEY就是专门给那些没有定义key的组件做默认,React对同一层级的同组子节点,添加唯一 key 进行区分进行位移而不是直接替换,这点对于整体性能尤为关键

我们首先针对不同类型做些区分处理

  1. import { isString, objForEach, aryForEach, isNotEmptyObj } from "./util";
  2. import { REPLACE, REORDER, PROPS, TEXT } from "./common";
  3. import listDiff from "list-diff2";
  4. /**
  5. *
  6. * @param {旧Dom树} oTree
  7. * @param {新Dom树} nTree
  8. * 返回差异记录
  9. */
  10. function diff(oTree, nTree) {
  11. // 节点位置
  12. let index = 0;
  13. // 差异记录
  14. const patches = {};
  15. dfsWalk(oTree, nTree, index, patches);
  16. return patches;
  17. }
  18. function dfsWalk(oNode, nNode, index, patches) {
  19. const currentPatch = [];
  20. // 首次渲染
  21. if (nNode === null) return;
  22. // 都是字符串形式并且不相同直接替换文字
  23. if (isString(oNode) && isString(nNode)) {
  24. oNode !== nNode &&
  25. currentPatch.push({
  26. type: TEXT,
  27. content: nNode
  28. });
  29. // 同种标签并且key相同
  30. } else if (oNode.tagName === nNode.tagName && oNode.key === nNode.key) {
  31. // 至少一方有值
  32. if (isNotEmptyObj(oNode.props) || isNotEmptyObj(nNode.props)) {
  33. // 计算props结果
  34. const propsPatches = diffProps(oNode, nNode);
  35. // 有差异则重新排序
  36. propsPatches &&
  37. currentPatch.push({
  38. type: PROPS,
  39. props: propsPatches
  40. });
  41. }
  42. // children对比
  43. if (
  44. !(!isNotEmptyObj(nNode.props) && nNode.props.hasOwnProperty("ignore"))
  45. ) {
  46. (oNode.children.length || nNode.children.length) &&
  47. diffChildren(
  48. oNode.children,
  49. nNode.children,
  50. index,
  51. patches,
  52. currentPatch
  53. );
  54. }
  55. } else {
  56. // 都不符合上面情况就直接替换
  57. currentPatch.push({ type: REPLACE, node: nNode });
  58. }
  59. // 最终对比结果
  60. currentPatch.length && (patches[index] = currentPatch);
  61. }

新旧节点的props属性比较

  1. /**
  2. *
  3. * @param {旧节点} oNode
  4. * @param {新节点} nNode
  5. */
  6. function diffProps(oNode, nNode) {
  7. let isChange = false;
  8. const oProps = oNode.props;
  9. const nProps = nNode.props;
  10. // 节点属性记录
  11. const propsPatched = {};
  12. // 替换/新增属性
  13. objForEach(oProps, key => {
  14. if (nProps[key] !== oProps[key] || !oProps.hasOwnProperty(key)) {
  15. !isChange && (isChange = true);
  16. propsPatched[key] = nProps[key];
  17. }
  18. });
  19. return !isChange ? null : propsPatched;
  20. }

新旧节点的子元素对比

  1. /**
  2. * 同级对比
  3. * @param {*} oChildren
  4. * @param {*} nChildren
  5. * @param {*} index
  6. * @param {*} patches
  7. * @param {*} currentPatch
  8. */
  9. function diffChildren(oChildren, nChildren, index, patches, currentPatch) {
  10. // 得出相对简化移动路径
  11. const diffs = listDiff(oChildren, nChildren, "key");
  12. // 保留元素
  13. nChildren = diffs.children;
  14. // 记录排序位移
  15. diffs.moves.length &&
  16. currentPatch.push({ type: REORDER, moves: diffs.moves });
  17. // 深度遍历
  18. let leftNode = null;
  19. let currentNodeIndex = index;
  20. aryForEach(oChildren, (_item, _index) => {
  21. const nChild = nChildren[_index];
  22. currentNodeIndex =
  23. leftNode && leftNode.count
  24. ? currentNodeIndex + leftNode.count + 1
  25. : currentNodeIndex + 1;
  26. _item !== nChild && dfsWalk(_item, nChild, currentNodeIndex, patches);
  27. leftNode = _item;
  28. });
  29. }

深度遍历的原型图如下

其中的listDiff来自于list-diff,能通过关键属性获得最小移动量,moves就是给第三步更新视图做铺垫指示,官方介绍如下

Diff two lists in time O(n). I The algorithm finding the minimal amount of moves is Levenshtein distance which is O(n*m). This algorithm is not the best but is enougth for front-end DOM list manipulation.

This project is mostly influenced by virtual-dom algorithm.

调用对比方式

  1. // 4. 比较两棵虚拟DOM树的不同
  2. const patches = diff(tree, newTree);

得出差异如下

相关代码可以查看diff.js

更新视图

进行深度遍历

  1. import {
  2. isString,
  3. isObject,
  4. objForEach,
  5. aryForEach,
  6. setAttr,
  7. toArray
  8. } from "./util";
  9. import { REPLACE, REORDER, PROPS, TEXT, NOKEY } from "./common";
  10. function patch(node, patches) {
  11. const walker = { index: 0 };
  12. dfsWalk(node, walker, patches);
  13. }
  14. // 深度遍历更新
  15. function dfsWalk(node, walker, patches) {
  16. const currentPatches = patches[walker.index];
  17. node.childNodes &&
  18. aryForEach(node.childNodes, item => {
  19. walker.index++;
  20. dfsWalk(item, walker, patches);
  21. });
  22. currentPatches && applyPatches(node, currentPatches);
  23. }

针对不同标志做对应处理

  1. // 更新类型
  2. function applyPatches(node, currentPatches) {
  3. aryForEach(currentPatches, item => {
  4. switch (item.type) {
  5. case REPLACE:
  6. const nNode = isString(item.node)
  7. ? document.createTextNode(item.node)
  8. : item.node.render();
  9. node.parentNode.replaceChild(nNode, node);
  10. break;
  11. case REORDER:
  12. reorderChildren(node, item.moves);
  13. break;
  14. case PROPS:
  15. setProps(node, item.props);
  16. break;
  17. case TEXT:
  18. if (node.textContent) {
  19. // 使用纯文本
  20. node.textContent = item.content;
  21. } else {
  22. // 仅仅对CDATA片段,注释comment,Processing Instruction节点或text节点有效
  23. node.nodeValue = item.content;
  24. }
  25. break;
  26. default:
  27. throw new Error("Unknown patch type " + item.type);
  28. }
  29. });
  30. }

先说简单的属性替换

  1. // 修改属性
  2. function setProps(node, props) {
  3. objForEach(props, key => {
  4. if (props[key] === void NOKEY) {
  5. node.removeAttribute(key);
  6. } else {
  7. setAttr(node, key, props[key]);
  8. }
  9. });
  10. }

最后就是列表渲染

  1. // 列表排序渲染
  2. function reorderChildren(node, moves) {
  3. const staticNodeList = toArray(node.childNodes);
  4. const maps = {};
  5. aryForEach(staticNodeList, node => {
  6. // Element
  7. if (node.nodeType === 1) {
  8. const key = node.getAttribute("key");
  9. key && (maps[key] = node);
  10. }
  11. });
  12. aryForEach(moves, move => {
  13. const index = move.index;
  14. // 0:删除 1:替换
  15. if (move.type === 0) {
  16. // 找到对应节点删除
  17. staticNodeList[index] === node.childNodes[index] &&
  18. node.removeChild(node.childNodes[index]);
  19. staticNodeList.splice(index, 1);
  20. } else if (move.type === 1) {
  21. let insertNode;
  22. if (maps[move.item.key]) {
  23. // 删除并返回节点
  24. insertNode = node.removeChild(maps[move.item.key]);
  25. // 获取删除节点位置
  26. staticNodeList.splice(Array.prototype.indexOf.call(node.childNodes, maps[move.item.key]), 1);
  27. } else {
  28. // 创建节点
  29. insertNode = isObject(move.item)
  30. ? move.item.render()
  31. : document.createTextNode(move.item);
  32. }
  33. // 同步staticNodeList信息
  34. staticNodeList.splice(index, 0, insertNode);
  35. // 操作Dom
  36. node.insertBefore(insertNode, node.childNodes[index] || null);
  37. }
  38. });
  39. }
  40. export default patch;

当这一步完成以后我们可以直接应用查看效果

  1. // 4. 比较两棵虚拟DOM树的不同
  2. const patches = diff(tree, newTree);
  3. // 5. 在真正的DOM元素上应用变更
  4. patch(root, patches);

结果如图
virtualdom - 图1
相关代码可以查看patch.js