从0开始设计一个树和扁平数组的双向同步方案

0 阅读15分钟

从0开始设计一个树和扁平数组的双向同步方案

背景:在前端开发中,展示和操作大型树形结构(如十万级节点的文件树、组织架构图)时,传统递归渲染 DOM 会导致严重的性能瓶颈。为了结合虚拟滚动技术,我们需要将树“扁平化”为一维数组。本文将从0开始,推导并设计一个支持“逻辑树”与“视图扁平数组”实时、高效双向同步的方案。


1. 核心操作提炼

在开始设计数据结构之前,我们先明确业务需求。一个完备的树和数组双向同步方案,必须支持以下核心操作:

  1. 初始化构建:将原始树结构转换为扁平数组,并建立辅助索引。
  2. 树添加子节点:在指定节点的子节点列表末尾添加新节点,并同步到数组。
  3. 树添加兄弟节点:在指定节点之后添加一个兄弟节点,并同步到数组。
  4. 树删除节点:删除指定节点及其所有子孙节点,并同步到数组。
  5. 树移动节点:将某棵子树从一个父节点移动到另一个父节点下(拖拽操作),并同步到数组。
  6. 树修改节点属性:修改节点的非结构属性(如名称、展开状态等),并触发视图更新。

2. 数据结构设计

为了让上述操作在树和数组中都能高效执行,我们需要精心设计节点的数据结构,并引入辅助索引(空间换时间)。

2.1 节点结构 (TreeNode)

最基础的树节点通常只包含 idchildren

interface TreeNode {
  id: string | number;
  children: TreeNode[];
  // ... 其他业务数据属性
}

但在我们的双向同步方案中,为了实现高效的查找和回溯,仅有这两个属性是远远不够的。在接下来的算法设计中,我们会根据具体的操作场景,一步步引入并添加必要的辅助属性和辅助 Map 索引(空间换时间)。

2.2 两大数据载体

  1. Tree (Array):原始的逻辑树结构,用于维护业务层级关系。
  2. flatArray (Array):基于 DFS 遍历生成的扁平数组,直接绑定到 UI 虚拟滚动组件上用于渲染。

2.3 辅助索引的引入思路

仅仅依靠树和数组依然不够,如果每次操作都要去遍历寻找目标节点,性能会大打折扣。为了将查找复杂度降至 O(1)O(1),我们会在接下来的算法设计中,根据具体的操作场景,一步步引入并建立必要的辅助 Map 索引(空间换时间)。


3. 算法与逻辑构思

接下来,我们针对提炼的每一个操作,进行详细的算法与逻辑设计。

3.1 初始化构建

逻辑构思:首先需要将一棵树展开为一维数组。在遍历过程中,为了支撑后续的高效查找,我们自然地引入前两个辅助索引:

  1. treeNodeMap (Map<id, TreeNode>):将节点 ID 映射到节点对象的引用,保证后续任何操作都能 O(1)O(1) 定位节点。
  2. flatIndexMap (Map<id, index>):记录节点在 flatArray 中的数组下标,用于后续在数组中快速进行切片(Splice)操作。

引入树节点属性 subTreeSize: 为了能在扁平数组中快速确定一棵子树占据的切片范围,我们需要为每个树节点引入一个核心字段 subTreeSize(以该节点为根的子树的节点总数,包含自身)。在 DFS(深度优先遍历)生成的扁平数组中,一棵子树的所有节点是绝对连续的。subTreeSize 就是这段连续区间的长度。

算法设计:采用深度优先遍历 (DFS)

  • 时间复杂度O(N)O(N),其中 NN 为树中节点的总数。需要遍历每个节点一次。
  1. 遍历过程中,将节点引用存入 treeNodeMap
  2. 将节点 pushflatArray 中,并将此时的数组长度(减1)作为 index 存入 flatIndexMap
  3. 在 DFS 回溯阶段,自底向上累加子节点的 subTreeSize,最终得出每个节点的正确规模。
function initTreeFlat(tree: TreeFlatNode[]) {
  treeData.value = tree;
  flatArray.value = [];
  treeNodeMap.clear();
  flatIndexMap.clear();
  siblingIndexMap.clear();

  const traverse = (
    nodes: TreeFlatNode[],
    parentId: TreeNodeId | null | undefined = null,
  ) => {
    let currentLevelSize = 0;
    nodes.forEach((node, index) => {
      node.parentId = parentId;
      if (!node.children) node.children = [];

      treeNodeMap.set(node.id, node);
      siblingIndexMap.set(node.id, index);

      flatArray.value.push(node);
      flatIndexMap.set(node.id, flatArray.value.length - 1);

      let childrenSize = 0;
      if (node.children && node.children.length > 0) {
        childrenSize = traverse(node.children, node.id);
      }

      node.subTreeSize = 1 + childrenSize;
      currentLevelSize += node.subTreeSize;
    });
    return currentLevelSize;
  };

  if (treeData.value && treeData.value.length > 0) {
    traverse(treeData.value);
  }

  return flatArray.value;
}

3.2 树添加子节点(追加到末尾)

逻辑构思:逻辑树中,只需往 children 里 push;但在扁平数组中,新节点应该紧挨着该父节点整棵现有子树的末尾插入。 算法设计

  • 时间复杂度O(N)O(N),主要受限于 flatArraysplice 插入操作和后续所有节点在 flatIndexMap 中的更新遍历。
  1. 树更新:通过 treeNodeMap 找到父节点,往 children 追加新节点。更新新节点的 subTreeSize = 1。向上回溯更新所有祖先的 subTreeSize += 1
  2. 寻找数组插入点
    • 若父节点无子节点:插入点 = flatIndexMap.get(parentId) + 1
    • 若有子节点:找到最后一个子节点 prev。插入点 = flatIndexMap.get(prev.id) + prev.subTreeSize
  3. 数组更新:使用 flatArray.splice(插入点, 0, newNode) 插入。
  4. 索引更新:新节点记入 flatIndexMap。由于数组元素后移,遍历 flatArray 从插入点之后的所有节点,将其在 flatIndexMap 中的值 +1
// 辅助函数:向上更新祖先节点的 subTreeSize
const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
  let current: TreeFlatNode | null | undefined = node;
  while (current) {
    if (current.subTreeSize !== undefined) {
      current.subTreeSize += delta;
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }
};

// 辅助函数:更新指定索引之后的 flatIndexMap
const updateFlatIndexMap = (startIndex: number) => {
  for (let i = startIndex; i < flatArray.value.length; i++) {
    const node = flatArray.value[i];
    flatIndexMap.set(node.id, i);
  }
};

// 辅助函数:扁平化并注册新节点及其子树
const flattenAndRegister = (
  node: TreeFlatNode,
  parentId: TreeNodeId | null | undefined,
  flatList: TreeFlatNode[],
) => {
  node.parentId = parentId;
  if (!node.children) node.children = [];

  flatList.push(node);
  treeNodeMap.set(node.id, node);

  let size = 1;
  if (node.children.length > 0) {
    node.children.forEach((child, index) => {
      siblingIndexMap.set(child.id, index);
      size += flattenAndRegister(child, node.id, flatList);
    });
  }
  node.subTreeSize = size;
  return size;
};

const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  if (!currentNode.children) currentNode.children = [];

  // 1. 树更新:添加到父节点的 children
  currentNode.children.push(newNode);
  const siblingIndex = currentNode.children.length - 1;
  siblingIndexMap.set(newNode.id, siblingIndex);

  // 2. 准备新节点(及其可能包含的子树)的扁平数组
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, currentNode.id, newFlatNodes);

  // 3. 寻找数组插入点并更新数组
  // 插入点在 currentNode 现有的子树之后
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 4. 索引更新与向上回溯
  updateFlatIndexMap(insertIndex);
  updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

  triggerUpdate();
};

3.3 树添加兄弟节点

逻辑构思:与添加子节点类似,区别在于插入位置是紧跟在目标兄弟节点的子树之后。 在更新逻辑树时,我们需要把新节点插入到父节点的 children 数组的特定位置,这就需要知道当前节点的父节点,以及当前兄弟节点的索引。

引入树节点属性 parentId: 为了能够在操作时(如添加兄弟节点、删除节点)方便地向上找到父节点,我们需要在初始化阶段为每个树节点注入 parentId 属性(根节点的 parentId 可以为 null)。

引入新索引 siblingIndexMap: 为了避免每次去 children 数组里执行 O(N)O(N) 的查找来获取当前兄弟节点的索引,我们需要引入第三个辅助索引(在实际开发中,它同样需要在初始化时收集,并在其他增删操作中同步维护):3. siblingIndexMap (Map<id, index>):记录节点在父节点 children 数组中的位置。

算法设计

  • 时间复杂度O(N)O(N),同样受限于数组插入时的移位操作,以及后续节点在相关 Map 索引中的更新。
  1. 树更新:通过 parentId 找到父节点,通过 siblingIndexMap 瞬间找到当前节点在 children 中的位置,在其后插入新节点。向上回溯更新祖先 subTreeSize += 1
  2. 寻找数组插入点
    • 插入点 = flatIndexMap.get(当前节点.id) + 当前节点.subTreeSize
  3. 数组更新flatArray.splice(插入点, 0, newNode)
  4. 索引更新:后续节点 flatIndexMap+1,同时将新节点记入 siblingIndexMap,并更新插入位置之后所有兄弟节点的 siblingIndexMap(值 +1)。
const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
  const parentId = currentNode.parentId;
  let parentNode: TreeFlatNode | null | undefined = null;
  let childrenArray: TreeFlatNode[] | null = null;

  if (parentId) {
    parentNode = treeNodeMap.get(parentId);
    childrenArray = parentNode!.children!;
  } else {
    childrenArray = treeData.value;
  }

  // 1. Insert into children array
  const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
  const insertIndexInChildren = currentSiblingIndex + 1;
  childrenArray.splice(insertIndexInChildren, 0, newNode);

  // 2. Update sibling indices for subsequent siblings
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  // 3. Prepare flat list
  const newFlatNodes: TreeFlatNode[] = [];
  flattenAndRegister(newNode, parentId, newFlatNodes);

  // 4. Insert into flatArray
  // Insert after currentNode's subtree
  const insertIndex =
    (flatIndexMap.get(currentNode.id) as number) +
    (currentNode.subTreeSize as number);
  flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

  // 5. Update maps and sizes
  updateFlatIndexMap(insertIndex);
  if (parentNode) {
    updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
  }

  triggerUpdate();
};

3.4 树删除节点(批量删除策略)

逻辑构思:如果逐个删除子树节点,由于 Array.prototype.splice 的底层实现需要将删除位置之后的所有元素向前移动以填补空缺,每次删除一个元素都会产生 O(N)O(N) 的移位开销(NN 为数组总长度)。如果子树包含 MM 个节点,逐个删除会导致大量的重复移位操作,总时间复杂度将退化为 O(N×M)O(N \times M),性能极差。利用 DFS 连续性特性,我们直接在扁平数组中“切掉”这一整段,只需要进行一次 O(N)O(N) 的数组移位操作即可。 算法设计

  • 时间复杂度O(N)O(N),一次性 splice 删除了 MM 个节点,产生了 O(N)O(N) 的数组元素前移开销,以及遍历更新剩余节点索引的 O(N)O(N) 开销。
  1. 获取规模:待删除节点数 count = node.subTreeSize
  2. 数组更新:起点 startIndex = flatIndexMap.get(node.id)。直接执行 flatArray.splice(startIndex, count)
  3. 树与索引更新
    • treeNodeMap 移除这 count 个节点。
    • 从父节点的 children 中移除目标节点,更新后续兄弟节点的 siblingIndexMap(值 -1)。
    • 祖先节点的 subTreeSize -= count
    • 遍历数组剩余元素,更新后续节点的 flatIndexMap(值 -count)。
const deleteNode = (node: TreeFlatNode) => {
  const { id, subTreeSize, parentId } = node;
  const startIndex = flatIndexMap.get(id) as number;

  // 1. Remove from flatArray
  flatArray.value.splice(startIndex, subTreeSize as number);

  // 2. Remove from parent's children
  if (parentId) {
    const parent = treeNodeMap.get(parentId)!;
    const index = parent.children!.findIndex((c) => c.id === id);
    if (index > -1) {
      parent.children!.splice(index, 1);
      // Update sibling indices
      for (let i = index; i < parent.children!.length; i++) {
        siblingIndexMap.set(parent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
  } else {
    // Root node
    if (treeData.value) {
      const index = treeData.value.findIndex((c) => c.id === id);
      if (index > -1) {
        treeData.value.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }
  }

  // 3. Update flatIndexMap
  updateFlatIndexMap(startIndex);

  // 4. Cleanup maps
  // Ideally we should recursively delete from maps, but for now simple delete is okay
  // as long as we don't reuse IDs or query deleted nodes.
  treeNodeMap.delete(id);
  flatIndexMap.delete(id);
  siblingIndexMap.delete(id);

  triggerUpdate();
};

3.5 树移动节点 (Cut & Paste)

逻辑构思:移动本质上是“先删后加”,但为了保留对象的引用和内部状态(避免触发大量的 UI 卸载/重挂载),我们采用“剪切-粘贴”策略。 算法设计

  • 时间复杂度O(N)O(N),相当于执行了一次删除和一次添加,需要进行两次数组元素的移位操作和索引更新。
  1. 剪切 (Detach)
    • 规模 count = node.subTreeSize。起点 oldIndex = flatIndexMap.get(node.id)
    • 提取子树:subTreeNodes = flatArray.splice(oldIndex, count)
    • 更新原父链的 subTreeSize -= count,更新原位置后续节点的 flatIndexMap。清理原父节点的 children
  2. 粘贴 (Attach)
    • 按照“添加节点”的逻辑计算出新的插入点 newIndex
    • 整体插入:flatArray.splice(newIndex, 0, ...subTreeNodes)
    • 更新新父链的 subTreeSize += count,更新新位置后续节点的 flatIndexMap。更新新父节点的 children
const moveNode = (
  node: TreeFlatNode,
  targetNode: TreeFlatNode,
  placement: "before" | "after" | "inner",
) => {
  if (!node || !targetNode) return;

  let current: TreeFlatNode | null | undefined = targetNode;
  while (current) {
    if (current.id === node.id) {
      throw new Error("Cannot move a node into itself or its descendants");
    }
    if (current.parentId) {
      current = treeNodeMap.get(current.parentId);
    } else {
      current = null;
    }
  }

  const { id, subTreeSize, parentId: oldParentId } = node;
  const oldIndex = flatIndexMap.get(id) as number;

  // 1. Cut (Detach)
  const subTreeNodes = flatArray.value.splice(oldIndex, subTreeSize as number);

  if (oldParentId) {
    const oldParent = treeNodeMap.get(oldParentId)!;
    const childIndex = oldParent.children!.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      oldParent.children!.splice(childIndex, 1);
      for (let i = childIndex; i < oldParent.children!.length; i++) {
        siblingIndexMap.set(oldParent.children![i].id, i);
      }
    }
    updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
  } else {
    const childIndex = treeData.value.findIndex((c) => c.id === id);
    if (childIndex > -1) {
      treeData.value.splice(childIndex, 1);
      for (let i = childIndex; i < treeData.value.length; i++) {
        siblingIndexMap.set(treeData.value[i].id, i);
      }
    }
  }

  // 2. Paste (Attach)
  let newParentId: TreeNodeId | null | undefined = null;
  let newParent: TreeFlatNode | null | undefined = null;
  let insertIndexInChildren = 0;
  let newFlatIndex = 0;
  let childrenArray: TreeFlatNode[] | null = null;

  if (placement === "inner") {
    newParentId = targetNode.id;
    newParent = targetNode;
    if (!newParent.children) newParent.children = [];
    childrenArray = newParent.children;
    insertIndexInChildren = childrenArray.length;

    newFlatIndex =
      (flatIndexMap.get(targetNode.id) as number) +
      (targetNode.subTreeSize as number);
    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  } else {
    newParentId = targetNode.parentId;
    if (newParentId) {
      newParent = treeNodeMap.get(newParentId);
      childrenArray = newParent!.children!;
    } else {
      childrenArray = treeData.value;
    }

    const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
    insertIndexInChildren =
      placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

    if (placement === "before") {
      newFlatIndex = flatIndexMap.get(targetNode.id) as number;
    } else {
      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
    }

    if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
      newFlatIndex -= subTreeSize as number;
    }
  }

  childrenArray.splice(insertIndexInChildren, 0, node);
  for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
    siblingIndexMap.set(childrenArray[i].id, i);
  }

  flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

  node.parentId = newParentId;

  if (newParent) {
    updateSubTreeSizeUpwards(newParent, subTreeSize as number);
  }

  updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

  triggerUpdate();
};

3.6 树修改节点属性

逻辑构思:仅修改非结构属性,不影响树形态。 算法设计:通过 treeNodeMapO(1)O(1) 复杂度拿到节点引用,直接修改。由于 flatArray 中保存的是同一个对象的引用,借助 Vue/React 的响应式机制,UI 会自动局部更新。

  • 时间复杂度O(1)O(1),通过 Map 瞬间定位,修改属性即完成操作,没有额外的遍历或数组移位开销。
const handleEdit = (data) => {
  data.label = "new value";
  emit("update:treeData", [...treeData.value]);
};

4. 虚拟滚动的实现与结合

完成了底层数据的双向同步后,我们在视图层引入虚拟滚动(Virtual Scrolling)以彻底解决 DOM 节点过多的性能问题。

4.1 结合 vue-virtual-scroll-list 实现虚拟滚动

在实际开发中,我们通常不需要手写虚拟滚动逻辑,可以直接借助成熟的第三方库(如 vue-virtual-scroll-list)来实现。

使用 vue-virtual-scroll-list 非常简单,我们只需要将维护好的扁平数组 flatArray 传递给组件即可:

<template>
  <virtual-list
    class="tree-virtual-list"
    :data-key="'id'"
    :data-sources="flatArray"
    :data-component="TreeNodeComponent"
    :estimate-size="30"
  />
</template>

<script setup>
import VirtualList from "vue-virtual-scroll-list";
import TreeNodeComponent from "./TreeNodeComponent.vue";
// ... 维护 flatArray 的逻辑
</script>

<style scoped>
.tree-virtual-list {
  height: 500px;
  overflow-y: auto;
}
</style>

4.2 结合双向同步方案的优势

  1. 直接驱动:上述设计的 flatArray 就是一个一维的响应式数组。每次发生增删改移操作后,flatArray 会实时发生变化(长度变化或元素变更)。
  2. 自动映射:虚拟滚动组件直接监听 flatArray 的长度变化,重新计算 totalHeight 和视图区间,开发者无需手动干预 DOM。
  3. 层级视觉还原:在渲染截取出来的节点时,可以结合节点在树中的层级关系动态计算内边距,在视觉上完美还原树的结构。
  4. 折叠/展开逻辑:树的折叠和展开,本质上就是对该节点的子树进行“批量删除”和“重新添加子节点”的操作。复用上述的 3.4 和 3.2 逻辑,配合虚拟滚动,即使展开包含几万个节点的目录,也只是一次数组 splice 操作,界面不会有任何卡顿。

完整代码

将数据结构和树的操作封装成组合式函数,便于复用。

useTreeFlat.ts源码:

import { ref, watch } from "vue";
import type {
  TreeFlatNode,
  TreeNodeMap,
  FlatIndexMap,
  SiblingIndexMap,
  TreeNodeId,
} from "./types";

export const useTreeFlat = (props: any, emit?: any) => {
  const flatArray = ref<TreeFlatNode[]>([]);
  const treeData = ref<TreeFlatNode[]>([]); // Store reference to the source tree array
  const treeNodeMap: TreeNodeMap = new Map();
  const flatIndexMap: FlatIndexMap = new Map();
  const siblingIndexMap: SiblingIndexMap = new Map();

  // Helper to update ancestor sizes
  const updateSubTreeSizeUpwards = (node: TreeFlatNode, delta: number) => {
    let current: TreeFlatNode | null | undefined = node;
    while (current) {
      if (current.subTreeSize !== undefined) {
        current.subTreeSize += delta;
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }
  };

  // Helper to update flatIndexMap for nodes after a certain index
  const updateFlatIndexMap = (startIndex: number) => {
    for (let i = startIndex; i < flatArray.value.length; i++) {
      const node = flatArray.value[i];
      flatIndexMap.set(node.id, i);
    }
  };

  // Recursive function to flatten a node and its children,
  // setting metadata and populating maps for NEW nodes.
  const flattenAndRegister = (
    node: TreeFlatNode,
    parentId: TreeNodeId | null | undefined,
    flatList: TreeFlatNode[],
  ) => {
    node.parentId = parentId;
    if (!node.children) node.children = [];

    flatList.push(node);
    treeNodeMap.set(node.id, node);
    // Note: flatIndexMap will be set after we insert into flatArray to be correct.

    let size = 1;
    if (node.children.length > 0) {
      node.children.forEach((child, index) => {
        siblingIndexMap.set(child.id, index);
        size += flattenAndRegister(child, node.id, flatList);
      });
    }
    node.subTreeSize = size;
    return size;
  };

  const initTreeFlat = (tree: TreeFlatNode[]) => {
    treeData.value = tree;
    flatArray.value = [];
    treeNodeMap.clear();
    flatIndexMap.clear();
    siblingIndexMap.clear();

    const traverse = (
      nodes: TreeFlatNode[],
      parentId: TreeNodeId | null | undefined = null,
    ) => {
      let currentLevelSize = 0;
      nodes.forEach((node, index) => {
        node.parentId = parentId;
        if (!node.children) node.children = [];

        treeNodeMap.set(node.id, node);
        siblingIndexMap.set(node.id, index);

        flatArray.value.push(node);
        flatIndexMap.set(node.id, flatArray.value.length - 1);

        let childrenSize = 0;
        if (node.children && node.children.length > 0) {
          childrenSize = traverse(node.children, node.id);
        }

        node.subTreeSize = 1 + childrenSize;
        currentLevelSize += node.subTreeSize;
      });
      return currentLevelSize;
    };

    if (treeData.value && treeData.value.length > 0) {
      traverse(treeData.value);
    }

    return flatArray.value;
  };

  // Watch props for changes
  if (props && props.treeData) {
    watch(
      () => props.treeData,
      (newVal) => {
        const newStr = JSON.stringify(newVal);
        const oldStr = JSON.stringify(treeData.value);
        if (newStr !== oldStr) {
          const newData = JSON.parse(newStr);
          initTreeFlat(newData);
        }
      },
      { immediate: true, deep: true },
    );
  }

  const triggerUpdate = () => {
    treeData.value = [...treeData.value];
    if (emit) {
      emit("update:treeData", treeData.value);
    }
  };

  const addChildNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    if (!currentNode.children) currentNode.children = [];

    // 1. Add to parent's children
    currentNode.children.push(newNode);
    const siblingIndex = currentNode.children.length - 1;
    siblingIndexMap.set(newNode.id, siblingIndex);

    // 2. Prepare flat list for new node(s)
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, currentNode.id, newFlatNodes);

    // 3. Insert into flatArray
    // Insert after the last node of currentNode's EXISTING subtree.
    // currentNode.subTreeSize currently includes existing children (before this new one is fully accounted for in the loop? No, subTreeSize is property).
    // Wait, updateSubTreeSizeUpwards is called AFTER. So currentNode.subTreeSize is the OLD size.
    // So insertion point is flatIndexMap[currentNode.id] + currentNode.subTreeSize.
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 4. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    updateSubTreeSizeUpwards(currentNode, newNode.subTreeSize as number);

    triggerUpdate();
  };

  const addSiblingNode = (currentNode: TreeFlatNode, newNode: TreeFlatNode) => {
    const parentId = currentNode.parentId;
    let parentNode: TreeFlatNode | null | undefined = null;
    let childrenArray: TreeFlatNode[] | null = null;

    if (parentId) {
      parentNode = treeNodeMap.get(parentId);
      childrenArray = parentNode!.children!;
    } else {
      childrenArray = treeData.value;
    }

    // 1. Insert into children array
    const currentSiblingIndex = siblingIndexMap.get(currentNode.id) as number;
    const insertIndexInChildren = currentSiblingIndex + 1;
    childrenArray.splice(insertIndexInChildren, 0, newNode);

    // 2. Update sibling indices for subsequent siblings
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    // 3. Prepare flat list
    const newFlatNodes: TreeFlatNode[] = [];
    flattenAndRegister(newNode, parentId, newFlatNodes);

    // 4. Insert into flatArray
    // Insert after currentNode's subtree
    const insertIndex =
      (flatIndexMap.get(currentNode.id) as number) +
      (currentNode.subTreeSize as number);
    flatArray.value.splice(insertIndex, 0, ...newFlatNodes);

    // 5. Update maps and sizes
    updateFlatIndexMap(insertIndex);
    if (parentNode) {
      updateSubTreeSizeUpwards(parentNode, newNode.subTreeSize as number);
    }

    triggerUpdate();
  };

  const deleteNode = (node: TreeFlatNode) => {
    const { id, subTreeSize, parentId } = node;
    const startIndex = flatIndexMap.get(id) as number;

    // 1. Remove from flatArray
    flatArray.value.splice(startIndex, subTreeSize as number);

    // 2. Remove from parent's children
    if (parentId) {
      const parent = treeNodeMap.get(parentId)!;
      const index = parent.children!.findIndex((c) => c.id === id);
      if (index > -1) {
        parent.children!.splice(index, 1);
        // Update sibling indices
        for (let i = index; i < parent.children!.length; i++) {
          siblingIndexMap.set(parent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(parent, -(subTreeSize as number));
    } else {
      // Root node
      if (treeData.value) {
        const index = treeData.value.findIndex((c) => c.id === id);
        if (index > -1) {
          treeData.value.splice(index, 1);
          // Update sibling indices
          for (let i = index; i < treeData.value.length; i++) {
            siblingIndexMap.set(treeData.value[i].id, i);
          }
        }
      }
    }

    // 3. Update flatIndexMap
    updateFlatIndexMap(startIndex);

    // 4. Cleanup maps
    // Ideally we should recursively delete from maps, but for now simple delete is okay
    // as long as we don't reuse IDs or query deleted nodes.
    // For completeness, we should probably clear entries for all descendants.
    // But since they are removed from flatArray and parent's children, they are effectively gone.
    treeNodeMap.delete(id);
    flatIndexMap.delete(id);
    siblingIndexMap.delete(id);

    triggerUpdate();
  };

  const moveNode = (
    node: TreeFlatNode,
    targetNode: TreeFlatNode,
    placement: "before" | "after" | "inner",
  ) => {
    if (!node || !targetNode) return;

    let current: TreeFlatNode | null | undefined = targetNode;
    while (current) {
      if (current.id === node.id) {
        throw new Error("Cannot move a node into itself or its descendants");
      }
      if (current.parentId) {
        current = treeNodeMap.get(current.parentId);
      } else {
        current = null;
      }
    }

    const { id, subTreeSize, parentId: oldParentId } = node;
    const oldIndex = flatIndexMap.get(id) as number;

    // 1. Cut (Detach)
    const subTreeNodes = flatArray.value.splice(
      oldIndex,
      subTreeSize as number,
    );

    if (oldParentId) {
      const oldParent = treeNodeMap.get(oldParentId)!;
      const childIndex = oldParent.children!.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        oldParent.children!.splice(childIndex, 1);
        for (let i = childIndex; i < oldParent.children!.length; i++) {
          siblingIndexMap.set(oldParent.children![i].id, i);
        }
      }
      updateSubTreeSizeUpwards(oldParent, -(subTreeSize as number));
    } else {
      const childIndex = treeData.value.findIndex((c) => c.id === id);
      if (childIndex > -1) {
        treeData.value.splice(childIndex, 1);
        for (let i = childIndex; i < treeData.value.length; i++) {
          siblingIndexMap.set(treeData.value[i].id, i);
        }
      }
    }

    // 2. Paste (Attach)
    let newParentId: TreeNodeId | null | undefined = null;
    let newParent: TreeFlatNode | null | undefined = null;
    let insertIndexInChildren = 0;
    let newFlatIndex = 0;
    let childrenArray: TreeFlatNode[] | null = null;

    if (placement === "inner") {
      newParentId = targetNode.id;
      newParent = targetNode;
      if (!newParent.children) newParent.children = [];
      childrenArray = newParent.children;
      insertIndexInChildren = childrenArray.length;

      newFlatIndex =
        (flatIndexMap.get(targetNode.id) as number) +
        (targetNode.subTreeSize as number);
      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    } else {
      newParentId = targetNode.parentId;
      if (newParentId) {
        newParent = treeNodeMap.get(newParentId);
        childrenArray = newParent!.children!;
      } else {
        childrenArray = treeData.value;
      }

      const targetSiblingIndex = siblingIndexMap.get(targetNode.id) as number;
      insertIndexInChildren =
        placement === "before" ? targetSiblingIndex : targetSiblingIndex + 1;

      if (placement === "before") {
        newFlatIndex = flatIndexMap.get(targetNode.id) as number;
      } else {
        newFlatIndex =
          (flatIndexMap.get(targetNode.id) as number) +
          (targetNode.subTreeSize as number);
      }

      if (oldIndex < (flatIndexMap.get(targetNode.id) as number)) {
        newFlatIndex -= subTreeSize as number;
      }
    }

    childrenArray.splice(insertIndexInChildren, 0, node);
    for (let i = insertIndexInChildren; i < childrenArray.length; i++) {
      siblingIndexMap.set(childrenArray[i].id, i);
    }

    flatArray.value.splice(newFlatIndex, 0, ...subTreeNodes);

    node.parentId = newParentId;

    if (newParent) {
      updateSubTreeSizeUpwards(newParent, subTreeSize as number);
    }

    updateFlatIndexMap(Math.min(oldIndex, newFlatIndex));

    triggerUpdate();
  };

  const moveUp = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex > 0) {
      const targetNode = childrenArray[siblingIndex - 1];
      moveNode(node, targetNode, "before");
    }
  };

  const moveDown = (node: TreeFlatNode) => {
    const parentId = node.parentId;
    const childrenArray = parentId
      ? treeNodeMap.get(parentId)!.children!
      : treeData.value;
    const siblingIndex = siblingIndexMap.get(node.id) as number;
    if (siblingIndex < childrenArray.length - 1) {
      const targetNode = childrenArray[siblingIndex + 1];
      moveNode(node, targetNode, "after");
    }
  };

  return {
    treeData,
    initTreeFlat,
    addChildNode,
    addSiblingNode,
    deleteNode,
    moveNode,
    moveUp,
    moveDown,
    flatArray,
  };
};

实现效果

image-5.png

结语

本方案通过引入 subTreeSize 字段并利用 DFS 的连续性原理,将复杂的树形结构拓扑变更,巧妙地降维成了简单的一维数组切片(Splice)操作。结合辅助 Map 索引换取时间,配合视图层的虚拟滚动,最终构建出了一个高性能、逻辑清晰的树与数组实时双向同步架构。