新闻详情

新闻详情

首页 / 资讯中心 / 详情

Vue3+TypeScript从零手写甘特图组件:数据模型到拖拽交互全解析

发布时间:2026/10/1 16:20:55来源:尧图网络
Vue3+TypeScript从零手写甘特图组件:数据模型到拖拽交互全解析
前阵子接了个排产系统的需求需求文档里赫然写着“类甘特图”。我盯着这三个字愣了半天——排产系统的核心界面说白了就是一张能拖着走、能改日期、能连线的时间任务表。在vue3体系里做这种图表市面上其实没有特别完美的现成方案要么收费贵要么功能冗余要么样式丑到没法交差。于是我一咬牙决定用Vue3 TypeScript Vite从零手搓一个。这篇文章就是把这个完整过程复盘出来从数据模型设计、时间轴算法、DOM渲染方案到拖拽交互、虚拟滚动和常见性能坑全部拆开讲清楚。如果你也在Vue3项目里被安排做排产图、项目计划图、资源调度图这类时间轴可视化需求这篇文章能让你少走不少弯路。1. 为什么我会在Vue3里手搓一个甘特图刚接到这个需求时我第一反应是去GitHub翻开源库。甘特图这个领域其实很老但有意思的是真正好用的现代前端甘特图库屈指可数。大多数开源项目停留在jQuery时代或者用Angular/React写的Vue生态里能打的更少。我把主流方案快速过了一遍发现一个共性痛点它们都试图给你一个“完整的甘特图”但你往往只需要其中30%的功能剩下70%是在跟它的API和样式搏斗。类甘特图这个词本身也很关键。它跟传统项目管理里的标准甘特图有区别更多时候是“看着像甘特图的时间轴图表”。比如排产场景里横轴是时间纵轴是产线、工位或订单批次任务条表示某道工序的占用时段。这种图不需要复杂的WBS层级、关键路径分析但很需要任务条能拖拽调整起止时间、能标记完成百分比、能画依赖线、能快速缩放时间粒度。这些需求如果靠改第三方库改造成本远高于自己写。真正让我下定决心自研的是数据驱动和定制自由度。业务方今天要加个“完成率颜色反馈”明天要加“点击任务条弹出操作面板”这些需求在自研方案里就是一个组件的事情但在第三方库里往往要绕很大一圈甚至要改源码。而且Vue3的Composition API配合响应式数据做这种定制化图表非常顺手。思路理清楚之后剩下的就是用代码把骨架填起来了。不过说句实在话手搓甘特图的门槛不在渲染而在数据结构和时间计算。后者如果没设计好后期每加一个交互功能都会踩坑。这部分我放在下一章细说。2. 选型对比第三方库与自研的取舍2.1 主流的甘特图开源方案速览为了不让自己“闭门造车”我把市面上叫得出名字的开源甘特图库都拉出来看了一遍简单做了个考察记录dhtmlxGantt是老牌商业库功能确实全面任务层级、关键路径、依赖线、工时日历全都有。但它有两个硬伤一是商业授权费用不低二是它的DOM结构和样式体系非常“自我”想融入现代前端项目的视觉体系要覆盖大量样式。如果你只是随便用用他们的CDN版和vue封装能用但深度定制时你会怀疑人生。Frappe Gantt是轻量级代表代码简洁上手快但功能边界很明显不支持任务依赖拖拽、不支持多层级任务、不支持虚拟滚动。数据一多性能就会出问题样式也比较基础。gantt-elastic曾经是我最看好的一个它用TypeScript写的支持SVG渲染还能自定义模板。但维护频率一般总感觉“试验性质”偏重生产项目里用会有点心虚。还有一堆个人维护的小库我就不点名了。它们的共性问题是可以“画”甘特图但很难“操作”甘特图——交互能力普遍薄弱日期的边界处理也经常有bug。我花了大半天在demo里摆弄这些库最终心里有了结论如果项目里只是“展示”一张静态甘特图用Frappe这种小库完全够了但如果要“交互”加“定制”自己写控制力最强。2.2 我为什么最终选择自研自研的决定做出来之后团队里有人问这样会不会太慢了我的回答是前两周会慢之后就会越来越快。为什么因为甘特图的核心引擎就那么几块——时间轴刻度计算、任务条定位、拖拽逻辑、依赖线绘制。这些基础能力一旦沉淀成组件后面的业务需求都是在上面堆功能。对比一下用第三方库你有60分的底子但要往90分定制时每一分都要对抗它的设计思路自己写虽然起步是0分但突破60分以后每加一分都是纯积累。而且vue3的响应式系统天然适合“数据—视图”联动我定义好任务数组改某个任务的start或end视图自动刷新这种体验用第三方库反而要写很多同步代码。所以即便自研的代码量一开始会多一些我还是按“基础设施”的标准来写这个组件。设计目标很明确数据驱动、可插拔、能虚拟滚动、能拖拽、能自定义任意单元格渲染。最后这套东西在项目里的表现比预期好代码沉淀下来后面新项目直接用省下的时间远远超过当初的投入。3. 数据模型与日期计算最容易翻车的部分3.1 任务数据结构的字段设计甘特图表面上是图表问题本质上是数据建模问题。如果你一上来就写渲染组件后面十有八九会返工。我先把任务的数据结构定清楚。这里我参考了实际排产系统的模型设计了一个Task类型用TypeScript写出来export interface GanttTask { id: string name: string /** 任务条的开始日期使用时间戳统一存储 */ start: number /** 任务条的截止日期注意这里采用“开区间”设计不包含end当天 */ end: number /** 完成百分比 0-100 */ progress: number /** 自定义数据业务方需要啥往里塞啥 */ raw?: Recordstring, any /** 价格/权重/优先级等用于着色 */ color?: string /** 是否显示为里程碑零时长标记 */ milestone?: boolean /** 依赖任务id列表画箭头用这组数据 */ dependencies?: string[] }这里有个细节要说清楚日期的存储统一用时间戳数字不要用字符串也不要存Date对象。为什么因为在计算横向偏移、比较大小、做加减法时时间戳是最纯粹的数字直接做算术就行不会出现时区、格式化的干扰。显示上要转字符串时再通过dayjs这类库去格式化。“end采用排他性设计”这一点也很重要。很多人习惯把end理解为“包含这一天”但在甘特图里一个任务从3月1日到3月3日应该显示成两个格子宽还是三个格子宽如果end含当天3月1日到3月3日是三天如果按开区间3月1日到3月3日是两天3月1日、3月2日。实际业务上任务在3月3日结束当天就不该占用产能所以我统一用开区间[start, end)渲染时宽度 (end - start) / 天。这样能避免“多出来一天”的经典bug。3.2 时间轴与定位的计算逻辑接下来是甘特图最核心的算法给定日期求它在时间轴上的x坐标。别觉得简单这里面的坑比你想象的多。我把时间轴定义为一条从整体开始日到整体结束日的线段然后按粒度日、周、月切成刻度每个刻度占固定的像素宽度比如一天 40px。那么任意一个任务条的left坐标就是const left (task.start - timelineStart) / ONE_DAY * dayWidth这个公式看着简单但有几个前置条件必须处理到位第一timelineStart必须是某一天的0点。也就是用dayjs的startOf(day)归一化。如果不归一化数据里带了时分秒计算出来的left会有几像素的误差任务条边缘对不齐网格线特别难看。第二跨时区问题。如果项目部署在国外服务器或者团队分散在不同时区new Date(2025-03-01)在不同时区解析出来的时间戳可能差8小时。统一打法是用dayjs解析字符串并且显式指定 UTC 或本地时区我的习惯是全项目统一“本地时间戳 dayjs显示”避免服务器时区干扰计算。第三计算任务条宽度时也要注意const width (task.end - task.start) / ONE_DAY * dayWidth如果你把end当包含当天那这个宽度公式得写成 (end - start ONE_DAY)否则每天少算一格。我的做法是后端接口在返回任务时就把end统一成“截止日期后一天的0点”这样前端完全不用处理“加一天”逻辑后端反而更贴近业务语义。时间轴的刻度生成我封装了一个函数按天、周、月三档粒度输出刻度数组每个刻度包含位置坐标和格式化后的label。粒度切换时只需要重新生成刻度数组视图自动响应更新。4. 动手实现从空项目到能跑起来的甘特图4.1 初始化项目与整体布局我用Vite快速初始化了一个Vue3 TypeScript项目装了两个必需依赖dayjs用来处理日期vitest后面做单测。UI组件库一个都没装因为甘特图的DOM结构完全自定义组件库反而碍事。npm create vitelatest gantt-demo -- --template vue-ts npm install dayjs布局上用了一个常见的双栏结构左侧是任务列表显示任务名称和基础信息右侧是时间轴区域包括表头刻度层和任务条层。核心难点在于左右两侧的垂直滚动同步左侧滚动时右侧要跟着滚反之亦然。我的做法是给两侧容器都绑定同一个scrollTop通过一个scroll事件同步到对方。这个布局和Excel表头冻结非常像但甘特图还多一层水平滚动——时间轴无限向右延伸任务条层也随之移动。我的实现是左侧任务列表固定宽度并设overflow-y: scroll右侧图表区overflow: auto表头层position: sticky固定顶部。右侧滚动时左侧同步scrollTop左侧滚动时右侧同步scrollTop。双向绑定各写一句实测很稳。4.2 渲染任务条和时间轴表头刻度层的渲染逻辑是根据整体时间范围按当前粒度生成刻度数组。如果是按日粒度每格显示“MM-DD”格式按周粒度每格显示“MM-DD”加“第W周”辅助文案按月粒度显示“YYYY-MM”。每个格子宽度 dayWidth * 天数。我给出核心的timeGrid计算函数function buildTimeGrid(startTs: number, endTs: number, gran: day | week | month, dayWidth: number) { const grid: { x: number; label: string; ts: number }[] [] let cursor dayjs(startTs).startOf(gran day ? day : gran week ? week : month) const end dayjs(endTs) let x 0 while (cursor.isBefore(end)) { const next cursor.add(1, gran) grid.push({ x: x * dayWidth * (gran day ? 1 : gran week ? 7 : 30), label: cursor.format(gran month ? YYYY-MM : MM-DD), ts: cursor.valueOf() }) cursor next x } return grid }注意我这里的month粒度用的是近似值30天计算x位置严格来说每个月天数不一样会产生累计误差。更严谨的写法是给dayjs对象存一个monIndex然后用与起始日的实际差值来定位单元格。上面这段代码是我简化过的实际项目里建议按月粒度时直接用“当月第几天”来定位避免跨月时长不同导致的错位。任务条的渲染我用的是绝对定位div方案。为什么不用canvas因为甘特图需要和DOM交互hover显示tooltip、点击弹窗、拖拽DOM方案在这些场景下天然顺手而且数据量在几千条以内时性能完全够。如果用canvas所有交互都要自己通过坐标换算命中检测开发成本翻倍收益却不明显。div v-fortask in visibleTasks :keytask.id classgantt-task-bar :style{ left: px(task.start), width: px(task.end - task.start), background: task.color || #4f8cff } span classgantt-task-bar-label{{ task.name }}/span /divpx是一个工具函数把时间戳差值转为像素宽度。这行代码虽然简单但它是整个图表正确性的根基。我专门为它写了单测随便挑几个日期验证跨天、跨月、跨年的计算结果避免季节类需求比如“春节前后产能下调”改坏基础逻辑。4.3 拖拽改期、进度调整和依赖连线甘特图不能光看能拖才是灵魂。我给任务条绑定了mousedown事件拖拽时通过document上的mousemove和mouseup配合计算鼠标横向移动了多少像素再换算成天数更新任务的start和end。关键代码如下注意要同步更新start和end保持时长不变否则拖拽一次任务就会被拉长或缩短function onDragStart(task: GanttTask, e: MouseEvent) { const startX e.clientX const startTs task.start const onMove (ev: MouseEvent) { const dx ev.clientX - startX const dDays Math.round(dx / props.dayWidth) const nextStart startTs dDays * ONE_DAY_MS if (nextStart props.minDate) return task.start nextStart task.end task.end (nextStart - startTs) } const onUp () { document.removeEventListener(mousemove, onMove) document.removeEventListener(mouseup, onUp) } document.addEventListener(mousemove, onMove) document.addEventListener(mouseup, onUp) }这里有个经验拖拽过程中不要实时保存到后端而是拖完再统一保存。否则一次拖拽可能触发几十个接口请求后端会非常感谢你。我一般是在mouseup时把任务变更记录收集起来批量提交一次。拖拽还有一个常见的细节dayWidth小的时候比如一天只有15px宽鼠标移动1像素就对应0.06天任务条会变得非常“敏感”拖不均匀。这时候应该做吸附处理。我的做法是让移动天数四舍五入到半天或整天吸附粒度可以通过props灵活配置。进度调整我用的是任务条内部填充层任务条底部覆盖一层半透明色块宽度等于进度百分比。然后给这层填充区域单独加一个“细条”手柄允许鼠标横向拖动来调整百分比。这个交互和拖拽整体改期不冲突手柄区域需要设置cursor: ew-resize给用户明确反馈。依赖连线是甘特图里视觉上最唬人的部分。我的实现是一个SVG覆盖层绝对定位在任务条层之上pointer-events: none避免挡住拖拽。每一条依赖线从依赖任务的右边缘中点画一条折线到后续任务的左边缘中点。折线先去终点同y处再折90度到达任务左侧形成经典的“L”型箭头。这里有个画线顺序问题如果有多个任务互相依赖直接按数组顺序绘制会让某些线重叠。业界通用的办法是先做拓扑排序把任务按依赖层级排好序再画线这样骨架线会被后画的线压在下面视觉更清晰。如果任务量不大简单按“依赖者id数组的索引”排序也能用。5. 性能优化与大数据量场景5.1 虚拟渲染的思路甘特图数据量一大性能问题立刻暴露。几千个任务全部渲染成DOM哪怕每个任务只有几个元素浏览器也会卡到飞起。解决方案是虚拟滚动——只渲染可视区域内的任务条。实现虚拟滚动核心是知道当前滚动容器的可视高度是多少。我用一个定时器在挂载后测量容器的clientHeight再配合scrollTop事件实时计算当前可见的任务范围。任务列表本身按顺序排列所以可以在数组中用二分法找到第一个大于scrollTop的任务索引再取可视高度除以行高得到可见数量截取slice渲染。const visibleTasks computed(() { const startIdx binarySearch(taskList, scrollTop.value) const count Math.ceil(viewHeight.value / rowHeight) 1 return taskList.slice(startIdx, startIdx count) })注意虚拟渲染必须给任务条容器设置一个总的高度让scrollTop能滚动到正确位置。做法是把所有任务占用的总高度设置在外层div的height上内部绝对定位的可见任务条再相对该容器定位。这样浏览器滚动条长度是基于总高度的视觉节奏正常。大数据量下还有一个优化点不要给每个任务条都绑定独立的事件监听而是用事件委托。我在图表容器上统一监听click和mousedown通过event.target的dataset.id找到对应的任务数据。这样即便渲染几千个任务条事件数量也只有一个性能差距非常明显。5.2 响应式数据与渲染优化的几个细节Vue3的响应式系统在甘特图场景里需要小心。任务数组如果用reactive包住每一项的改动都会被深层监听这在任务达到几千条时会产生巨大的代理开销。我的做法是外层任务数组用ref存储但内部任务对象不做深响应式拖拽任务时直接替换整个数组引用触发computed重新计算。const tasks refGanttTask[]([]) // 拖拽结束后更新某个任务 function updateTask(payload: GanttTask) { tasks.value tasks.value.map(t t.id payload.id ? { ...t, ...payload } : t) }这样每次只生成一个浅拷贝性能开销可以忽略而且永远不会出现深层响应式的递归陷阱。另一个优化细节是日期格式化。在虚拟渲染里每一帧都要格式化大量任务的名字、日期字符串如果直接在模板里写dayjs().format()每渲染一次就是几十上百次dayjs调用很拖速度。我通常的做法是任务数据进来时在map阶段就预格式化好显示字符串渲染时直接读字段。比如name显示、开始日期显示、结束日期显示都提前拼好渲染时只做字符串拼接性能会好很多。最后说一个很多教程不会提的冷门优化给任务条容器开启will-change: transform让它变成合成层滚动时能走GPU加速路径。Vue渲染的DOM样式频繁变动合成层能避免大部分重绘开销。实测极端场景下滚动帧率从40帧提回到60帧效果显著。6. 常见问题与排查心得6.1 经典翻车现场我来把几个最典型的坑集中列一下每个都是我实际踩过的时间差一天。任务明明写的是3月1日到3月5日怎么就显示成4格宽度又恢复成6格十有八九是end定义问题。排查方法是把任务的时间戳打印出来直接看date字符串。如果end打印出来是3月5日0点而start是3月1日0点那就是“含当天”和“开区间”混用了。这个问题的解法就是统一用开区间让end截止日1天。时区导致任务条偏移。本地调试一切正常部署到服务器后所有任务条整体右移了一些像素。这是由于服务器时区不同dayjs解析Date对象时取的是UTC时间与本地存在时差。我在项目里用了统一的“时间戳代表本地时刻”约定后端也返回的是本地时间戳就彻底消除了这个问题。拖拽卡顿。拖拽任务条时整个页面都卡有时候鼠标都跟不上。绝大多数原因是对任务数组做了深度响应式拖拽时每一帧都触发整个数组的更新。我的解决办法是拖拽过程中直接操作一个深拷贝的任务对象不进入响应式系统只在mouseup时一次性赋值回tasks.value。数据从后端返回时瞎排序。甘特图的任务列表是有顺序的如果后端没按滚动顺序排序虚拟滚动就会出现内容错位。我用了二分查找的前提就是数组按滚动顺序排好序后端返回时如果没经过排序前端先在mounted里补一次sort然后就不再动顺序了。6.2 我的排查心法遇到复杂问题我习惯先画一个简化版复现demo把其他业务逻辑全部剥掉只看甘特图本身。大部分问题其实都是数据边界条件引发的比如空数组、只有一条任务、所有任务跨年等。这些边界条件在业务数据里不常见但恰恰是它们会导致算法异常。另一个经验是关注格式化函数。甘特图里出现视觉错位先怀疑时间格式化再怀疑布局CSS。因为CSS相对简单直接反而是日期计算里隐藏的“半天”“1毫秒”等问题很难一眼看出来。我写了一个打印函数可以快速输出时间轴的可视化调试信息每个刻度的x坐标、label、对应时间戳。排列出来一眼就能看出“哪个刻度错了”。还有就是依赖线消失的问题。画依赖线时如果发现箭头没了先检查ID匹配。很多后端返回的依赖字段是字符串而任务id是数字或者相反一套严格的数据约束Service就能避免这类低级错误。我在前端做了一个依赖检查函数专门找出“指向不存在任务”的依赖id渲染前过滤掉避免拖垮画线性能。从项目立项到上线这套自研甘特图组件大概花了两周多。中间踩过的坑一个没少但也正是这些坑让组件越来越顺手。你如果也要做类似的东西建议直接复制我的数据结构设计和虚拟渲染思路这会让你省掉一大半的调试时间。剩下的细节问题无非是兵来将挡、水来土掩多写几个测试用例跑通之后就没那么容易坏了。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

货拉拉营销广告大模型落地实战:提示词工程与智能体工作流 2026/10/1 18:46:05

货拉拉营销广告大模型落地实战:提示词工程与智能体工作流

1. 货拉拉营销广告的真实痛点:为什么通用大模型直接拿来用会翻车 货拉拉的营销广告业务有个很鲜明的特点:它不是那种"一个品牌对全网喊话"的标准化投放,而是 同城货运场景下、司机端与货主端双角色、多城市多车型多时段 的碎片化…

阅读更多 →
TypeScript从入门到实践:类型系统、泛型与工程迁移指南 2026/10/1 18:46:05

TypeScript从入门到实践:类型系统、泛型与工程迁移指南

如果你写过一段时间的JavaScript,大概率经历过这种时刻:一个函数跑得好好的,换个调用方式突然就报错了;一段别人留下的老代码,改了一行数据格式,十几个地方跟着崩;又或者一个对象明明有某个字段…

阅读更多 →
Java与Python项目服务器部署实战:从环境配置到前后端分离 2026/10/1 18:46:05

Java与Python项目服务器部署实战:从环境配置到前后端分离

干开发这些年,最常见的场景就是:代码写得挺欢,一到“部署”这两个字就头疼。Java项目打包出个jar或者war,扔到服务器上跑不起来;Python项目本地运行没问题,换台机器一堆依赖报错。尤其是从“能运行”到“稳…

阅读更多 →
TypeScript 实战:从类型系统到渐进迁移 2026/10/1 18:46:04

TypeScript 实战:从类型系统到渐进迁移

1. 为什么说 TypeScript 是 JavaScript 的一次蜕变做了这么多年前端,我最初对 TypeScript 的态度也是“多此一举”。JavaScript 写得好好的,为什么要多一层编译?直到在一个中型项目里被一个undefined is not a function的报错折腾了三个小时&…

阅读更多 →
二进制与十六进制互转及float还原:大小端、移位全解析 2026/10/1 18:46:04

二进制与十六进制互转及float还原:大小端、移位全解析

前阵子帮人排查一个嵌入式设备日志,里面打了一串十六进制字节,对方问我怎么把它还原成真实的 float 数值。说实话,干这行久了,这类问题见得太多,但每次被问还是会感慨一句:二进制和十六进制,平时…

阅读更多 →
054振荡排序 2026/10/1 18:45:45

054振荡排序

振荡排序 (Oscillating Sort / Reversing Merge) 054钟摆算法:解码振荡排序故事:钟摆的节拍 在磁带机时代,有一个令工程师头疼的问题:磁带倒带很慢。每次排序合并之后,都要把磁带倒回起始位置,才能进行下一…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉