二阶贝塞尔曲线在可视化连线中的实战指南
2026/9/13 13:46:59 网站建设 项目流程

做可视化项目时,最常用的连接线绘制方式就是二阶贝塞尔曲线。不管是流程图、拓扑图、还是拖拽连线,二次贝塞尔曲线几乎能覆盖绝大多数“两点之间画一条平滑曲线”的需求。我最早接触它是在做一个类似脑图的编辑工具,节点之间有大量连线,起初我直接用直线,后来改成一阶直线加箭头,怎么看都像电路板走线,直到换成二阶贝塞尔曲线,整体观感才真正像个“产品”。这篇内容会从原理、控制点选择、Canvas/SVG实现、性能优化和典型坑位几个方面,把我实际项目中验证过的做法整理出来。

它适合谁看?如果你正在做流程编辑器、拓扑可视化、low-code 平台、或者任何需要在界面里动态连线的功能,这篇文章可以直接“抄作业”。即使你不用 Canvas,思路和公式也完全适用于 SVG、WebGL,甚至游戏里的 UI 导线。看完后你至少能解决三件事:控制点到底怎么取,曲线怎么控制弯向,以及如何避免曲线绕路。

1. 内容整体设计与思路拆解

1.1 为什么两点之间的连线要用二阶贝塞尔曲线

先明确一个场景:起点 P0 和终点 P2 固定,你需要在它们之间画一条“好看”的线。直线最简单,但僵硬;而且当线和节点重叠时,视觉层次非常差。三阶贝塞尔曲线能拧出 S 形和复杂弯曲,但它有两个控制点,参数调整不好容易画成奇奇怪怪的一团线,调试成本高。

二阶贝塞尔曲线处在两者之间的甜点区:它只需要一个控制点 P1,数学简单、计算开销小,同时又能制造自然的弧度。用生活里的话说,二阶曲线像一根被手指从中间轻轻拨动的绳子,只有“一个弯”,方向可控,韧性可控。绝大多数连线场景,例如流程图、组织架构图、知识图谱、节点编辑器,需要的恰恰就是这种“一个弯”的效果。

从我自己的项目经验看,快速原型阶段用二阶曲线几乎不会出错,原因有三个:

  • 控制点只有 1 个,心智负担轻,调参时能直观知道“上移控制点曲线就更弯”。
  • 公式是纯初等运算,没有递归和矩阵求逆,渲染大量曲线时性能极其友好。
  • 曲线的凸包性质保证它不会离谱地飞到两个端点的“外侧”太远,形状天然可控。

当然它也有明显边界:如果两点之间有障碍物,需要 S 形绕行,单条二阶曲线做不到。这个我在后面“常见问题”里详细讲。

1.2 二阶贝塞尔曲线的数学原理与直觉

二阶贝塞尔曲线在参数 t 上的表达式是:

P(t) = (1 - t)^2 * P0 + 2 * (1 - t) * t * P1 + t^2 * P2

其中 t 从 0 到 1 连续变化。当 t = 0 时 P 在起点 P0,当 t = 1 时 P 在终点 P2,t 在中间时会逐渐靠近 P1 再离开,形成一条平滑的曲线。

这个公式如果你第一次见,可以拆成两次线性插值来理解。先在 P0 到 P1 之间取一点 A,在 P1 到 P2 之间取一点 B,A 和 B 都在参数 t 处:

A = (1 - t) * P0 + t * P1 B = (1 - t) * P1 + t * P2

然后让 A 到 B 之间再做一次相同比例的线性插值:

P = (1 - t) * A + t * B

把 A 和 B 的表达式代进去展开,正好就是二阶贝塞尔公式。这就是“二次”这个名字的由来:整个表达式对 t 的最高次数是 2。几何上,曲线就是这三条线段在逐步“拉线”过程中留下的轨迹,计算机图形学里也叫“德卡斯特里奥构造法”的特例。

在实际代码中,如果把 P0、P1、P2 分别拆成 x 和 y 坐标,两个分量互不干扰,所以公式也能写成:

x(t) = (1 - t)^2 * x0 + 2 * (1 - t) * t * x1 + t^2 * x2 y(t) = (1 - t)^2 * y0 + 2 * (1 - t) * t * y1 + t^2 * y2

另一个很重要的性质是:曲线一定落在 P0、P1、P2 三个点形成的凸包(三角形)内。也就是说,控制点放在哪里,曲线就会在它附近拐弯,但永远不会超出“三角形”边界。这给了我们极大的安全感,调控制点时不需要担心曲线飞出画布。

1.3 画连线前先想清楚你的需求

我见过很多同事直接套公式,结果画出的曲线方向怪怪的。问题往往不在公式,而在没定义清楚“你要这条线表达什么”。

如果只是两个节点之间画装饰性连线,控制点可以直接取中点,加一个垂直方向的固定偏移。如果是流程图的“有向”连线,并且需要表示数据从 A 流向 B,那么曲线最好能体现“从 A 的出口出来,经过一段路径,进入到 B 的入口”,这时控制点的确定需要参考 A 和 B 的朝向,而不是简单的中点偏移。如果是树状图里的父子节点连线,通常希望曲线像“括号”一样先向上走再落到子节点,这时控制点的位置又完全不同。

还有一个容易被忽略的点:连线两端是否带箭头?如果带箭头,曲线末端的切线方向就是 t = 1 时的一阶导数方向,你需要用这个方向来旋转箭头,否则箭头会歪。计算方式也不复杂,对 P(t) 求导得到:

P'(t) = 2 * (1 - t) * (P1 - P0) + 2 * t * (P2 - P1)

在 Canvas 里画箭头时,我就用这个公式算出终点切线向量,再旋转到箭头形状。这个细节很多教程不提,但实际项目里作用很大。

2. 核心细节解析与实操要点

2.1 控制点选择的三种常用策略

控制点是二阶贝塞尔曲线的灵魂。同一个起点和终点,控制点位置不同,画出来的曲线视觉差异巨大。我整理了三种我实际用过的策略,按适用场景分类。

第一种:固定垂直偏移法

这是最通用的做法。先算出两个端点的中点 M:

M = ((x0 + x2) / 2, (y0 + y2) / 2)

然后找出从 P0 到 P2 的垂直方向向量。对于二维坐标,垂直方向只有两个,一个向左旋转 90 度,一个向右旋转 90 度。假设方向向量 D = P2 - P0,那么它的一个垂直向量是 N = (-D.y, D.x)。控制点就是:

P1 = M + N * k

其中 k 是偏移量。k 的绝对值控制弯曲程度,正负号控制弯曲方向。偏移量可以是固定像素,但更好的做法是让它跟两点距离成比例,例如 k = 距离 * 0.25。这样不管两个节点是远是近,曲线弧度看起来都足够匀称。

第二种:固定角度法

在某些需要“层级感”的场景,比如组织结构图,我们不希望曲线乱弯,而是希望它先从当前节点垂直向上走一段,再水平拐到目标节点附近。这时可以让控制点落在“从起点出发、沿着某个角度延伸”的方向上。

例如,希望曲线从起点沿竖直方向往上“弹”出去,控制点就是:

P1 = (x0 + (x2 - x0) * 0.5, y0 - offset)

这个做法直观,调起来也简单。缺点是当目标节点位于起点左上方时,这条曲线可能像绕了一个大圈。

第三种:按节点朝向偏移法

节点编辑器、流程图工具最常用。每个节点通常知道自己有哪些“输出锚点”和“输入锚点”,例如输出锚点在右侧,输入锚点在左侧。连接线发出端应该沿输出锚点的方向延伸一段距离,再转向终点;进入端也应该从目标方向反推一段距离。这样曲线会很自然地从“出口”流出,流进“入口”。

具体实现是:P1 不再用中点垂直偏移,而是取两个端点的“方向点”再平均:

P1 = P0 的方向延长点 L0 与 P2 的反方向延长点 L1 的中点

L0 = P0 + 方向0 * 距离 L1 = P2 + 方向1 * 距离

其中方向0 是输出锚点方向,方向1 是输入锚点方向的反方向(因为要从 P2 的反方向“靠近”)。这样得到的曲线整体更贴合布局,比全局统一偏移更有设计感,代价是要给每个节点维护锚点方向的数据结构。

2.2 向量运算基础与坐标计算

如果你已经熟悉向量运算,可以直接跳过这段。但很多新手第一次写下面的代码时容易把垂直向量求反,所以我单独讲一下。

  • 两点向量:D = P2 - P0,也就是 D.x = x2 - x0,D.y = y2 - y0。
  • 单位向量:把 D 除以它的长度 len。len = Math.hypot(D.x, D.y)。当 len 为 0 时要特别处理。
  • 垂直向量:逆时针旋转 90 度得到 (-D.y, D.x),顺时针旋转 90 度得到 (D.y, -D.x)。画布坐标系里 y 轴向下,你可能需要反复试一下哪个是“向上”,哪个是“向下”,记住一个原则即可:采样代入公式看结果,不要靠脑补。
  • 中点:M = (P0 + P2) / 2,也就是 M.x = (x0 + x2) / 2,M.y = (y0 + y2) / 2。

有了这些基础,控制点计算完全可以封装成几个纯函数,方便测试和复用。我通常会在代码里维护一个Vec2辅助对象,或者直接用一个包含 x/y 的普通对象,没必要引入额外库。坐标计算不依赖框架,纯 JS 就好。

2.3 如何控制曲线的弯曲方向和弯曲程度

弯曲方向本质上由控制点 P1 相对中点 M 的位置决定。Canvas 坐标系中 y 轴向下,这就导致一个反直觉的现象:想让曲线朝“上”弯,控制点的 y 坐标反而要小于中点 y;想让曲线朝“下”弯,控制点 y 坐标要比中点 y 大。

为了让方向判断不出错,我建议在开发时直接在控制台打几个采样点,看曲线初始的方向。例如,P0 在 (100, 100),P2 在 (300, 100),中点 M 是 (200, 100)。此时如果我把控制点放到 (200, 50),也就是中点的正上方,那曲线会像一个桥洞一样向上拱起。如果放到 (200, 150),曲线就向下凹。这是最容易理解的例子。

弯曲程度则由控制点到中点的距离控制。偏移量越大,曲线越“鼓”。我在项目里一般把偏移系数控制在 0.2 到 0.5 之间,小于 0.2 时曲线太直,几乎没有存在感;大于 0.5 时,曲线可能会超出很多人的审美范围,而且当两个节点距离很近时会显得异常突兀。

3. 实操过程与核心环节实现

3.1 用 Canvas 实现完整的“两点连线”组件

先说最直接的方式:用 Canvas 2D 的quadraticCurveTo,它能直接用二阶贝塞尔曲线绘制路径,底层已经帮你处理好了公式,不需要手动采样。

核心代码很简洁:

function drawBezierLine(ctx, p0, p2, bend = 0.25) { const mx = (p0.x + p2.x) / 2; const my = (p0.y + p2.y) / 2; const dx = p2.x - p0.x; const dy = p2.y - p0.y; const distance = Math.hypot(dx, dy); if (distance < 1e-6) { // 两点重合,直接画点或不做处理 return; } // 垂直向量,这里选逆时针旋转90度的方向 const nx = -dy / distance; const ny = dx / distance; const p1 = { x: mx + nx * distance * bend, y: my + ny * distance * bend, }; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.quadraticCurveTo(p1.x, p1.y, p2.x, p2.y); ctx.stroke(); }

bend就是弯曲系数。我习惯传 0.25,效果比较克制。如果你想画“向上弯”还是“向下弯”,可以传符号,例如bend = -0.25就是反方向。

如果不用quadraticCurveTo,你也可以直接采样手动生成折线点,这在 WebGL 或导出图片时比较有用:

function sampleQuadratic(p0, p1, p2, count = 50) { const points = []; for (let i = 0; i <= count; i++) { const t = i / count; const mt = 1 - t; const x = mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x; const y = mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y; points.push({ x, y }); } return points; }

采样点数 30 到 100 之间视觉上基本没有区别,性能开销也极低。手动采样的好处是可以对点做后续处理,比如拖动过程中二次编辑、物理碰撞、避免遮挡等。

3.2 使用 SVG 的实现方式

SVG 和 Canvas 在画这条曲线上的思路是一样的,只不过把公式交给了浏览器解析。

<path d="M 100 100 Q 200 50 300 100" fill="none" stroke="#333" stroke-width="2" />

M是起点,Q后面跟着控制点和终点。如果用 JS 动态生成,就是字符串拼接:

const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); const mx = (p0.x + p2.x) / 2; const my = (p0.y + p2.y) / 2; const dx = p2.x - p0.x; const dy = p2.y - p0.y; const dist = Math.hypot(dx, dy); const p1 = { x: mx + (-dy / dist) * dist * bend, y: my + (dx / dist) * dist * bend, }; path.setAttribute('d', `M ${p0.x} ${p0.y} Q ${p1.x} ${p1.y} ${p2.x} ${p2.y}`); svg.appendChild(path);

SVG 方案的好处是每条线都是独立 DOM 节点,方便绑定事件、做动画、单独修改样式。缺点是节点特别多时 DOM 数量会拖慢页面,此时 Canvas 明显更快。

3.3 动态拖拽场景下的实时重绘

如果节点可以拖动,连线要跟着变。我通常用一个 state 管理所有节点和连线,每次拖拽触发update,然后重绘画布。

简化版逻辑:

let pointA = { x: 150, y: 150 }; let pointB = { x: 400, y: 260 }; let dragging = null; canvas.addEventListener('pointerdown', (e) => { const pos = getPos(e); if (isClose(pos, pointA)) dragging = 'A'; else if (isClose(pos, pointB)) dragging = 'B'; }); canvas.addEventListener('pointermove', (e) => { if (!dragging) return; const pos = getPos(e); if (dragging === 'A') pointA = pos; if (dragging === 'B') pointB = pos; redraw(); }); canvas.addEventListener('pointerup', () => { dragging = null; }); function redraw() { ctx.clearRect(0, 0, width, height); drawNodes(); drawBezierLine(ctx, pointA, pointB, 0.3); }

这里有一个细节:pointermove在拖拽过程中会高频触发,每一次都重新clearRect和重绘所有节点,简单场景没问题;如果节点和连线非常多,就建议用requestAnimationFrame合并绘制。你可以在事件回调里只更新坐标,把真正的绘制放到 rAF 中,避免连续鼠标事件把渲染队列塞满。

3.4 大量连线的性能与渲染优化

当你画了几百甚至上千条二阶贝塞尔曲线时,性能瓶颈多半不在贝塞尔计算,而在绘制调用次数和路径重建上。Canvas 的路径对象如果反复构建,GC 压力会变大;我这里有一个实测可用的优化方法:预先创建Path2D对象并缓存。

const pathCache = new Map(); function getCachedPath(id, p0, p2, bend) { let path = pathCache.get(id); if (!path) { path = new Path2D(); // 计算控制点... const cx = ...; const cy = ...; path.moveTo(p0.x, p0.y); path.quadraticCurveTo(cx, cy, p2.x, p2.y); pathCache.set(id, path); } return path; }

前提是线和节点没有频繁移动。如果节点位置会变化,缓存命中率低,那就别缓存,老老实实每次重建路径,然后把strokeStyle之类的样式设置放在绘制循环外面,否则 Canvas 会不停地切换状态,这个开销往往比画曲线本身还大。

另一个思路是用离屏 Canvas 先把固定不变的连线层画好,拖拽时只把动态连线画在顶层。这个方案做节点编辑器时非常香,静态节点和背景不需要每次重画,帧率能明显上去。

4. 常见问题与排查技巧实录

4.1 曲线弯的方向“反了”或“绕路”

这是新手最容易踩的坑。我最初调 Canvas 连线时,希望线条从起点往右下方延伸,结果控制点公式算出来,曲线总是往左上拱,看起来像背道而驰。

原因很简单:坐标系 y 轴正方向是向下,你脑中默认“上方是正方向”,但代码里 y 越小越靠上。解决方式有两种:要么统一用“垂直向量直接改成顺时针旋转”的那一套,要么保留公式但记住符号的影响。

我给的排查方法很笨但很快:画一条从 (100, 100) 到 (300, 100) 的线,然后分别尝试p1.y = 50p1.y = 150,看哪一种符合你的预期,再确定偏移系数符号。一旦确认,把这个符号写成常量,不要到处手写正负号。

4.2 两个端点重合或距离过近时曲线异常

当起点和终点距离非常近时,垂直向量计算里的除法会出现问题:distance趋向 0,控制点会被推到极远的位置,曲线狰狞。

处理方式有两条路:

  1. 距离小于某个阈值(例如 5px)时,直接不画曲线,或者画一条直线。
  2. 给偏移量设一个最小绝对值,例如bendDistance = Math.max(distance * bend, 20),保证控制点不会变成无法收敛的数学异常。

这类边界情况在编辑器里很常见,因为用户完全可能把两个节点叠在一起。你必须在渲染函数里做防御性判断,而不是依赖上层逻辑保证节点不重合。

4.3 曲线穿过自己的节点或被文字遮挡

节点连线时,如果你把起点 P0 直接用节点中心坐标,曲线很可能从节点中心“穿膛而过”,视觉上非常糟糕。

解决方案是引入“锚点”概念:连线的起点不是节点中心,而是节点边框与出口方向的交点。简单做法是:取节点中心沿出口方向偏移一个半径 r,得到P0';终点同样从目标节点的中心沿入口反方向偏移。这样曲线只会从边框边缘开始,到另一个边框边缘结束,看起来就是连接在“边缘”上。

偏移量 r 可以设成节点宽度的一半或者固定 8px,具体看节点形状。如果是圆角矩形,建议稍微比理论半径小一点,否则线头和边框之间会有一条若隐若现的缝。

4.4 二阶贝塞尔曲线表达不了 S 形路径

我遇到过这样的需求:两个节点之间有一堆别的元素,连线希望从左边绕开障碍,像一个“几”字型。这种用单条二阶贝塞尔曲线做不出,因为二阶曲线只有一个控制点,几何上只能产生一个“弯折趋势”,无法形成两个转弯。

解决方案是:

  • 用两条二阶贝塞尔曲线拼接,中间加一个过渡点。例如从 P0 连到 M,再从 M 连到 P2,两条曲线在 M 处自然衔接。
  • 直接改用三阶贝塞尔曲线,用两个控制点在两端各“拧”出需要的方向。

第二种更省事。三阶公式:

P(t) = (1-t)^3 * P0 + 3*(1-t)^2t * C1 + 3(1-t)*t^2 * C2 + t^3 * P2

控制点 C1、C2 分别控制起点和终点的切线方向。你可以把 C1 放在 P0 右侧,C2 放在 P2 左侧,曲线就会先向右拐,再向左拐,形成 S 形。整体还是受凸包性质约束,不会乱飞。

但对于绝大多数“两个节点之间一条连接线”的产品需求,二阶够了。我一般不会为了极小概率的绕行需求一开始就上三阶,而是先用二阶把整体跑通,需要 S 形的地方再局部切到三阶。

4.5 曲线模糊或高清屏下线条发虚

Canvas 在高 DPI 屏幕上默认会按 CSS 尺寸进行像素映射,导致曲线发虚。标准做法是读取devicePixelRatio,把 Canvas 的物理像素尺寸放大,再通过ctx.scale(ratio, ratio)把坐标系还原到逻辑像素。

const dpr = window.devicePixelRatio || 1; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = width + 'px'; canvas.style.height = height + 'px'; ctx.scale(dpr, dpr);

SVG 是矢量渲染,没有这个问题。所以如果项目对清晰度要求苛刻,且连线数量不多,直接用 SVG 更省心。

4.6 大量连线时页面卡顿的排查思路

遇到卡顿,先别急着优化贝塞尔计算。先打开 Performance 面板看是脚本执行时间长,还是绘制时间长。我遇到过的最常见瓶颈出现在几个地方:

  • 每次 draw 都重新设置strokeStylelineWidth,导致 Canvas 状态频繁切换。
  • 用大量 DOM 节点表示连线并频繁更新d属性。
  • 拖拽事件回调里直接同步重绘,没有做节流或 rAF 合并。
  • 节点和连线混在同一个数组里,每次都做排序或过滤。

贝塞尔公式本身在 t=0 到 1 区间只是四次乘法加法级别的计算,性能开销很小。真正的开销来自于不必要的全局重绘和状态切换。优化的顺序是:先砍重复绘制,再减少路径重建,最后实在不行才考虑 WebGL 或离屏 Canvas。

最后分享一个调试技巧

在我自己画这类连线时,最常用的小技巧是临时把控制点也画出来。用一个小圆圈标记 P1,再把 P0 到 P1、P1 到 P2 用半透明直线连起来。这样你能立刻看出曲线为什么这么弯、方向为什么不对,比闭着眼调参数快得多。

调试代码可以这样加:

// 调试用:画控制点和辅助线 ctx.save(); ctx.globalAlpha = 0.3; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.stroke(); ctx.globalAlpha = 1; ctx.beginPath(); ctx.arc(p1.x, p1.y, 4, 0, Math.PI * 2); ctx.fillStyle = 'red'; ctx.fill(); ctx.restore();

这个习惯帮我省了很多排查时间。等曲线形状确定后,再把这个调试块删掉或放到一个debug开关下面就行。项目上线后如果用户反馈连线不好看,你也可以直接打开这个开关截图排查,比让用户描述“线歪了”要高效很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询