流程图节点之间的关系如果只用直线表示,在节点较多、连线交叉时几乎无法阅读。引入三次贝塞尔曲线以后,连线可以绕过障碍、保持平滑,但这也给数据模型提出了新问题:除了起点和终点,还需要表达两个控制点;如果控制点不是用户手工拖出来的,又需要通过张力参数自动推导。TypeScript里的定义如果写成controlPoints?: any或者只保留一个tension?: number,类型系统就无法区分手动曲线和自动曲线,后续计算贝塞尔路径时很容易把参数用错。所以第一步要把控制点和张力作为明确的类型成员,而不是散落在坐标数组里的隐含值。

为什么控制点与张力要分开定义
在流程图编辑器中,一条连线经常经历几种状态:初始直线、自动曲线、手动调整后的曲线。自动曲线通常只需要一个张力值,渲染时根据起点和终点实时计算两个控制点;手动曲线则依赖用户拖拽出来的两个控制点坐标,张力反而可能不需要参与计算。如果类型定义成interface Edge { source: string; target: string; controlPoints?: [Point, Point]; tension?: number; },表面上看两种模式都能覆盖,但实际上会出现四种组合:两个字段都缺失、只给张力、只给控制点、两个都提供。其中两个都提供时,渲染优先级靠约定,比如控制点优先还是张力优先,类型本身并不表达这个规则。
这种模糊定义会让贝塞尔路径计算函数变得非常脆弱。例如一个函数签名写成function buildPath(source: Point, target: Point, controlPoints?: [Point, Point], tension?: number),调用方可以完全绕过业务规则传入undefined, 0.8这类参数。更麻烦的是,当项目后来增加一种新的连线类型,比如正交连线或阶梯连线,原来的宽泛接口还会诱导开发者继续往上堆可选字段。把控制点和张力分开,并不是简单地把两个属性拆开,而是要把它们的语义绑定到不同的连线模式上。
核心类型:用可辨识联合区分手动与自动模式
针对上面的问题,TypeScript 的可辨识联合类型非常适合用来表达互斥配置。先定义基础的坐标和连线字段,再定义两个具体模式:curvature: 'auto' 表示通过张力自动计算控制点,curvature: 'manual' 表示使用显式控制点。这样类型本身就能说明一条边到底处于哪种状态。
type Point = {
x: number;
y: number;
};
type BezierTension = number;
interface FlowEdgeBase {
id: string;
source: string;
target: string;
sourcePoint: Point;
targetPoint: Point;
}
interface AutoBezierEdge extends FlowEdgeBase {
curvature: 'auto';
tension: BezierTension;
}
interface ManualBezierEdge extends FlowEdgeBase {
curvature: 'manual';
controlPoints: [Point, Point];
}
type FlowEdge = AutoBezierEdge | ManualBezierEdge;
这里 BezierTension 只是一个 number 别名,但从命名上能表达取值范围。它并不是真正的运行时约束,如果想要更严格的张力范围,可以进一步使用品牌类型或模板字面量类型,不过对于大多数流程图场景,先保留 number 并在计算函数中做范围检查已经足够。关键是 FlowEdge 把自动和手动两种模式彻底分开,任何接收 FlowEdge 的函数都能通过 edge.curvature 精确收窄。
相比把张力写成可选字段,这种设计还有一个明显好处:AutoBezierEdge 中必须提供张力,ManualBezierEdge 中必须提供控制点,四种非法组合里只剩下一种会被类型系统允许,也就是每条边要么自动、要么手动,不可能同时拥有两种计算依据。新增其他连线类型时,也只需要扩展联合成员,不会破坏已有逻辑。
从张力到控制点:计算函数与类型约束
当边被判定为自动模式后,就需要用张力把两个控制点算出来。常见做法是取起点和终点的中点,沿垂直方向偏移一段距离,偏移量由张力和连线长度共同决定。这样可以保证曲线在两端自然衔接,同时弯曲程度可控。下面给出一个基础实现,返回类型直接声明为 [Point, Point],避免调用方拿到普通数组后再做长度判断。
function computeControlPoints(
source: Point,
target: Point,
tension: number
): [Point, Point] {
const dx = target.x - source.x;
const dy = target.y - source.y;
const length = Math.hypot(dx, dy);
const safeLength = length > 0 ? length : 1;
const unitX = dx / safeLength;
const unitY = dy / safeLength;
const normalX = -unitY;
const normalY = unitX;
const offset = safeLength * tension;
const cp1: Point = {
x: source.x + unitX * safeLength * 0.25 + normalX * offset,
y: source.y + unitY * safeLength * 0.25 + normalY * offset
};
const cp2: Point = {
x: target.x - unitX * safeLength * 0.25 + normalX * offset,
y: target.y - unitY * safeLength * 0.25 + normalY * offset
};
return [cp1, cp2];
}
在这个实现中,length > 0 ? length : 1 是为了避免起点和终点重合时除零,offset 代表控制点偏离连线的距离。张力越大,曲线弯得越明显;张力为 0 时,两个控制点会落在起点和终点附近的直线上,整体接近直线。实际渲染时还需要一个三次贝塞尔插值函数,把起点、两个控制点、终点代入标准公式,生成路径点。
需要注意的是,张力并不是一个可以无限放大的值。如果张力过大,曲线可能绕过节点太远,反而干扰布局。通常建议在类型层面或文档层面约定 0 到 0.5 或 0 到 1 的范围。若项目要求更严格,可以在设置 tension 之前做归一化处理,例如 Math.min(1, Math.max(0, tension))。但无论如何,computeControlPoints 的返回类型固定为元组,使自动模式与手动模式在控制点这一层保持一致。
类型守卫、默认值与实际集成建议
在渲染组件或路径更新逻辑中,往往需要先判断边是自动还是手动,再决定调用哪个计算函数。可以写一个类型守卫,把 FlowEdge 收窄到 ManualBezierEdge,这样 TypeScript 在 else 分支能自动推断为 AutoBezierEdge。
function isManualBezierEdge(edge: FlowEdge): edge is ManualBezierEdge {
return edge.curvature === 'manual';
}
function resolveControlPoints(edge: FlowEdge): [Point, Point] {
if (isManualBezierEdge(edge)) {
return edge.controlPoints;
}
return computeControlPoints(edge.sourcePoint, edge.targetPoint, edge.tension);
}
这里的 resolveControlPoints 屏蔽了两种模式的差异,外部只需要拿到最终的两个控制点。当用户拖拽手动控制点时,更新逻辑也要通过类型收窄来修改对应字段。例如在状态管理中,如果直接写 edge.controlPoints = newPoints,TypeScript 会因为 edge 可能是 AutoBezierEdge 而报错;必须先检查 edge.curvature === 'manual',或者使用不可变更新。
另一个容易忽略的是默认张力。很多流程图库内部会提供一个全局默认值,但当边数据来自用户保存的 JSON 时,旧数据可能没有 tension 字段。使用可辨识联合后,没有 curvature 的旧数据会无法通过类型校验。可以在数据入口做一次迁移,例如把没有 curvature 的边补成 auto 模式,并写入默认张力 0.25。这样保证所有运行时数据都能落到联合类型中。配合 Readonly<FlowEdge> 或 ReadonlyArray<FlowEdge> 使用,还能减少非预期修改。
总的来说,把流程图贝塞尔曲线控制点与张力在 TypeScript 中定义清楚,并不需要复杂的高级类型技巧。可辨识联合加一个明确的元组返回类型,就能解决大部分参数错配问题。实际项目中还可以进一步区分三次贝塞尔和二次贝塞尔,或者增加 curvature: 'orthogonal' 等模式,只要保持每种模式都有自己的必填字段,类型系统就能持续提供保护。
TypeScript贝塞尔曲线流程图连线修改时间:2026-09-25 06:42:52