D3.js 入门实战:用力导向图可视化项目依赖关系

D3.js 入门实战:用力导向图可视化项目依赖关系

上次团队做微前端拆分时,我在会议室白板上画了一下午的依赖图:A 模块引了 B,B 又偷偷依赖 C,C 和 D 之间还有循环引用。画到后面,箭头像蜘蛛网一样缠在一起,产品同学看了直摇头。那一刻我意识到:静态架构图已经追不上代码的实际演化速度了。如果能把 package.json 里的依赖关系直接动态渲染成一张可交互的图,不仅能自己看清全局,还能在复盘会上甩给同事一个链接,让大家一起拖动、缩放、找循环。D3.js 的力导向图(Force Simulation)就是做这件事最趁手的工具。

一、为什么选力导向图

项目依赖天然是一张图:包是节点,引用关系是边。但依赖图有两个很讨厌的特点:

  1. 节点数量多:一个中型前端项目往往有几十上百个内部模块。
  2. 关系错综复杂:父子依赖、兄弟依赖、循环依赖交织在一起。

如果手动排版,不仅费时,而且代码一改动就过时。力导向图把物理模型搬到画布上:节点之间像带电粒子一样互相排斥,边像弹簧一样把相关节点拉在一起。最终系统会自己收敛到一个相对平衡的布局,越相关的节点越靠近,孤立节点自然散到边缘。D3 的 d3-force 模块已经把这套模型封装好了,我们要做的只是把依赖数据喂给它。

二、准备数据:从 package.json 到图数据

实战的第一步是把项目里的依赖关系解析成 { nodes, links } 的格式。下面这段脚本扫描 src 目录下所有 .js/.ts/.vue 文件,提取 import / require 语句,统计模块之间的引用关系。

javascript 复制代码
// deps-scanner.js
const fs = require('fs');
const path = require('path');
const glob = require('glob');

const SRC = path.resolve(process.argv[2] || 'src');
const files = glob.sync(`${SRC}/**/*.{js,ts,vue}`);

const nodes = new Map();
const links = [];

function addNode(id) {
  if (!nodes.has(id)) {
    nodes.set(id, { id, group: path.dirname(id).split(path.sep)[0] || 'root' });
  }
}

files.forEach((file) => {
  const relative = path.relative(SRC, file).replace(/\\/g, '/');
  addNode(relative);

  const content = fs.readFileSync(file, 'utf-8');
  const importRegex = /from\s+['"](\.\.?\/[^'"]+)['"]|require\(['"](\.\.?\/[^'"]+)['"]\)/g;
  let match;

  while ((match = importRegex.exec(content)) !== null) {
    const rawTarget = match[1] || match[2];
    const resolved = path.posix.normalize(path.posix.join(path.posix.dirname(relative), rawTarget));
    // 简单处理:去掉 .vue/.js/.ts 后缀
    const cleanTarget = resolved.replace(/\.(vue|js|ts)$/, '');
    if (files.some((f) => path.relative(SRC, f).replace(/\\/g, '/').replace(/\.(vue|js|ts)$/, '') === cleanTarget)) {
      addNode(cleanTarget);
      links.push({ source: relative, target: cleanTarget, value: 1 });
    }
  }
});

fs.writeFileSync('deps.json', JSON.stringify({
  nodes: Array.from(nodes.values()),
  links,
}, null, 2));
console.log(`扫描完成:${nodes.size} 个节点,${links.length} 条边`);

运行 node deps-scanner.js ./src 后,你会得到一份 deps.json。节点里的 group 字段按目录第一层分组,后续可以给不同分组上色,一眼看出哪些模块属于同一业务域。

三、渲染可交互的力导向图

有了数据,接下来就是 D3 的主场。下面是一份最小可运行的 HTML,直接打开就能看到效果。

html 复制代码
<!-- force-graph.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>项目依赖力导向图</title>
  <style>
    body { margin: 0; overflow: hidden; background: #0f172a; }
    svg { width: 100vw; height: 100vh; }
    .link { stroke: #64748b; stroke-opacity: 0.4; }
    text { fill: #e2e8f0; font-size: 10px; pointer-events: none; }
  </style>
</head>
<body>
<svg id="chart"></svg>
<script src="https://d3js.org/d3.v7.min.js"></script>
<script>
async function draw() {
  const width = window.innerWidth;
  const height = window.innerHeight;
  const color = d3.scaleOrdinal(d3.schemeTableau10);

  const { nodes, links } = await d3.json('deps.json');

  const svg = d3.select('#chart')
    .attr('viewBox', [0, 0, width, height]);

  // 缩放容器
  const g = svg.append('g');
  svg.call(d3.zoom().on('zoom', (event) => g.attr('transform', event.transform)));

  const simulation = d3.forceSimulation(nodes)
    .force('link', d3.forceLink(links).id(d => d.id).distance(80))
    .force('charge', d3.forceManyBody().strength(-200))
    .force('center', d3.forceCenter(width / 2, height / 2))
    .force('collide', d3.forceCollide().radius(20));

  const link = g.append('g')
    .selectAll('line')
    .data(links)
    .join('line')
    .attr('class', 'link')
    .attr('stroke-width', d => Math.sqrt(d.value));

  const node = g.append('g')
    .selectAll('g')
    .data(nodes)
    .join('g')
    .call(d3.drag()
      .on('start', (event, d) => { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; })
      .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
      .on('end', (event, d) => { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; }));

  node.append('circle')
    .attr('r', 8)
    .attr('fill', d => color(d.group));

  node.append('text')
    .attr('dx', 12)
    .attr('dy', 4)
    .text(d => d.id);

  simulation.on('tick', () => {
    link
      .attr('x1', d => d.source.x)
      .attr('y1', d => d.source.y)
      .attr('x2', d => d.target.x)
      .attr('y2', d => d.target.y);

    node.attr('transform', d => `translate(${d.x},${d.y})`);
  });
}

draw();
</script>
</body>
</html>

代码虽然看起来长,但核心只有四股力:

  • forceLink:把有依赖关系的节点拉在一起,distance 越小关系越紧密。
  • forceManyBody:让节点互相排斥,避免堆叠。
  • forceCenter:把整个图固定在画布中心。
  • forceCollide:防止节点重叠。

d3.drag 让节点可以拖动,拖动时设置 fx/fy 把节点暂时钉住,松开后置空让它继续受力。d3.zoom 则负责整图缩放和平移,适合在大型依赖图里快速定位。

四、进阶:找出循环依赖并高亮

力导向图除了好看,还能帮我们做分析。比如循环依赖是重构时最想揪出来的问题。可以用深度优先搜索(DFS)在 links 里找环,然后把环上的节点和边染成红色。

javascript 复制代码
function findCycles(nodes, links) {
  const adj = new Map(nodes.map(n => [n.id, []]));
  links.forEach(l => adj.get(l.source.id || l.source).push(l.target.id || l.target));

  const cycles = new Set();
  const stack = [];
  const visited = new Set();
  const inStack = new Set();

  function dfs(id) {
    visited.add(id);
    stack.push(id);
    inStack.add(id);

    for (const next of adj.get(id) || []) {
      if (!visited.has(next)) dfs(next);
      else if (inStack.has(next)) {
        const cycleStart = stack.indexOf(next);
        const cycle = stack.slice(cycleStart).concat(next);
        cycle.forEach(n => cycles.add(n));
      }
    }

    stack.pop();
    inStack.delete(id);
  }

  nodes.forEach(n => { if (!visited.has(n.id)) dfs(n.id); });
  return cycles;
}

拿到 cycles 集合后,在渲染阶段给环上的节点加粗边框、把相关 link 的 stroke 改成 #ef4444 即可。上周我们就是靠这个方法,发现了一个 utils → api → utils 的隐蔽循环,解开后构建缓存命中率直接提升了一截。

五、性能优化:大项目怎么办

当节点超过 300 个,力导向图的 tick 计算开始吃 CPU。几个实用的优化点:

  1. 降采样显示:默认只渲染 import 次数大于 N 的模块,用户可以拖动阈值滑块逐步展开细节。
  2. Web Worker 跑 simulation :把 d3-force 放到 Worker 里计算主线程只负责绘制,避免页面卡顿。
  3. 预计算布局 :首次渲染后把节点坐标缓存到 localStorage,下次打开直接复用,只在依赖关系变化时重新跑 simulation。

另外,D3 的 alphaDecayvelocityDecay 也可以微调。默认 alphaDecay 约 0.02,收敛较慢;如果数据量大,可以适当调大,让布局更快稳定,牺牲一点最终精度换取响应速度。

总结

  • 数据即真相 :把 package.json 和源码 import 解析成图数据,是动态架构图的基础。
  • 物理模型省人工:D3 力导向图让依赖关系自动布局,省去手动排版的痛苦。
  • 交互提升可读性:缩放、拖动、高亮循环依赖,能让静态图变成可探索的工具。
  • 工程化落地:扫描脚本可以接进 CI,每次 MR 自动生成最新依赖图,防止架构图与代码脱节。
  • 性能要分层:大项目通过降采样、Worker 计算和布局缓存,保证可视化不卡。

现在你可以把上面两段代码拷到项目里跑一遍,大概率会发现自己之前没意识到的依赖纠缠。把那张图贴到下次技术复盘里,效果通常比 PPT 上的手绘箭头好得多。

相关推荐
不好听6132 小时前
JavaScript 的 this 到底指向谁?
javascript·面试
触底反弹2 小时前
🔥 2026 年爆火的 Harness Engineering 到底是什么?从原理到实战一文讲透
javascript·人工智能·程序员
mONESY2 小时前
一文搞定JavaScript不同场景中 this 的指向问题
javascript
用户298698530142 小时前
在 React 中使用 JavaScript 合并 Excel 文件
前端·javascript·react.js
大流星2 小时前
LangChainJs之基础模型(一)
javascript·langchain
橘子星2 小时前
JavaScript this 指向全解实战指南
前端·javascript
weedsfly2 小时前
JS垃圾回收:从原理到项目实战,彻底根治内存泄漏
前端·javascript·面试
万少14 小时前
万少的博客 - 技术分享与解决方案
前端·javascript·后端
尘世中一位迷途小书童16 小时前
用 Cesium 撸了一个森林火情监控大屏,弧线、粒子、发光效果都齐了
前端·javascript