目录
3075 字
15 分钟
如何阅读源码:从入口到脉络

卡住的原因通常不是「难」#

接手一个内部库,五千行,README 只有一行 npm install。某个接口慢,你打开 index.js,下定决心从第一行开始读。

两小时后你读到第 200 行,已经忘了自己最初要找什么。

换个做法:先把程序跑起来,用 node --cpu-prof 采一份 profile,五分钟后你知道 parseQuery 吃掉了 80% 的时间,然后只读那 30 行。

差别不在于谁更聪明,而在于读源码的核心动作不是「读」,是「定位」。你不需要理解整个代码库,只需要理解「通往你那个问题的那条路径」。这篇文章讲的就是怎么把这条路径找出来。

一、先分清你为什么读它#

同一个库,四种目的对应四种完全不同的读法:

目的读的范围深度什么时候停
改 bug从报错现场倒推的那条链每一行都要懂改完就停
评估依赖入口 API、依赖树、测试只读边界和契约能判断风险就停
借鉴设计核心抽象与数据结构要懂 why,不用懂每行能自己复刻出来
学习主线流程允许跳过细节没有终点

最常见的错误是用「学习」的方式去做「改 bug」的事——从第一行读起,试图先建立完整心智模型,再回头解决问题。这条路在五千行的库里基本走不通。

如果目的是改 bug 或排查性能,你只需要一条链;如果是评估依赖,你可能连源码都不用读,读它的 package.json、类型定义和测试就够了。

二、找入口:三个可靠的起点#

入口选错,后面全是白费。有三个起点几乎从不出错:

1. 你自己代码里正在调用的那一行。 这是最好的入口,因为它是你唯一确定「一定会被执行到」的地方。从 client.request() 跳到定义处,比从库的第一行开始读高效一个数量级。

2. 报错堆栈。 运行时已经免费帮你把调用链画出来了,别浪费它。堆栈里第一个属于这个库的帧,就是入口。

3. 框架反向调用你的地方。 生命周期钩子、事件回调、插件接口——「谁调了我」比「我调了谁」更难追,但往往更有价值,因为它决定了你的代码运行在什么上下文里。

定位的命令行三件套:

Terminal window
# 这个符号定义在哪?三种常见写法一次搜全
rg -n "function parseQuery|const parseQuery|parseQuery =" src/
# 谁在调用它?(是「反向」脉络的起点)
rg -n "parseQuery\(" src/
# 语言的隐式约定——这类「魔法」最容易看漏
rg -n "toJSON|Symbol\.toPrimitive|Symbol\.iterator" src/

第三条经常被忽略。一个对象被 JSON.stringify 时结果不对,光看调用点永远找不到原因,问题在它自己实现的 toJSON 里。

三、三条脉络线#

找到入口之后,沿着三个方向展开:

  • 向下:调用链。 谁调用了谁,控制流怎么走。
  • 横向:数据流。 一个对象从创建到销毁,哪些字段被谁改过。这条线最容易被忽略,也是 bug 最集中的地方。
  • 向上:生命周期。 谁在什么时机调用了这段代码,它又会在什么时机被销毁。

看一个真实的坑。这个迷你中间件管道只有 10 行:

const middlewares = [];
function use(fn) { middlewares.push(fn); }
async function handle(req) {
let i = 0;
const next = async () => {
if (i >= middlewares.length) return;
await middlewares[i++](req, next);
};
await next();
return req;
}

代码量小到可以逐行读,但三条脉络线全都绕:

  • 调用链上,next 是递归的——每个中间件拿到的 next 其实是同一个闭包,只是闭包里的 i 在变;
  • 数据流上,req 被每个中间件依次修改,i 是共享可变状态;
  • 生命周期上,use() 必须在 handle() 之前调用完,否则管道是不完整的。

如果你在这个库里排查「为什么第三个中间件没执行」,光读 handle 是能看出来的;但如果是「为什么请求对象里少了一个字段」,你就得横向追 req。同一个入口,不同的问题,要走的脉络线不一样。

而如果这段代码是别人写的一万行版本,读就不如跑了。

四、让程序自己说:先跑,再读#

这是整篇文章最想传达的一点:你有一个别人读源码时没有的武器——你可以让程序运行起来,然后问它刚才发生了什么。

Node.js 内置的 CPU profiler 就是干这个的。它不需要你改一行业务代码:

Terminal window
node --cpu-prof --cpu-prof-dir=. app.mjs

跑完会在当前目录生成一个 CPU.*.cpuprofile,本质是一份 JSON:samples 是每次采样命中的函数,timeDeltas 是那次采样的耗时,nodes 是调用树。它记录的是真实执行过的路径,而不是你猜的那条。

原始文件不好读,下面这个脚本把它还原成调用树。完整代码:

// analyze-cpu-profile.mjs —— 把 node --cpu-prof 的产物还原成一棵真实运行过的调用树
// 用法: node analyze-cpu-profile.mjs <*.cpuprofile> [函数名关键字]
import { readFileSync } from 'node:fs';
const [file, keyword] = process.argv.slice(2);
if (!file) {
console.error('用法: node analyze-cpu-profile.mjs <*.cpuprofile> [函数名关键字]');
process.exit(1);
}
const profile = JSON.parse(readFileSync(file, 'utf8'));
const byId = new Map(profile.nodes.map((n) => [n.id, n]));
// samples[i] 是第 i 次采样命中的节点 id,timeDeltas[i] 是这次采样的时长(微秒)
const selfTime = new Map();
for (let i = 0; i < profile.samples.length; i++) {
const id = profile.samples[i];
const dt = profile.timeDeltas[i] ?? 0;
selfTime.set(id, (selfTime.get(id) ?? 0) + dt);
}
// 建立「子 → 父」索引,后面要沿它回溯调用路径
const parentOf = new Map();
for (const node of profile.nodes) {
for (const childId of node.children ?? []) parentOf.set(childId, node.id);
}
// 自底向上累加,得到每个节点的总耗时(含全部子调用)。
// 用显式栈而不是递归:真实项目的调用树可能深到把 JS 调用栈撑爆。
const totalTime = new Map();
const stack = [[profile.nodes[0].id, false]];
while (stack.length) {
const [id, expanded] = stack.pop();
if (!expanded) {
stack.push([id, true]);
for (const childId of byId.get(id).children ?? []) stack.push([childId, false]);
continue;
}
let sum = selfTime.get(id) ?? 0;
for (const childId of byId.get(id).children ?? []) sum += totalTime.get(childId) ?? 0;
totalTime.set(id, sum);
}
const ms = (us) => (us / 1000).toFixed(1);
// 只保留仓库内的相对路径,node_modules 和 node: 内部函数单独标注
const shortUrl = (url = '') => {
if (url.startsWith('node:')) return url;
const i = url.indexOf('/node_modules/');
if (i >= 0) return `…${url.slice(i)}`;
return url.replace(/^file:\/\//, '').replace(`${process.cwd()}/`, '');
};
// 按「自身耗时」排序:总耗时高只说明它在调用链上游,自身耗时高才是真正吃 CPU 的地方
const top = [...selfTime.entries()]
.map(([id, self]) => ({ node: byId.get(id), self }))
.filter((x) => x.node)
.sort((a, b) => b.self - a.self)
.slice(0, 15);
console.log(`采样 ${profile.samples.length} 次,总耗时 ${ms(totalTime.get(profile.nodes[0].id))}ms\n`);
console.log('自身耗时 Top 15(真正在烧 CPU 的函数):');
for (const { node, self } of top) {
const f = node.callFrame;
console.log(` ${ms(self).padStart(8)}ms ${(node.hitCount ?? 0).toString().padStart(5)} 次 ${f.functionName || '(anonymous)'} @ ${shortUrl(f.url)}:${f.lineNumber + 1}`);
}
// 想知道「这个函数是被谁调起来的」,就沿 parentOf 一路回溯到根
if (keyword) {
const hits = [...totalTime.entries()]
.map(([id, total]) => ({ frame: byId.get(id).callFrame, id, total }))
.filter((x) => x.frame.functionName?.includes(keyword))
.sort((a, b) => b.total - a.total)
.slice(0, 3);
for (const { id, total } of hits) {
const path = [];
for (let cur = id; cur !== undefined; cur = parentOf.get(cur)) path.unshift(cur);
console.log(`\n调用路径(总耗时 ${ms(total)}ms):`);
for (const cur of path) {
const f = byId.get(cur).callFrame;
console.log(` ${ms(totalTime.get(cur)).padStart(8)}ms ${f.functionName || '(anonymous)'} @ ${shortUrl(f.url)}:${f.lineNumber + 1}`);
}
}
}

拿上面那个中间件管道的例子跑一遍:

Terminal window
node --cpu-prof --cpu-prof-dir=. app.mjs
node analyze-cpu-profile.mjs CPU.*.cpuprofile parseQuery

输出是这样(数值随机器波动,路径已简化):

采样 157 次,总耗时 337.0ms
自身耗时 Top 15(真正在烧 CPU 的函数):
164.1ms 106 次 parseQuery @ app.mjs:16
84.1ms 26 次 parseQuery @ app.mjs:16
62.3ms 0 次 (program) @ :0
10.3ms 8 次 (anonymous) @ app.mjs:25
1.1ms 1 次 next @ app.mjs:8
…(其余为 Node 内部模块的启动开销,此处省略)
调用路径(总耗时 164.1ms):
337.0ms (root) @ :0
172.5ms (anonymous) @ app.mjs:1
172.5ms handle @ app.mjs:6
172.5ms next @ app.mjs:8
172.5ms (anonymous) @ app.mjs:25
164.1ms parseQuery @ app.mjs:16

这份输出直接回答了「脉络」的三个问题:

  • 谁是热点? parseQuery 两行加起来 248ms,占七成以上。你不用再猜。
  • 它是被谁调起来的? 从根一路回溯:handle → next(管道调度)→ app.mjs:25 的匿名中间件 → parseQuery。这就是向下调用链反过来的读法——你已经有了完整路径,只需要读这四层。
  • 为什么会有两条 parseQuery? 因为它们挂在调用树的不同节点下,一次在模块求值路径里,一次在 handle 的循环里。这种重复只有跑出来才看得见,纯读代码很容易漏掉。

值得注意的是,两个关键词都指明了同一件事:「自身耗时」和「总耗时」要分开看。handle 的总耗时最高(172.5ms),但它自己的耗时接近 0——它只是个调度者,优化它毫无意义。真正该动的是 parseQuery。

这套方法对 node_modules 里的第三方库一视同仁:不必有源码可读,profile 里的 URL 会直接告诉你热点落在哪个包、哪一行。

五、考古:读「为什么」,而不是「是什么」#

代码只告诉你 what,git 才告诉你 why。当某段逻辑看起来毫无道理时,先别急着改,去问历史:

Terminal window
# 这个符号是哪个提交引入的?(-S 搜的是「内容变化」,不是文件名)
git log -S "parseQuery" --oneline
# 第 16 到 20 行经历过什么?逐次改动的 diff 和提交信息都会列出来
git log -L 16,20:src/parse.js --oneline
# 文件被重命名过也要一路跟到底
git log --follow -p src/parse.js

git log -S 能找到「某段代码是何时被加进来的」,这是它比 git blame 强的地方——blame 只告诉你最后碰过这一行的是谁,-S 告诉你这个想法从哪来。

实践中,一次「看起来多余的判空」背后往往是一次真实的线上事故。那份提交信息和它关联的 issue,比代码本身信息量大得多。改之前先看一眼历史,是成本最低的自我保护。

六、把测试当规格说明书#

如果这个库有测试,从测试文件开始读,比从 index.js 开始读快得多。

原因很简单:测试是唯一不会说谎的文档。注释会过期,README 会漏,但测试必须和实现保持一致,否则 CI 会红。而且测试天然回答了三个问题:

  • 这个 API 正常怎么用?(用例)
  • 边界在哪里?(异常分支的断言)
  • 哪些行为是被刻意保证的,哪些是碰巧的?(有没有对应的测试)

一个可操作的顺序是:先跑一遍失败的测试,再看实现。 失败的测试会直接把你带到出问题的那条脉络线上——比自己摸索快得多。

一份可以贴在显示器上的流程#

把上面这些压缩成六步:

  1. 写下一句话:我读这个源码,是为了回答什么问题?答不上来就先别打开文件。
  2. 找入口:从我调用的那一行倒推,或顺着报错堆栈走。
  3. 选脉络线:控制流问题看调用链,状态问题看数据流,时序问题看生命周期。
  4. 先跑起来:--cpu-prof 拿真实调用树,比读代码快一个数量级。
  5. 考古:git log -S 问清楚「为什么是这样」。
  6. 拿测试兜底:测试是唯一不会说谎的规格说明书。

最后一句:读源码的能力,本质上不是阅读能力,而是提问能力。 带着一个具体问题进去,五千行也会自动收窄成几十行;不带问题进去,五十行你也会迷路。

参考来源#

如何阅读源码:从入口到脉络
https://www.hehonglei.cn/posts/how-to-read-source-code/
作者
Honglei He
发布于
2026-09-25
许可协议
CC BY-NC-SA 4.0