本文将介绍如何使用Node开发命令行工具分析项目中的lock文件,并启动浏览器展示lock文件依赖关系。阅读本文你可以学到:
- 如何使用commander等工具开发一个命令行工具
- 联动:使用Node启动一个Server服务并打开浏览器展示数据
- NPM包的发布流程
- others:解析lock文件,使用d3等展示关系图
效果展示: 安装
npm install -g packdep
使用,默认打开浏览器展示,也可以输出为JSON文件
packdep <root of your project>
NPM包:www.npmjs.com/package/pac…
分模块开发
这个工具由三个部分组成:命令行工具,解析,web展示。可以将工具解耦为三个模块:
- core:负责lock文件的解析,除了npm,还可以支持yarn和pnpm
- web:负责展示core解析得到的数据,本文使用D3。也可以使用ECharts等替代
- cli:命令行工具,负责调用core解析项目,启动server打开浏览器展示数据
core:解析package lock文件
首先需要根据可视化工具确定解析出的格式:Node代表依赖的包,Link代表依赖关系,Dependencies是解析出的格式
export interface Node {
package: string // 包名
version: string // 包版本
depth: number // 这个包依赖的深度
}
export interface Link {
source: Node
target: Node
}
export interface Dependencies {
nodes: Node[]
links: Link[]
}
npm,yarn和pnpm的解析都需要支持,所以可以定义一个基类PackageParser。这里我们将PackageParser定义成abstract class,既可以负责通用模块的实现,又可以定义接口。
实现parse函数判断lock文件是否存在,然后根据需要解析的深度depth解析lock文件
实现DFS函数,使用深度遍历的方法将获取的Node和内部的依赖解析成Dependencies,同时可以记录下每个包依赖的深度
export interface Package {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
bundledDependencies?: Record<string, string>
}
const DEFAULT_DEPTH: number = Infinity
export default abstract class PackageParser {
protected filepath: string // 项目根目录
protected lockfile: string | undefined // lock文件
protected nodes: Node[] // 解析出的节点
protected links: Link[] // 解析出的链
protected depth: number // 当前解析的深度
private nodesMap: { [key: string]: boolean } = {} // 保存node是否被解析过
protected constructor(filepath: string) {
this.filepath = filepath
this.nodes = []
this.links = []
this.lockfile = undefined
this.depth = 0
}
public async parse(depth: number = DEFAULT_DEPTH): Promise<Dependencies> {
if (!(depth >= 0 && (Number.isSafeInteger(depth) || depth === Infinity))) {
throw new Error('Depth should be a non-negative integer.')
}
if (!(await this.isFileExist('package.json'))) {
throw new Error(
`${path.resolve(this.filepath, 'package.json')} is not exist.`
)
}
if (!(await this.isFileExist(this.lockfile!))) {
throw new Error('package lock file is not exist.')
}
await this.parseLockfile(depth)
return {
nodes: this.nodes,
links: this.links
}
}
protected DFS(node: Node, depth: number): void {
this.addToNodes(node)
if (depth <= 0) {
return
}
const depNodes = this.getNodesFromPackages(node)
for (const depNode of depNodes) {
this.addToLinks(node, depNode)
if (!this.isNodeInCollection(depNode)) {
++this.depth
this.DFS(depNode, depth - 1)
}
}
}
protected isNodeInCollection(node: Node): boolean {
return this.nodesMap[this.nodeToDependencyName(node)]
}
protected nodeToDependencyName(node: Node): string {
return `${node.package}@${node.version}`
}
protected addToNodes(node: Node): void {
this.nodes.push(node)
this.nodesMap[this.nodeToDependencyName(node)] = true
}
protected addToLinks(node: Node, depNode: Node): void {
this.links.push({
source: node,
target: depNode
})
}
private async isFileExist(filename: string): Promise<boolean> {
try {
await fsPromises.access(path.resolve(this.filepath, filename))
return true
} catch {
return false
}
}
protected abstract parseLockfile(depth: number): Promise<Dependencies>
/**
* 获取根节点的dependencies devDependencies peerDependencies
* @returns {Node[]}
* @private
*/
protected abstract getRootNodesFromImporters(): Node[]
/**
* 从某个包中获取dependencies devDependencies peerDependencies
* @returns {Node[]}
* @private
*/
protected abstract getNodesFromPackages(node: Node): Node[]
}
核心的解析工作(DFS)已经在PackageParser中实现了,在扩展的类NpmPackageParser,YarnPackageParser和PnpmPackageParser中,我们只需要关系如何将各种lock文件解析成Package的格式
npm是json格式,yarn有@yarnpkg/lockfile,pnpm有@pnpm/lockfile-file帮助我们解析。这里如何解析不是重点,源码在此:github.com/Devil-Train…
web:使用D3展示依赖关系图
关系图force graph非常适合展示npm依赖关系,ECharts示例中也有展示:echarts.apache.org/examples/zh…
这里我们使用的是基于D3的react-force-graph github.com/vasturiano/… 展示数据
在web中可以通过api获取数据:/api/getDependencies。我们可以使用ViteDevServer或koa等工具启动一个server服务器并将解析的数据通过中间件传输
我们可以将解析出的数据通过API获取,然后格式化成需要的数据
import ForceGraph2D, {
GraphData,
LinkObject,
NodeObject
} from 'react-force-graph-2d'
import { type Dependencies } from '@dep-graph/core/dist/PackageParser'
import { useEffect, useMemo, useState } from 'react'
const format = (deps: Dependencies): GraphData => {
const data = {
nodes: deps.nodes.map((node) => ({
id: node.package,
name: `${node.package}@${node.version}`,
node
})),
links: deps.links.map((link) => ({
source: link.source.package,
target: link.target.package,
sourceNode: link.source,
targetNode: link.target
}))
}
data.nodes.map((node) => {
const current = node as typeof node & { links: LinkObject[] }
current.links = data.links.reduce<LinkObject[]>((acc, cur) => {
if (
node.node.package === cur.source ||
node.node.package === cur.target
) {
acc.push(cur)
}
return acc
}, [])
return current
})
return data
}
export default function DepsGraph() {
const [renderData, setRenderData] = useState<GraphData | null>(null)
const [highlightLinks, setHighlightLinks] = useState<Set<LinkObject>>(
new Set()
)
const maxDepth = useMemo(() => {
return (
renderData?.nodes.reduce(
(acc, cur) => Math.max(acc, cur.node.depth),
0
) ?? 0
)
}, [renderData])
useEffect(() => {
const fetchData = async () => {
const res = await fetch('http://localhost:9995/api/getDependencies', {
method: 'GET'
})
const deps: Dependencies = (await res.json()) as Dependencies
setRenderData(format(deps))
}
fetchData()
}, []) // 添加空的依赖数组
// 根据深度生成大小
const genNodeVal = (d: NodeObject) => {
return ((maxDepth - d.node.depth) / maxDepth) * 5 + 1
}
// 节点hover
const handleNodeHover = (d: NodeObject | null) => {
console.log(d)
highlightLinks.clear()
if (d) {
d.links.forEach((l: LinkObject) => {
highlightLinks.add(l)
})
setHighlightLinks(highlightLinks)
}
}
const nodeCanvasObject = (
node: NodeObject,
ctx: CanvasRenderingContext2D,
globalScale: number
) => {
const label = node.name || node.id // 显示的标签,可以是 name 或 id
const fontSize = 12 / globalScale // 调整字体大小以适应缩放
// 绘制节点
ctx.beginPath()
ctx.arc(node.x!, node.y!, 5, 0, 2 * Math.PI, false)
ctx.fillStyle = '#69b3a2'
ctx.fill()
ctx.closePath()
// 绘制标签
ctx.font = `${fontSize}px Sans-Serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#333'
if (globalScale > 1.5) {
ctx.fillText(label, node.x!, node.y!) // 标签显示在节点中心
}
}
if (!renderData) {
return <div>Loading...</div>
}
return (
<ForceGraph2D
graphData={renderData as GraphData}
linkDirectionalArrowLength={2}
nodeLabel="name"
nodeVal={genNodeVal}
linkDirectionalParticles={4}
linkDirectionalParticleSpeed={0.005}
linkDirectionalParticleWidth={(l) => (highlightLinks.has(l) ? 2 : 0)}
linkColor={(l) => (highlightLinks.has(l) ? 'orange' : '')}
onNodeHover={handleNodeHover}
nodeCanvasObject={nodeCanvasObject}
/>
)
}
cli:命令行工具,调用core和web
cli是连接core和web的桥梁,在这里我们可以判断lock文件的类型,调用core解析,然后启动一个web server打开浏览器展示。
首先使用commander定义命令行参数,-d指定解析依赖的深度,-j决定是否只输出为json文件, #!/usr/bin/env node告诉shell运行环境,需要加上。
#!/usr/bin/env node
import { Command } from 'commander'
import action from './action'
const program = new Command()
program
.name('dep-cli')
.description('CLI to parse node modules dependencies')
.version('1.0.0')
program
.argument('[path]', 'path to analyze node project dependencies', './')
.option('--depth, -d <depth>', 'set the depth of parse')
.option(
'--json, -j <json>',
'set the output as JSON file instead of opening a graph HTML file'
)
.action(action)
program.parse(process.argv)
在action中使用工厂函数执行解析,然后根据参数决定输出形式
/**
*
* @param {string} dir 解析的文件夹路径
* @param {AnalyzeOptions} options analyze命令的可选项
* @returns {Promise<void>}
*/
const action = async (dir: string, options: AnalyzeOptions): Promise<void> => {
const depth: number = options.D === undefined ? Infinity : Number(options.D)
const pp = await packageParserFactory(dir)
const deps: Dependencies = await pp.parse(depth)
if (options.J) {
// 输出为JSON文件
await outputAsJSON(deps, options.J)
} else {
// 打开网站
await openHTML(deps)
}
}
工厂函数中,可以根据package-lock.json,yarn.lock和pnpm-lock.yaml决定使用哪个parser
const packageParserFactory = async (filepath: string) => {
if (await FileSystem.isFileExist(path.resolve(filepath, 'pnpm-lock.yaml'))) {
return new PP.PnpmPackageParser(filepath)
} else if (
await FileSystem.isFileExist(path.resolve(filepath, 'yarn.lock'))
) {
return new PP.YarnPackageParser(filepath)
} else if (
await FileSystem.isFileExist(path.resolve(filepath, 'package-lock.json'))
) {
return new PP.NpmPackageParser(filepath)
} else {
throw new Error(
`${path.resolve(filepath)} is not exist or package lock file is not exist`
)
}
}
export default packageParserFactory
输出为JSON可以使用fs的writeFile
启动服务器可以使用koa启动一个静态服务器,并添加一个中间件接口/api/getDependencies。最后使用node的child_process执行命令打开服务。以上可以放在一个类中
使用koa启动一个静态服务器
public start() {
const koa: Koa = new Koa()
// koa充当服务器
koa.use(this.getDependencies.bind(this)())
// koa充当静态服务器
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
koa.use(serve(path.resolve(__dirname, '../../web/dist/')))
koa.listen(this.port, () => {
console.log(`Server is running at http://localhost:${this.port}/`)
})
}
koa添加一个中间件,接口是获取解析结果
// 获取依赖的接口
private getDependencies(): Middleware {
return async (ctx: Context, next: Next) => {
if (ctx.request.url === '/api/getDependencies') {
ctx.response.status = 200
ctx.response.body = this.deps
} else {
await next()
}
}
}
child_process打开浏览器,这里根据不同的平台使用不同的命令
// 打开浏览器
public open(): void {
const openCommand: string =
process.platform === 'win32'
? 'start'
: process.platform === 'darwin'
? 'open'
: 'xdg-open'
console.log('openCommand', openCommand)
c.exec(`${openCommand} http://localhost:${this.port}/index.html`)
}
}
还可以使用ViteDevServer启动服务,添加中间件。这样可以避免web打包
如何使用命令行执行命令
调试的时候可以使用node *.js执行,如何像vue-cli等工具直接执行js文件。需要两步:
- package.json中添加bin
- npm link(本地项目)
首先在package.json中添加bin参数,指向需要执行的js文件,packdep就是是命令行名字。
"bin": {
"packdep": "cli/dist/index.js"
}
如果是本地项目,还没有发布到npm,可以在项目根目录执行npm link,将项目链接到全局,可以直接在终端中直接使用packdep执行命令
发布到npm.org
修改版本号,将name改为packdep,使用npm publish命令,网页登录后就可以发布到npm.org上
最后使用npm install -g packdep检查我们的成果!