甘特图里除了条形的任务块,最容易被忽视但体验差异最大的就是任务之间的依赖线。依赖线一头连着前置任务,一头挂着箭头指向后续任务,如果这些线条在渲染时能按拓扑顺序逐条延伸出来,而不是瞬间全部铺满屏幕,视觉层级会清晰很多。要实现这种效果,第一步不是写动画代码,而是把类型定义设计好。本文从依赖关系的数据结构入手,逐步推导出一套支持箭头方向、延迟与交错动画的TypeScript类型体系。

依赖关系的基础数据结构
在定义动画之前,先要明确依赖线本身长什么样。一条依赖线至少包含三个信息:前置任务id、后续任务id,以及依赖类型。常见的依赖类型有四种:完成到开始(FS)、开始到开始(SS)、完成到完成(FF)、开始到完成(SF)。这四种类型直接决定了连线从哪个任务的哪个边缘出发,以及箭头落在哪个任务的哪个边缘。
用TypeScript描述时,建议先定义依赖类型的最小枚举,避免直接用字符串导致拼写错误无法被编译器捕获:
// 依赖类型:FS=完成到开始,SS=开始到开始,FF=完成到完成,SF=开始到完成
export type DependencyType = 'FS' | 'SS' | 'FF' | 'SF';
// 单条依赖关系
export interface TaskDependency {
id: string; // 依赖线唯一标识
fromTaskId: string; // 前置任务
toTaskId: string; // 后续任务
type: DependencyType; // 依赖类型
}这个结构是纯数据的,不掺任何视觉信息。把数据层和视觉层分开,后面换渲染引擎(SVG、Canvas或者WebGL)时类型可以原封不动地复用,这是甘特图组件设计里很关键的一个解耦点。
定义箭头方向与连线端点类型
箭头是依赖线的灵魂。在动画场景下,箭头有两个需要类型化的属性:一是它的朝向,二是它的位置锚点。朝向通常由路径末段的走向决定,比如路径最后一段是水平向右的,箭头就朝右;位置锚点则描述箭头挂在任务块的哪条边(上、下、左、右)以及边的具体坐标。
这里可以定义一组联合类型来覆盖常见情况:
// 任务块的四条边
export type TaskEdge = 'top' | 'right' | 'bottom' | 'left';
// 箭头朝向,与绘制路径的末段方向一致
export type ArrowDirection = 'up' | 'down' | 'left' | 'right';
// 连线端点:任务id + 挂载边 + 坐标
export interface DependencyEndpoint {
taskId: string;
edge: TaskEdge;
x: number;
y: number;
}
// 带箭头的完整路径描述
export interface DependencyPath {
dependencyId: string;
points: Array<{ x: number; y: number }>; // 折线拐点序列
start: DependencyEndpoint;
end: DependencyEndpoint;
arrowDirection: ArrowDirection;
}把arrowDirection单独列出而不是在动画配置里随意填写,好处是渲染层可以据此计算箭头多边形的旋转角度,动画层也能决定箭头是在路径绘制完成后再淡入,还是跟随路径末端一起移动。这两种表现效果差异很大,前者更克制,后者更强调流向感,建议在类型层面就把这个选择显式表达出来,避免动画实现里到处写if判断。
动画延迟与交错效果的类型设计
接下来是核心部分:延迟与交错。所谓延迟,是指某条依赖线在图表初次渲染后等多久才开始播放动画;所谓交错,是指多条依赖线之间的动画不是同时进行,而是错开一小段时间依次播放,形成波浪式的入场节奏。
交错可以按两种策略编排:按拓扑层级(第一层依赖先播,第二层次之)或按固定间隔顺序播放。两种策略的字段需求不同,用可辨识联合类型来区分是TypeScript里的经典做法:
// 基础动画参数
export interface DependencyAnimationBase {
duration: number; // 单条线的绘制时长(毫秒)
easing: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut';
arrowFadeIn: boolean; // 箭头是否在路径画完后淡入
}
// 按拓扑层级交错:同一层级的线同时播放,层与层之间间隔固定时长
export interface TopologyStaggerConfig extends DependencyAnimationBase {
strategy: 'topology';
baseDelay: number; // 所有动画的起始延迟
levelGap: number; // 相邻层级之间的间隔
}
// 按顺序交错:逐条播放,每条间隔固定时长
export interface SequenceStaggerConfig extends DependencyAnimationBase {
strategy: 'sequence';
baseDelay: number;
itemGap: number; // 相邻两条线之间的间隔
}
// 可辨识联合
export type DependencyAnimationConfig = TopologyStaggerConfig | SequenceStaggerConfig;用了可辨识联合后,调用方在拿到配置对象时,TypeScript会在strategy字段收窄后自动提示对应的字段,比如判断config.strategy === 'topology'之后,levelGap才会出现在可访问属性中。这比把所有字段平铺在一个接口里安全得多,平铺写法下很容易出现levelGap和itemGap同时被赋值却只生效一个的隐患。
另外建议提供一层带默认值的辅助函数,把必填项收敛到最少:
export function createAnimationConfig(
partial: Partial<DependencyAnimationConfig> & { strategy: 'topology' | 'sequence' }
): DependencyAnimationConfig {
const defaults: DependencyAnimationBase = {
duration: 400,
easing: 'easeOut',
arrowFadeIn: true,
};
if (partial.strategy === 'topology') {
return { ...defaults, baseDelay: 100, levelGap: 150, ...partial };
}
return { ...defaults, baseDelay: 100, itemGap: 80, ...partial };
}这个函数利用交叉类型Partial<DependencyAnimationConfig>允许调用方只传关心的字段,内部负责补全默认值。注意展开顺序上把partial放在最后,保证用户显式传入的值不会被默认值覆盖。
计算每条线的实际延迟并组合成完整方案
类型定义只是骨架,还需要一个纯函数根据配置计算出每条依赖线的实际开始时间。这个函数的输出同样要有类型约束,这样渲染层拿到结果后不需要再做任何猜测:
// 动画计划:每条线一条记录
export interface DependencyAnimPlanItem {
dependencyId: string;
delay: number; // 相对图表首次渲染的延迟(毫秒)
duration: number;
arrowDelay: number; // 箭头动画的延迟,通常等于 delay + duration
}
export interface DependencyAnimPlan {
items: DependencyAnimPlanItem[];
totalDuration: number; // 整个入场动画的总时长
}
export function buildAnimPlan(
paths: DependencyPath[],
layerOf: (dependencyId: string) => number,
config: DependencyAnimationConfig
): DependencyAnimPlan {
const items = paths.map((p, index) => {
let delay: number;
if (config.strategy === 'topology') {
delay = config.baseDelay + layerOf(p.dependencyId) * config.levelGap;
} else {
delay = config.baseDelay + index * config.itemGap;
}
return {
dependencyId: p.dependencyId,
delay,
duration: config.duration,
arrowDelay: config.arrowFadeIn ? delay + config.duration : delay,
};
});
const totalDuration = items.reduce(
(max, it) => Math.max(max, it.delay + it.duration + (config.arrowFadeIn ? 200 : 0)),
0
);
return { items, totalDuration };
}函数中layerOf作为参数传入而不是内部计算拓扑层级,是为了保持这个模块的纯粹性:层级计算依赖任务关系图,属于数据层的职责,把图算法和动画编排分开,两边都更好测试。箭头的arrowDelay按照arrowFadeIn开关分别处理,路径画完再出现的箭头需要额外预留一段淡入时间,这个细节在总时长计算里也体现了,否则整图入场结束的回调会在箭头还没显示完时就被触发。
类型与渲染层的衔接及几点实践建议
类型体系搭好后,渲染层只需要消费DependencyAnimPlan。如果用SVG实现,典型做法是用stroke-dasharray配合stroke-dashoffset做路径延伸动画,箭头用一个<polygon>元素,根据arrowDirection设置旋转角度,延迟取计划里的arrowDelay。如果用Canvas或DOM实现,思路类似,只是需要自己维护requestAnimationFrame的时间轴,此时totalDuration就是判断动画全部结束的依据。
实践中还有三点值得注意。第一,easing字段建议保持字符串联合而不是接受任意函数,除非你的动画库确实需要传入自定义缓动函数,那时可以扩展成easing: EasingName | ((t: number) => number)的形式。第二,当依赖线数量超过两三百条时,交错间隔要适当压缩,否则最后一条线要等很久才出现,可以把itemGap设为随总量衰减的值,这一逻辑加在buildAnimPlan里即可,类型不用改。第三,尽量保持DependencyAnimationConfig向后兼容地扩展,比如后续要支持hover时单独高亮某条线的动画,可以新建一个独立的配置接口再交叉组合,而不是往现有联合类型里塞字段,这样老代码的编译不会受影响。
TypeScript甘特图动画类型定义修改时间:2026-09-14 01:50:59