命令行工具解析package-lock.json文件

190 阅读4分钟

本文将介绍如何使用Node开发命令行工具分析项目中的lock文件,并启动浏览器展示lock文件依赖关系。阅读本文你可以学到:

  1. 如何使用commander等工具开发一个命令行工具
  2. 联动:使用Node启动一个Server服务并打开浏览器展示数据
  3. NPM包的发布流程
  4. others:解析lock文件,使用d3等展示关系图

效果展示: 安装

npm install -g packdep

使用,默认打开浏览器展示,也可以输出为JSON文件

packdep <root of your project>

image.png

项目地址:github.com/Devil-Train…

NPM包:www.npmjs.com/package/pac…

分模块开发

这个工具由三个部分组成:命令行工具,解析,web展示。可以将工具解耦为三个模块:

  1. core:负责lock文件的解析,除了npm,还可以支持yarn和pnpm
  2. web:负责展示core解析得到的数据,本文使用D3。也可以使用ECharts等替代
  3. 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文件。需要两步:

  1. package.json中添加bin
  2. 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检查我们的成果!