实现一个幽灵依赖扫描工具
什么是幽灵依赖
项目中使用了一些没有被定义在其 package.json 文件中的包。
部分地方也被翻译成了”幻影依赖“,在英文文章中一般称为phantom dependencies
现状
在现有工程里,除 pnpm
外使用的最多的包管理工具就是 yarn
其次才是 npm
。
后两者,在完成依赖安装后,都会有一个依赖提升的动作,也就是依赖的 扁平化
。
于是装一个库 vue
,不同包管理器的结果如下
npm | yarn | pnpm |
---|---|---|
由于依赖的扁平化
,可以看到前两者会使 node_modules
中多出一些其它的东西
也由于这个特性,很多基于 yarn
的工程化方案,会将许多常用的依赖或者重要依赖去做一个版本的管控和依赖的收敛,于是项目里需要安装的依赖就少了,看上去就十分清晰。
比如安装@xx/vue
,同时将相关的lint
,test-util
,git hooks
,lodash
,xx-utils
等等包都做了安装,这样在工程里只需要装一个包就能使用这些包的能力。这也算是幽灵依赖
的好处。
背景
包管理工具切换pnpm
随着项目的迭代时间越来越长,工程里的依赖包越来越多。依赖安装时间越来越长,即便有安装缓存,还是觉得非常的慢。于是有了换依赖管理工具的诉求。
一番调研后,选择把包管理工具切换为pnpm
。
为什么是 pnpm
通过 pnpm 官方的测评数据 可以看出,在大多数场景下
安装速度是 npm/yarn
的 2-3
倍,一个项目就算节约几十秒
,对于承载上前工程的CI/CD
平台来说,几乎时时刻刻都存在发布的情况,每天的收益是很可观的。对于用户来说等待时间也大幅缩短。
为什么做了这个工具
在yarn
切换到pnpm
,可以通过pnpm import
指令实现lock文件的一键转换,避免依赖版本发生变更。
但由于pnpm
没有依赖扁平化的动作,大部分项目切换后没发直接正常工作,主要原因就是幽灵依赖
需要为pnpm项目单独添加依赖提升的配置
于是就需要一个扫描幽灵依赖的工具,协助做包管理工具的迁移。网上搜索了一番,没有找到能用的就只好自己🐴一个了。
原理介绍
一图胜千言
总结下就是4步
- 扫文件
- 提取导入资源路径
- 提取包名
- 剔除
package.json
中存在的
具体实现
这里只贴几个关键步骤的代码,代码的组织逻辑即上述流程图所示
获取扫描目标文件
这块利用path
与fs
模块配合即可实现
- 使用
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)
)
}
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')
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
- 只需要把
script
和style
内容分别拆开处理即可
- 只需要把
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
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)
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)
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
}
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
}
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
}
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]
}
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'
)
})
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
)
}
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
}
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)
})
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查看最终方法源码
上手体验
已将最终实现整成了npm
包@sugarat/ghost
,项目可引入直接使用
为什么叫ghost
而不是phantom
?可能大家对ghost
👻这个单词的意思更加熟悉一些
CLI 工具
npm i -g @sugarat/ghost
# default scan src
ghost scan
npm i -g @sugarat/ghost
# default scan src
ghost scan
项目中调用
npm i @sugarat/ghost
# or
yarn add @sugarat/ghost
# or
pnpm add @sugarat/ghost
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'
import { findGhost } from '@sugarat/ghost'
// or
import { findPhantom } from '@sugarat/ghost'
const phantomDependency = findGhost(
path.join(__dirname, 'src'),
path.join(process.cwd(), 'package.json')
)
const phantomDependency = findGhost(
path.join(__dirname, 'src'),
path.join(process.cwd(), 'package.json')
)
最后
pnpm
是个好东西,推荐大家可以用起来了
欢迎评论区交流指正,有 case
可以抛出来帮助工具完善得更好