双语面试:实现一个简化版的 Virtual DOM diff 函数

6 阅读9分钟

面试题 Interview Question

实现一个简化版的 Virtual DOM diff 函数 diff,用于比较两个 Virtual DOM 树(以对象表示)并返回它们之间的差异(patches)
Implement a simplified Virtual DOM diff function diff that compares two Virtual DOM trees (represented as JavaScript objects) and returns the differences (patches) between them.

要求 Requirements

  1. diff(oldTree, newTree) 接收两个参数,分别代表旧的 Virtual DOM 树和新的 Virtual DOM 树(均为嵌套对象结构)。
    diff(oldTree, newTree) takes two parameters representing the old Virtual DOM tree and the new Virtual DOM tree, both as nested JavaScript object structures.

  2. Virtual DOM 树节点格式示例(示意):
    The Virtual DOM tree node format example (for illustration):

    {
      type: 'div',
      props: { id: 'container' },
      children: [
        { type: 'h1', props: {}, children: ['Title'] },
        { type: 'p', props: {}, children: ['Content'] }
      ]
    }
    

    每个节点包含 type(标签名或文本标识)、props(属性对象)、以及 children(子节点数组或文本) Each node contains a type (tag name or text identifier), props (an object of attributes), and children (an array of child nodes or text).

  3. 比较两个节点时需考虑以下三种变化类型:
    When comparing two nodes, the following three types of changes need to be considered:

    • 节点类型不同:如果 oldNode.type !== newNode.type,则生成替换整个节点的 patch。
      Different node types: If oldNode.type !== newNode.type, generate a patch to replace the entire node.
    • 属性变化:如果相同类型节点的 props 对象不同,生成更新属性的 patch,包括新增、删除或修改属性。
      Props changes: If the same-type nodes have different props objects, generate attribute-update patches (add, remove, or update props).
    • 子节点变化:递归比较子数组,根据索引定位,生成子节点 diff,对应插入、删除或修改操作。
      Children changes: Recursively compare the children arrays by index to generate diff for insertion, deletion, or modification of child nodes.
  4. 返回值为一个对象 patches,其键为节点在树中的唯一路径标识(如 "0", "0.1", "0.1.2"),值为对应位置的操作列表(例如 { type: 'REPLACE', node: newNode }, { type: 'PROPS', props: {...} }, { type: 'TEXT', text: 'Hello' })。
    The return value should be an object patches where keys are unique path identifiers in the tree (e.g., "0", "0.1", "0.1.2"), and values are lists of operations for that position (e.g., { type: 'REPLACE', node: newNode }, { type: 'PROPS', props: {...} }, { type: 'TEXT', text: 'Hello' }).

  5. 不要依赖第三方库(如 React、自定义哈希函数等),可直接操作 JavaScript 对象和数组。
    Do not rely on third-party libraries (e.g., React or custom hashing functions); operate directly on plain JavaScript objects and arrays .

  6. 代码应保持可读性,使用函数式实现。
    The code should remain readable and be implemented in a functional style.

参考答案 Reference Solution

下面示例实现一个基本的 diff 函数,生成 patches 对象。 The following example implements a basic diff function that generates a patches object.

// 定义 patch 类型常量
const PATCH_TYPES = {
  REPLACE: 'REPLACE', // 替换整个节点
  PROPS:   'PROPS',   // 更新属性
  TEXT:    'TEXT',    // 文本变更
  REORDER: 'REORDER'  // 子节点插入/删除
};

/**
 * 计算两个对象浅比较后的属性差异
 * @param {Object} oldProps 旧属性
 * @param {Object} newProps 新属性
 * @returns {Object} propPatches 包含要更新、删除或新增的属性
 */
function diffProps(oldProps = {}, newProps = {}) {
  const patches = {};
  // 查找要修改或删除的属性
  Object.keys(oldProps).forEach(key => {
    if (!newProps.hasOwnProperty(key)) {
      patches[key] = null; // 属性被删除
    } else if (oldProps[key] !== newProps[key]) {
      patches[key] = newProps[key]; // 属性值被修改
    }
  });
  // 查找要新增的属性
  Object.keys(newProps).forEach(key => {
    if (!oldProps.hasOwnProperty(key)) {
      patches[key] = newProps[key]; // 属性被新增
    }
  });
  return patches;
}

/**
 * 递归比较两个 Virtual DOM 树生成 patch 集合
 * @param {Object|string} oldNode 旧节点(可能为文本或对象)
 * @param {Object|string} newNode 新节点(可能为文本或对象)
 * @param {string} path 当前节点在树中的路径标识,例如 ''、'0.1' 等
 * @param {Object} patches 累积的 patch 对象
 */
function walk(oldNode, newNode, path, patches) {
  const currentPatches = [];

  // 1. 节点为 null 或 未定义,直接忽略
  if (oldNode == null && newNode == null) {
    return;
  }
  // 2. 类型不同时,替换整个节点
  if (oldNode && newNode && oldNode.type !== newNode.type) {
    currentPatches.push({ type: PATCH_TYPES.REPLACE, node: newNode });
  }
  // 3. 文本节点比较
  else if (typeof oldNode === 'string' && typeof newNode === 'string') {
    if (oldNode !== newNode) {
      currentPatches.push({ type: PATCH_TYPES.TEXT, text: newNode });
    }
  }
  // 4. 相同类型的元素节点,属性比较 & 子节点递归
  else if (oldNode && newNode && oldNode.type === newNode.type) {
    // 比较属性差异
    const propDiffs = diffProps(oldNode.props, newNode.props);
    if (Object.keys(propDiffs).length > 0) {
      currentPatches.push({ type: PATCH_TYPES.PROPS, props: propDiffs });
    }
    // 递归比较子节点
    diffChildren(oldNode.children || [], newNode.children || [], path, patches, currentPatches);
  }

  // 如果当前节点有任何 patch,则将其存入 patches 对象
  if (currentPatches.length > 0) {
    patches[path] = currentPatches;
  }
}

/**
 * 比较两个子节点列表,递归调用 walk,生成索引路径的 patches
 * @param {Array} oldChildren 旧子节点数组
 * @param {Array} newChildren 新子节点数组
 * @param {string} path 父节点路径
 * @param {Object} patches 累积的 patch 对象
 * @param {Array} currentPatches 当前节点的补丁列表,可能包含子节点 reorder 操作
 */
function diffChildren(oldChildren, newChildren, path, patches, currentPatches) {
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  // 遍历所有可能的索引
  for (let i = 0; i < maxLen; i++) {
    const oldChild = oldChildren[i];
    const newChild = newChildren[i];
    const currentPath = path ? `${path}.${i}` : `${i}`;
    if (oldChild && newChild) {
      // 递归比较同索引子节点
      walk(oldChild, newChild, currentPath, patches);
    } else if (newChild && !oldChild) {
      // 插入新节点
      currentPatches.push({ type: PATCH_TYPES.REORDER, insert: { index: i, node: newChild } });
    } else if (oldChild && !newChild) {
      // 删除旧节点
      currentPatches.push({ type: PATCH_TYPES.REORDER, remove: { index: i } });
    }
  }
}

/**
 * diff 函数入口,用于初始化 patch 集合并调用 walk
 * @param {Object|string} oldTree 旧 Virtual DOM 树
 * @param {Object|string} newTree 新 Virtual DOM 树
 * @returns {Object} patches 包含每个路径上的操作列表
 */
function diff(oldTree, newTree) {
  const patches = {};
  walk(oldTree, newTree, '', patches);
  return patches;
}

解释 Explanation

  1. PATCH_TYPES 定义了四种补丁类型:
    PATCH_TYPES defines four types of patches:

    • REPLACE:替换整个节点(包括元素节点或文本)。
      REPLACE: Replaces the entire node (either an element or a text node).
    • PROPS:更新属性,包括新增、删除或修改属性。
      PROPS: Updates properties, including adding, deleting, or modifying attributes.
    • TEXT:文本节点内容变化。
      TEXT: Indicates changes in the content of a text node.
    • REORDER:子节点插入或删除(重排操作)。
      REORDER: Represents the insertion or deletion (reordering) of child nodes.
  2. diffProps 函数通过浅比较两个属性对象,产生一个差异映射,值为 null 表示删除该属性。
    The diffProps function performs a shallow comparison between two property objects and produces a difference map. A value of null indicates that the property should be removed.

  3. walk 函数是递归核心:
    The walk function is the core of the recursion:

    • 若节点类型不同,生成 REPLACE 补丁;若都是字符串且内容不同,生成 TEXT 补丁。
      If node types differ, a REPLACE patch is generated. If both are strings but their content differs, a TEXT patch is generated.
    • 若节点类型相同且为元素节点,则比较属性差异并递归比较子节点。
      If node types are the same and both are element nodes, it compares property differences and recursively compares child nodes.
    • 每次对比后,如 currentPatches 非空,则以路径 path 为键将补丁列表写入 patches 对象。
      After each comparison, if currentPatches is not empty, the patch list is written into the patches object using the current path as the key.
  4. diffChildren 依次比较旧子节点与新子节点:
    diffChildren compares old and new child nodes sequentially:

    • 如果两者同在,则递归调用 walk
      If both exist, it recursively calls walk.
    • 如果新子节点存在而旧子节点已被删除,则生成 REORDER 插入补丁。
      If the new child exists but the old one does not, a REORDER insert patch is generated.
    • 如果旧子节点存在而新子节点不存在,则生成 REORDER 删除补丁。
      If the old child exists but the new one does not, a REORDER remove patch is generated.
  5. 初次调用 diff(oldTree, newTree) 时,path 传空字符串 '',表示根节点;后续子节点路径以 "0", "0.1", "0.1.2" 等形式标识,以便调用者在实际 DOM 上执行对应操作。
    When initially calling diff(oldTree, newTree), the path is passed as an empty string '', representing the root node. Subsequent child node paths are identified as "0", "0.1", "0.1.2", etc., allowing the caller to apply corresponding operations on the actual DOM.

示例 Example

以下示例展示如何使用 diff 函数比较两棵简单的 Virtual DOM 树,并查看结果:
The following example demonstrates how to use the diff function to compare two simple Virtual DOM trees and inspect the result:

// 旧树 (oldTree)
const oldTree = {
  type: 'div',
  props: { id: 'container' },
  children: [
    { type: 'h1', props: {}, children: ['Hello'] },
    { type: 'p', props: {}, children: ['World'] }
  ]
};

// 新树 (newTree):将 h1 文本改为 Hi,并新增一个 span
const newTree = {
  type: 'div',
  props: { id: 'container', className: 'main' }, // 新增 className
  children: [
    { type: 'h1', props: {}, children: ['Hi'] },            // 文本变更
    { type: 'p', props: {}, children: ['World'] },
    { type: 'span', props: {}, children: ['!'] }             // 新增节点
  ]
};

// 计算差异
const patches = diff(oldTree, newTree);
console.log(JSON.stringify(patches, null, 2));
/*
预期输出 (示例):
{
  "": [
    { "type": "PROPS", "props": { "className": "main" } }
  ],
  "0": [
    { "type": "TEXT", "text": "Hi" }
  ],
  "2": [
    { "type": "REORDER", "insert": { "index": 2, "node": { type: "span", props: {}, children: ["!"] } } }
  ]
}
*/
  • 在根路径 "",属性从 { id: "container" } 变为 { id: "container", className: "main" },生成 PROPS 补丁
    At the root path "", the properties change from { id: "container" } to { id: "container", className: "main" }, generating a PROPS patch.
  • 在路径 "0"(对应第一个子节点 h1),文本从 "Hello" 变为 "Hi",生成 TEXT 补丁
    At path "0" (corresponding to the first child node h1), the text changes from "Hello" to "Hi", generating a TEXT patch.
  • 在路径 "2"(新插入的第三个子节点),旧树没有该节点,生成 REORDER 插入补丁
    At path "2" (newly inserted third child node), the old tree does not have this node, so a REORDER insert patch is generated.

面试考察点 Interview Focus

  1. Virtual DOM 原理:理解 Virtual DOM 在 React 中的作用,包括如何通过内存中的 JS 对象树比对并最小化更新实际 DOM .
    Principle of the Virtual DOM: Understand the role of the Virtual DOM in React, including how it compares trees in memory using JavaScript objects and minimizes updates to the real DOM.
  2. Diff 算法思想:掌握基本的树形结构对比思路,包括节点类型判断、属性对比、文本节点处理和子节点递归等 .
    Diff Algorithm Concepts: Master the basic ideas of comparing tree structures, such as node type checks, property comparison, text node handling, and recursive traversal of child nodes.
  3. Patch 数据结构设计:设计能准确描述各种变更(替换、属性变更、文本更新、重排)的补丁格式,以便后续直接应用到真实 DOM 上 .
    Patch Data Structure Design: Design patch formats that accurately describe changes (replacement, property updates, text changes, and reordering) so they can be directly applied to the real DOM.
  4. JavaScript 对象与数组操作:熟练使用 JS 对象浅比较 (diffProps),以及基于索引的数组遍历实现子节点对比 .
    JavaScript Object & Array Manipulation: Be proficient in shallow comparison of JS objects (diffProps) and array traversal based on indexes to compare child nodes.
  5. 可扩展性与性能考量:讨论如何提升 diff 性能(如 keyed diff、增量渲染)、如何处理大规模树或动态增删节点,以及如何生成最小补丁集以避免多余操作 .
    Scalability & Performance Considerations: Discuss how to improve diff performance (e.g., keyed diff, incremental rendering), handle large trees or dynamic node additions/deletions, and generate minimal patch sets to avoid redundant operations.
  6. 错误处理与边界情况:处理 nullundefined 节点、非数组 children、节点类型异常等情况,确保函数鲁棒性;必要时进行类型检查,以免遍历空值导致运行时错误。
    Error Handling & Edge Cases: Handle null or undefined nodes, non-array children, and abnormal node types to ensure function robustness. Include type checks when necessary to prevent runtime errors during traversal.
  7. 实际 DOM 更新:面试官可进一步询问如何将 patches 应用到真实的 DOM(例如使用 document.createElementreplaceChildsetAttributeremoveChild 等原生 API)。
    Real DOM Updates: Interviewers may further ask how to apply patches to the real DOM (e.g., using native APIs like document.createElement, replaceChild, setAttribute, and removeChild).
  8. 高级优化与框架启示:了解 React Fiber、调度优先级、分片更新等高级主题;讨论为何框架选用分层 diff 策略以保证用户交互流畅 .
    Advanced Optimizations & Framework Insights: Learn about advanced topics such as React Fiber, scheduling priorities, and concurrent rendering. Discuss why frameworks adopt layered diff strategies to ensure smooth user interactions.