🔥别再用递归了!WeakMap 的影子索引“让树不再是树!”
一、前言
大家好,我是微澜。今天来分享一个基于 WeakMap 实现的快速对树形结构数据进行增删改查操作的useTree hook函数,它是基于JavaScript 的 WeakMap 特性,在不改动原始数据的前提下,实现了一套 O(1) 查找的影子索引结构,这个影子其实就是对象的引用地址,让树形数据操作像操作数组一样简单!
二、为什么选择 WeakMap?
1. 非侵入性 (Non-invasive)
通过 WeakMap 在内存中构建了一套 Node -> Parent 的映射。原始数据对象保持纯净,没有任何多余属性,完美支持序列化。
2. 内存安全 (Memory Safety)
这是最关键的一点。WeakMap 对键(对象)是弱引用的。
-
如果你删除了原始树中的某个节点,且没有其他地方引用它,垃圾回收器(GC)会自动清理索引表中的对应项。
-
这种特性非常适合处理大型动态树,完全不用担心手动维护索引带来的内存泄漏。
3、不同实现方案对比
在开始之前,我们先看看为什么选择 WeakMap。我们可以通过一个综合对比来看看操作树形数据不同方案的差异:
| 维度 / 方案 | 传统递归遍历 | 节点注入 parent | Map 缓存方案 | WeakMap 优化方案 (本项目) |
|---|---|---|---|---|
| 初始化复杂度 | (双层循环) | (单次递归) | ||
| 溯源时间复杂度 | (D为深度,极快) | |||
| 数据污染 | 无 | 高(直接修改对象) | 无 | 无(外部映射,保持纯净) |
| 内存管理 | 差 | 一般 | 一般 (强引用,需手动清理) | 优秀(弱引用自动 GC) |
| 序列化友好度 | 友好 | 极差(循环引用) | 友好 | 友好 |
| 适用场景 | 极小数据量 | 中等数据量 | 中等数据量 | 大规模数据、高频转换 |
三、核心实现原理
1. 整体架构图
我们通过一个外部的 WeakMap 来存储节点与其父级的对应关系。这种设计最大的好处是:“不改变原始树结构,不产生循环引用,且能实现 O(1) 的溯源。”
graph TD
A[原始树形数据] --> B{useTree Hook}
B -- "1. 递归扫描" --> C[WeakMap 存储库]
subgraph "WeakMap 存储策略"
C -- "Key: 根节点" --> D["Value: 整个 Tree 数组 (方便删除)"]
C -- "Key: 子节点" --> E["Value: 直接父节点对象 (实现溯源)"]
end
F[业务请求: 查找/删除] --> C
C -- "返回父级引用" --> G[完成操作]
2. 数据映射图解 (核心逻辑)
为了让大家更直观地看到 WeakMap 里到底存了什么,我们看下面这个例子:
示例数据:
const list = [
{
id: 1, name: '掘金',
children: [ { id: 11, name: '前端组' } ]
},
{
id: 2, name: '社区',
children: []
}
];
WeakMap 内部映射关系:
| 节点 (Key) | 映射值 (Value) | Value类型 | 存储逻辑说明 |
|---|---|---|---|
{ id: 11, name: '前端组' } |
{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] } |
Object (父节点) | 非根节点:指向它的直接父对象 |
{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] } |
[{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] }, { id: 2, name: '社区', children: [] }] |
Array (根数组) | 根节点:指向它所属的整棵树数组 |
{ id: 2, name: '社区', children: [] } |
[{ id: 1, name: '掘金', children: [{ id: 11, name: '前端组' }] }, { id: 2, name: '社区', children: [] }] |
Array (根数组) | 根节点:指向它所属的整棵树数组 |
💡 核心秘诀: 以上设计了一套巧妙的判断规则:如果节点是根节点,其映射值就是数组;如果节点是子节点,其映射值就是父对象。 这样在执行
removeChild时,我们只需要判断Array.isArray(parent),就能自动切换“从数组中删除”根节点或“从children属性中删除”子节点的逻辑,极其优雅!
四、useTree()实现方法逐一拆解
接下来,我们看看 useTree 内部每个方法的具体实现原理。
1. initTree - 初始化索引
功能:递归遍历整棵树,建立 WeakMap 映射。
// 数据索引对象
const _treeWeakMap = new WeakMap<TreeNode, TreeNode | TreeNode[]>();
const useTree = (
props: Props = {
// 外部数据对象字段映射
treeNodeProp: {
value: 'value',
label: 'label',
children: 'children',
}
}
){
// 初始化树结构索引
function initTree(list, parent){...}
}
// 初始化树结构索引
function initTree(list: TreeNode[], parent?: TreeNode) {
list.forEach((item) => {
// 根节点的父级为完整的树数据,在删除根节点时需要通过完整的数组删除
_treeWeakMap.set(item, parent || list);
if (item[treeNodeProp.children]) {
// 删除子节点只需要通过对应子节点的children数组删除
initTree(item[treeNodeProp.children], item);
}
});
}
原理:在组件挂载或数据更新时调用。通过这种“差异化映射”,我们让每个节点都拥有了一个指向其“归属集合”的指针,也就是指向了父级的引用地址。
以上文的表格列出的示例数据为例,调用initTree方法初始化后,可以得到以下对应关系:
graph LR
subgraph Keys [节点对象 - Key]
K11("前端组 (id:11)")
K1("掘金 (id:1)")
K2("社区 (id:2)")
end
subgraph Values [映射值 - Value]
V_Obj1["父节点对象 (id:1)"]
V_Arr["根数据数组 [Tree]"]
end
K11 -- "指向直接父级" --> V_Obj1
K1 -- "指向所属根数组" --> V_Arr
K2 -- "指向所属根数组" --> V_Arr
style K11 fill:#f9f,stroke:#333
style K1 fill:#bbf,stroke:#333
style K2 fill:#bbf,stroke:#333
style V_Arr fill:#dfd,stroke:#333
style V_Obj1 fill:#fff,stroke:#333
2. _setParent - 节点索引设置
功能:建立节点与其所属容器(父节点对象或根数组)的映射关系。
// 设置节点的父节点映射。为防止用户错误使用,不向外暴露,内部使用
function _setParent(node: TreeNode, parent: TreeNode | TreeNode[]) {
_treeWeakMap.set(node, parent);
}
原理:这是对 WeakMap.set 的一层简单封装。通过这种方式,我们将节点对象引用作为 Key,其父级引用作为 Value 存入索引库。通过_前缀标记为私有是为了防止外部直接篡改映射关系,保证索引的单一可靠性。
3. getParent - 获取父节点
功能:获取指定节点的直接父节点。
// 获取节点的父节点
function getParent(node: TreeNode) {
return _treeWeakMap.get(node);
}
-
原理:利用
WeakMap.get直接获取。时间复杂度为 O(1)。
4. getParents - 获取全路径父节点
功能:递归向上检索父级,获取从当前节点到根部的所有父节点数组。
// 获取节点的所有父节点
function getParents(item: TreeNode, parentList: (TreeNode | string)[] = [], key?: string): (TreeNode | string)[] {
const parent = _treeWeakMap.get(item);
// 递归到根节点数组时停止
if (parent && !_isRootNode(parent)) {
parentList.push(key ? parent[key] : parent);
getParents(parent, parentList, key);
}
return parentList;
}
// 判断是否根节点,下文都用这个方法判断是否根节点
function _isRootNode(node: TreeNode | TreeNode[]) {
return Array.isArray(node);
}
原理:通过节点自身的引用地址在WeakMap中查找父级。相比于DFS(深度优先遍历)全树,这种垂直爬升的方式效率极高。
什么是垂直爬升?
类似于:
show code is me.parent.parent. ......
就这样,连祖宗十八代都能给你扒出来!
5. addChild - 动态添加节点
功能:向指定节点添加子节点,并自动更新索引。
// 添加子节点或根节点,节点不存在.children时,代表添加到根节点数组
function addChild(node: TreeNode, child: TreeNode) {
if(_isRootNode(node)) {
// 根节点数组直接添加
const i = node.push(child) - 1;
/**
* 为新增加的子节点构建一个WeakMap索引,指向父节点
* 注意:注册为key的引用地址是经过proxy处理后的节点
*/
node[i] && _setParent(node[i], node);
} else {
// 非根节点,添加到children数组
if (!node[treeNodeProp.children]) {
node[treeNodeProp.children] = [];
}
const i = node[treeNodeProp.children].push(child) - 1;
// 和上面设置根节点同理
node.children[i] && _setParent(node.children[i], node);
}
}
原理:在操作原始数据的同时,同步更新 WeakMap索引。也就是为新增的对象建立父级索引。
注意:这里不能把新添加的child对象当做WeakMap的Key,因为child只是一个外部传入的一个临时变量,还没有和整棵树建立联系,需要在添加到树当中后,获取树当中的对象地址作为Key建立索引。
6. removeChild - 删除指定节点
功能:无痛删除任意节点,无需手动遍历查找。
// 删除指定子节点
function removeChild(node: TreeNode) {
// 找到父节点,通过父节点删除
const parent = getParent(node);
if(!parent) {
// 没有找到父级节点,抛出错误
throw new Error('没有找到父级节点!');
}
if(_isRootNode(parent)) {
// 删除根节点
const index = parent.findIndex((item: TreeNode) => item === node);
if(index >= 0) {
parent.splice(index, 1);
} else {
// 没有找到要删除的根节点,抛出错误
throw new Error('没有找到要删除的根节点!');
}
} else {
// 通过找到父级删除自己
parent.children = parent.children.filter((item: TreeNode) => item !== node);
}
}
原理:这是该方案最精妙的地方!通过 initTree 建立的差异化映射,我们只需要简单的 Array.isArray 判断,就能准确找到节点所在的“容器”,实现秒删。
五、完整代码实现
代码不多,却能快速~实现对树形数据的增删改查操作。
// useTree.ts
type Props = {
// 树形数据字段名映射类型
treeNodeProp: TreeNodeProp
}
// 树节点属性映射类型
type TreeNodeProp = {
value: string;
label: string;
children: string;
}
// 数节点
type TreeNode = any
/**
* 树结构索引数据
* key为节点本身,value为父节点或根节点数组(即整棵树)
* 如果value为Array,代表根节点数组
* 如果value为Object,代表子节点
*/
const _treeWeakMap = new WeakMap<TreeNode, TreeNode | TreeNode[]>();
// 使用weakmap构建树形数据数据索引
export const useTree = (
props: Props = {
// 外部数据对象字段映射
treeNodeProp: {
value: 'value',
label: 'label',
children: 'children',
}
}
) => {
const { treeNodeProp } = props;
// 设置节点的父节点映射。为防止用户错误使用,不向外暴露,内部使用
function _setParent(node: TreeNode, parent: TreeNode | TreeNode[]) {
_treeWeakMap.set(node, parent);
}
// 判断是否根节点
function _isRootNode(node: TreeNode | TreeNode[]) {
return Array.isArray(node);
}
// 初始化树结构索引
function initTree(list: TreeNode[], parent?: TreeNode) {
list.forEach((item) => {
// 根节点的父级为完整的树数据,在删除根节点时需要通过完整的数组删除
_treeWeakMap.set(item, parent || list);
if (item[treeNodeProp.children]) {
// 删除子节点只需要通过对应子节点的children数组删除
initTree(item[treeNodeProp.children], item);
}
});
}
// 添加子节点或根节点,节点不存在.children时,代表添加到根节点数组
function addChild(node: TreeNode, child: TreeNode) {
if(_isRootNode(node)) {
// 根节点数组直接添加
const i = node.push(child) - 1;
/**
* 为新增加的子节点构建一个WeakMap索引,指向父节点
* 注意:注册为key的引用地址是经过proxy处理后的节点
*/
node[i] && _setParent(node[i], node);
} else {
// 非根节点,添加到children数组
if (!node[treeNodeProp.children]) {
node[treeNodeProp.children] = [];
}
const i = node[treeNodeProp.children].push(child) - 1;
// 和上面设置根节点同理
node.children[i] && _setParent(node.children[i], node);
}
}
// 删除指定子节点
function removeChild(node: TreeNode) {
// 找到父节点,通过父节点删除
const parent = getParent(node);
if(!parent) {
// 没有找到父级节点,抛出错误
throw new Error('没有找到父级节点!');
}
if(_isRootNode(parent)) {
// 删除根节点
const index = parent.findIndex((item: TreeNode) => item === node);
if(index >= 0) {
parent.splice(index, 1);
} else {
// 没有找到要删除的根节点,抛出错误
throw new Error('没有找到要删除的根节点!');
}
} else {
// 通过找到父级删除自己
parent.children = parent.children.filter((item: TreeNode) => item !== node);
}
}
// 获取节点的父节点
function getParent(node: TreeNode) {
return _treeWeakMap.get(node);
}
// 获取节点的所有父节点
function getParents(item: TreeNode, parentList: (TreeNode | string)[] = [], key?: string): (TreeNode | string)[] {
const parent = _treeWeakMap.get(item);
// 递归到根节点数组时停止
if (parent && !_isRootNode(parent)) {
parentList.push(key ? parent[key] : parent);
getParents(parent, parentList, key);
}
return parentList;
}
// 获取节点的所有父节点label数组
function getParentLabels(item: TreeNode, labelList: string[] = []): string[] {
return getParents(item, labelList, treeNodeProp.label) as string[];
}
// 获取节点的所有父节点value数组
function getParentValues(item: TreeNode, valueList: string[] = []): string[] {
return getParents(item, valueList, treeNodeProp.value) as string[];
}
return {
getParents,
getParentLabels,
getParentValues,
getParent,
initTree,
addChild,
removeChild,
};
};
六、总结
通过 WeakMap 实现的 useTree,核心优势在于解耦。它将“业务数据”与“层级关系”分离开来,既保证了数据的纯净,又获得了极高的查找性能。
WeakMap 作为一个容易被忽视的原生特性,在处理这类关联关系映射时有着天然的优势。
如果你也在为复杂树结构的管理发愁,不妨尝试下这种“影子索引”的思路。
如有不足或可优化的地方,欢迎在评论区交流讨论,如果觉得有用,点个👍也挺好的!👏
如果你在处理大型树形表格或复杂的组织架构,这个 Hook 绝对是你的提效神器!
七、预览和源码地址
多场景演示 (Demo)
-
项目预览总入口: java6688.github.io/Tree_By_Wea…
-
原生 HTML 版本: java6688.github.io/Tree_By_Wea…
-
Vue 3 + Element+ 版本: java6688.github.io/Tree_By_Wea…
-
React + AntD 版本: java6688.github.io/Tree_By_Wea…