从0开始设计一个树和扁平数组的双向同步方案
背景:在前端开发中,展示和操作大型树形结构(如十万级节点的文件树、组织架构图)时,传统递归渲染 DOM 会导致严重的性能瓶颈。为了结合虚拟滚动技术,我们需要将树“扁平化”为一维数组。本文将从0开始,推导并设计一个支持“逻辑树”与“视图扁平数组”实时、高效双向同步的方案。
1. 核心操作提炼
在开始设计数据结构之前,我们先明确业务需求。一个完备的树和数组双向同步方案,必须支持以下核心操作:
- 初始化构建:将原始树结构转换为扁平数组,并建立辅助索引。
- 树添加子节点:在指定节点的子节点列表末尾添加新节点,并同步到数组。
- 树添加兄弟节点:在指定节点之后添加一个兄弟节点,并同步到数组。
- 树删除节点:删除指定节点及其所有子孙节点,并同步到数组。
- 树移动节点:将某棵子树从一个父节点移动到另一个父节点下(拖拽操作),并同步到数组。
- 树修改节点属性:修改节点的非结构属性(如名称、展开状态等),并触发视图更新。
2. 数据结构设计
为了让上述操作在树和数组中都能高效执行,我们需要精心设计节点的数据结构,并引入辅助索引(空间换时间)。
2.1 节点结构 (TreeNode)
最基础的树节点通常只包含 id 和 children:
interface TreeNode {
id: string | number;
children: TreeNode[];
// ... 其他业务数据属性
}
但在我们的双向同步方案中,为了实现高效的查找和回溯,仅有这两个属性是远远不够的。在接下来的算法设计中,我们会根据具体的操作场景,一步步引入并添加必要的辅助属性和辅助 Map 索引(空间换时间)。
2.2 两大数据载体
- Tree (Array):原始的逻辑树结构,用于维护业务层级关系。
- flatArray (Array):基于 DFS 遍历生成的扁平数组,直接绑定到 UI 虚拟滚动组件上用于渲染。
2.3 辅助索引的引入思路
仅仅依靠树和数组依然不够,如果每次操作都要去遍历寻找目标节点,性能会大打折扣。为了将查找复杂度降至 ,我们会在接下来的算法设计中,根据具体的操作场景,一步步引入并建立必要的辅助 Map 索引(空间换时间)。
3. 算法与逻辑构思
接下来,我们针对提炼的每一个操作,进行详细的算法与逻辑设计。
3.1 初始化构建
逻辑构思:首先需要将一棵树展开为一维数组。在遍历过程中,为了支撑后续的高效查找,我们自然地引入前两个辅助索引:
treeNodeMap(Map<id, TreeNode>):将节点 ID 映射到节点对象的引用,保证后续任何操作都能 定位节点。flatIndexMap(Map<id, index>):记录节点在flatArray中的数组下标,用于后续在数组中快速进行切片(Splice)操作。
引入树节点属性 subTreeSize:
为了能在扁平数组中快速确定一棵子树占据的切片范围,我们需要为每个树节点引入一个核心字段 subTreeSize(以该节点为根的子树的节点总数,包含自身)。在 DFS(深度优先遍历)生成的扁平数组中,一棵子树的所有节点是绝对连续的。subTreeSize 就是这段连续区间的长度。
算法设计:采用深度优先遍历 (DFS)。
- 时间复杂度:,其中 为树中节点的总数。需要遍历每个节点一次。
- 遍历过程中,将节点引用存入
treeNodeMap。 - 将节点
push到flatArray中,并将此时的数组长度(减1)作为 index 存入flatIndexMap。 - 在 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;但在扁平数组中,新节点应该紧挨着该父节点整棵现有子树的末尾插入。
算法设计:
- 时间复杂度:,主要受限于
flatArray的splice插入操作和后续所有节点在flatIndexMap中的更新遍历。
- 树更新:通过
treeNodeMap找到父节点,往children追加新节点。更新新节点的subTreeSize = 1。向上回溯更新所有祖先的subTreeSize += 1。 - 寻找数组插入点:
- 若父节点无子节点:插入点 =
flatIndexMap.get(parentId) + 1。 - 若有子节点:找到最后一个子节点
prev。插入点 =flatIndexMap.get(prev.id) + prev.subTreeSize。
- 若父节点无子节点:插入点 =
- 数组更新:使用
flatArray.splice(插入点, 0, newNode)插入。 - 索引更新:新节点记入
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 数组里执行 的查找来获取当前兄弟节点的索引,我们需要引入第三个辅助索引(在实际开发中,它同样需要在初始化时收集,并在其他增删操作中同步维护):3. siblingIndexMap (Map<id, index>):记录节点在父节点 children 数组中的位置。
算法设计:
- 时间复杂度:,同样受限于数组插入时的移位操作,以及后续节点在相关 Map 索引中的更新。
- 树更新:通过
parentId找到父节点,通过siblingIndexMap瞬间找到当前节点在children中的位置,在其后插入新节点。向上回溯更新祖先subTreeSize += 1。 - 寻找数组插入点:
- 插入点 =
flatIndexMap.get(当前节点.id) + 当前节点.subTreeSize。
- 插入点 =
- 数组更新:
flatArray.splice(插入点, 0, newNode)。 - 索引更新:后续节点
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 的底层实现需要将删除位置之后的所有元素向前移动以填补空缺,每次删除一个元素都会产生 的移位开销( 为数组总长度)。如果子树包含 个节点,逐个删除会导致大量的重复移位操作,总时间复杂度将退化为 ,性能极差。利用 DFS 连续性特性,我们直接在扁平数组中“切掉”这一整段,只需要进行一次 的数组移位操作即可。
算法设计:
- 时间复杂度:,一次性
splice删除了 个节点,产生了 的数组元素前移开销,以及遍历更新剩余节点索引的 开销。
- 获取规模:待删除节点数
count = node.subTreeSize。 - 数组更新:起点
startIndex = flatIndexMap.get(node.id)。直接执行flatArray.splice(startIndex, count)。 - 树与索引更新:
- 从
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 卸载/重挂载),我们采用“剪切-粘贴”策略。 算法设计:
- 时间复杂度:,相当于执行了一次删除和一次添加,需要进行两次数组元素的移位操作和索引更新。
- 剪切 (Detach):
- 规模
count = node.subTreeSize。起点oldIndex = flatIndexMap.get(node.id)。 - 提取子树:
subTreeNodes = flatArray.splice(oldIndex, count)。 - 更新原父链的
subTreeSize -= count,更新原位置后续节点的flatIndexMap。清理原父节点的children。
- 规模
- 粘贴 (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 树修改节点属性
逻辑构思:仅修改非结构属性,不影响树形态。
算法设计:通过 treeNodeMap 以 复杂度拿到节点引用,直接修改。由于 flatArray 中保存的是同一个对象的引用,借助 Vue/React 的响应式机制,UI 会自动局部更新。
- 时间复杂度:,通过 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 结合双向同步方案的优势
- 直接驱动:上述设计的
flatArray就是一个一维的响应式数组。每次发生增删改移操作后,flatArray会实时发生变化(长度变化或元素变更)。 - 自动映射:虚拟滚动组件直接监听
flatArray的长度变化,重新计算totalHeight和视图区间,开发者无需手动干预 DOM。 - 层级视觉还原:在渲染截取出来的节点时,可以结合节点在树中的层级关系动态计算内边距,在视觉上完美还原树的结构。
- 折叠/展开逻辑:树的折叠和展开,本质上就是对该节点的子树进行“批量删除”和“重新添加子节点”的操作。复用上述的 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,
};
};
实现效果
结语
本方案通过引入 subTreeSize 字段并利用 DFS 的连续性原理,将复杂的树形结构拓扑变更,巧妙地降维成了简单的一维数组切片(Splice)操作。结合辅助 Map 索引换取时间,配合视图层的虚拟滚动,最终构建出了一个高性能、逻辑清晰的树与数组实时双向同步架构。