1. 项目缘起:代码不是拿来读的,是拿来摸的
我先说个自己的经历。去年下半年我接手一个老项目,光入口文件就 3000 多行,模块之间相互 require 和 export 的路径用 IDE 自带的 call hierarchy 一层层跳,跳到第 4 层就晕了,根本记不清哪个函数被谁引用、哪个类被谁继承、状态变更到底影响了多少条链路。当时我就一个想法:如果代码之间的关系能像地图一样,从一个点出发,直接看见整片“路网”,这比任何文档都管用。
后来我搜了一圈开源方案,要么太重,要么只支持单一语言,要么输出格式丑到没法看。于是动手做了个小工具,名字就叫 codegraph,核心思路一句话:把源码解析成构建脚本、类、函数、变量、调用关系组成的一张图,用可视化界面和 JSON 数据结构两种方式把这层关系“摊开”给你看。项目托管在 GitHub 上,完全开源,支持 JavaScript、TypeScript、Python 三种语言的解析,命令行直接跑,也能以库的形式集成到现有项目里做静态分析。
这里先说清楚 codegraph 适合谁用:中大型项目的维护者、准备做重构但不敢下手的老代码管理员、需要快速摸清新接手项目脉络的新人,还有对代码分析和可视化有兴趣的开发者。它不适合用来做精细的复杂依赖分析或者运行期动态追踪,它定位是“图式化阅读工具”,不是“静态分析平台”。后续所有细节都围绕这个定位展开,我能用多直白的话就用多直白的话讲。
2. 整体设计思路:为什么会是“图谱”这个形态
2.1 树状结构和图状结构的本质区别
很多人第一反应是:IDE 里不也能看调用树吗,为什么非得用图?我举个生活化的例子:树状结构像家谱,只能表达“谁是爹、谁是儿子”,一个儿子在父亲下面只能出现一次;而图状结构像城市地铁图,一个站可以同时出现在多条线上,换乘关系、交叉影响清清楚楚。代码的真实关系就是“地铁图”而不是“家谱”——一个工具函数可能被十几个模块引用,一个类可能同时被继承又组合,这种一对多的网状依赖用树形层级根本表达不完整。
我在设计 codegraph 的第一天就定死了这个底层模型:节点(node)代表符号,边(edge)代表符号之间的关系。所有功能都围绕这一套图模型展开,不搞特例。
2.2 三种最核心的节点类型
节点不是把 AST(抽象语法树)里的所有东西都塞进去,那样图会变成毛线球。我把节点收缩到四个对理解代码最有用的符号类型:
- 文件/模块节点:代表一个源码文件,是图的最外层容器。
- 函数节点:代表函数声明、函数表达式、箭头函数、方法定义。
- 类节点:代表 class 声明或者构造函数。
- 变量节点:代表模块级别的 const / let / var 声明,函数内部变量默认不纳入,避免噪声。
这种收缩非常关键。我曾经试着把所有 AST 节点全部导出成图,结果一个 500 行的文件生成了 800 多个节点,图上一团乱麻,连自己都看不懂。做图谱工具的第一课就是做减法。
2.3 边的类型:调用、引用、继承、导入
边就是关系,关系必须语义明确。codegraph 一期支持四种边:
imports:文件 A 导入了文件 B 的内容,这种边只存在于模块节点之间。calls:函数 A 调用了函数 B,可以跨文件。refers:函数或类内部引用了某个变量或其他模块导出符号。extends/implements:类继承或接口实现关系。
叫extends还是implements我纠结过一阵,后来干脆合并成一种语义,用relationship: "inheritance"统一表达,简化了后期可视化的样式控制。
3. 技术选型的思考过程:为什么没有用正则,也没有用 IDE 内核
3.1 正则解析的问题很大
最早的原型,我天真地想用正则表达式匹配函数定义:function\s+(\w+)\s*\(这种。跑个几百行的小文件没问题,一到网关请求就露馅:箭头函数写成一行的、泛型中间有换行、装饰器带了一堆参数、字符串里有伪代码。正则本质上是状态机,对嵌套结构的表达能力约等于零,而且在 100MB 级别的代码库上性能堪忧。用了不到三天,我把它整个推翻了。
3.2 为什么挑 tree-sitter
当时我对比了三类方案:一是使用 Babel 的 parser 体系,二是直接调用语言官方编译器,三是用 tree-sitter。最终选了 tree-sitter,原因很实际:
- 它对多语言支持友好,同一套 API 可以处理 JS、TS、Python,不需要为每种语言单独适配。
- 它生成的是具体语法树(CST),比 AST 保留了更多源码位置、空白、注释等细节,对于做“源码地图”非常关键。
- 它支持增量解析,大项目改动单个文件时不用整棵重解析,性能优势明显。
- 它提供了 WebAssembly 版本,在浏览器和 Node 里都能跑,这为后续做纯前端可视化版本埋了伏笔。
需要说明一下:tree-sitter 的解析结果是纯语法层面的,不包含类型信息。所以 codegraph 目前对 TypeScript 的类型别名、接口结构不做过深挖掘,这也符合我上面说的“图式阅读”定位。
3.3 图存储和可视化的取舍
图数据格式我选了 JSON 作为第一输出格式,因为 JSON 是人类可读、机器可读、任何语言都能解析的最低公约数。可视化方面,第一次迭代直接输出 Graphviz 的 DOT 文件,利用dot命令生成 SVG;后续再做成 Web 界面,底层用 D3.js 或 Canvas 渲染。如果将来项目积累够,再考虑内置更现代的可视化组件。
我把整个技术栈列成表,方便你看:
| 模块 | 方案 | 选型理由 |
|---|---|---|
| 语言解析 | tree-sitter | 多语言、CST 保留源码位置、增量解析快 |
| 开发语言 | TypeScript + Node.js | 生态丰富,CLI 开发方便,类型安全 |
| 图数据格式 | JSON | 通用、可调试、易对接 |
| 图形输出 v1 | DOT + Graphviz | 零依赖出图,成熟稳定 |
| 图形输出 v2 | Canvas 交互界面 | 支持缩放、点击跳转、模糊搜索 |
4. 核心流程拆解:源码是怎么变成一张图的
4.1 文件扫描与语言识别
程序入口会接收一个目录路径,然后递归扫描所有支持的文件类型,默认规则:.js、.jsx、.mjs识别为 JavaScript,.ts、.tsx识别为 TypeScript,.py识别为 Python。这里有个容易踩坑的点:不要只看扩展名,有些项目把.ts文件当纯 JS 写,有些.py文件其实内容不合法。我的做法是先按扩展名粗判,再用 tree-sitter 的 parser 尝试解析,失败就把该文件标记为parse_error,不会让整个任务崩溃。
4.2 AST 遍历与符号提取
拿到 parser 之后,对每个文件生成 CST,然后做一次递归遍历。树遍历这部分我总结了三个经验:
- 每种节点类型在 query 里写专门的 pattern 捕获,不要写一个 if else 大杂烩。
- 捕获符号时同时记录
startPosition和endPosition,行号和列号都存下来,后续做“点击跳转到源码”功能时直接能用。 - 不要把嵌套的函数定义都提成顶层节点,只提取文件作用域下的符号;嵌套函数作为其外层节点的内部信息保留,但不上图。这样图上的函数节点数量控制在合理范围。
比如 JavaScript 里捕获函数声明的 query pattern 大概是这样的:
const query = parser.getLanguage().query(` (function_declaration name: (identifier) @function.name parameters: (formal_parameters) @function.params body: (statement_block) @function.body) @function `);这个 pattern 会精确匹配函数声明,并把函数名、参数列表、函数体三部分分别捕获,方便后面提取内部调用关系。
4.3 调用关系提取与消歧策略
调用关系提取是精度最敏感的部分。第一步,从函数体的 CST 节点里找到所有call_expression和member_expression;第二步,解析被调用目标。这里有两种情况:
- 单个
identifier直接调用,比如parseFile(),那就一定是在当前文件内、当前作用域下能找到的那个函数。 module.exportName()、this.method()、obj.handler()这类成员调用,则需要查引用关系。
我的消歧策略比较简单粗暴但实用性很强:优先检查当前文件里已捕获的符号;找不到就去导入表里查;再找不到就标记为external_call,不强行解析。这个方案牺牲了小部分精度,换来了大部分代码库上的可用性。
4.4 跨文件依赖聚合
每个文件独立解析完成后,会得到各自的“局部图”。这时候进入聚合阶段:先合并所有节点的全局 ID(规则:文件路径:符号名:符号类型),再合并边。合并边时要注意去重,同一个调用关系可能被多个文件分别解析出来,需要用起点 ID + 终点 ID + 关系类型的组合做去重。聚合结果最终写成一个包含nodes和edges两个数组的 JSON 对象,每个节点标了file、name、type、position四个属性。
4.5 DOT 图的生成与定制
DOT 生成属于锦上添花的部分,但很能提升初期的使用体验。我写了一个toDot()函数,把节点映射成带颜色区分的方框:函数是蓝色,类是橙色,变量是绿色,模块是灰色;边用实线画调用,虚线画引用。生成后直接丢给 Graphviz:
codegraph analyze ./src --format dot --output code-graph.dot dot -Tsvg code-graph.dot -o code-graph.svg生成的 SVG 可以直接放进 Markdown 文档、插入在线 wiki,或者在浏览器里看。这一步的成本极低,但“零依赖出一张图”这个特性,比想象中的更受欢迎。
5. 实操记录:从零开始把 codegraph 跑起来的全过程
5.1 环境准备与安装
我假设你已经装好了 Node.js 18 以上版本。codegraph 发布成一个 npm 包,全局安装即可:
npm install -g codegraph如果只是临时用,也可以不全局装,直接在项目里跑npx codegraph。我实测下来全局安装在后续写脚本时会更顺手,因为可以直接在任意目录调用codegraph命令。
安装完先看版本:
codegraph --version如果输出了版本号说明安装成功。注意 npm 源的问题,如果安装超时,多半是默认源太慢,可以换镜像源,但这属于常规操作,我就不展开了。
5.2 初始化项目配置
codegraph 支持一个可选的配置文件codegraph.config.json,放在项目根目录。我建议第一次使用先初始化一份默认配置:
codegraph init生成的配置大概是这样的:
{ "rootDir": "./src", "languages": ["javascript", "typescript", "python"], "ignore": ["node_modules", "dist", "build", ".git"], "outputDir": "./codegraph-output", "includeNestedFunctions": false, "maxFileSizeKB": 512 }这里解释几个重要字段。ignore是必须配的,不排除 node_modules 的话,扫描结果会被依赖库淹没,几个月前我扫描一个项目没配 ignore,结果图里画了 9000 多个节点,吓得我以为自己的工具写崩了。maxFileSizeKB是防护阈值,超过 512KB 的文件不解析,防止某些压缩过的大文件把解析线程拖死。includeNestedFunctions设为 false 时只保留文件作用域的符号,建议新手先用默认值。
5.3 执行解析:analyze 命令详解
接下来就是对项目跑分析。最完整的命令长这样:
codegraph analyze --config ./codegraph.config.json如果项目结构简单,也可以直接指定目录:
codegraph analyze ./src --format json --output ./codegraph-output/graph.json跑完的提示信息会告诉你:总共扫描了多少个文件、成功解析多少个、失败多少个、提取了多少节点和边。我拿一个 300 多文件的 TypeScript 项目实测,全量解析耗时大约 6 秒,其中大部分时间耗在 tree-sitter 的 wasm 初始化上,真正解析文本其实很快。
5.4 查看 JSON 结果
解析产物是一个 JSON 文件,结构大概是这样的:
{ "meta": { "generatedAt": "2025-06-14T12:00:00.000Z", "language": "typescript", "fileCount": 342 }, "nodes": [ { "id": "src/utils/parser.ts:parseFile:function", "file": "src/utils/parser.ts", "name": "parseFile", "type": "function", "position": { "row": 12, "column": 0 } }, { "id": "src/utils/parser.ts:SourceFile:class", "file": "src/utils/parser.ts", "name": "SourceFile", "type": "class", "position": { "row": 38, "column": 0 } } ], "edges": [ { "source": "src/utils/parser.ts:parseFile:function", "target": "src/utils/reader.ts:readSource:function", "relationship": "calls" } ] }这种结构非常透明。我经常直接用 jq 命令来查询,比如找出所有调用过parseFile的地方:
jq '.edges[] | select(.target | contains("parseFile"))' codegraph-output/graph.json5.5 起一个本地可视化服务
JSON 和图文件都只是静态产物,codegraph 还有一个轻量的本地可视化模式:
codegraph server --port 8080它会起一个本地 HTTP 服务,打开浏览器访问http://localhost:8080之后,可以上传 JSON 图文件,然后在一个 Canvas 画布上拖动查看节点关系,支持按文件名搜索、点击节点查看源码位置信息。这个界面目前还很朴素,但比纯看 DOT 文件舒服得多。我日常的做法是:一边开着代码编辑器,一边开着这个图,遇到搞不清的调用关系直接在图里定位,然后跳到对应的源码行。
6. 常见问题排查:这些坑我踩过,说出来给你避雷
6.1 解析失败:提示无法加载 tree-sitter 的 wasm 文件
这个我遇到的频率最高。tree-sitter 的 WebAssembly 文件是随 npm 包发布的,如果环境里的绝对路径里有特殊符号,或者你在自定义的构建流程里改了 node_modules 目录结构,就容易找不到 wasm。解决办法是在配置文件里显式指定parserPath或者干脆重新执行codegraph init还原默认路径。如果是在浏览器里用,必须通过 HTTP 服务访问,不能直接file://打开,否则 wasm 跨域加载直接失败。
6.2 大项目解析慢,甚至卡死
先说结论:绝大多数情况不是解析有多慢,而是你忘了忽略node_modules。我踩过最大的坑是扫描一个 Electron 工程,ignore 漏了vendor目录,结果 20 分钟没跑完。把 ignore 配好之后,同类项目时间降到 8 秒。另外,如果项目很大,建议先把maxFileSizeKB调低到 128,先看整体骨架,再看细节。
6.3 图上全是乱线,箭头密密麻麻
这个现象通常有两种原因。第一种是导出符号太多,每个文件都把内部函数全导出了,导致模块之间互相引用;第二种是匿名函数被统一打上了anonymous标签,所有匿名函数汇到一个节点,图里自然呈现放射状。我的建议:解析时开启includeNestedFunctions: false,并且把“只提取文件级导出符号”的逻辑打开,代码里非 export 的符号不参与跨文件连边。
6.4 某些调用关系识别不到
比较常见的遗漏场景:动态导入import("./module")、成员方法通过thisMethod间接调用、装饰器包裹后的函数。tree-sitter 能解析出语法结构,但语义关系需要额外的常量传播分析,codegraph 目前不做这层计算。遇到这种识别不到的情况,我的建议是不要期望工具能 100% 还原调用关系。它是一个辅助理解工具,不是形式化验证工具,只要图上 80% 的关系是准确的,就已经能发挥很大的价值了。
6.5 Windows 下 DOT 文件转 SVG 乱码
Graphviz 在 Windows 上的 UTF-8 支持一直有历史问题,解决办法是在生成的 DOT 文件头部显式声明字体,或者干脆不要用dot命令行,改用 codegraph 内置的 HTML 可视化页面展示。
7. 真实案例复盘
7.1 接手老项目,我靠图谱省了两天
一个真实经历。有一次我需要为一个 5 年历史、没人维护的 Express 项目增加一套鉴权逻辑。一开始靠 grep 找到了鉴权中间件的定义,但不知道哪些路由挂载了它。用 codegraph 对这个项目跑了一遍,把 middleware 相关的调用边全部高亮出来,一眼看出三个路由文件漏挂了鉴权。原先至少需要花半天手动梳理,图谱让我在 40 分钟内就得出了结论。
7.2 重构前先摸依赖关系
有一次准备把一个 10000 多行的 util 文件拆成多个小模块。codegraph 的模块导入关系图显示了哪些函数被哪些文件引用,我直接按“依赖簇”划分边界,拆完之后看图确认模块之间没有意外交叉引用。这种安全感是代码 review 给不了的。
7.3 一个有价值的意外用法:文档自动化
有用户在 issue 里反馈,他们把 codegraph 生成的 JSON 接入了内部的文档生成系统,每周自动对核心模块生成依赖关系图,贴在 wiki 上。这确实是我一开始没预料到的场景,也说明把中间产物做成 JSON 是正确的决定。
8. 现在你还能拿它做什么:扩展思路
codegraph 目前定位是源码阅读和重构辅助,但它的数据模型是通用的。我后来在别的项目里直接 import 它的解析 API,把图数据当作二次加工的输入——比如做循环依赖检测:遍历所有imports类型的边,检测图中是否存在环;再比如做“受影响的模块分析”:从某个节点出发做 BFS,找出所有直接或者间接依赖它的模块。
如果你也想试试手,可以从 GitHub 拉源码、跑跑测试,看看解析器部分是每语言一个 parser adapter,加新的语言支持时只需要实现统一的extractSymbols接口就行,并不需要改核心图模型代码。这个设计是我最满意的一点,它让工具的边界非常清晰:解析归解析,建图归建图,渲染归渲染。
我实际体会是,这类工具最难的不是解析器,而是“克制”。代码里能提取的信息太多了,每种信息都有人会觉得有用,但一贪多,图就废了。保留最小但有解释力的符号集合,反而是让 codegraph 真正能落地使用的关键。如果你也在搞类似代码可视化,希望这篇过程记录能帮少走几段弯路;如果发现有意思的用法,也欢迎在项目里提 issue,我天天都在看。