思路:使用 Vue 3 和 TypeScript 实现依赖分析工具

259 阅读5分钟

读书是易事,思索是难事,但两者缺一,便全无用处。

前言

依赖分析工具的目标是解析项目中的依赖关系,并以可视化的方式呈现这些关系。通过这样的工具,我们可以更直观地理解项目结构,发现潜在的问题,优化依赖管理。

项目流程图

未命名绘图.drawio.png

效果图

ezgif-3-9fd6ba98df.gif

源码github: 传送阵

要点分享

1. web

渲染组件

1.定义 createGraph 函数,用于创建和绘制图表。

const createGraph = (data: GraphData) => {})

2.使用 D3.js 创建缩放功能,并为图表添加缩放行为。

const zoom = d3
  .zoom()
  .scaleExtent([1, 10])
  .on('zoom', (d3: any) => zoomed(d3))

const svg = d3
  .select(graph.value)
  .append('svg')
  .attr('width', width)
  .attr('height', height)
  .call(zoom as d3.ZoomBehavior<any, any>)

function zoomed(event: any) {
  const transform = event.transform
  link.attr('transform', transform)
  node.attr('transform', transform)
}

设置缩放比例范围,并为图表添加缩放行为。在 zoomed 函数中,调整节点和链接的变换属性,实现缩放效果。

3.创建 D3.js 力导向模拟,定义节点和链接的力作用。

simulation = d3
  .forceSimulation(data.nodes as any)
  .force(
    'link',
    d3.forceLink(data.links).id((d: any) => d.id),
  )
  .force('charge', d3.forceManyBody())
  .force('center', d3.forceCenter(width / 2, height / 2))

定义节点和链接的力作用。forceSimulation 创建一个力导向图的模拟,forceLink 用于定义链接的力,forceManyBody 用于定义节点之间的引力或斥力,forceCenter 将图的中心设定在 SVG 的中心位置。

4.创建 SVG 元素,并为链接和节点添加样式和交互行为。

const link = svg
  .append('g')
  .attr('class', 'links')
  .selectAll('line')
  .data(data.links)
  .enter()
  .append('line')
  .attr('stroke-width', 2)
  .attr('stroke', '#999')

const node = svg
  .append('g')
  .attr('class', 'nodes')
  .selectAll('circle')
  .data(data.nodes)
  .enter()
  .append('circle')
  .attr('r', 5)
  .attr('fill', '#69b3a2')
  .call(drag)
  
node.append('title').text((d: any) => d.id)

link 变量用于创建链接,并设置其宽度和颜色。node 变量用于创建节点,设置其半径和填充颜色,并添加拖动行为。同时,为每个节点添加一个 title 元素,显示节点的 id

打开前端页面逻辑

import express from 'express'
import path from 'path'
import open from 'open'

let app: express.Application

export const startWebProject = (data: any): void => {
  app = express()
  const port = 3000

  // 设置静态文件目录为Vue构建后的dist目录
  const publicDir = path.join(__dirname, '../../dist')

  console.log(publicDir, 'publicDir')

  app.use(express.static(publicDir))

  app.use(express.json())

  // 提供一个API接口获取graphData
  app.get('/api/graph-data', (req, res) => {
    res.json(data)
  })

  // 默认路由
  app.get('*', (req, res) => {
    res.sendFile(path.join(publicDir, 'index.html'))
  })

  app.listen(port, () => {
    console.log(`Web project is running at http://localhost:${port}`)
  })
  open(`http://localhost:${port}`)
}

export const renderGraph = (graphData: any): void => {
  console.log('Rendering graph with data:', graphData)
  // 这里可以调用实际的渲染逻辑,例如将数据发送到前端页面
}

2. cli

定义命令行

利用commander这个插件进行命令行的编写

import { Command } from 'commander'
import { doAnalysis } from './analysis'

export const main = async () => {
  const program = new Command()

  program.name('dep-cli').usage('<command> [options]').version('1.0.0')

  program
    .command('ana <pnpm-lock-filename>')
    .description('分析模块依赖关系,目前仅支持 pnpm lock file')
    .option('--depth <number>', '分析深度', '5') // 添加 depth 选项,默认值为 5
    .action(async (filename: string, options: { depth: string }) => {
      const depth = parseInt(options.depth, 10) //十进制转换
      await doAnalysis(filename, depth)
    })

  program.parse(process.argv)
}

调用其他项目中的方法

两种方式:

1. 利用如rush类似的monorepo,首先在rush.json注册项目,然后执行rush install/rush build

"projects": [
    {
      "packageName": "xxx_name",
      "projectFolder": "xxx_path"
    },
    {
      "packageName": "xxx_name2",
      "projectFolder": "xxx_path2"
    },
    ...
]

-在xxx_name项目中定义项目依赖

{
  "name": "xxx_name",
  "version": "1.0.0",
  "dependencies": {
    "xxx_name2": "workspace:*",
    ...
  }
}

-调用xxx_name2的方法

import { helloFrom } from 'xxx_name2';
helloFrom();

2. 一种简单的方法:在package.json中定义项目的路径,并执行pnpm install 会在自己的node_module中生产一个依赖包

"dependencies": {
    "commander": "^12.1.0",
    "core": "file:../core",
    "web": "file:../web"
  }

3. core

解析依赖文件

1.解析规范字符串

const parseFromSpecify = (specifier: string) => {
  const REGEXP = /(@?[\w\-\d\.]+(\/[\w\-\d\.]+)?)@?([\d\w\.\-]+)?/
  if (!REGEXP.test(specifier)) {
    throw new Error(`Can not parse this key: ${specifier}`)
  }
  const [, name, , version] = REGEXP.exec(specifier)!

  return {
    name,
    specifier,
    localVersion: version,
    version,
  }
}

用于解析依赖项的规范字符串。正则表达式用于提取名称和版本信息,如果不匹配规范字符串格式,则抛出错误。

2.获取依赖关系节点

const getDepGraphNode = (
  name: string,
  version: string,
  depType: DepTypes,
  packages: Record<string, PackageSnapshot>,
  currentDepth: number,
  maxDepth: number,
  packageKey: string
): DepGraphNode => {
  const packageInfo = packages[packageKey] // 获取包信息

  if (!packageInfo) {
    return {
      name,
      version,
      external: true,
      dependencies: [],
    }
  }

  // 获取包的依赖列表,并确保递归深度限制
  const deps: DepGraphNode[] =
    currentDepth < maxDepth
      ? Object.entries(packageInfo.dependencies || {}).map(
          ([depName, depVersion]) => {
            const { name } = parseFromSpecify(depName)
            return getDepGraphNode(
              name,
              depVersion as string,
              depType,
              packages,
              currentDepth + 1,
              maxDepth,
              depName + '@' + depVersion
            )
          }
        )
      : []

  return {
    name,
    version,
    external: !!packageInfo.resolution && 'type' in packageInfo.resolution, // 判断是否为外部依赖
    dependencies: deps,
  }
}

递归获取依赖关系节点。函数检查包信息是否存在,并获取包的依赖列表,确保递归深度不超过最大深度。返回包含名称、版本、是否为外部依赖和依赖列表的节点。

3.定义继承自 BaseDepGraph 的类

export class PnpmDepGraph extends BaseDepGraph {
  private filePath: string
  private maxDepth: number

  constructor(filePath: string, maxDepth: number) {
    super()
    this.filePath = filePath
    this.maxDepth = maxDepth
  }
}

构造函数接收文件路径和最大递归深度,初始化类属性。后续可扩展npm、yarn等其他依赖文件lock.file

4.解析依赖图的方法

async parse(): Promise<DepGraph> {
  const lockfile: Lockfile | null = await readWantedLockfile(
    path.dirname(this.filePath),
    {
      ignoreIncompatible: false,
    }
  )
  if (!lockfile) throw new Error('Failed to read lockfile')

  const packages = lockfile.packages as Record<string, PackageSnapshot>

  if (!packages) {
    throw new Error('No packages found in lockfile')
  }

  const depGraph: DepGraph = Object.entries(packages).map((keys) => {
    const [packageKey, packageInfo] = keys
    const { name, version } = parseFromSpecify(packageKey)
    const depType: DepTypes =
      'peerDependencies' in packageInfo
        ? 'peer'
        : 'devDependencies' in packageInfo
        ? 'dev'
        : 'prod'

    return getDepGraphNode(
      name,
      version,
      depType,
      packages,
      0,
      this.maxDepth,
      packageKey
    ) // 获取依赖关系节点并控制递归深度
  })

  return depGraph
}

首先读取并解析锁文件,如果锁文件不存在则抛出错误。然后获取包信息,遍历包并调用 getDepGraphNode 递归获取依赖关系节点,最终返回构建的依赖图数据结构。

image.png

可能遇到的问题及解决方案:

1.如何打开前端项目: 利用open依赖安装8.4.2,注意高版本可能会报错导致无法正常打开网址

2.d3渲染逻辑看不懂: 建议查看官网或者使用chatgpt

3.为什么要拆分成3个项目: 解耦,同时支持后续的扩展和方便维护。