
1. 为什么我要在 VS Code 里做「自动移除未使用变量」写 JavaScript 的时候最烦的不是逻辑复杂而是文件顶部堆了一排import和const改完需求之后有一半根本没人用。ESLint 能提示no-unused-vars但它只负责「告诉你」不负责「帮你删」。手动一个个删既慢又容易误删——尤其是解构赋值const { a, b } obj这种删错一个整个文件就报错。所以我决定自己写一个 VS Code 插件用 babel 把当前打开的 JS 文件解析成 AST遍历作用域找出「声明了但没被引用」的变量、函数、import然后自动删掉最后把新代码写回编辑器。整个过程一键完成快捷键Ctrl6触发。这个插件适合谁三类人一是天天写 JS、被 lint 警告烦到想批量清理的前端二是想入门 VS Code 插件开发、但不知道从哪下手的同学三是想学 babel AST 实战、把「解析-遍历-生成」跑通一遍的开发者。核心检索词就三个babel AST 遍历、VS Code 插件开发、自动移除未使用变量。下面我会从工程搭建讲到作用域分析每一步都能复制运行。先说清楚原理避免你后面看代码懵。babel 处理代码分三步babel/parser把字符串解析成 AST抽象语法树可以理解成代码的结构化 JSONbabel/traverse遍历这棵树配合path.scope做作用域分析判断某个变量有没有被引用babel/generator把改过的 AST 重新生成代码字符串。VS Code 这边只负责三件事拿到当前编辑器文本、把文本交给 babel 处理、把结果替换回去。分工非常清晰。我试过直接正则删变量结果遇到const a 1, b 2这种一行多声明就翻车遇到解构更是灾难。AST 方案虽然前期要理解节点类型但一旦跑通准确率和可维护性完全不是一个量级。这也是我坚持用 babel 而不是字符串替换的原因。2. 前置准备TaoToken 接入与 babel 依赖安装在动手写插件之前有两个前置动作一是把 babel 相关依赖装好二是如果你打算在插件里调用大模型做「智能判断」比如判断某个变量是不是真的可以删、或者生成删除建议需要先准备好模型接入。这里我用 TaoToken 作为统一入口它兼容 OpenAI 风格的接口配置简单适合插件这种需要稳定调用的场景。先说 babel 依赖。在插件工程根目录执行npm install babel/parser babel/traverse babel/generator babel/types --save四个包各司其职babel/parser负责解析babel/traverse负责遍历和作用域分析babel/generator负责回写代码babel/types提供节点类型判断工具比如t.isObjectPattern。版本上建议用 7.x和大多数工程兼容。再说 TaoToken 的接入。如果你只是做纯本地 AST 删除其实不需要模型但如果你想扩展成「让模型判断哪些变量是调试残留、可以安全删除」就需要一个稳定的 API 入口。TaoToken 的 API 地址是https://taotoken.net/api控制台在https://taotoken.net/consoleAPI Key 在https://taotoken.net/api-keys生成。拿到 Key 之后在插件里通过环境变量或配置读取不要硬编码进源码。这里给一个插件里调用模型做「删除建议」的最小配置示例放在package.json的contributes.configuration里让用户自己填 Key{ contributes: { configuration: { title: rm-unuse-var, properties: { rmUnuseVar.apiBase: { type: string, default: https://taotoken.net/api, description: 模型接口 Base URL }, rmUnuseVar.apiKey: { type: string, default: , description: TaoToken API Key }, rmUnuseVar.modelId: { type: string, default: claude-sonnet-4-5, description: 用于判断的模型 ID } } } } }注意三件套必须齐全Base URL API Key Model ID。少任何一个请求都会失败。Base URL 用https://taotoken.net/api不要加多余路径Model ID 按你实际可用的填。如果你只是跑本地 AST 删除这一节可以跳过直接进第 3 节。提示API Key 属于敏感信息建议通过 VS Code 的 SecretStorage 存储而不是明文写进 settings.json。插件里用context.secrets.store(rmUnuseVar.apiKey, key)保存读取时用context.secrets.get。3. 可复制配置插件工程结构与 package.json 完整片段这一节给你一份可以直接抄的工程配置。先用官方脚手架生成项目npm install -g yo generator-code yo code选择New Extension (JavaScript)名字填rm-unuse-varidentifier 也是rm-unuse-var描述写「移除未使用的变量」type checking 选 Yesgit 选 Yes包管理器选 yarn 或 npm 都行。生成后的目录结构大致是rm-unuse-var/ ├── .vscode/ │ ├── launch.json │ └── tasks.json ├── extension.js ├── package.json ├── jsconfig.json └── README.md接下来是package.json的关键配置。命令、快捷键、菜单、激活事件都在这里声明。完整片段如下你可以直接替换{ name: rm-unuse-var, displayName: rm-unuse-var, description: 移除未使用的变量, version: 0.0.1, engines: { vscode: ^1.80.0 }, activationEvents: [ onCommand:rm-unuse-var.remove ], main: ./extension.js, contributes: { commands: [ { command: rm-unuse-var.remove, title: 移除未使用变量 } ], keybindings: [ { command: rm-unuse-var.remove, key: ctrl6, mac: cmd6, when: resourceLangId javascript } ], menus: { editor/context: [ { command: rm-unuse-var.remove, when: resourceLangId javascript, group: navigation } ] } }, scripts: { lint: eslint ., pretest: npm run lint }, devDependencies: { types/vscode: ^1.80.0, eslint: ^8.0.0 }, dependencies: { babel/generator: ^7.23.0, babel/parser: ^7.23.0, babel/traverse: ^7.23.0, babel/types: ^7.23.0 } }几个关键点解释一下。activationEvents里的onCommand:rm-unuse-var.remove表示只有用户触发这个命令时才激活插件避免拖慢启动。keybindings里的when: resourceLangId javascript保证只在 JS 文件里生效打开 JSON、Markdown 时快捷键不会误触发。menus.editor/context把命令加进右键菜单方便鼠标党。extension.js的骨架先搭起来注册命令const vscode require(vscode); const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); function activate(context) { const disposable vscode.commands.registerCommand( rm-unuse-var.remove, function () { const editor vscode.window.activeTextEditor; if (!editor) { vscode.window.showInformationMessage(没有打开的编辑器); return; } const code editor.document.getText(); const newCode removeUnused(code); editor.edit((editBuilder) { editBuilder.replace( new vscode.Range( new vscode.Position(0, 0), new vscode.Position(editor.document.lineCount 1, 0) ), newCode ); }); } ); context.subscriptions.push(disposable); } function deactivate() {} module.exports { activate, deactivate };removeUnused就是核心处理函数下一节展开。这里注意editor.edit的替换范围从(0,0)到(lineCount1, 0)覆盖整个文件避免残留旧内容。4. 核心实现babel 解析、作用域分析与代码回写这一节是全文技术含量最高的部分。先讲清楚「未使用」的判定逻辑再给完整代码。判定一个变量是否被使用靠的是path.scope.getBinding(name)。它返回一个 binding 对象关键属性有三个referenced表示是否被引用过constantViolations表示被重新赋值的地方referencePaths是所有引用路径。如果referenced为 false说明这个变量声明后从没被读过可以删。但有个坑如果变量被重新赋值过比如var b 2; b 3;referenced可能仍是 false但constantViolations有值这时要把那些赋值语句也一起删掉否则会留下b 3这种悬空赋值。先看一个最小可运行示例把var b 2删掉const parser require(babel/parser); const traverse require(babel/traverse).default; const generate require(babel/generator).default; const t require(babel/types); const code var a 1; var b 2; b 3; console.log(a);; const ast parser.parse(code); traverse(ast, { VariableDeclaration(path) { const { node } path; node.declarations node.declarations.filter((declaration) { const { id } declaration; if (t.isObjectPattern(id)) { id.properties id.properties.filter((property) { const binding path.scope.getBinding(property.key.name); if (!binding) return false; const { referenced, constantViolations } binding; if (!referenced constantViolations.length 0) { constantViolations.forEach((v) v.remove()); } return referenced; }); return id.properties.length 0; } else { const binding path.scope.getBinding(id.name); if (!binding) return false; const { referenced, constantViolations } binding; if (!referenced constantViolations.length 0) { constantViolations.forEach((v) v.remove()); } return referenced; } }); if (node.declarations.length 0) { path.remove(); } }, }); console.log(generate(ast).code);跑出来结果是var a 1; console.log(a);b相关的声明和赋值都被清掉了。注意t.isObjectPattern分支处理的是解构比如const { test1, test2 } obj逐个属性判断全都没用就把整个解构删掉。接下来把 import 和 function 也纳入处理。import 的判断逻辑类似遍历node.specifiers看每个local.name对应的 binding 是否 referencedfunction 则看函数名 binding 是否 referenced。完整版removeUnusedfunction removeUnused(code) { const ast parser.parse(code, { sourceType: module, plugins: [jsx], }); traverse(ast, { VariableDeclaration(path) { const { node } path; node.declarations node.declarations.filter((declaration) { const { id } declaration; if (t.isObjectPattern(id)) { id.properties id.properties.filter((property) { const binding path.scope.getBinding(property.key.name); if (!binding) return false; const { referenced, constantViolations } binding; if (!referenced constantViolations.length 0) { constantViolations.forEach((v) v.remove()); } return referenced; }); return id.properties.length 0; } else { const binding path.scope.getBinding(id.name); if (!binding) return false; const { referenced, constantViolations } binding; if (!referenced constantViolations.length 0) { constantViolations.forEach((v) v.remove()); } return referenced; } }); if (node.declarations.length 0) { path.remove(); } }, ImportDeclaration(path) { const { node } path; const { specifiers } node; if (!specifiers.length) return; node.specifiers specifiers.filter((specifier) { const { local } specifier; const binding path.scope.getBinding(local.name); return !!(binding binding.referenced); }); if (node.specifiers.length 0) { path.remove(); } }, FunctionDeclaration(path) { const { node } path; const { id } node; if (!id) return; const binding path.scope.getBinding(id.name); if (!binding || !binding.referenced) { path.remove(); } }, }); return generate(ast).code; }用一段综合示例验证import vue from vue; var a 1; var b 2; var { test1, test2 } { test1: 1, test2: 2 }; function c() {} function d() {} d(); console.log(a, test1);处理后输出var a 1; var { test1 } { test1: 1, test2: 2 }; function d() {} d(); console.log(a, test1);vue、b、test2、c都被删了test1因为被console.log引用而保留d因为被调用而保留。逻辑完全符合预期。注意sourceType要设成module否则 import 语句解析会报错。如果项目里有 JSX记得加plugins: [jsx]否则div会被当成语法错误。5. 常见报错排查401、local proxy failed、reading choices 怎么解这一节把我踩过的坑和社区高频报错整理出来对照着查。报错一401 Unauthorized或invalid api key。如果你在插件里接了模型做判断这个报错说明 Key 不对或没传。检查三件套Base URL 是不是https://taotoken.net/api不要带多余斜杠或路径API Key 是不是从https://taotoken.net/api-keys生成的最新值Model ID 是不是你账号下真实可用的。三个都对还报 401多半是 Key 被复制时带了空格或者环境变量没读到。建议在插件里加一行日志打印实际使用的 Base URL 和 Model ID不要打印 Key方便定位。报错二local proxy failed或connect ECONNREFUSED。这类通常是网络层问题。先确认你的机器能正常访问https://taotoken.net/api用 curl 测一下curl -I https://taotoken.net/api。如果返回 200 或 401 都说明网络通问题在鉴权如果直接超时检查本地网络配置。插件里如果用了自定义的 http agent也要确认没有配错端口。报错三Cannot read properties of undefined (reading choices)。这个报错说明你拿到的响应体结构不对choices字段不存在。常见原因有两个一是请求根本没成功返回的是错误对象而不是正常响应你却直接去读data.choices二是接口返回格式和预期不一致。正确做法是先判断response.ok或检查data.error再读choices。示例const res await fetch(${baseUrl}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey}, }, body: JSON.stringify({ model: modelId, messages: [{ role: user, content: 判断以下变量是否可删 }], }), }); const data await res.json(); if (!res.ok || data.error) { console.error(请求失败, data.error || res.status); return; } const content data.choices?.[0]?.message?.content;报错四OAuth相关或authentication failed。如果你用的是需要 OAuth 的客户端比如某些 CLI 工具报这个错说明 token 过期或没登录。重新走一遍登录流程或者改用 API Key 方式。插件场景建议直接用 Key别引入 OAuth链路更短。报错五AST 处理时报SyntaxError: Unexpected token。这是 parser 配置问题。检查sourceType是否设成moduleJSX 文件是否加了plugins: [jsx]TypeScript 文件需要加plugins: [typescript]。不同语法要开对应插件否则解析直接失败。报错六删完之后代码格式全乱了。babel/generator默认会重新格式化缩进可能和你原来的不一样。如果在意格式可以在 generate 时传{ retainLines: true }尽量保留原行号或者删完后调用 VS Code 的格式化命令editor.action.formatDocument。排查顺序建议先看报错关键词401 查 Keyproxy 查网络choices 查响应结构OAuth 查登录SyntaxError 查 parser 配置。大部分问题都能在这六类里找到答案。6. 从本地验证到长期使用接入方式与工具选择代码跑通之后怎么把它变成日常能用的工具分两种场景。如果你只是自己用直接在插件工程里按F5启动调试窗口在新窗口打开一个 JS 文件按Ctrl6就能看到未使用变量被清掉。想打包分发给同事用vscenpm install -g vsce vsce package生成.vsix文件后在 VS Code 里「从 VSIX 安装」即可。发布到市场需要先注册 publisher然后vsce login再vsce publish。如果你想把「删除判断」做得更智能比如让模型帮你识别哪些是调试残留、哪些是业务代码不能删那就需要稳定的模型接入。这时候建议用 Coding Plan 这类长期方案适合插件、Agent 这种需要反复调用的场景比按次调用更划算。配置入口在https://taotoken.net/coding-plan按提示绑定即可。模型对话调试可以在https://taotoken.net/chat先验证 prompt 效果确认判断准确了再写进插件。接入文档在https://taotoken.net/doc里面有完整的请求示例和参数说明。API Key 管理在https://taotoken.net/api-keys建议给插件单独建一个 Key方便随时吊销。控制台https://taotoken.net/console可以看调用量和余额。最后说个实用技巧AST 删除虽然准但对「有副作用但没被引用」的代码要谨慎。比如const result doSomething()result没被用但doSomething()有副作用直接删整个声明会改变行为。稳妥做法是只删纯声明字面量、纯函数遇到函数调用赋值的变量先跳过或者交给模型判断。这个边界处理好了插件才敢在日常项目里长期开着用。