我是如何给项目“减肥”的:一个依赖分析工具的自白

29 阅读8分钟

我是如何给项目“减肥”的:一个依赖分析工具的自白

那天我看着 node_modules 里那 1GB 的依赖,突然意识到——我们的项目该减肥了。

从发现到动手:一个令人不安的现实

参与公司项目重构的第二个月,我开始注意到一些不对劲的地方。

随手点开几个工具文件,看到了dateFormatterdateUtilsdateHelper——三个功能几乎完全相同的日期格式化函数,分散在不同的目录里。再看package.json,好家伙,lodashunderscoreramda三个工具库并存,每个都被部分使用着。

我对着团队里的老张吐槽:“咱们这项目,是不是有点‘重复造轮子’的癖好啊?”

老张苦笑着回我:“大家都图方便,谁还记得半年前小李写过类似的函数。新功能来了,要么引入新依赖,要么自己写一个。反正硬盘空间又不贵。”

但真的只是硬盘空间的问题吗?我算了一笔账:

  • 打包时间:每次CI/CD要多等3分钟
  • 认知负担:新同事要熟悉三套不同的工具函数
  • 维护成本:修复一个bug要在五个地方改同样的代码

那一刻我决定,得做点什么。

探索之路:为什么现有的工具都不够用?

首先,我尝试了市面上现有的方案。

npm ls告诉我依赖关系,但不告诉我lodash.getramda.path功能有80%的重叠。jscpd找到了完全相同的代码片段,但对那些“相似但不相同”的函数束手无策。webpack-bundle-analyzer展示了一个个漂亮的彩色方块,却没有告诉我:“嘿,这两个方块其实干的是同一件事!”

这种不上不下的感觉最难受——你知道有问题,但没人给你指出来具体在哪里。

于是我萌生了自己造轮子的念头。等等,这不正是我要解决的问题吗?有点讽刺。

设计思考:在理想与现实之间找平衡

我开始列需求。最理想的工具应该是:能理解代码语义、运行速度快、结果准确、易于集成到现有工作流……然后我笑了,这要求简直是在找超人。

冷静下来后,我分析了四种可能的技术路径:

  1. 文本比对——简单粗暴,但会把add(a,b)sum(x,y)判为完全不同
  2. AST分析——准确但重,分析一个大型项目可能需要喝两杯咖啡的时间
  3. API签名对比——轻量级,适合快速筛查依赖,但对代码内部实现视而不见
  4. 机器学习——很酷,但想到要标注训练数据我就头疼

最后我选择了妥协的艺术:分层策略

对于依赖分析,用轻量级的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描述里。“这样审查时更有针对性”,他说。

反思:我真正构建的是什么?

刚开始,我以为我在构建一个“代码相似度检测工具”。但看着团队使用它的方式,我意识到我构建的其实是:

  1. 一面镜子:让团队看到自己代码的习惯模式
  2. 一座桥梁:连接不同时期、不同开发者写的相似代码
  3. 一个提醒:在引入新依赖前,先看看已有的是什么

那些比代码更重要的收获

关于技术决策:在“完美方案”和“可用方案”之间选择后者。先让工具跑起来,再让它跑得快,最后让它跑得准。

关于算法:重新认识了“合适”比“先进”更重要。有时候,简单的启发式方法比复杂的机器学习更实用。

关于工具设计:最好的工具不是那些功能最多的,而是那些能融入现有工作流的。

开源与未来

这个工具基础已经开源([GitHub链接](ggonekim9/webpackOptIdeas: 源于webpack的一个优化打包的方法,review阶段实施 (github.com)))。

未来我还想探索:

  • 机器学习辅助:不是完全替代规则,而是在模糊地带给出建议
  • 实时协作视图:展示团队中谁经常写相似代码,促进知识共享
  • 重构建议:不仅指出问题,还给出具体的重构方案

最后,回到那个问题

为什么我们的项目中会有这么多重复代码?

我现在觉得,原因不是技术上的,而是人性上的。我们容易忘记,容易图方便,容易觉得“我的情况不一样”。而好的工具,应该温柔地提醒我们,而不是严厉地指责。

所以,如果你也在面对一个日渐“肥胖”的项目,我的建议是:不要试图一次性解决所有问题。从一个小工具开始,从一个简单的分析开始,从让问题变得可见开始。

毕竟,减肥的第一步,永远是站上体重秤。