如果你平时写代码靠console.log一路打点大概率体会过这种痛苦变量值在某个瞬间悄悄变了但你没打那行日志或者变量在十几个函数之间传来传去等真正发现问题的时候日志已经刷了几千行根本找不到源头。断点调试也未必省心点击“下一步”的次数一多脑子就乱了尤其面对异步逻辑、事件回调、定时器这类代码断点经常停在奇怪的位置变量面板里的值和直觉对不上。我这次要分享的是我自己写的一个面向“代码变量”监控的开源小工具名字叫var-watch。它解决的问题很具体在不改业务代码的前提下自动捕获指定函数作用域内的变量值变化然后把每个变量的变更过程渲染成时间线在网页面板里实时查看。它适合这几类人维护遗留项目但不想加一堆日志的你、排查跑了一万遍都复现不出来的偶发 bug 的你、以及想知道某个 npm 包内部状态变化但不想改源码的你。整个项目以 Node.js 和 Babel 为中心前后端加起来约一千多行代码这次我会把设计和实现完整拆开讲清楚。1. 背景一个变量值错了通常是怎么排查的先说一个我自己的真实场景。前阵子在调一个数据报表模块某个计算结果偶尔会少一条数据。代码链路大概是这样接口返回原始列表 → 经过filter→ 走groupBy→ 再merge出最终结构。我怀疑是groupBy的结果在后边某个环节被覆盖了但当时所有业务代码里没有一行日志我又不想为了排查这个问题往代码里塞五六个console.log因为改完源码还要记得删很容易忘。传统调试工具在这样的场景里也帮不上太多忙断点能看“某一个瞬间”但对“变量从什么值变成什么值”的过程追踪能力很弱。于是我开始认真琢磨能不能有一个工具像给代码装上行为记录仪一样把变量值的每次变化都记下来回头慢慢看。1.1 console.log、断点、日志库都有各自的短板先说console.log。它最大的问题就是侵入性强你得手动决定“在哪打日志、打哪个变量、打多少次”。要是漏掉一个关键赋值点整个排查就得推翻重来。而且日志输出是扁平的变量之间是什么作用域关系、调用链是怎么走的全凭脑补。生产环境还不能随便打打多了日志本身就成了一种负担。断点调试则相反它给你的是另一个极端的体验太“微观”了。你确实能看到每一帧调用栈、每一个变量的当前值但要找到一个变量“是从哪一步开始不对的”你得一直点“单步执行”一旦涉及异步跳转控制流就跳到别的地方去了很容易把自己绕晕。另外一个隐藏痛点是断点只能在你熟悉的代码里下如果问题出在node_modules里某个包的内部你大概率不会去那个目录打断点。至于日志类库比如winston、log4js它们擅长的是“分级输出”和“落盘”本质还是人肉打点。我要的恰恰相反希望源码保持干净由工具自动决定监控哪些变量并且把变化过程结构化地呈现出来。var-watch就是朝着这个方向做的。2. var-watch 的整体设计不写一行监控代码动手之前我先把目标拆了一下大概是这么几条零侵入业务源码不手动添加任何监控语句。自动感知作用域对于一个函数能够自动记录参数初值、局部变量的每次赋值、以及函数返回值。实时可视化多个函数的变量变化能汇总到一个时间线上支持过滤、暂停、回溯。尽量通用既能用在 Node.js 服务端脚本也能用在浏览器项目里。想达成“零侵入”最直接的办法是在代码编译阶段做文章。JavaScript 生态里Babel 是绕不开的编译基础设施。它能把你写的代码解析成 AST抽象语法树我在 AST 上识别函数和变量声明然后自动插入一段监控代码这段代码在运行时把变量值发送给采集服务。最终用户跑业务代码的时候感知到的只是多了一个带var-watch前缀的中间产物源码一个字节都没改。2.1 为什么选择编译期插桩而不是运行时代理其实还有一个候选方案是运行时代理比如用Proxy包装对象、或者改写Object.defineProperty但这套在 JavaScript 里有一个大坑它只能拦截“通过对象属性访问的变量”对基础类型的局部变量根本无能为力。也就是说你监控let count 0; count 1这种最简单的情形运行时代理是没辙的因为count不是对象的属性它就是一个栈上的字面变量。普通函数里的局部变量、参数运行时都探不到。所以只要目标覆盖局部变量就必须在编译期把探针插进去。Babel 插件本质上就是遍历 AST在合适的位置改树。它做的是静态改写做得好的话对语义的影响可以压到最低。另一个同样可行的路线是写一个自定义的 Vite/Rollup 插件因为社区生态成熟构建工具接入方便后面我会分别在两个场景里说明接入方式。2.2 整体架构插桩端、采集端、可视化端整个工具最终拆成三个部分这样职责清楚也方便在不同项目里裁剪着用模块职责主要产物var-watch/babelBabel 插件自动插入探针中间代码var-watch/core运行时探针逻辑负责收集变量变化、去重、序列化一个内置的探针函数var-watch/ui可视化面板接收探针数据并渲染时间线一个浏览器页面 WebSocket 服务数据流大概是你的源码 → Babel 插件插桩 → 业务代码运行 → 探针函数触发 → 数据发往 WebSocket 服务 → 浏览器面板实时展示。这里有一个关键设计探针函数本身不会把每次变化都立刻发送而是先放进一个环形缓冲区按时间窗口批量推送避免高频变量赋值把网络和渲染都拖垮。后面我会讲到具体的节流策略。3. 自动插桩的细节实现探针长什么样编译期插桩是这个项目最核心的部分。我先给一个简单的目标函数作为例子function fetchUsers(projectId) { const baseUrl /api/users; let page 1; let userList []; while (page 3) { userList userList.concat(loadPage(projectId, page)); page page 1; } return userList; }var-watch/babel插件要做的事就是把上面这段代码改写成类似下面这种逻辑function fetchUsers(projectId) { __varWatchProbe.enter(fetchUsers, [projectId], [projectId]); const baseUrl /api/users; __varWatchProbe.set(baseUrl, baseUrl, const); let page 1; __varWatchProbe.set(page, page, let); let userList []; __varWatchProbe.set(userList, userList, let); while (page 3) { userList userList.concat(loadPage(projectId, page)); __varWatchProbe.set(userList, userList, let); page page 1; __varWatchProbe.set(page, page, let); } const __varWatchReturn userList; __varWatchProbe.exit(fetchUsers, __varWatchReturn); return __varWatchReturn; }实际生成的代码会比这个严谨一些比如要处理return在try/finally里的情况但核心思想就这四类探针函数进入、变量赋值、变量更新、函数退出。这套设计覆盖了“一个变量生命周期”里的所有关键节点。3.1 Babel 插件里怎么区分“声明”和“赋值”Babel 插件里有两种访问节点很重要一个是VariableDeclarator一个是AssignmentExpression。VariableDeclarator对应let page 1、const baseUrl ...这类声明同时赋值。AssignmentExpression对应page page 1、userList ...这类对已声明变量的二次赋值。我分别实现两个visitor在逻辑上做一个变量登记表。当看到一个VariableDeclarator时我会用path.scope.getBinding(name)去查这个绑定到底属于哪个函数作用域然后在这个作用域的探针上下文里注册变量名。等到看到AssignmentExpression时做两件事查这个变量是不是之前登记过的、以及赋值表达式所在的函数还是不是同一个。如果同一个函数作用域里的变量重新被赋值我就在原来的赋值点之后插入新的set探针。这里有个细微处for (let i 0; i n; i)这种循环里的i其实是一个UpdateExpression不是普通的赋值表达式。Babel 的 AST 里i会被解析成UpdateExpression它的内部有一个argument节点指向变量i。如果不处理这类节点很多循环里的计数变量你根本监控不到。我一开始就是漏了这块导致看到面板上只有初始化值没有变化排查半天才发现是这里的问题。所以插件里要同时处理三种节点VariableDeclarator、AssignmentExpression、UpdateExpression。3.2 函数作用域与调用栈怎么对应每个探针发回的数据如果只有变量名和值回溯的时候根本没法看因为不同函数里可能都有同一个index变量。所以探针必须知道自己当前在哪个“作用域实例”里。我在函数进入探针里生成一个唯一 ID类似fetchUsers#001然后用一个模块级的栈结构维护当前调用链。进入函数时 push 一个调用帧退出时 pop。变量数据里记录callStack字段面板端就靠这个字段把所有变量按调用链分组展示。// var-watch/core 内部的数据结构简化示意 const callStack []; function enter(name, argNames, argValues) { const frame { id: name # (uid), parentId: callStack.length ? callStack[callStack.length - 1].id : null, name, args: buildSnapshot(argNames, argValues), entries: [], }; callStack.push(frame); publish({ type: scope_enter, data: frame }); } function set(name, value, kind) { const frame callStack[callStack.length - 1]; if (!frame) return; const record { type: var_set, name, value: safeSerialize(value), kind, time: Date.now() }; frame.entries.push(record); publish(record); }这里我刻意用了“调用栈”而不是“作用域链”这个词因为 JavaScript 运行时对每个函数调用建立一个执行上下文局部变量属于执行上下文和调用栈是绑定关系。探针用栈来对齐执行上下文是最贴近运行时模型的做法。不过要小心异步回调执行时外层函数可能已经退出调用栈就空了。也就是说fetchUsers里的setTimeout回调一旦执行栈顶已经不是fetchUsers。我在处理这种情况时会把异步创建的探针上下文挂在父调用帧的子级用asyncId辅助关联这部分的实现细节我在常见问题里再展开。3.3 值的序列化不能只靠 JSON.stringify变量监控里最麻烦的是序列化。很多变量不是普通对象可能是Map、Set、Error、Promise、甚至循环引用的对象。直接JSON.stringify遇到循环引用会直接抛异常这会让探针本身成为新的 bug 源。我采取的策略是深度优先遍历对象维护一个seen集合来检测循环引用同时为特殊类型加了标签function safeSerialize(value, depth 0) { if (value null || typeof value ! object) { if (typeof value bigint) return { $type: bigint, $value: value.toString() }; if (typeof value function) return { $type: function, $value: value.name }; return value; } if (depth 5) return { $type: truncated }; if (value instanceof Map) { const entries []; value.forEach((v, k) entries.push([safeSerialize(k, depth 1), safeSerialize(v, depth 1)])); return { $type: map, $entries: entries }; } if (value instanceof Set) return { $type: set, $values: Array.from(value).map(v safeSerialize(v, depth 1)) }; const keys Object.keys(value); const result {}; for (const key of keys) { try { result[key] safeSerialize(value[key], depth 1); } catch (e) { result[key] { $type: error, $value: e.message }; } } return result; }序列化层还有一层价值它等于天然做了一次深拷贝快照。时间线上选两个点做变量 diff 时直接拿序列化结果对比就行不用再回到运行中的应用里拿值。后面我会讲到面板上的“变量冻结对比”功能就是依赖这份快照的。4. 实时面板把变量变化变成时间线探针采集到的数据如果只落在终端里和console.log区别不大。真正让这个工具好用的是可视化面板。面板端是一个简单的 Vite React 应用通过 WebSocket 和采集端通信。它主要展示四块内容当前调用链、实时变量流、变量时间线、元信息侧栏。调用链是树形结构能看到某个变量当前是哪个函数在执行、它的父调用是谁。实时变量流更像一个全局事件日志每条记录都有时间、函数名、变量名、操作类型。变量时间线是核心横轴是时间纵轴是所有被监控的变量每个赋值点用事件点表示点开能看到值快照。4.1 数据协议WebSocket 消息怎么设计采集端和面板端之间的协议必须稳定不然面板端换一套 UI 就得重写底层。我定义了四种核心消息{ cmd: scope_enter, data: { callId: fetchUsers#001, parentCallId: null, name: fetchUsers, args: [] } } { cmd: var_set, data: { callId: fetchUsers#001, name: userList, value: { $type: array, $items: [...] }, kind: let, time: 1690000000000 } } { cmd: scope_exit, data: { callId: fetchUsers#001, returnValue: { $type: array, $items: [...] } } } { cmd: ping, data: { time: 1690000000000 } }消息体刻意把cmd和data分开这样面板端可以按照cmd分发到不同处理器。var_set一定是挂在某个callId下的否则时间线画不出来。我还在每个消息里加了time字段这是探针采集时的时间戳而不是面板端收到时的时间戳。为什么不用后者因为 WebSocket 传输有延迟消息可能会乱序到达如果用“收到时间”排序可能出现page变量已经变到 5界面还显示变成 4 的错乱情况。排序一律按探针里的time字段来面板收到后先放入队列按time排序再渲染。4.2 高频变量更新怎么处理真实项目里有些函数里变量更新频率非常高比如动画循环里requestAnimationFrame回调里的progress变量一秒钟渲染 60 次。如果每个赋值都发一条 WebSocket 消息浏览器面板直接卡死。我在var-watch/core里做了三个层级的节流时间窗口节流同一个变量在同一函数、同一上下文里5ms 内只发一次。值变化检测如果新值和旧值经过safeSerialize后相等直接丢弃不进入发送队列。环形缓冲发送端维护一个最多 200 条记录的缓冲超过就合并成批量消息发送。class ThrottledPublisher { constructor(emit, windowMs 5) { this.emit emit; this.windowMs windowMs; this.cache new Map(); // key - { lastTime, lastValue, record } } publish(record) { const key ${record.callId}:${record.name}; const cached this.cache.get(key); const serialized safeSerialize(record.value); if (cached Date.now() - cached.lastTime this.windowMs) { cached.lastValue serialized; return; } this.cache.set(key, { lastTime: Date.now(), lastValue: serialized }); this.emit(record); } }这套节流在 UI 上不会丢掉关键变化因为大部分中间值在实际调试时并不关心你关心的是“第一帧”和“最后一帧”的差异。如果确实需要看每一帧的详细变化可以在面板设置里把时间窗口调成 0但那时网络和渲染的压力得自己背。4.3 变量冻结与对比查看某个瞬间的状态排查 bug 的常见姿势是定义了一个状态对象在不同事件回调里改它最后表现不对。这个时候你很想在某个瞬间“冻结”所有变量然后和另一个瞬间做对比。我在面板里做了一个简单的快照功能点击时间线上任意一点所有当前变量的值会固化成一个快照。再点另一个点系统会 diff 两份快照列出哪些变量发生了变化变化前后的值以靠左对齐的格式显示出来。这个功能的实现在面板端核心是一个递归 diff 函数function diffValues(oldValue, newValue, path ) { const changes []; if (oldValue newValue typeof oldValue object typeof newValue object) { const keys new Set([...Object.keys(oldValue), ...Object.keys(newValue)]); for (const key of keys) { changes.push(...diffValues(oldValue[key], newValue[key], path ? ${path}.${key} : key)); } } else if (oldValue ! newValue) { changes.push({ path, from: oldValue, to: newValue }); } return changes; }有了这个能力很多“状态覆盖”类问题就非常好查。比如两个接口并发回来A 接口和 B 接口都往同一个数组里塞数据你先看到数组被 A 的数据填满又被 B 的数据覆盖面板上 diff 结果直接告诉你“谁覆盖了谁、在哪个函数里、精确到哪一行”。5. 真实案例我用它定位了两个难缠的 bug工具写出来之后我不可能只拿 toy example 验证所以直接把它接入了正在维护的内部管理系统选了排查难度最高的两个页面做测试。结果还真帮了大忙。5.1 异步竞态早到的结果覆盖了晚到的第一个问题是列表数据偶尔被“旧数据”刷掉。看代码逻辑已经加锁了为什么还是出问题如果靠断点得在多个请求回调里来回跳非常难模拟。用var-watch跑一遍把listData这个变量单独抽出来看时间线我惊讶地发现赋值顺序和请求返回顺序根本不一致。具体来说第二次请求先触发.then把 listData 设置为结果 B然后第一次请求的.then才触发把 listData 覆盖成了结果 A。锁只加在了发送请求之前却没有在.then里判断响应是否过期。这个问题在时间线上一眼就能看出来listData 的值从空 → A → B → A后两次赋值来自两个不同的 promise 回调。定位到代码位置后修复方式是在回调里比对请求序号旧的响应直接丢弃。这类竞态 bug 在断点调试下极难复现但时间线视图下它就像路面上两道清晰的车辙印。5.2 第三方包内部的私有状态第二个问题来自一个 npm 包它内部缓存了一批配置但我的业务代码拿不到这个缓存。我怀疑缓存更新逻辑有 bug但那个包代码比较绕直接在源码里打断点不现实。用var-watch跑我其实也没法完全看懂它内部所有变量但我在面板上把configCache和cacheVersion这两个变量关系单独挑出来看发现cacheVersion每次都会变但configCache的 key 没有跟着更新导致查询走到旧的缓存分支。这就是典型的“内部变量不一致”问题从外部黑盒很难推断但从变量时间线看因果关系非常清楚。我没有改这个包的代码而是在业务侧增加了一个缓存失效重查逻辑问题解决。6. 常见问题与踩坑速查表这段可能对想自己接这个工具的人最实用。我把自己开发和使用过程中踩到的坑全部整理出来。6.1 性能影响有多大这是所有人第一个会问的问题。插入探针本质上是“每个变量赋值多调用一次函数 一次序列化”开销肯定不是零。我自己在包含上千次变量操作的服务端函数上做了压测开启插桩大概有 5% 到 10% 的耗时增加。大部分场景能接受但有几类代码必须避开超高频循环内、渲染热路径、GC 敏感区域。为此我在 Babel 插件里加了include和exclude配置可以指定只监控某一个目录比如var-watch/babel只处理src/**完全不碰node_modules。另外还提供了一个表达式级别的开关如果赋值语句本身在某个高频循环体内部可以选择跳过探针注入。这个功能是通过检查 AST 上方的Loop父节点来实现的实现简单但收益很大。6.2 为什么有些变量监控不到最常见的丢失场景发生在块级作用域里。if分支、try/catch块内部声明的let/const它们的作用域不是整个函数。我的探针是按函数作用域登记的如果变量声明块在某个分支内部我就额外给这个块单独建一个作用域实例而不是简单挂在函数上。这样不会漏但需要仔细处理块嵌套。另一个场景是switch里每个case的代码块也会形成独立作用域同样要处理。还有一个我一开始没意识到的问题解构赋值。const { a, b } obj这种写法在 AST 里不是简单的VariableDeclarator能覆盖的因为id是ObjectPattern里面嵌套了多个变量名。我对ObjectPattern做了递归展开把它当成多个变量分别登记才能逐个监控。ArrayPattern也同理。6.3 和 source map 配合时怎么看源码位置因为代码被 Babel 转换过直接看堆栈会指向中间产物。解决方法是让插桩阶段生成标准 source map面板端拿到source-map库反查原始位置。探针采集数据里附带当前源码的line和column这个信息来自 Babel 节点上的loc属性只要保证最终 bundle 加载了 source map就能精确到源码行。// 在 Babel 插件中获取节点位置 const loc path.node.loc.start; probeData.push({ line: loc.line, column: loc.column });6.4 箭头函数与普通函数的插桩差异箭头函数没有自己的this、arguments也没有new.target但这些和变量监控关系不大。真正要小心的是箭头函数不能作为生成器函数处理同时它的函数体可能是一个表达式而不是块语句。比如const add (a, b) a b;它的body不是BlockStatement而是BinaryExpression。直接往body里插入探针会语法错误。我的处理方式是把表达式体包成块语句if (t.isExpression(path.node.body)) { path.node.body t.blockStatement([ t.returnStatement(path.node.body) ]); }这样既能插入探针又能保持返回语义一致。不过要注意如果箭头函数原本返回的是一个对象字面量包装时容易把语义搞错必须显式写return ({ ... })不能直接return { ... }加不加括号会导致解析差异。6.5 调用栈与异步回调的映射前面提到过异步回调触发时外层调用栈已经退出了探针会丢失上下文。我在实现里用 Node.js 的async_hooks模块做了辅助关联。具体做法是记录异步资源创建时所在的调用帧 ID然后在回调执行时如果栈为空就用这个帧 ID 作为父帧重新创建上下文。但这只在 Node 环境有效浏览器端我退而求其次把回调里监控到的变量挂在当前模块的根级上下文下同时标记async: true确保变量不会因为上下文丢失而完全消失。6.6 变量名冲突与关键词问题探针函数如果叫__varWatchProbe有可能和业务代码里的某个变量重名。为了避免这种情况生成的探针名可以带一个随机后缀比如__varWatchProbe_7x3k9。同时还要小心不能把探针插入到已经包含同名的作用域里否则变量遮蔽会导致探针不可用。我在插件里维护了一张所有已声明变量名的表发现作用域内已有同名变量时生成另一个更不可能冲突的名字。7. 插桩配置与实践接入讲完原理和坑最后说说怎么在真实项目里快速跑起来。整个接入分三步安装依赖、配置 Babel、启动面板。7.1 Node.js 服务端场景如果你调试的是一个 Node.js 脚本最省事的接法是用 CLI 包装器。我这里做了一个var-watch命令它会调用babel/core对入口文件做一次即时转换转换结果写入临时目录然后用node执行npm i -D var-watch/cli var-watch/babel var-watch/core npx var-watch ./src/index.js命令启动后默认监听127.0.0.1:8899同时打开面板页面。CLI 内部做的事情很简单先读取入口文件用transformFileSync处理找到所有src目录下的文件并批量插桩再把产物交给 Node 执行。探针数据统一打到本地的 WebSocket 服务。7.2 浏览器项目场景在 Vite 项目里更简单Vite 的插件机制允许我直接复用 Babel 插件。我写了一个var-watch/vite插件核心配置如下// vite.config.js import varWatch from var-watch/vite; export default { plugins: [ varWatch({ include: [src/**/*.{js,ts,vue}], exclude: [src/utils/performance.js], enabled: process.env.NODE_ENV ! production, panel: true, }), ], };这里我给浏览器的探针做了个适配不再依赖async_hooks而是把采集到的数据封装到postMessage或 WebSocket 里发出去。Vite 插件本质上也是调用 Babel 插件的 transform 逻辑只是为了和 Vite 的模块加载机制融合需要在transform钩子里拿到模块路径然后再执行 Babel 转换。7.3 Vue/React 组件里的变量监控有用吗有用但要区分变量类型。组件的状态变量比如useState的返回值本质上是局部变量探针能捕获到它们的赋值变化吗这里有一个天然限制useState的值存放在 React 内部返回的更新函数是dispatchSetState我们不能在编译期知道内部存储位置所以在组件里直接插桩捕获不到setState之后的具体值变化。但如果你把setState的参数先存到一个局部变量里再调用setState(localValue)那么localValue的赋值就能被探针捕获到。这对于排查“状态更新之前数据为什么不对”的场景非常有效。Vue 3 的ref和reactive也类似探针捕获的是let rawData ...; data.value rawData;这个流程中的rawData而不是data.value内部的响应式代理变化。8. 一些真实的使用体会工具虽然是我自己写的但我不会无脑推荐所有人任何场景都用。它最适合的是“变量之间因果关系复杂、且现在没有一个好的观测手段”的排查场景尤其是异步竞态、第三方包内部状态、复杂业务链路这些。反过来如果你只是想快速打印一个值看一眼console.log还是最快的毕竟一个工具从接入到熟练操作也有学习成本。我个人开发完这个工具最大的感受是调试工具的终极价值不是帮你“看得更多”而是帮你“少想一点”。变量时间线一旦建立你不需要在脑内模拟整个执行流的先后顺序只要顺着时间线看谁先变了、谁后变了、最终停在哪逻辑链条自然浮现。这种体验在排查竞态和偶发 bug 时尤其珍贵。如果你也有类似的痛点不妨按这个思路自己写一个。不需要做到和我一样的面板复杂度哪怕是往日志文件里按固定格式输出变量变化再写个脚本做 diff也能解决很大一部分问题。关键是抓住“自动插桩 结构化数据 时间线回放”这个核心剩下的都是锦上添花。后续我还在计划给面板加条件监控能力比如“当变量值等于某个值时自动截停”以及支持更细粒度的批注功能让多个协作者可以在同一根时间线上标记可疑点。这些都还在迭代中有机会再单独写一篇分享。