You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

D3.js层级树折叠/展开时会重建整个树的问题排查与解决请求

D3.js层级树折叠/展开时会重建整个树的问题排查与解决请求

我在尝试用TypeScript复现D3.js的层级树示例时遇到了一个动画异常问题:树能正常初始化渲染,但每次点击折叠/展开节点时,过渡效果看起来像是重新构建了整个树,而不是只对点击节点的子节点进行添加/移除操作。按住Alt键慢放动画的话,能明显看到从点击位置生成了新树,原来的树被“吸”进了点击的节点里,这和预期的局部过渡效果完全不符。

我先定义了TreeNodeData类型来规范节点数据结构:

export interface TreeNodeData {
  name: string;
  fill?: string;
  children?: TreeNodeData[];
  value?: number;
  id?: string | number;
}

以下是我的核心实现代码:

const root = d3.hierarchy(data);
const dx = 40;
const dy = (this.width - this.marginRight - this.marginLeft) / (1 + root.height);

// 定义树布局和链接的绘制形状
const tree = d3.tree().nodeSize([dx, dy]);
const diagonal: d3.Link<any, d3.HierarchyLink<TreeNodeData>, d3.HierarchyLink<TreeNodeData>> = d3.linkHorizontal<d3.HierarchyLink<TreeNodeData>, d3.HierarchyLink<TreeNodeData>>()
  .x(d => d.y!)
  .y(d => d.x!);

// 创建SVG容器、链接图层和节点图层
const svg = d3.create("svg")
  .attr("width", this.width)
  .attr("height", this.height)
  .attr("viewBox", [-this.marginLeft, -this.marginTop, this.width, dx])
  .attr("style", "max-width: 100%; height: auto; font: 20px sans-serif; user-select: none;");

const gLink = svg.append("g")
  .attr("fill", "none")
  .attr("stroke", "#555")
  .attr("stroke-opacity", 0.4)
  .attr("stroke-width", 1.5);

const gNode = svg.append("g")
  .attr("cursor", "pointer")
  .attr("pointer-events", "all");

const update = (event: KeyboardEvent | MouseEvent | null, source: d3.HierarchyNode<TreeNodeData>) => {
  const duration = event?.altKey ? 2500 : 250; // 按住Alt键慢放过渡动画

  const nodes = root.descendants().reverse();
  const links = root.links();

  // 计算新的树布局
  tree(root);

  let left = root;
  let right = root;
  root.eachBefore(node => {
    if(node.x !== undefined){
      if(left.x !== undefined){
        if (node.x < left.x) left = node;
      }
      if(right.x !== undefined){
        if (node.x > right.x) right = node;
      }
    }
  });

  const height = right.x! - left.x! + this.marginTop + this.marginBottom;
  const transition = svg.transition()
    .duration(duration)
    .attr("height", height)
    .attr("viewBox", [-this.marginLeft, left.x! - this.marginTop, this.width, height].join(" "));

  if (window.ResizeObserver) {
    transition.tween("resize", null);
  } else {
    const tweenFactory = () => {
      svg.dispatch("toggle");
      return () => { };
    };
    transition.tween("resize", tweenFactory);
  }

  // 绑定节点数据,指定key函数做数据匹配
  const node = gNode.selectAll<SVGGElement, d3.HierarchyNode<TreeNodeData>>("g")
    .data(nodes, (d) => {
      if (d && d.id !== undefined) {
        return d.id;
      }
      // 处理d.id不存在的 fallback 情况
      return String(Math.random()); // 临时用随机字符串作为key
    });

  // 进入阶段:将新节点添加到父节点的初始位置
  const nodeEnter = node.enter().append("g")
    .attr("transform", d => `translate(${source.y0},${source.x0})`)
    .attr("fill-opacity", 0)
    .attr("stroke-opacity", 0)
    .on("click", (event, d: d3.HierarchyNode<TreeNodeData>) => {
      d.children = d.children ? undefined : d._children;
      update(event, d);
    });

  nodeEnter.append("circle")
    .attr("r", 2.5)
    .attr("fill", d => d._children ? "#555" : "#999")
    .attr("stroke-width", 10);

  nodeEnter.append("text")
    .attr("dy", "0.31em")
    .attr("x", (d : d3.HierarchyNode<TreeNodeData>) => d._children ? -6 : 6)
    .attr("text-anchor", d => d._children ? "end" : "start")
    .text((d : d3.HierarchyNode<TreeNodeData>) => d.data.name)
    .attr("stroke-linejoin", "round")
    .attr("stroke-width", 3)
    .attr("stroke", "white")
    .attr("paint-order", "stroke");

  // 更新阶段:将节点过渡到新的布局位置
  const nodeUpdate = node.merge(nodeEnter).transition(transition)
    .attr("transform", d => `translate(${d.y},${d.x})`)
    .attr("fill-opacity", 1)
    .attr("stroke-opacity", 1);

  // 注:原代码中节点退出阶段的逻辑未完整编写
};

我初步怀疑问题可能出在数据绑定的key函数上?或者是节点/链接的过渡逻辑有遗漏?希望能有人帮忙排查下,为什么折叠/展开时的过渡不是只针对子节点的局部动画,而是触发了整个树的重建效果。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.08 11:53:09