D3 V4树形图更新异常:新增节点触发全量重绘问题排查
嘿,我仔细梳理了你遇到的D3树形图更新问题,咱们一步步拆解根源和解决办法:
先说说核心问题出在哪
1. 初始阶段:所有节点被判定为新数据
最开始你没给.data()设置唯一标识的key函数,D3默认用数组索引来匹配新旧数据。当你新增节点后,整个树的结构变了,索引完全对应不上,旧节点全被标记为“要移除”,新节点全是“要添加”,自然就出现所有节点向中心过渡后消失的情况——这部分你后来加key函数的思路是对的,但key选得不合适。
2. 优化后仍存在的两个异常
(1)Branch 7.2.1节点消失
看你的初始数据,Branch 7.2下有两个同名的Branch 7.2.1节点,但你用.data(d, d => d.data.name)作为节点的key。D3遇到重复key时,只会保留第一个,第二个会被当成重复数据直接判定为“退出”,所以其中一个就消失了。
(2)多次添加节点后链路错位
你给链路设置的key是.data(d, d => d.source.data.name),这个太模糊了:同一个父节点下的所有子节点,它们的链路source name都一样,导致链路的key重复,D3没法正确匹配新旧链路;另外,你在update函数里每次都重新创建tree布局,状态没法复用;还有,链路的过渡动画里你没更新d属性,旧链路不会挪到新位置,新链路直接用初始坐标,自然就错位了。
具体解决步骤,照着改就行
1. 给所有节点加唯一ID,替换name当key
name可能重复,必须用唯一标识。给每个节点加id字段:
- 初始数据里补全唯一id:
let parsedList = { "id": "root-stapler", "name": " Stapler", "children": [{ "id": "node-bind", "name": " Bind", "children": [] }, // ... 其他节点都加唯一id,比如Branch7.2下的两个子节点可以设为"node-7.2.1"和"node-7.2.2" { "id": "node-7.2.1", "name": " Branch 7.2.1", "children": [] }, { "id": "node-7.2.2", "name": " Branch 7.2.1", "children": [] } ] }
- 新增节点时用时间戳生成唯一id:
function addNewLeftChild(d, i, nodes) { event.stopPropagation(); var newNodeObj = { id: `node-${Date.now()}`, // 时间戳保证唯一 name: "New Child", children: [] }; // ... 后续代码不变 }
然后修改key函数:
// 节点用唯一id匹配 var node = gLeftNode.selectAll(".nodeMindMap-left") .data(nodes, d => d.data.id); // 链路用「sourceId-targetId」的组合当key,确保绝对唯一 var link = gLeftLink.selectAll(".link-left") .data(links, d => `${d.source.data.id}-${d.target.data.id}`);
2. 修复链路的过渡与绘制逻辑
链路是用d属性描述路径的,过渡时必须重新计算这个值,不然旧链路不会动:
var linkUpdate = linkEnter.merge(link); linkUpdate.transition() .duration(750) .attr("d", d3.linkHorizontal() .x(d => d.y) .y(d => d.x));
退出的链路也要用路径来收缩,别用x1/y1(那是line元素的属性,不是path的):
var linkExit = link.exit() .transition() .duration(750) .attr("d", () => { // 让退出的链路收缩到根节点位置 const rootPos = {x: source.x, y: source.y}; return d3.linkHorizontal()({source: rootPos, target: rootPos}); }) .remove();
3. 复用tree布局对象,别每次都创建
把tree布局移到全局,复用状态,避免每次更新都重新初始化:
// 全局定义左侧的tree布局(因为左侧是向左展开,size的第二个参数设为负数) const leftTreeLayout = d3.tree() .size([heightMindMap, -1 * (widthMindMap - 150) / 2]); function update(source) { var root = leftTreeLayout(source); // ... 后续代码 }
然后drawLeft里直接调用update(root)就行,不用传SWITCH_CONST了。
4. 修正节点的enter/merge逻辑
你之前把enter和merge写在一起,导致每次update都重复添加circle、按钮这些元素,DOM会堆积。要把仅需创建一次的元素放在enter阶段,更新逻辑放在merge阶段:
// JOIN新旧节点 var node = gLeftNode.selectAll(".nodeMindMap-left") .data(nodes, d => d.data.id); // ENTER:只给新节点创建元素 var nodeEnter = node.enter().append("g") .attr("class", d => `nodeMindMap-left nodeMindMap${d.children ? " node--internal" : " node--leaf"}`) .attr("transform", d => `translate(${d.parent.y},${d.parent.x})`) // 新节点从父节点位置开始过渡 .attr("id", d => d.data.name.replace(/\s/g, '')); // 只在enter阶段添加circle、添加按钮、文本框这些元素 nodeEnter.append("circle").attr("r", 2.5); // ... 这里放addLeftChild、foreignObject、rect的创建代码,只执行一次 // MERGE:更新所有节点的位置 var nodeUpdate = nodeEnter.merge(node); nodeUpdate.transition() .duration(750) .attr("transform", d => `translate(${d.y},${d.x})`); // EXIT:移除旧节点 var nodeExit = node.exit() .transition() .duration(750) .attr("transform", d => `translate(${source.y},${source.x})`) .remove(); nodeExit.select('circle').attr('r', 0);
最后总结下
你的问题本质是key函数选择错误(用了重复的name),加上链路过渡逻辑缺失和布局对象重复创建导致的。按上面的步骤改完,应该就能解决节点消失、链路错位的问题了。
内容的提问来源于stack exchange,提问作者hiswendy

