从虚拟dom到真实dom

概念

真实 DOM

谈虚拟 DOM 之前,我们先了解什么是真实 DOM,就是 HTML 文件中真实的元素,提供了 API 让我们可以直接操作真实的 DOM 节点,比如修改 innerHTML,增加子节点...


image

html:

<div id="app">Hello World</div>
var dom = document.getElementById("app");
dom.innerHTML; // ""

虚拟 DOM

虚拟 DOM 就是能用抽象后对象来描述真实 DOM, 在实际操作下,描述真实 DOM 只是虚拟 DOM 需要满足的一个最主要的需求

下面就是满足上面 html 的虚拟 DOM

var vApp = {
  // 标签名
  tagName: "div", 
  // 属性名
  attrs: {
    id: "app",
  },
  // 子节点
  children: [],
};

Vue 的虚拟节点接口定义

export interface VNode {
  tag?: string;
  data?: VNodeData;
  children?: VNode[];
  text?: string;
  elm?: Node;
  ns?: string;
  context?: Vue;
  key?: string | number;
  componentOptions?: VNodeComponentOptions;
  componentInstance?: Vue;
  parent?: VNode;
  raw?: boolean;
  isStatic?: boolean;
  isRootInsert: boolean;
  isComment: boolean;
}

虚拟 DOM 的好处:

  1. 抽象:渲染过程抽象化后,增加代码可维护性;
  2. 跨平台:不同平台最终的渲染有不同的实现,比如服务端渲染,小程序,基于虚拟 DOM 兼容多个平台的渲染就容易多了。

注意:vDOM 性能不一定高,参考:尤雨溪关于虚拟 DOM 性能的回答

下面内容都是基于 Web 的处理

转换过程

从模板创建虚拟 DOM 有前置操作,我们写的 vue 和小程序,都是需要通过解析成 AST(抽象语法树)、AST 转换、生成可执行代码,才能生成我们需要的虚拟 DOM,有机会再展开;

render

生成真实 DOM,真实 DOM 节点有 8 种,我们只实现其中的元素节点和文字节点。

// render.js
function createElement(vNode) {
  const { tagName, attrs = {}, children = [] } = vNode;
  const $el = document.createElement(tagName);

  // 处理属性
  for (const [k, v] of Object.entries(attrs)) {
    $el.setAttribute(k, v);
  }

  // 处理子元素
  for (const child of children) {
    $el.appendChild(render(child));
  }

  return $el;
}

const render = (vNode) => {
  // 生成文字节点
  if (typeof vNode === "string") {
    return document.createTextNode(vNode);
  }

  // 生成元素节点
  return createElement(vNode);
};

export default render;
import render from "./vdom/render";

var vApp = {
  tagName: "div",
  attrs: {
    id: "app",
  },
  children: [
    `hello world`
  ],
}

var $node = render(vApp);
console.log($node)

mount

挂载生成的 DOM

export default ($node, $target) => {
  $target.replaceWith($node);
  return $node;
};

main.js

// ...
var $rootEl = mount($node, document.getElementById('app'));

mount 之后更新

import render from "./vdom/render";
import mount from "./vdom/mount";

const createVApp = (count) => {
  var vApp = {
    tagName: "div",
    attrs: {
      id: "app",
    },
    children: [
      `count is:`,
      String(count),
      {
        tagName: "input",
        attrs: { style: "display:block" },
        children: [],
      },
      ...Array.from({ length: count }, () => {
        return {
          tagName: "img",
          attrs: {
            src: "https://i.loli.net/2020/12/29/vxB5o4Cy1hk6YOV.png",
          },
          children: []
        }
      }),
    ],
  };
  return vApp;
};
var count = 0;

var vApp = createVApp(count);
var $node = render(vApp);
var $rootEl = mount($node, document.getElementById("app"));

setInterval(() => {
  count++;
  var newVApp = createVApp(count);
  $rootEl = mount(render(newVApp), $rootEl);
}, 1000);

审查元素发现,输入框的数据每次更新都被清除,整个DOM都会被刷新,如何只更新修改的节点,就需要 diff 来判断修改的节点了

diff

我们先讨论节点更新简单的三种情况

  1. 新的节点树是 undefined, 那么直接销毁
  2. 新旧节点有节点是文本类型,比较替换
  3. 新旧节点 tagName 不一致,直接替换
if (newVTree === undefined) {
  return ($node) => {
    $node.remove();
    return undefined;
  };
}

if (typeof newVTree === "string" || typeof oldVTree === "string") {
  if (newVTree !== oldVTree) {
    return ($node) => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $node;
    };
  } else {
    return ($node) => $node;
  }
}

if (newVTree.tagName !== oldVTree.tagName) {
  return ($node) => {
    const $newNode = render(newVTree);
    $node.replaceWith($newNode);
    return $node;
  };
}

接下来的就是相同 tagName 的元素,可能属性、子节点的 diff

  1. 属性
const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  for (const [k, v] of Object.entries(newAttrs)) {
    if (!(k in oldAttrs)) {
      patches.push(($node) => {
        $node.setAttribute(k, v);
        return $node;
      });
    }
  }

  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push(($node) => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return ($node) => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};
  1. 子节点根据新旧子节点长度,1. 递归 diff 旧子节点,2. 增加新节点(新子节点数多于旧子节点数)
// 用来生成新旧patch数组
const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};
// diffChildren
const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  // 旧节点diff递归
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });
  // 新增节点
  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push(($node) => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }
  // 更新节点
  return ($parent) => {
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

// diff
const diff = (oldVTree, newVTree) => {
  // ......

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return ($node) => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

完整的diff方法, 传入新旧虚拟DOM, 生成patch回调(传入真实DOM后返回更新后的真实DOM)


import render from "./render";

const zip = (xs, ys) => {
  const zipped = [];
  for (let i = 0; i < Math.min(xs.length, ys.length); i++) {
    zipped.push([xs[i], ys[i]]);
  }
  return zipped;
};

const diffAttrs = (oldAttrs, newAttrs) => {
  const patches = [];

  for (const [k, v] of Object.entries(newAttrs)) {
    if (!(k in oldAttrs)) {
      patches.push(($node) => {
        $node.setAttribute(k, v);
        return $node;
      });
    }
  }

  for (const k in oldAttrs) {
    if (!(k in newAttrs)) {
      patches.push(($node) => {
        $node.removeAttribute(k);
        return $node;
      });
    }
  }

  return ($node) => {
    for (const patch of patches) {
      patch($node);
    }
    return $node;
  };
};

const diffChildren = (oldVChildren, newVChildren) => {
  const childPatches = [];
  // 旧节点diff
  oldVChildren.forEach((oldVChild, i) => {
    childPatches.push(diff(oldVChild, newVChildren[i]));
  });
  // 新增节点
  const additionalPatches = [];
  for (const additionalVChild of newVChildren.slice(oldVChildren.length)) {
    additionalPatches.push($node => {
      $node.appendChild(render(additionalVChild));
      return $node;
    });
  }
  // 更新节点
  return $parent => {
    for (const [patch, $child] of zip(childPatches, $parent.childNodes)) {
      patch($child);
    }

    for (const patch of additionalPatches) {
      patch($parent);
    }
    return $parent;
  };
};

const diff = (oldVTree, newVTree) => {
  if (newVTree === undefined) {
    return ($node) => {
      $node.remove();
      return undefined;
    };
  }

  if (typeof newVTree === "string" || typeof oldVTree === "string") {
    if (newVTree !== oldVTree) {
      return ($node) => {
        const $newNode = render(newVTree);
        $node.replaceWith($newNode);
        return $node;
      };
    } else {
      return ($node) => $node;
    }
  }

  if (newVTree.tagName !== oldVTree.tagName) {
    return ($node) => {
      const $newNode = render(newVTree);
      $node.replaceWith($newNode);
      return $node;
    };
  }

  const patchAttrs = diffAttrs(oldVTree.attrs, newVTree.attrs);
  const patchChildren = diffChildren(oldVTree.children, newVTree.children);

  return ($node) => {
    patchAttrs($node);
    patchChildren($node);
    return $node;
  };
};

export default diff;

// main.js
import diff from "./vdom/diff";

// ....

setInterval(() => {
  count = Math.floor(Math.random() * 5);
  const vNewApp = createVApp(count);
  const patch = diff(vApp, vNewApp);

  $rootEl = patch($rootEl);
  vApp = vNewApp;
}, 1000);

审查元素可发现,只有发生变化的元素DOM发生了更新,input数据不再丢失。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容