实现一个幽灵依赖扫描工具

简介: 实现一个幽灵依赖扫描工具

什么是幽灵依赖


项目中使用了一些没有被定义在其 package.json 文件中的包。


部分地方也被翻译成了”幻影依赖“,在英文文章中一般称为phantom dependencies


现状


在现有工程里,除 pnpm 外使用的最多的包管理工具就是 yarn 其次才是 npm

后两者,在完成依赖安装后,都会有一个依赖提升的动作,也就是依赖的 扁平化

于是装一个库 vue,不同包管理器的结果如下


npm yarn pnpm
网络异常,图片无法展示
|
网络异常,图片无法展示
|
网络异常,图片无法展示
|


由于依赖的扁平化,可以看到前两者会使 node_modules 中多出一些其它的东西

也由于这个特性,很多基于 yarn 的工程化方案,会将许多常用的依赖或者重要依赖去做一个版本的管控和依赖的收敛,于是项目里需要安装的依赖就少了,看上去就十分清晰。


比如安装@xx/vue,同时将相关的linttest-utilgit hookslodashxx-utils等等包都做了安装,这样在工程里只需要装一个包就能使用这些包的能力。这也算是幽灵依赖的好处。


背景


包管理工具切换pnpm


随着项目的迭代时间越来越长,工程里的依赖包越来越多。依赖安装时间越来越长,即便有安装缓存,还是觉得非常的慢。于是有了换依赖管理工具的诉求。


一番调研后,选择把包管理工具切换为pnpm


为什么是 pnpm


通过 pnpm 官方的测评数据 可以看出,在大多数场景下


安装速度是 npm/yarn2-3倍,一个项目就算节约几十秒,对于承载上前工程的CI/CD平台来说,几乎时时刻刻都存在发布的情况,每天的收益是很可观的。对于用户来说等待时间也大幅缩短。


为什么做了这个工具


yarn切换到pnpm,可以通过pnpm import指令实现lock文件的一键转换,避免依赖

版本发生变更。


但由于pnpm没有依赖扁平化的动作,大部分项目切换后没发直接正常工作,主要原因就是幽灵依赖


需要为pnpm项目单独添加依赖提升的配置


于是就需要一个扫描幽灵依赖的工具,协助做包管理工具的迁移。网上搜索了一番,没有找到能用的就只好自己🐴一个了。


原理介绍


一图胜千言


网络异常,图片无法展示
|



总结下就是4步


  1. 扫文件
  2. 提取导入资源路径
  3. 提取包名
  4. 剔除package.json中存在的


具体实现


这里只贴几个关键步骤的代码,代码的组织逻辑即上述流程图所示


获取扫描目标文件


这块利用pathfs模块配合即可实现


  • 使用fs.readdirSync读取文件列表,然后递归即可
  • 通过文件后缀ext筛选出需要的文件


type Exclude = string | RegExp
function scanDirFiles(
  dir: string,
  extList: string[] = [],
  exclude: Exclude | Exclude[] = ['node_modules', '.git', '.vscode']
) {
  const files = readdirSync(dir, { withFileTypes: true })
  const res: string[] = []
  for (const file of files) {
    const filename = join(dir, file.name)
    if (isExclude(filename, exclude)) {
      continue
    }
    if (
      file.isFile() &&
      (extList.length === 0 || extList.includes(parse(filename).ext))
    ) {
      res.push(filename)
    }
    if (file.isDirectory()) {
      res.push(...scanDirFiles(filename, extList, exclude))
    }
  }
  return res
}
function isExclude(value: string, exclude: Exclude | Exclude[]) {
  const patterns = [exclude].flat()
  return patterns.find((v) =>
    typeof v === 'string' ? value.includes(v) : v.test(value)
  )
}


调用示例


scanDirFiles(path.join(__dirname))
scanDirFiles(path.join(__dirname), ['.ts'])
scanDirFiles(path.join(__dirname), ['.ts','.js'], 'test')


  • js 系资源主要包含.js,.jsx,.ts,.tsx四类资源
  • css 系资源包含.css,.scss,.less,.sass
  • vue 主要就是.vue
  • 只需要把 scriptstyle内容分别拆开处理即可


JS资源引入路径提取


这里使用 gogocode 操作AST,配合astexplorer着使用,操作起来非常简单

导入模块的方式主要有以下4种


const x = require(value)
const x = import(value)
const x = () => import(value)
import x from value
import value
export x from value


import/export提取


import AST, { GoGoAST } from 'gogocode'
const sources: string[] = []
const ast = AST(fileText)
const callback = (node: GoGoAST) => {
  const importPath = node.attr('source.value') as string
  sources.push(importPath)
}
ast.find({ type: 'ImportDeclaration' }).each(callback)
ast.find({ type: 'ExportNamedDeclaration' }).each(callback)


require/import()提取


const callback = (node: GoGoAST) => {
  const importPath = node.match[0][0]?.value
  sources.push(importPath)
}
// 处理import('')
ast.find('import($_$)').each(callback)
// 处理require('')
ast.find('require($_$)').each(callback)


CSS资源引入路径提取


针对css,只考虑@import场景的情况下,使用正则 /^@import\s+['"](.*)?['"]/即可实现提取


网络异常,图片无法展示
|


function getCssFileImportSource(fileText: string) {
  const sources: string[] = []
  const importRegexp = /^@import\s+['"](.*)?['"]/
  const lines = fileText.split('\n')
  for (const line of lines) {
    const match = line.trim().match(importRegexp)?.[1]
    if (match) {
      sources.push(match)
    }
  }
  return sources
}


Vue文件中引入路径提取


一个.vue文件主要就 template, script, style三部分构成,只需要把脚本样式拆开处理即可


import AST from 'gogocode'
function getVueFileImportSource(fileText: string) {
  const sources: string[] = []
  // 目前发现Vue3 <script lang="ts" setup> 的无法正常解析,所以在解析前先处理一下setup关键字
  const ast = AST(fileText.replace(/<script(.*)setup(.*)>/, '<script$1$2>'), {
    parseOptions: { language: 'vue' }
  })
  // 提取script内容
  const script = ast.find('<script></script>').generate().trim()
  sources.push(...getJsFileImportSource(script))
  // css直接正则处理
  sources.push(...getCssFileImportSource(fileText))
  return sources
}


第三方依赖判断


资源路径提取出来后,就只需要判断路径是否是node_modules下的资源即可了,流程如下


网络异常,图片无法展示
|


import path, { parse } from 'path'
import { existsSync } from 'fs'
function isValidNodeModulesSource(
  filePath: string,
  importSourcePath: string
) {
  const { dir } = parse(filePath)
  if (!importSourcePath) {
    return false
  }
  if (importSourcePath.includes('node_modules')) {
    return true
  }
  if (
    ['./', '../', '@/', '~@/', '`'].some((prefix) =>
      importSourcePath.startsWith(prefix)
    )
  ) {
    return false
  }
  if (
    ['', ...cssExt, ...jsExt].some((ext) =>
      existsSync(join(dir, `${importSourcePath}${ext}`))
    )
  ) {
    return false
  }
  return true
}


提取包名


接下来就是从筛选出来的有效资源路径里提取出包名了,通常就两种场景pkgName@scope/pkgName,通过几个常用的API就能搞定


function getPkgNameBySourcePath(pkgPath: string) {
  const paths = pkgPath
    .replace(/~/g, '')
    .replace(/.*node_modules\//, '')
    .split('/')
  return paths[0].startsWith('@') ? paths.slice(0, 2).join('/') : paths[0]
}


test('getPkgNameBySourcePath', () => {
  expect(getPkgNameBySourcePath('fs')).toBe('fs')
  expect(getPkgNameBySourcePath('@vue/ssr')).toBe('@vue/ssr')
  expect(getPkgNameBySourcePath('vue/dist/index.js')).toBe('vue')
  expect(getPkgNameBySourcePath('../node_modules/vue')).toBe('vue')
  expect(getPkgNameBySourcePath('~@element/ui/dist/index.css')).toBe(
    '@element/ui'
  )
})


过滤掉不合法的包名


上述规则不能涵盖到所有情况,取到的包名可能有不合法的如this.xxx,xx.resolve(yyy),Node内置的包 fs/path/process/...etc

针对Node内置的包可以直接写个正则搞定,当然这段正则来源于Copilot推荐


function isNodeLib(v: string) {
  return /^(?:assert|buffer|child_process|cluster|console|constants|crypto|dgram|dns|domain|events|fs|http|https|module|net|os|path|punycode|querystring|readline|repl|stream|string_decoder|sys|timers|tls|tty|url|util|vm|zlib)$/.test(
    v
  )
}


当然也有现成的第三方包可以直接使用 validate-npm-package-name ,这个是官方出品的,用法也就更加简单了


  • 不仅仅能过滤掉Node内置包,还能过滤掉不合法命名的包


import validPkgName from 'validate-npm-package-name'
function isValidPkgName(pkgName: string): boolean {
  const { validForNewPackages, validForOldPackages} = validPkgName(pkgName)
  return validForNewPackages
}


test('isValidPkgName', () => {
  expect(isValidPkgName('vue')).toBe(true)
  expect(isValidPkgName('some-package')).toBe(true)
  expect(isValidPkgName('@jane/foo.js')).toBe(true)
  expect(isValidPkgName('r.resolve("custom-token.js")')).toBe(false)
  expect(isValidPkgName('dayjs/dsds/abc.js')).toBe(false)
})


关键一系列方法搞定后,只需要进行逻辑的组织即可,Github查看最终方法源码


当然可能存在一些未考虑到的case场景,遇到了再case by case完善即可


上手体验


已将最终实现整成了npm@sugarat/ghost,项目可引入直接使用

为什么叫ghost而不是phantom?可能大家对ghost👻这个单词的意思更加熟悉一些


CLI 工具


npm i -g @sugarat/ghost
# default scan src 
ghost scan


项目中调用


npm i @sugarat/ghost
# or
yarn add @sugarat/ghost
# or
pnpm add @sugarat/ghost


import { findGhost } from '@sugarat/ghost'
// or
import { findPhantom } from '@sugarat/ghost'


const phantomDependency = findGhost(
  path.join(__dirname, 'src'),
  path.join(process.cwd(), 'package.json')
)


最后


pnpm 是个好东西,推荐大家可以用起来了


欢迎评论区交流指正,有 case 可以抛出来帮助工具完善得更好


相关文章
|
JavaScript 前端开发 API
【第42期】一文了解服务端渲染框架NextJS
【第42期】一文了解服务端渲染框架NextJS
1104 0
|
JavaScript 前端开发
VUE组件:如何在Vue中实现组件的动态引入?
VUE组件:如何在Vue中实现组件的动态引入?
2451 0
|
资源调度 JavaScript 测试技术
vite的项目,使用rollup打包的方法
vue-cli 自带的是 webpack 的打包方式,打出的包体积有点大,而 vite 自带的是 rollup 的打包方式,这种方式打包的体积就非常小,官网也有一些使用说明,所以学会之后还是比较很方便的。
vite的项目,使用rollup打包的方法
|
缓存 安全 NoSQL
Spring Cloud实战 | 第六篇:Spring Cloud Gateway+ Spring Security OAuth2 + JWT实现微服务统一认证鉴权
Spring Cloud实战 | 第六篇:Spring Cloud Gateway+ Spring Security OAuth2 + JWT实现微服务统一认证鉴权
Spring Cloud实战 | 第六篇:Spring Cloud Gateway+ Spring Security OAuth2 + JWT实现微服务统一认证鉴权
|
9月前
|
传感器 人工智能 算法
傅利叶开源人形机器人,提供完整的开源套件!Fourier N1:具备23个自由度和3.5米/秒运动能力
傅利叶推出的开源人形机器人N1搭载自研动力系统与多模态交互模块,具备23个自由度和3.5米/秒运动能力,提供完整开源套件助力开发者验证算法。
761 3
傅利叶开源人形机器人,提供完整的开源套件!Fourier N1:具备23个自由度和3.5米/秒运动能力
|
Rust 前端开发 jenkins
Tauri 开发实践 — 使用 CI/CD 自动构建发布 Tauri 桌面端应用
本文介绍如何使用 CI/CD 自动构建发布 Tauri 应用。Tauri 是一个轻量级跨平台客户端框架,适合个人应用。文章首先概述了 CI/CD 的基本流程,并介绍了 GitHub Actions、GitLab CI 和 Jenkins 三种工具。最终选择了 GitHub Actions 进行配置。文中详细展示了使用 GitHub Actions 脚本实现 Tauri 应用构建的过程,并解决了权限和安全问题。项目源码可在 GitHub 上获取。
971 5
Tauri 开发实践 — 使用 CI/CD 自动构建发布 Tauri 桌面端应用
|
存储 人工智能 人机交互
PC Agent:开源 AI 电脑智能体,自动收集人机交互数据,模拟认知过程实现办公自动化
PC Agent 是上海交通大学与 GAIR 实验室联合推出的智能 AI 系统,能够模拟人类认知过程,自动化执行复杂的数字任务,如组织研究材料、起草报告等,展现了卓越的数据效率和实际应用潜力。
1846 1
PC Agent:开源 AI 电脑智能体,自动收集人机交互数据,模拟认知过程实现办公自动化
|
机器学习/深度学习 算法 人机交互
智能语音识别技术的最新进展与未来趋势####
【10月更文挑战第21天】 在当今这个信息爆炸的时代,人机交互方式正经历着前所未有的变革。本文深入探讨了智能语音识别技术的前沿动态,从深度学习模型的创新应用到跨语言、跨领域的适应性增强,揭示了该领域如何不断突破技术壁垒,提升用户体验的真实案例与数据支撑。通过对比分析当前主流算法的性能差异,本文旨在为研究者和开发者提供一幅清晰的技术演进蓝图,同时展望了多模态融合、情感识别等新兴方向的广阔前景。 ####
1316 7
|
Java Linux Windows
java系列之 复制原始目录文件到新的 目录文件【Windows 和 Linux 均可使用】
这篇文章提供了Java中复制或移动目录及其文件(包括权限)的示例代码,包括删除目标目录内容、复制或移动整个目录的过程,并强调了在操作过程中需要注意的一些关键点。
|
人工智能 自然语言处理 Swift
ModernBERT-base:终于等到了 BERT 回归
BERT于 2018 年发布(史前人工智能!),但它至今仍被广泛使用,BERT的纯编码器架构使其成为每天出现的各种场景的理想选择,例如检索、分类和实体提取。
1312 3