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

823 阅读10分钟

一、前言

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

二、为什么选择 WeakMap?

1. 非侵入性 (Non-invasive)

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

2. 内存安全 (Memory Safety)

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

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

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

3、不同实现方案对比

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

维度 / 方案传统递归遍历节点注入 parentMap 缓存方案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