第一次接到用 Canvas 画曲线动画的需求,是在给一个直播 H5 写礼物特效的时候:一颗小球要从屏幕左下角飞向右上角,身后还要拖一条柔和的轨迹。我当时“贝塞尔曲线”四个字听过,公式也看过两三遍,真到写代码还是只会lineTo硬画,结果画出来的路径像折线,被设计师一眼看穿。
所以这篇完全是写给前端菜鸟的实操笔记。我会从贝塞尔曲线到底在做什么讲起,把 Canvas 怎么落笔、二阶三阶 API 怎么用、动画怎么做到“看起来丝滑”这些事拆开揉碎,最后再讲我实际踩过的坑。你不需要学过图形学,只需要打开编辑器跟着敲一遍就行。
1. 贝塞尔曲线到底是什么:先忘掉公式,用一个“切分点”理解它
1.1 贝塞尔曲线解决的是“从 A 到 B 时姿势怎么摆”
如果只要求从 A 点到 B 点,直线的确够了。但真实需求里,曲线往往有明确的“意图”:我想要它先往上拱、再往下沉,或者从左侧平缓转进、到右侧急转出去。这些意图在几何上靠什么表达?答案是控制点。
贝塞尔曲线最通俗的理解是:起点和终点之间,塞进一个或几个“控制点”。曲线本身不经过控制点,但会被控制点像磁铁一样“吸”过去。控制点离得越近,曲线越偏向它;控制点离得越远,曲线被拉得越弯。
- 二阶贝塞尔:P0(起点)、P1(控制点)、P2(终点),比如一条抛物线。
- 三阶贝塞尔:P0(起点)、P1 和 P2(两个控制点)、P3(终点),比如一个 S 形或来回转折的曲线。
你可以把二阶和三阶看成“曲线的两个常见档位”。Canvas 的 API 恰好也只提供这两档,刚好覆盖绝大多数前端动效需求。
1.2 用“切分”建立直觉:德卡斯特里奥的一次切割
有个叫“德卡斯特里奥”的算法思想,比公式好懂得多。拿二阶贝塞尔举例:假设 t 是 0 到 1 之间的一个进度值,比如 t=0.5。
- 先在 P0 到 P1 这条线段上,取一个 50% 位置的点 A;
- 再在 P1 到 P2 这条线段上,取一个 50% 位置的点 B;
- 连接 A、B 两点,在这条新线段上再取 50% 位置的点 C。
这个 C 点,就是贝塞尔曲线在 t=0.5 时对应的位置。t 从 0 走到 1,C 点画出来的轨迹就是整条曲线。
这个思路的好处是:它直接告诉你“曲线上任意一个点都是周围几个点在不同比例下混合的结果”。理解了这一点,后面写动画时,你就知道怎么让一个小球沿着曲线运动——其实就是让 t 从 0 慢慢走到 1,然后每个时刻算出对应的点坐标。
1.3 参数方程先背下来,后面写动画要用
虽然 Canvas 有现成 API,画静态曲线不需要自己算点,但做“小球沿曲线运动”这类动画时,你必须自己根据 t 算出曲线上的坐标。二阶和三阶的参数方程长这样:
二阶:
B(t) = (1 - t)^2 · P0 + 2t(1 - t) · P1 + t^2 · P2三阶:
B(t) = (1 - t)^3 · P0 + 3t(1 - t)^2 · P1 + 3t^2(1 - t) · P2 + t^3 · P3写成函数更直观:
function quadPoint(p0, p1, p2, t) { const mt = 1 - t; return { x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x, y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y, }; } function cubicPoint(p0, p1, p2, p3, t) { const mt = 1 - t; const a = mt * mt * mt; const b = 3 * mt * mt * t; const c = 3 * mt * t * t; const d = t * t * t; return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y, }; }你暂时不需要理解每一项为什么长这样,只要记住:把 t 从 0 到 1 依次代入,就能得到曲线上的点。这个认知是你写贝塞尔动画的“地基”。
2. 动手前先调教 Canvas 画布:坐标系、真尺寸和高分屏
2.1 Canvas 宽高和 CSS 尺寸是两套东西
很多新手第一次设 Canvas 只写style="width: 520px; height: 360px",结果画出来图形模糊或比例不对。原因是:Canvas 的真实分辨率由width和height属性决定,不是由 CSS 决定。
canvas.width和canvas.height:画布内部的像素尺寸,决定你能画多少像素。- CSS 的宽高:只是画布在页面上显示的尺寸。
如果你只设置了 CSS 尺寸,Canvas 默认内部是 300 x 150。浏览器会把这块 300x150 的小图等比例拉大显示,效果自然发虚。正确写法是:
<canvas id="curve" width="520" height="360"></canvas>或者用 JS 设置:
const canvas = document.getElementById('curve'); canvas.width = 520; canvas.height = 360;这里还要注意一点:Canvas 的坐标系原点在左上角,x 轴向右,y 轴向下。也就是说,y 值越大越靠下,和数学课上常见的 y 轴方向是反的。写代码时控制点坐标别凭直觉反着来。
2.2 高分屏适配:不想看到模糊曲线就先做这一步
Mac 的 Retina 屏、手机的高清屏上,window.devicePixelRatio往往是 2 或 3。这意味着一个 CSS 像素里要放 2x2 甚至 3x3 个物理像素。如果你 Canvas 的真实宽度只等于 CSS 宽度,浏览器会强行拉伸,曲线边缘就发糊。
标准做法是把 Canvas 的真实尺寸乘以 dpr,再通过ctx.scale把坐标系拉回来:
function setupCanvas(canvas, cssWidth, cssHeight) { const dpr = window.devicePixelRatio || 1; canvas.width = cssWidth * dpr; canvas.height = cssHeight * dpr; canvas.style.width = cssWidth + 'px'; canvas.style.height = cssHeight + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }之后写代码时,坐标仍然按 CSS 像素来写,setTransform会帮你自动放大。这样在 Retina 屏上画出来的线条清晰锐利,不会像隔了一层雾。
2.3 路径三连:beginPath、moveTo、stroke
Canvas 画线很像手持一支笔在纸上画:
beginPath()通知画布“我要开始一条新路径了”,相当于把笔离开纸面;moveTo(x, y)把笔移动到某个位置,但不画线;lineTo(x, y)从当前笔画位置连一条线到新位置;stroke()把这条路径真正描边显示出来。
如果忘了beginPath(),新路径会和旧路径连在一起,出现“串线”的诡异效果。这个坑我后面专门讲。你只需要先形成肌肉记忆:一段独立图形,一定是beginPath → 移动/连线 → stroke。
3. 画出第一笔曲线:quadraticCurveTo 与 bezierCurveTo 的正确打开方式
3.1 quadraticCurveTo:一个控制点画出抛物线
先看一段完整可运行代码。假设画布尺寸 520x360,起点 P0 在左下,控制点 P1 在正上方,终点 P2 在右下:
const canvas = document.getElementById('demo'); const ctx = canvas.getContext('2d'); canvas.width = 520; canvas.height = 360; ctx.lineWidth = 3; ctx.strokeStyle = '#1677ff'; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(60, 300); // 起点 ctx.quadraticCurveTo(260, 60, 460, 300); // 控制点P1, 终点P2 ctx.stroke();API 参数顺序要记牢:
quadraticCurveTo(控制点x, 控制点y, 终点x, 终点y)起点不是参数,而是当前路径的位置(也就是之前moveTo定的点)。这是新手最容易懵的地方:为什么这个 API 只有三个点而不是四个?因为它默认把“当前点”当作了起点。
想看控制点是怎么“拉”曲线的,可以画两条辅助虚线把 P0-P1、P1-P2 连起来:
ctx.save(); ctx.strokeStyle = 'rgba(0, 0, 0, 0.2)'; ctx.lineWidth = 1; ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(60, 300); ctx.lineTo(260, 60); ctx.lineTo(460, 300); ctx.stroke(); ctx.restore();你会发现,蓝色曲线并不经过 P1,而是被它“吸”向中间。P1 越靠近上方,曲线拱得越高;P1 越靠近左右两侧,曲线越平。这个直观感受,比记任何描述都有用。
3.2 bezierCurveTo:两个控制点带来的 S 形转折
二阶曲线只有“单方向弯折”,想画 S 形就得用三阶贝塞尔——它有两个控制点,可以中途改变弯曲方向:
ctx.beginPath(); ctx.moveTo(60, 280); ctx.bezierCurveTo(160, 80, 340, 80, 460, 280); ctx.stroke();参数顺序:
bezierCurveTo(控制点1x, 控制点1y, 控制点2x, 控制点2y, 终点x, 终点y)在当前这个例子里,两个控制点都比较靠上,但一个偏左、一个偏右,曲线会先往左上方向拱,再转向右上方向拱,整体看就是一个类似 S 形或波浪的曲线。三阶贝塞尔之所以在 SVG、CSS 动画、AE 导出路径里到处都是,就是因为它“表达能力”更强,一条线就能表达更复杂的趋势。
3.3 两个 API 的选型对比
| API | 控制点数量 | 常见用途 | 记忆口诀 |
|---|---|---|---|
quadraticCurveTo | 1 个 | 抛物线、简单过渡、小球拖尾 | 控制点 + 终点 |
bezierCurveTo | 2 个 | S 形、复杂曲线、图表平滑线 | 控制点1 + 控制点2 + 终点 |
做 H5 动效时,我的习惯是:先尝试二阶,形状不够再升级三阶。控制点越多,调节成本也越高,没必要一开始就上最复杂的。
4. 让小球沿曲线“丝滑”运动:动画循环与匀速参数的坑
4.1 最小动效循环:t 从 0 走到 1
静态曲线只是第一步,很多场景需要“动画”。比如一个礼物图标沿着贝塞尔曲线飞过,或者一条轨迹线逐渐生长出来。核心逻辑就是:把 t 从 0 缓慢增加到 1,每帧算出当前点坐标,然后清屏重绘。
下面这个例子,红色小球会沿二阶贝塞尔曲线循环运动:
const canvas = document.getElementById('anim'); const ctx = canvas.getContext('2d'); canvas.width = 520; canvas.height = 360; const p0 = { x: 60, y: 300 }; const p1 = { x: 260, y: 40 }; const p2 = { x: 460, y: 300 }; function getPoint(t) { const mt = 1 - t; return { x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x, y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y, }; } function drawCurve() { ctx.strokeStyle = '#1677ff'; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); for (let i = 1; i <= 80; i++) { const p = getPoint(i / 80); ctx.lineTo(p.x, p.y); } ctx.stroke(); } function drawBall(t) { const p = getPoint(t); ctx.beginPath(); ctx.arc(p.x, p.y, 7, 0, Math.PI * 2); ctx.fillStyle = '#ff4d4f'; ctx.fill(); } let t = 0; let last = 0; const speed = 0.4; // 每秒 t 增加 0.4 function frame(time) { const dt = last ? (time - last) / 1000 : 0; last = time; t += dt * speed; if (t > 1) t -= 1; ctx.clearRect(0, 0, canvas.width, canvas.height); drawCurve(); drawBall(t); requestAnimationFrame(frame); } requestAnimationFrame(frame);注意这里绘制曲线用了 80 段lineTo来逼近,而不是直接quadraticCurveTo。原因很简单:既然小球位置是靠参数方程算出来的,用同样方式采样曲线,就能保证“曲线本身”和“小球轨迹”严丝合缝。如果只是静态展示,直接用quadraticCurveTo更快,你也可以混合使用——画曲线用 API,算小球位置用参数方程。
4.2 隐藏得很深的“不丝滑”问题:t 匀速不等于速度匀速
如果你把上面代码跑起来,会感觉小球在某些位置明显快、某些位置明显慢。这不是你的错觉。贝塞尔曲线里 t 是参数,不是距离;t 匀速增加时,曲线上移动的像素距离并不均匀。尤其控制点离起点比较近时,小球会在刚出发时“冲”一下,靠近终点又“拖”一下。
想要视觉上的丝滑,需要按“弧长”来控制运动,而不是按 t。思路是:
- 先把曲线采样成很多小段,计算每一段的累计长度;
- 每帧让小球前进固定的像素距离;
- 根据累计长度反推出当前应该使用的 t。
预计算弧长表:
const samples = []; function precomputeArcLength(steps = 100) { let total = 0; let prev = getPoint(0); samples.push({ t: 0, dist: 0 }); for (let i = 1; i <= steps; i++) { const t = i / steps; const p = getPoint(t); total += Math.hypot(p.x - prev.x, p.y - prev.y); samples.push({ t, dist: total }); prev = p; } return total; } function getTByDistance(total, dist) { const d = ((dist % total) + total) % total; for (let i = 0; i < samples.length - 1; i++) { const a = samples[i]; const b = samples[i + 1]; if (d >= a.dist && d <= b.dist) { if (b.dist === a.dist) return a.t; const ratio = (d - a.dist) / (b.dist - a.dist); return a.t + (b.t - a.t) * ratio; } } return 1; }动画循环里不再维护 t,而是维护一个累计距离dist,每帧加上dt * pixelSpeed,再通过getTByDistance找到对应的 t,最后取点绘制。这样小球在弧线上匀速前进,观感立刻“顺”很多。
提示:这个细节属于进阶优化。菜鸟阶段不用死磕,但如果你做直播礼物、H5 动效类需求,迟早会遇到“为什么这条曲线跑起来一快一慢”的问题,到时候回来看这里就有答案了。
5. 实战场景:散点平滑、拖拽控制点与可复用的调试面板
5.1 给你一行散点,怎么画出一条平滑曲线
数据图表在前端很常见,比如折线图。直接用lineTo连起来,点多了就是一条折线,又硬又难看。一个经典的做法是“中点逼近法”:把每个原始点当作控制点,取相邻两个原始点的中点作为每一段quadraticCurveTo的终点。
function drawSmoothLine(ctx, points) { if (points.length < 2) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i = 1; i < points.length - 1; i++) { const midX = (points[i].x + points[i + 1].x) / 2; const midY = (points[i].y + points[i + 1].y) / 2; ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke(); }效果是原本尖锐的折角变成了圆润过渡。注意,这个方法是“逼近”而不是“精确穿过所有点”,如果产品要求曲线必须严格经过每个数据点,那要换成 Catmull-Rom 转换贝塞尔的插值方案,复杂度会高一些。大多数视觉平滑场景,中点逼近已经够用。
5.2 做一个可拖拽控制点的贝塞尔调试器
写贝塞尔曲线时最痛苦的是“盲调坐标”:在代码里改数字,刷新页面,再改数字。更好的方式是把控制点画到画布上,允许鼠标直接拖动,实时看效果。
const editor = document.getElementById('editor'); const ctx2 = editor.getContext('2d'); editor.width = 520; editor.height = 360; const points = { p0: { x: 60, y: 300 }, p1: { x: 260, y: 60 }, p2: { x: 380, y: 180 }, p3: { x: 460, y: 300 }, }; let dragging = null; function hitTest(x, y) { for (const key in points) { const p = points[key]; if (Math.hypot(p.x - x, p.y - y) < 14) return key; } return null; } editor.addEventListener('pointerdown', (e) => { const rect = editor.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; dragging = hitTest(x, y); }); editor.addEventListener('pointermove', (e) => { if (!dragging) return; const rect = editor.getBoundingClientRect(); points[dragging].x = e.clientX - rect.left; points[dragging].y = e.clientY - rect.top; redraw(); }); editor.addEventListener('pointerup', () => { dragging = null; }); editor.addEventListener('pointerleave', () => { dragging = null; }); function redraw() { ctx2.clearRect(0, 0, editor.width, editor.height); ctx2.lineWidth = 3; ctx2.strokeStyle = '#1677ff'; ctx2.beginPath(); ctx2.moveTo(points.p0.x, points.p0.y); ctx2.bezierCurveTo(points.p1.x, points.p1.y, points.p2.x, points.p2.y, points.p3.x, points.p3.y); ctx2.stroke(); // 画控制线段和圆点 ctx2.setLineDash([4, 4]); ctx2.strokeStyle = 'rgba(0, 0, 0, 0.3)'; ctx2.beginPath(); ctx2.moveTo(points.p0.x, points.p0.y); ctx2.lineTo(points.p1.x, points.p1.y); ctx2.lineTo(points.p2.x, points.p2.y); ctx2.lineTo(points.p3.x, points.p3.y); ctx2.stroke(); for (const key in points) { const p = points[key]; ctx2.beginPath(); ctx2.arc(p.x, p.y, 6, 0, Math.PI * 2); ctx2.fillStyle = key === 'p0' || key === 'p3' ? '#ff4d4f' : '#52c41a'; ctx2.fill(); } } redraw();这里用了pointerdown / pointermove / pointerup一套组合,覆盖鼠标和触屏。把这段代码保存成 HTML 文件,你以后写任何复杂曲线前都能先拖一拖、找找手感,再回代码里填数字。这比纯靠想象高效得多。
提示:如果页面里有高清屏适配,记得先调用前面写的
setupCanvas,并把clientX - rect.left得到的坐标转换成逻辑坐标。用了setTransform(dpr, 0, 0, dpr, 0, 0)后,CSS 像素坐标和 Canvas 逻辑坐标一致,拖拽位置不会偏。
5.3 场景延伸:不只是画线,还能做更多
把贝塞尔曲线学会后,你还能用它做出不少实用效果:
- 数据可视化:股票的 K 线趋势、走势图的平滑曲线;
- H5 动效:礼物飞入、引导箭头、气泡飘动;
- 加载动画:文字绘制动画、线条生长效果;
- Canvas 小游戏:敌人巡逻路径、子弹跟踪轨迹。
原理都一样:要么用参数方程计算点位置,要么用 Canvas API 直接描边。曲线本身不复杂,复杂的是“你想让它表达什么”。
6. 我掉进去过的三个坑:模糊、串线和鬼影
6.1 坑一:canvas.width 设置到了 CSS 里
我早期给 Canvas 调样式时,习惯性把宽高写进style,结果画出来的贝塞尔曲线总是发虚。排查半天发现 Canvas 内部还是默认的 300x150,所有漂亮曲线都被 CSS 暴力拉大了。
现在我的习惯是:初始化任何 Canvas,第一件事就是明确canvas.width和canvas.height。如果是动态创建,就再检查一次getBoundingClientRect()拿到的尺寸,确保真实尺寸和显示尺寸一致。
6.2 坑二:路径没有 beginPath,所有曲线糊成一片
有一段时间我写循环画多条曲线,发现屏幕上线条越来越多,越画越“脏”。原因很简单:每次stroke()之后路径没有重置,新旧线段全保留在同一个路径里,下一次stroke()会把之前的线条再描一遍。
正确做法是每条独立曲线的开头都调用beginPath()。尤其注意,moveTo和lineTo之后哪怕画了辅助线,也要在正式曲线前重新beginPath,否则辅助虚线可能混进主曲线里。
6.3 坑三:动画循环中清屏时机不对,出现“鬼影”
动画里每帧开头必须clearRect,但如果清屏区域算错,旧画面就会残留。比如画布是 520x360,你只清了局部区域,小球跑到区域外就会留下残影。
另一个相关问题是:如果你故意不清屏,可以把轨迹线留在画布上,来一版“拖尾生长动画”,但抖动的清屏会导致闪烁。我的建议是:默认每帧clearRect(0, 0, canvas.width, canvas.height),需要保留轨迹时再改为“只在画新一帧之前把上一帧内容复制保留”这类特殊做法,而不是依赖不清屏这种碰运气的方式。
提示:如果想做贝塞尔曲线“生长”动画,也就是线条一点点画出来,可以每帧根据当前 t 只绘制曲线的前半段,后半段留空。这时
clearRect的逻辑需要放在 draw 之前,顺序反了也会看到残影。
最后分享一个我个人的习惯:现在写任何带曲线的需求,第一步都会打开一个拖拽控制点的调试面板,先把形状调到满意,再记录控制点坐标回填到业务代码里。贝塞尔曲线这个知识点,说穿了就是“起点、终点、控制点”三个角色,你在画布上拖一次、看一次曲线变化,比背十遍公式都管用。菜鸟阶段不怕不懂数学,怕的是不敢动手试。把上面这几个例子跑通,你就已经超过大部分只会lineTo的同行了。