我是如何给项目“减肥”的:一个依赖分析工具的自白
那天我看着 node_modules 里那 1GB 的依赖,突然意识到——我们的项目该减肥了。
从发现到动手:一个令人不安的现实
参与公司项目重构的第二个月,我开始注意到一些不对劲的地方。
随手点开几个工具文件,看到了dateFormatter、dateUtils、dateHelper——三个功能几乎完全相同的日期格式化函数,分散在不同的目录里。再看package.json,好家伙,lodash、underscore、ramda三个工具库并存,每个都被部分使用着。
我对着团队里的老张吐槽:“咱们这项目,是不是有点‘重复造轮子’的癖好啊?”
老张苦笑着回我:“大家都图方便,谁还记得半年前小李写过类似的函数。新功能来了,要么引入新依赖,要么自己写一个。反正硬盘空间又不贵。”
但真的只是硬盘空间的问题吗?我算了一笔账:
- 打包时间:每次CI/CD要多等3分钟
- 认知负担:新同事要熟悉三套不同的工具函数
- 维护成本:修复一个bug要在五个地方改同样的代码
那一刻我决定,得做点什么。
探索之路:为什么现有的工具都不够用?
首先,我尝试了市面上现有的方案。
npm ls告诉我依赖关系,但不告诉我lodash.get和ramda.path功能有80%的重叠。jscpd找到了完全相同的代码片段,但对那些“相似但不相同”的函数束手无策。webpack-bundle-analyzer展示了一个个漂亮的彩色方块,却没有告诉我:“嘿,这两个方块其实干的是同一件事!”
这种不上不下的感觉最难受——你知道有问题,但没人给你指出来具体在哪里。
于是我萌生了自己造轮子的念头。等等,这不正是我要解决的问题吗?有点讽刺。
设计思考:在理想与现实之间找平衡
我开始列需求。最理想的工具应该是:能理解代码语义、运行速度快、结果准确、易于集成到现有工作流……然后我笑了,这要求简直是在找超人。
冷静下来后,我分析了四种可能的技术路径:
- 文本比对——简单粗暴,但会把
add(a,b)和sum(x,y)判为完全不同 - AST分析——准确但重,分析一个大型项目可能需要喝两杯咖啡的时间
- API签名对比——轻量级,适合快速筛查依赖,但对代码内部实现视而不见
- 机器学习——很酷,但想到要标注训练数据我就头疼
最后我选择了妥协的艺术:分层策略。
对于依赖分析,用轻量级的API签名对比,毕竟npm install时我们等不了太久。对于项目内代码,用AST分析,因为重构时需要更高的准确性。同时提供两种使用方式:CLI给CI流水线用,VSCode插件给开发者实时用。
这不是最完美的方案,但却是最实用的。
实施过程:那些让我掉头发的问题
第一关:如何解析千奇百怪的依赖结构?
你以为每个包都会规规矩矩地导出它的API?太天真了。
// 我经历过的导出方式,足够写一本《JavaScript模块导出迷惑行为大赏》
async extractAPIs(depInfo: DependencyInfo) {
const candidates = [
depInfo.packageJson.types, // 理想公民:TypeScript类型入口
depInfo.packageJson.typings, // 怀旧派:旧版类型入口
depInfo.packageJson.module, // 现代派:ESM入口
depInfo.packageJson.main, // 保守派:CommonJS入口
'index.d.ts', 'index.js', // 乐观派:也许在根目录?
'dist/index.js', // 打包派:在dist里
'src/index.ts', // 源码派:直接看源码
// 还遇到过叫 'main.js', 'bundle.js', 'lib/index.js' 的...
];
// 挨个尝试,直到找到一个能用的
for (const candidate of candidates) {
try {
const api = await this.tryExtractFromFile(candidate);
if (api) {
console.log(`终于!在${candidate}找到了API`);
return api;
}
} catch (e) {
// 默默继续尝试下一个,开发者的日常就是与报错共存
}
}
// 如果都找不到...可能这个包真的没什么可导出的
return null;
}
第二关:相似度算法,准确与性能的拔河
最初的版本,我用的是经典的最长公共子序列算法(LCS)。准确率不错,但当我尝试分析一个2000+函数的项目时,它跑了——整整——三分钟。
“这不行”,我心想,“没人会用一个要等三分钟的工具。”
于是有了这个分治策略:
calculateStructureSimilarity(func1, func2) {
// 1. 先标准化:去掉注释、统一变量名、规整空格
// (不然'const a=1'和'const b = 1'会被判为不同)
const tokens1 = this.standardizeFunction(func1.body);
const tokens2 = this.standardizeFunction(func2.body);
// 2. 长短分离处理策略
// 短函数:用精确算法(能承受得起)
if (tokens1.length <= 200 && tokens2.length <= 200) {
return this.calculateExactLCS(tokens1, tokens2);
}
// 长函数:用近似算法(要追求速度)
// 就像比较两篇文章,先看段落结构,再看关键句子
return this.approximateSimilarity(tokens1, tokens2);
}
这个优化让分析时间从三分钟降到了三十秒。在准确率从95%降到88%的时候,我停下了——这个平衡点,感觉对了。
第三关:VSCode插件,让工具变得友好
一个只能在命令行用的代码分析工具,注定是孤独的。我想要的是开发者在写代码时就能获得反馈。
“小王,你这个formatDate函数,跟老李上个月写的dateFormatter有87%的相似度,要看看吗?”——这样的提示,比在CI流水线失败后才发现要友好得多。
实现VSCode插件的过程,又是一部血泪史。异步UI更新、进度反馈、树视图展示……每个细节都在考验耐心。但当我第一次在侧边栏看到那个清晰的重复代码列表时,感觉值了。
效果:数字会说话,但故事更动人
量化成果
在我们那个测试项目上:
- 依赖分析:15秒,找出3组可合并的依赖
- 代码分析:30秒,发现47处重复或相似代码
- 包体积:减少15.3MB(相当于删掉了半个React生态)
- 代码行数:重构后减少了215行(7.2%)
这些数字不错,但不是最让我兴奋的。
真实故事
真正的胜利发生在第二周。团队新来的实习生小陈兴奋地跑过来:“我用你的工具找到了一个隐藏的bug!”
原来,项目里有三个地方用三种不同的方式处理日期时区。其中两种处理夏令时切换时有边界问题。小陈通过工具的“高相似度代码”提示发现了这一点,一次性修复了三个潜在bug。
老张也开始用起来了。他在代码审查前先跑一遍分析工具,把重复代码列表附在PR描述里。“这样审查时更有针对性”,他说。
反思:我真正构建的是什么?
刚开始,我以为我在构建一个“代码相似度检测工具”。但看着团队使用它的方式,我意识到我构建的其实是:
- 一面镜子:让团队看到自己代码的习惯模式
- 一座桥梁:连接不同时期、不同开发者写的相似代码
- 一个提醒:在引入新依赖前,先看看已有的是什么
那些比代码更重要的收获
关于技术决策:在“完美方案”和“可用方案”之间选择后者。先让工具跑起来,再让它跑得快,最后让它跑得准。
关于算法:重新认识了“合适”比“先进”更重要。有时候,简单的启发式方法比复杂的机器学习更实用。
关于工具设计:最好的工具不是那些功能最多的,而是那些能融入现有工作流的。
开源与未来
这个工具基础已经开源([GitHub链接](ggonekim9/webpackOptIdeas: 源于webpack的一个优化打包的方法,review阶段实施 (github.com)))。
未来我还想探索:
- 机器学习辅助:不是完全替代规则,而是在模糊地带给出建议
- 实时协作视图:展示团队中谁经常写相似代码,促进知识共享
- 重构建议:不仅指出问题,还给出具体的重构方案
最后,回到那个问题
为什么我们的项目中会有这么多重复代码?
我现在觉得,原因不是技术上的,而是人性上的。我们容易忘记,容易图方便,容易觉得“我的情况不一样”。而好的工具,应该温柔地提醒我们,而不是严厉地指责。
所以,如果你也在面对一个日渐“肥胖”的项目,我的建议是:不要试图一次性解决所有问题。从一个小工具开始,从一个简单的分析开始,从让问题变得可见开始。
毕竟,减肥的第一步,永远是站上体重秤。