普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月29日首页

🔥别再用递归了!WeakMap 的影子索引“让树不再是树!”

作者 vilan_微澜
2026年1月28日 23:57

一、前言

大家好,我是微澜。今天来分享一个基于 WeakMap 实现的快速对树形结构数据进行增删改查操作useTree hook函数,它是基于JavaScriptWeakMap 特性,在不改动原始数据的前提下,实现了一套 O(1) 查找的影子索引结构,这个影子其实就是对象的引用地址,让树形数据操作像操作数组一样简单!

二、为什么选择 WeakMap?

1. 非侵入性 (Non-invasive)

通过 WeakMap 在内存中构建了一套 Node -> Parent 的映射。原始数据对象保持纯净,没有任何多余属性,完美支持序列化。

2. 内存安全 (Memory Safety)

这是最关键的一点。WeakMap 对键(对象)是弱引用的。

  • 如果你删除了原始树中的某个节点,且没有其他地方引用它,垃圾回收器(GC)会自动清理索引表中的对应项。

  • 这种特性非常适合处理大型动态树,完全不用担心手动维护索引带来的内存泄漏。

3、不同实现方案对比

在开始之前,我们先看看为什么选择 WeakMap。我们可以通过一个综合对比来看看操作树形数据不同方案的差异:

维度 / 方案 传统递归遍历 节点注入 parent Map 缓存方案 WeakMap 优化方案 (本项目)
初始化复杂度 O(N2)O(N^2) (双层循环) O(N)O(N) O(N)O(N) O(N)O(N) (单次递归)
溯源时间复杂度 O(N)O(N) O(1)O(1) O(D)O(D) O(D)O(D) (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对象当做WeakMapKey,因为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)

项目源码地址

github:https://github.com/java6688/Tree_By_WeakMap

❌
❌