练习: 个人学习专用知识库~ - Gitee.com

什么是虚拟 DOM?

虚拟 DOM(Virtual DOM)是对真实 DOM 的一种抽象表示,它是一个普通的 JavaScript 对象,包含了真实 DOM 节点的所有信息。通过操作虚拟 DOM,我们可以避免直接操作真实 DOM,从而提高性能

核心实现

1. 虚拟节点类 (VNode)

class VNode {
  tag: string;
  props: Record<string, any>;
  children: VNode[] | string | null;
  key: string | number | null;
  text: string | null;
  el: string | HTMLElement | null | Text;

  constructor(
    tag: string,
    props = {},
    children: Array<VNode> | string | null = [],
    key = null,
    text: string | null = null
  ) {
    this.tag = tag;
    this.props = props;
    this.children = children;
    this.key = key;
    this.text = text;
    this.el = null;
  }
}

VNode 类是虚拟 DOM 的核心,它包含了以下属性:

  • tag: 标签名(如 'div')

  • props: 属性对象(如 { class: 'app' })

  • children: 子节点(数组,元素为 VNode 或文本)

  • key: 节点唯一标识,用于 diff 算法

  • text: 文本节点内容(文本节点时生效)

  • el: 对应的真实 DOM 元素(创建后挂载)

2. 节点创建函数

h 函数

export const h = function (
  tag: string,
  props: Record<string, any> = {},
  children: Array<VNode> | string | null = [],
  key = null
) {
  if (typeof children === 'string' || typeof children === 'number') {
    return new VNode(tag, props, [], key, (children as string | number).toString());
  }
  return new VNode(tag, props, children, key);
};

h 函数是创建虚拟节点的工厂函数,它接受标签名、属性、子节点和 key 作为参数,返回一个新的 VNode 实例。

createTxtVNode 函数

export const createTxtVNode = (text: string) => {
  return new VNode('', {}, [], null, text);
};

createTxtVNode 函数专门用于创建文本虚拟节点。

3. 节点挂载

export const mount = function (
  vnode: VNode,
  container: HTMLElement,
  anchor: HTMLElement | null = null
) {
  if (vnode.text !== null) {
    const textNode = document.createTextNode(vnode.text);
    vnode.el = textNode;
    container.insertBefore(textNode, anchor);
    return;
  }
  const el = document.createElement(vnode.tag);
  vnode.el = el;
  // 属性挂载
  Object.entries(vnode.props).forEach(([key, value]) => {
    setProp(el, key, value);
  });
  // 子节点挂载
  (vnode.children as VNode[]).forEach((child) => {
    mount(child, el);
  });
  // 挂载到容器
  container.insertBefore(el, anchor);
};

mount 函数负责将虚拟节点挂载到真实 DOM 上,它的工作流程是:

  1. 如果是文本节点,创建文本节点并挂载

  2. 如果是元素节点,创建元素并设置属性

  3. 递归挂载子节点

  4. 将元素挂载到容器中

4. 属性设置

export const setProp = (el: HTMLElement, key: string, value: any) => {
  if (key === 'class') {
    el.className = value;
  } else if (key === 'style') {
    Object.entries(value as Record<string, string>).forEach(([k, v]) => {
      if (v !== null && v !== undefined && v !== '') {
        el.style.setProperty(k, v);
      } else {
        el.style.removeProperty(k);
      }
    });
  } else if (key.startsWith('on')) {
    const eventName = key.slice(2).toLowerCase();
    el.addEventListener(eventName, value);
  } else {
    el.setAttribute(key, value);
  }
};

setProp 函数负责设置元素的属性,支持类名、样式、事件等属性的设置。

5. 节点更新 (Diff 算法)

patch 函数

export const patch = function (oldVNode: VNode, newVNode: VNode, container?: HTMLElement | null) {
  // 1. 节点类型不同直接挂载
  if (
    newVNode.tag !== oldVNode.tag ||
    (newVNode.text !== null && oldVNode.text !== null && newVNode.text !== oldVNode.text)
  ) {
    const oldEL = (oldVNode.el as HTMLElement) || container;
    const parent = (oldEL?.parentNode as HTMLElement) || container;
    mount(newVNode, parent);
    if (oldVNode.el !== null) {
      parent.removeChild(oldVNode.el as HTMLElement);
    }
    return;
  }
  // 2.对比文本内容
  if (newVNode.text !== oldVNode.text) {
    (oldVNode.el as HTMLElement).textContent = newVNode.text;
    newVNode.el = oldVNode.el;
    return;
  }
  // 3.复用真实DOM
  newVNode.el = oldVNode.el;
  const newEL = newVNode.el as HTMLElement;

  // 4.对比属性
  patchProps(newEL, oldVNode.props, newVNode.props);
  // 5.对比子节点
  patchChildren(newEL, oldVNode.children as VNode[], newVNode.children as VNode[]);
};

patch 函数是 diff 算法的核心,它的工作流程是:

  1. 如果节点类型不同,直接创建新节点并替换旧节点

  2. 如果是文本节点,直接更新文本内容

  3. 复用真实 DOM 元素

  4. 对比并更新属性

  5. 对比并更新子节点

双端对比算法

export const patchListChildren = (EL: HTMLElement, oldChildren: VNode[], newChildren: VNode[]) => {
  // 新旧节点列表首尾指针
  let oldStartIdx = 0;
  let oldEndIdx = oldChildren.length - 1;
  let newStartIdx = 0;
  let newEndIdx = newChildren.length - 1;

  // 新列表 key 构建映射
  const keyToOldIdxMap = new Map();
  oldChildren.forEach((item, idx) => {
    if (item.key !== null) {
      keyToOldIdxMap.set(item.key, idx);
    }
  });

  // 循环对比
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 获取节点
    const oldStartVNode = oldChildren[oldStartIdx] as VNode;
    const oldEndVNode = oldChildren[oldEndIdx] as VNode;
    const newStartVNode = newChildren[newStartIdx] as VNode;
    const newEndVNode = newChildren[newEndIdx] as VNode;

    // 头头对比
    if (oldStartVNode?.key === newStartVNode?.key) {
      patch(oldStartVNode, newStartVNode, EL);
      oldStartIdx++;
      newStartIdx++;
      continue;
    }

    // 尾尾对比
    if (oldEndVNode?.key === newEndVNode?.key) {
      patch(oldEndVNode, newEndVNode, EL);
      oldEndIdx--;
      newEndIdx--;
      continue;
    }

    // 头尾对比
    if (oldStartVNode.key === newEndVNode.key) {
      patch(oldStartVNode, newEndVNode, EL);
      EL.insertBefore(oldStartVNode.el as Node, (oldEndVNode.el as Node).nextSibling);
      oldStartIdx++;
      newEndIdx--;
      continue;
    }

    // 尾头对比
    if (oldEndVNode.key === newStartVNode.key) {
      patch(oldEndVNode, newStartVNode, EL);
      EL.insertBefore(oldEndVNode.el as Node, oldStartVNode.el as Node);
      oldEndIdx--;
      newStartIdx++;
      continue;
    }

    // 其他: 默认使用 newStartVNode.key 查找
    const oldTargetKeyIdx = keyToOldIdxMap.get(newStartVNode.key);
    if (oldTargetKeyIdx !== undefined) {
      // 找到旧节点 移动 DOM
      const oldTargetVNode = oldChildren[oldTargetKeyIdx] as VNode;
      patch(oldTargetVNode, newStartVNode, EL);
      EL.insertBefore(oldTargetVNode.el as Node, oldStartVNode.el as Node);
    } else {
      // 未找到,创建新节点
      mount(newStartVNode, EL, oldStartVNode.el as HTMLElement);
    }
    newStartIdx++;
  }

  // 剩余节点处理
  // 新列表有剩余节点, 插入
  while (newStartIdx <= newEndIdx) {
    mount(newChildren[newStartIdx] as VNode, EL, oldChildren[oldStartIdx]?.el as HTMLElement);
    newStartIdx++;
  }

  // 旧列表有剩余节点则删除
  while (oldStartIdx <= oldEndIdx) {
    EL.removeChild(oldChildren[oldStartIdx]?.el as Node);
    oldStartIdx++;
  }
};

双端对比算法是 Vue 2.x 中使用的高效列表更新算法,它通过头尾指针的方式进行对比,减少 DOM 操作:

  1. 头头对比:如果新旧列表头部节点相同,直接更新

  2. 尾尾对比:如果新旧列表尾部节点相同,直接更新

  3. 头尾对比:如果旧列表头部节点与新列表尾部节点相同,移动节点到尾部

  4. 尾头对比:如果旧列表尾部节点与新列表头部节点相同,移动节点到头部

  5. 其他情况:使用 key 查找旧节点,找到则移动,未找到则创建新节点

  6. 处理剩余节点:新列表有剩余则插入,旧列表有剩余则删除

总结

虚拟 DOM 是 Vue 等现代前端框架的核心技术之一,它通过抽象真实 DOM,减少直接操作 DOM 的次数,从而提高应用性能。本实现展示了虚拟 DOM 的基本原理,包括虚拟节点的创建、挂载、更新等核心功能,以及双端对比算法的实现。