从需求出发开源一款gitTreeGraph组件

24 阅读9分钟

从需求出发开发一款gitTreeGraph组件

需求背景

因为最近接到一个需求,大概为现在的平台是管理模型训练相关的数据的,每份数据对应一张数据表。

先上开源地址欢迎stars:www.npmjs.com/package/@ha…

原文博客:www.hayrsiane.com/articles/45… 与git提交代码类似,具有权限的管理员可以基于某个“main”分支的表,拉出自己的分支,对表内的数据进行增删等操作,最后在合并回自己的分支。

查找字节内部组件库,发现居然没有相关的组件,因为是toD的平台,所以就在外部找了找相关的开源组件。寻找一番,要么是样式不全,不好看,或者太久没有维护,都不是特别成熟。

唯一找到一个符合样式需求的,并且还算成熟的是:Commit-Graph的项目

对应链接:github.com/liuliu-dev/…

Demo:liuliu-dev.github.io/CommitGraph…

CommitGraph

但是后续接入开发发现,组件并不完善,应该是Demo和开源版本有较大出入,如,不支持hover和click选中的效果,并且,左侧的svg和commit信息之间的间距无法调整等问题,目前是通过对原组件进行hack完成的需求。

所以,最后打算自己手搓一个,并且也感谢CommitGraph作者提供的思路。 参考了www.dolthub.com/blog/2024-0…

开发过程

1. 项目初始化

一切从零开始,我们首先初始化一个标准的 npm 项目,并安装必要的依赖。

1.1 初始化 package.json
mkdir git-commit-tree
cd git-commit-tree
npm init -y
1.2 安装核心依赖

我们需要 React 全家桶以及 TypeScript 支持。由于是开发组件库,我们将 React 设为 peerDependencies,但在开发环境中我们需要安装它。

# 安装开发依赖
npm install -D typescript @types/react @types/react-dom react react-dom

# 安装构建工具 tsup
npm install -D tsup

# 安装 Demo 开发环境工具 vite
npm install -D vite @vitejs/plugin-react
1.3 配置构建工具 (tsup)

因为需要构建出既支持 ESM 又支持 CJS 的包,所以选择 tsup创建 tsup.config.ts:

import { defineConfig } from 'tsup'
export default defineConfig({
  entry: ['src/index.tsx'],
  format: ['cjs', 'esm'],
  dts: true,
  clean: true,
})
1.4 搭建 Demo 环境

为了实时调试组件,在 demo/ 目录下放置了一个简单的 Vite React 应用,并在 package.json 中添加了启动命令:

"scripts": {
  "dev": "tsup --watch",
  "build": "tsup",
  "demo": "vite"
}

这样,通过 npm run demo 我就可以在浏览器中实时看到组件的渲染效果,而 npm run dev 则负责监听源码变化并实时重新打包。

2. 核心架构

组件主要由以下两个核心层组成:

  1. 布局引擎 (src/utils/layout.ts):负责处理原始的 commit 数据,将其转换为可视化的图结构(节点 Nodes 和 连线 Edges)。
  2. 渲染层 (src/components/GitGraph.tsx):使用 SVG 技术将计算好的图结构渲染到页面上。
  3. 虚拟滚动 (src/index.tsx):负责管理可视区域,高效渲染大量数据。

3. 图渲染算法 (Graph Rendering Algorithm)

组件最复杂的部分是将线性的 git commit 列表转换为拓扑图的布局算法。

3.1 基于"插槽" (Slot) 的布局系统

我们使用一套"插槽"系统来追踪活跃的分支。一个插槽代表图中的一个垂直列。

算法步骤:

  1. 遍历 Commits:算法从最新到最旧(从上到下)遍历所有 commit。
  2. 处理入度 (Incoming):对于当前 commit,检查有哪些活跃的插槽正在"寻找"这个 commit 的 hash(即当前 commit 是之前某个节点的父节点)。
    • 如果没有插槽指向它,说明这是一个新分支的顶端 -> 分配一个新的插槽。
    • 如果有插槽指向它,将该 commit 分配给对应的插槽(优先延续主线)。
  3. 节点定位:确定 commit 的坐标 (x, y),其中 x 是插槽索引,y 是 commit 在列表中的索引。
  4. 处理出度 (Outgoing/Parents):
    • 第一个父节点继承当前的插槽(延续分支线条)。
    • 后续的父节点(如 Merge Base 或分叉点)分配新的可用插槽。
  5. 插槽管理:活跃插槽会记录 nextParentHash(下一个要找的父节点 hash)和当前分支的 color。
3.2 连线绘制 (贝塞尔曲线)

为了实现类似 GitHub 提交记录的平滑曲线效果:

  • SVG Path:使用 <path> 元素配合三次贝塞尔曲线指令 (C)。
  • 曲线逻辑:
    • 起点:(x1, y1) (子节点)
    • 终点:(x2, y2) (父节点)
    • 控制点:通过计算控制点,确保线条从起点垂直向下出发,并垂直进入终点。
    • 公式:M x1 y1 C x1 (y1 + c), x2 (y2 - c), x2 y2,其中 c 是曲率因子。

遇到的问题&改进

1. 外层Container和内层commit.message的hover问题

由于外层的commit容器当鼠标划过的时候会有hover的效果,同时对于较长的commit-message在截断处理之后也需要通过hover来查看完整的message,但message是子内容,所以他的hover会因为外层的container的hover而无法触发。

hover

问题原因:外层容器的 Hover 效果(背景色高亮)会触发组件重渲染,这导致浏览器原生的 title 属性 Tooltip 变得不稳定或无法显示。

解决方案:

  1. 移除了原生的 title 属性 :不再依赖浏览器默认行为。
  2. 通过自定义一个 React Tooltip :
    • 在 Commit 信息容器上添加 onMouseEnter 和 onMouseLeave 事件来追踪 hoveredMessageHash 。
    • 当鼠标悬停且信息被截断(长度 > 30)时,渲染一个绝对定位的自定义 Tooltip。
    • 添加了 pointerEvents: 'none' 防止 Tooltip 自身干扰鼠标事件(避免闪烁)。

message-hover

这里后续还发现了一个小bug:

当同时触发commit-message的hover效果和container容器的hover效果的时候,container:hover的z-index会比左侧的svg Graph要高,但是正常只触发container:hover是正常的

[container:hove]

主要堆叠上下文紊乱的问题,修复如下。

  • 移除了 Row 容器的动态 z-index :

之前当 Tooltip 显示时,将整行的 z-index 提升到了 100。这导致该行建立了一个新的堆叠上下文,使得其内部的背景色(z-index: 0)也随之提升,从而覆盖了位于左侧的 SVG Graph(z-index: 10)。

2. 样式自适应,左侧Graph的宽度等

在CommitGraph组件里遇到的问题,其中一个就是左侧的Graph和右侧的commit之间的宽度居然不能动态调整,甚至是写死的。

[]

因为在我的需求里这里的tree组件的右侧还需要有一半用来展示详细的信息等,或者一些其他的搜索,提交等操作,所以对于空间要求很高,另外考虑到数据分支并不是很多于是对这边的宽度进行了动态的调整。

// 动态调整宽度
const graphWidth = useMemo(() => {
  // 1. 优先使用外部传入的宽度(propGraphWidth),有值则直接返回
  if (propGraphWidth !== undefined) return propGraphWidth;
  
  // 2. 计算节点的最大列数(x坐标)
  const maxColumn = nodes.length > 0 ? Math.max(...nodes.map(n => n.x)) : 0;
  
  // 3. 动态计算宽度:左侧内边距 + 列数*列宽 + 右侧额外内边距
  return PADDING_X + (maxColumn + 1) * COLUMN_WIDTH + 20;
}, [nodes, propGraphWidth]); // 依赖项:nodes或propGraphWidth变化时重新计算

主要就是计算出分支数,看看需要渲染多少再计算处理,实测对空间利用率还是比较高的,解决了之前的组件适应的问题。

3. Tag出现多个和分支名称挤压

这个问题正常来说是不会出现的,一个tag+一个分支名称理应是不会被容器的宽度挤压的,但是由于我这边业务的需求是可能存在1一个以上的多个tag,所以此处做一个兜底处理。

用ResizeObserver实时监听组件容器的宽度变化,当宽度小于了tags渲染所需要的宽度,就替换为一个+ N Badge。

性能优化

这里主要面对的性能问题是commit的数量,主要是加载的性能与渲染的性能,此处分为两种情况。

  1. 直接渲染大量的数据,对于本次的toD业务,后端是直接返回了所有的commit数据,这里就需要考虑如何加载数据而不卡顿,后续滚动的时候不卡顿。
  2. 增量加载,采用Lazy load的形式,分页请求自动加载,这个首次加载需求不高,主要是后续连续滚动后的防止重复请求与数据的缓存渲染的问题。

都是比较基本的性能优化就不细说了。

边界测试

这部分主要是对一些极端情况进行测试,也是两种测试方式,一是将mock的数据发给ai,推荐der包,因为感觉der包适合干这种傻事 。二是写个生成器生成一万条mock的数据

可以看到豆包也是坏事做尽,生成的极端情况已经覆盖了很多情况下的问题了。

另外进行largeData测试,10000条mock的数据,在虚拟列表下问题也不大。

发布包

这里就问一下相关ai遵循流程就行了

1.1 登录 npm 账号

确保终端已登录你的 npm 账号(未登录会发布失败):

# 检查是否登录
npm whoami

# 未登录则执行登录(按提示输入用户名、密码、邮箱)
npm login

⚠️ 注意:如果提示登录成功,但 npm whoami 报错,大概率是 npm 源不对,先切换源:

# 切换到 npm 官方源(关键!淘宝源等私有源无法发布)
npm config set registry https://registry.npmjs.org/

执行发布(一步到位)

1.1.1 首次发布

在项目根目录执行:

# 普通包(非作用域)
npm publish

# 作用域包(包名以 @你的用户名/ 开头)→ 发布为公有包(免费)
npm publish --access public

✅ 发布成功的提示:终端会输出 + 你的包名@版本号。

1.1.2 验证发布结果
# 先切到其他文件夹,避免本地依赖干扰
cd ~ && mkdir test-npm && cd test-npm
npm install 你的包名
# 创建测试文件验证功能
echo "const pkg = require('你的包名'); console.log(pkg)" > test.js
node test.js # 能正常输出包的导出内容即没问题

总结

这次也算是第一次自己开发并提交了一个正式可用的开源组件吧,基于需求产生,并非为了造轮子而造轮子,因为hack另一个组件实在是太麻烦了,调试网页,查看class名称,global覆盖样式,还有可能为以后埋下奇怪的伏笔。

以前也写过一些组件,不过这次给我的感受最大的不同在于,这次根据需求出发,了解过此前其他相关组件的问题,对自己开发的目的非常明确,知道哪里有问题,哪里需要优化适配,剩下的就是写代码而已。

如果有相关需求或者bug也可以提交issues,或者评论联系或者发送邮箱~