D3.js 入门实战:用力导向图可视化项目依赖关系
上次团队做微前端拆分时,我在会议室白板上画了一下午的依赖图:A 模块引了 B,B 又偷偷依赖 C,C 和 D 之间还有循环引用。画到后面,箭头像蜘蛛网一样缠在一起,产品同学看了直摇头。那一刻我意识到:静态架构图已经追不上代码的实际演化速度了。如果能把 package.json 里的依赖关系直接动态渲染成一张可交互的图,不仅能自己看清全局,还能在复盘会上甩给同事一个链接,让大家一起拖动、缩放、找循环。D3.js 的力导向图(Force Simulation)就是做这件事最趁手的工具。
一、为什么选力导向图
项目依赖天然是一张图:包是节点,引用关系是边。但依赖图有两个很讨厌的特点:
- 节点数量多:一个中型前端项目往往有几十上百个内部模块。
- 关系错综复杂:父子依赖、兄弟依赖、循环依赖交织在一起。
如果手动排版,不仅费时,而且代码一改动就过时。力导向图把物理模型搬到画布上:节点之间像带电粒子一样互相排斥,边像弹簧一样把相关节点拉在一起。最终系统会自己收敛到一个相对平衡的布局,越相关的节点越靠近,孤立节点自然散到边缘。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。几个实用的优化点:
- 降采样显示:默认只渲染 import 次数大于 N 的模块,用户可以拖动阈值滑块逐步展开细节。
- Web Worker 跑 simulation :把
d3-force放到 Worker 里计算主线程只负责绘制,避免页面卡顿。 - 预计算布局 :首次渲染后把节点坐标缓存到
localStorage,下次打开直接复用,只在依赖关系变化时重新跑 simulation。
另外,D3 的 alphaDecay 和 velocityDecay 也可以微调。默认 alphaDecay 约 0.02,收敛较慢;如果数据量大,可以适当调大,让布局更快稳定,牺牲一点最终精度换取响应速度。
总结
- 数据即真相 :把
package.json和源码 import 解析成图数据,是动态架构图的基础。 - 物理模型省人工:D3 力导向图让依赖关系自动布局,省去手动排版的痛苦。
- 交互提升可读性:缩放、拖动、高亮循环依赖,能让静态图变成可探索的工具。
- 工程化落地:扫描脚本可以接进 CI,每次 MR 自动生成最新依赖图,防止架构图与代码脱节。
- 性能要分层:大项目通过降采样、Worker 计算和布局缓存,保证可视化不卡。
现在你可以把上面两段代码拷到项目里跑一遍,大概率会发现自己之前没意识到的依赖纠缠。把那张图贴到下次技术复盘里,效果通常比 PPT 上的手绘箭头好得多。