☰
Canvas贝塞尔曲线从入门到实战:手写板平滑与动画动效全攻略
2026/10/4 3:02:10 网站建设 项目流程

写这篇文章之前,我特意翻了下后台的留言,发现关于 Canvas 绘图的私信里,十个里面有七个都在问贝塞尔曲线。有准备跳槽的应届生说面试被追问到怀疑人生,有刚转前端的小哥说产品要做一个手写签名板,自己用 lineTo 画出来的字一卡一卡的像锯齿。说实话,贝塞尔曲线这东西,光看 Canvas 文档里那两个 API 的名字——quadraticCurveTo和bezierCurveTo——就够劝退新手的了,再加上网上铺天盖地的数学公式,很多人还没开始学就先怂了。但你要真把它弄明白了,就会发现这玩意儿不仅不难,还特别好玩,你就是用一根手指头在屏幕上划拉,它都能帮你生成一条丝滑到爆的曲线。

这篇东西我打算换个思路,不讲虚头巴脑的数学推导,完全从实战角度出发,带你把二阶、三阶贝塞尔曲线逐个敲一遍,再教你几个在真实项目里特别管用的平滑技巧和动画玩法。不管你是刚入行三个月的菜鸟,还是被产品经理折磨到想转行的老油条,只要照着我的代码敲一遍,你就能 get 到 Canvas 绘图的精髓。

1. 先别急着写代码,把贝塞尔曲线的“脾气”摸清楚

很多前端新手有个毛病,打开文档看到一长串参数就直接开写,写完发现效果不对,就开始瞎调参,调了半天也不知道为什么。这就是典型的没搞懂曲线“脾气”就上手,结果自然是事倍功半。我建议你花十分钟把这一节看完,之后写代码的效率和准确率会翻倍。

1.1 一个初中数学就能理解的“插值”思路

贝塞尔曲线这个名字听着唬人,但它背后的核心逻辑,其实就是你初中数学课上学过的“两点确定一条直线”的延伸版。想象一下,你在纸上点了两个点 A 和 B,然后用一根笔把它们连起来,这就是一阶贝塞尔曲线,也就是一条直线。现在,我想让这条直线变弯,怎么办?最简单粗暴的办法是,在这两个点中间放一个“控制点” C,然后让线段 AB 上的点,都朝着 C 的方向被“吸”过去。

具体怎么“吸”,就涉及到t这个概念。你可以把t理解成一个从 0 走到 1 的进度条。当t = 0时,我们处在起点 A;当t = 1时,我们处在终点 B。而曲线上的每一个点,都是根据当时的t值,把起点、终点和控制点做加权平均算出来的。这个计算过程在 Canvas 内部帮你封装好了,但你心里得有这个“插值”的概念。可以说,贝塞尔曲线本质上就是一个关于t的插值游戏——这让它天然适合做动画,因为动画就是和时间强绑定的。

1.2 二阶和三阶:Canvas 里出场率最高的两兄弟

Canvas 原生 API 里,贝塞尔曲线就分两种:二阶(quadraticCurveTo)和三阶(bezierCurveTo)。它们的区别,用通俗的话讲就是:二阶曲线有一个控制点,你往左拽,曲线就往左弯;你往右拽,曲线就往右弯,控制点就像是拴在绳子上的一个手柄。三阶曲线则有两个控制点,你可以分别控制曲线在起点附近和终点附近的不同弯曲程度,自由度更高,能画出 S 形或者更复杂的造型。

大家用得最多的 CSS 缓动函数cubic-bezier(),其实就是三阶贝塞尔曲线,只不过坐标被规范化到了 0 到 1 的区间。表格对比一下就是:

类型API 名称控制点数量适合场景
一阶lineTo无连接直线、折线
二阶quadraticCurveTo1抛物线、圆角、简单的弧线、手写平滑
三阶bezierCurveTo2复杂曲线、S 形、图表曲线、动画路径、LOGO 描边

我个人的经验是,新手阶段,你把二阶曲线玩明白了,就已经能干 80% 的活儿了。但如果你要做图表库或者高阶动效,三阶曲线是你绕不开的坎。

2. 上手实战:亲手把一个点变成一条丝滑曲线

纸上谈兵没意思,我们现在就打开编辑器,亲手画一条曲线出来。这一节我带你从零搭一个画面,然后一步步把鼠标交互加进去,整个过程大概二十分钟就能搞定。

2.1 画布准备与坐标系,别在这个基础坑里栽跟头

先说一个新手最常见的问题:canvas.width和canvas.height跟 CSS 里设置的宽度高度不一样。很多人在 CSS 里给 canvas 设了width: 500px; height: 300px;,然后画出来的图老是模糊、拉伸,原因就是 Canvas 的默认画布尺寸其实是 300 x 150。如果你的 CSS 把它拉伸到了 500 宽,那每个像素点都被放大了一倍多,不模糊才怪。

正确做法是直接在 HTML 属性里写死画布的物理像素大小:

<canvas id="curveCanvas" width="800" height="500"></canvas>

接着用 JavaScript 拿一下绘图上下文,初始化一下画笔的基本属性:

const canvas = document.getElementById('curveCanvas'); const ctx = canvas.getContext('2d'); // 设置画笔和线条的基本属性 ctx.strokeStyle = '#3B82F6'; // 线条颜色,蓝色 ctx.lineWidth = 3; // 线条粗细 ctx.lineCap = 'round'; // 线条端点样式,圆头看起来更柔和 ctx.lineJoin = 'round'; // 线条交点样式,避免出现尖锐的折角

这里把lineCap和lineJoin都设成round是一个小细节,很多新手会忽略,导致画出来的曲线端点处有毛刺,视觉上就不够“丝滑”。

2.2 从直线到首条曲线:quadraticCurveTo 的正确打开方式

我们要画一个最简单的二阶曲线。逻辑是:先用moveTo把笔移动到起点 (startX,startY),然后调用quadraticCurveTo(controlX, controlY, endX, endY)。注意,这个方法的第一个参数对是控制点,第二个参数对才是终点。我见过有同事把这两个参数写反了,结果画出来的图形诡异得像心电图。

直接看代码:

const startX = 50; const startY = 250; const controlX = 200; const controlY = 50; // 控制点明显高于起点和终点,曲线会向上凸起 const endX = 450; const endY = 250; ctx.beginPath(); ctx.moveTo(startX, startY); ctx.quadraticCurveTo(controlX, controlY, endX, endY); ctx.stroke();

这段代码画出来的就是一条从左侧平移到右侧、中间向上拱起的弧线。你可以试着把controlY从 50 改成 300,曲线瞬间就会从凸起变成凹陷。曲线一定会经过起点和终点,但不会经过控制点——控制点只是提供一个“引力”,这是新手最容易误解的地方。

2.3 三阶曲线 bezierCurveTo:自由造型的秘密武器

二阶曲线只能提供一个方向的弯曲,如果想画 S 形,就得上三阶。bezierCurveTo(c1X, c1Y, c2X, c2Y, endX, endY),它比二阶多了一个控制点,一共两组控制参数,再加上终点坐标,总共六个参数。

举个例子,我想画一个波浪线:

ctx.beginPath(); ctx.moveTo(50, 200); // 第一段:向下弯的 S 形 ctx.bezierCurveTo(150, 100, 250, 300, 350, 200); // 第二段:再来一个反向的 S 形 ctx.bezierCurveTo(450, 100, 550, 300, 650, 200); ctx.stroke();

你控制第一个控制点离起点越远,曲线在起点区域的走势就越接近控制点的方向。这句话读起来有点绕,但非常重要。你可以想象你是一辆车,起点是车所在位置,控制点是方向盘往右打的幅度,终点是你最终要去的位置。控制点越远,相当于你在很长一段路程内都保持右转,弯的幅度自然就大。

2.4 让曲线“活”起来:用鼠标拖拽实时预览控制点

上面的代码是写死的,太枯燥了。真实项目里,控制点往往是用户用鼠标或者手指滑动实时生成的。我这里给你一个核心思路:监听mousemove事件,把鼠标经过的路径点收集起来,然后实时调用绘制函数。

简化版的交互逻辑可以这样写:

let points = []; canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; points.push({ x, y }); // 模拟一个控制点:取当前点前后各一个点的中间位置作为控制点 if (points.length >= 3) { ctx.clearRect(0, 0, canvas.width, canvas.height); 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.stroke(); } });

这里我先卖个关子,上面的代码其实已经用到了一点“中点平滑”的技巧,也就是后文要大讲特讲的核心。你先把这个 demo 跑起来,感受一下鼠标划过去,屏幕上出现的再也不是难看的折线了,而是一条自然的曲线。

3. 丝滑的核心秘密:把折线变成曲线的“中点法”

这一节是全文的精髓,也是我在多年项目实战里踩了无数坑之后摸索出来的经验。如果你只想记住一个技巧,那就是这个“中点法”。搞懂了它,你的手写板、签名板、画笔工具就都能迎来质变。

3.1 基础画线为什么永远是一条“折线”?

很多新手写手写板,第一反应是监听鼠标事件,然后把每个采样点用lineTo连起来。听起来没毛病,但实际效果大家心里都有数:屏幕上输出的不是一条平滑的轨迹,而是带着明显折角的“线”,如果手一抖,就是一条惨不忍睹的折线。

原因很简单:鼠标移过去,浏览器不可能每像素都触发一次回调,它是按固定频率采样的,比如每 16 毫秒采一个点。点与点之间实际上有几百微米的跨度,在这跨度之间,你强行用lineTo连上一条直线,那它的方向骤变就会被赤裸裸地暴露出来。说白了,你是在用低分辨率的点连高分辨率的线,自然不丝滑。

3.2 中点法的核心逻辑:曲线端点不落在采样点上

那怎么解决?答案是:不要让曲线经过采样点,而是让曲线经过“相邻采样点的中点”。这听起来像绕口令,但逻辑非常简单透彻。

假设你有三个连续的采样点P0、P1、P2。原来你是从P0拉直线到P1,再从P1拉直线到P2。现在我们换个思路:从P0到P1的中点开始,以P1作为控制点,画一条二阶贝塞尔曲线,终点落在P1到P2的中点上。这样一来,曲线始终在采样点之间穿行,控制点是采样点本身,既保留了采样点的“引力”走势,又避免了经过采样点时产生夹角。

代码已经在上面的mousemove里写过了,我再单独拎出来强调一遍核心逻辑:

// 每次从上一个中点画到当前中点,控制点用采样点 for (let i = 1; i < points.length - 1; i++) { const pPrev = points[i - 1]; const pCur = points[i]; const pNext = points[i + 1]; const midPrevX = (pPrev.x + pCur.x) / 2; const midPrevY = (pPrev.y + pCur.y) / 2; const midCurX = (pCur.x + pNext.x) / 2; const midCurY = (pCur.y + pNext.y) / 2; ctx.moveTo(midPrevX, midPrevY); ctx.quadraticCurveTo(pCur.x, pCur.y, midCurX, midCurY); }

这就是“中点法”,也是你在那些高逼格手绘板代码里经常见到的操作。原理没有多高深,就是把转折点藏在了线段的中点里。

3.3 触摸屏适配:把鼠标事件升级为指针事件

现在手写板一般要兼容手机和平板,如果还在监听mousemove,在触屏设备上必然是白屏一片。我推荐你直接用 Pointer Events。这个 API 是 HTML5 后来整合的统一事件体系,一个事件搞定鼠标、触摸和手写笔。

改造起来也简单:

canvas.addEventListener('pointerdown', (e) => { isDrawing = true; canvas.setPointerCapture(e.pointerId); points = [getPos(e)]; }); canvas.addEventListener('pointermove', (e) => { if (!isDrawing) return; points.push(getPos(e)); drawSmooth(); }); canvas.addEventListener('pointerup', () => { isDrawing = false; }); function getPos(e) { const rect = canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }

这里有个细节你一定要注意:setPointerCapture。如果没有它,当手指快速划出画布边缘时,pointermove会立刻丢失后续事件,导致曲线突然被截断,体验极其糟糕。加上这行代码,等于告诉浏览器:“这手指是我的了,你继续把事件送过来”,直到我主动松开。

3.4 管理海量数据点,别让数组撑爆内存

手写板或者自由绘图应用,一个核心隐患是采样点数组无限增长。你画个十分钟不松手,内存里可能存在上万个点,每次重绘都要遍历这一万次,卡顿是没跑的。

我常用的策略是:在pointerdown时新建一个空的点数组,在pointerup时结束本次绘制并把这条路径“固化”到离屏 Canvas 上,同时清空临时点数组。这样每次重绘只处理当前一笔的几百个点,性能完全可控。

// 离屏 canvas 缓存已完成内容 const offscreenCanvas = document.createElement('canvas'); offscreenCanvas.width = canvas.width; offscreenCanvas.height = canvas.height; const offCtx = offscreenCanvas.getContext('2d'); canvas.addEventListener('pointerup', () => { isDrawing = false; // 把当前笔迹固化到离屏画布 offCtx.clearRect(0, 0, offscreenCanvas.width, offscreenCanvas.height); offCtx.drawImage(canvas, 0, 0); points = []; });

这样既保留了画面完整性,又避免了点数爆炸,是一举两得的做法。

4. 进阶玩法:贝塞尔曲线在动画和图表里的实战应用

画得出来不算本事,画得好看、动起来才是本事。这一节,我给你分享三个高频实战场景:沿曲线运动的动画、平滑折线图,以及波浪特效。

4.1 让一个小球沿着贝塞尔曲线滚动

贝塞尔曲线的参数t天然就是动画进度。我们要做的,就是根据当前的时间进度,实时算出曲线上对应的点坐标,然后移动元素。

三阶贝塞尔曲线上任一点的坐标公式可以直接硬编码成函数,不用依赖任何库,拿来即用:

function getBezierPoint(t, p0, p1, p2, p3) { const x = Math.pow(1 - t, 3) * p0.x + 3 * Math.pow(1 - t, 2) * t * p1.x + 3 * (1 - t) * t * t * p2.x + Math.pow(t, 3) * p3.x; const y = Math.pow(1 - t, 3) * p0.y + 3 * Math.pow(1 - t, 2) * t * p1.y + 3 * (1 - t) * t * t * p2.y + Math.pow(t, 3) * p3.y; return { x, y }; }

然后在requestAnimationFrame里,遍历t从 0 到 1,每帧调用一次这个函数,设置小球的位置:

let progress = 0; const speed = 0.005; // 每帧递增的进度 // 起终点和控制点定义略 function animate() { ctx.clearRect(0, 0, canvas.width, canvas.height); const pos = getBezierPoint(progress, p0, p1, p2, p3); ctx.beginPath(); ctx.arc(pos.x, pos.y, 10, 0, Math.PI * 2); ctx.fillStyle = '#FF5733'; ctx.fill(); progress += speed; if (progress > 1) progress = 0; requestAnimationFrame(animate); } animate();

这个技巧我后来用在了几个 H5 活动页里,比如产品飞行轨迹、金币掉落金币动线,效果都比直线移动好得多。用 CSS 做直线位移需要嵌套好几层动画,用贝塞尔曲线几行代码就搞定。

4.2 把普通折线图变成让人眼前一亮的光滑曲线图

前端开发中做图表时,ECharts 这类库虽然自带平滑曲线,但很多公司是可定制的东西多,自己画的时候反而是折线居多。你要记住,把折线图变平滑,核心思路跟手写板一样,还是中点法,只不过可以利用三次贝塞尔每两个数据点之间画一段 S 形。

常用策略是两点之间取中点,用前一线段的中点作为起点,当前数据点作为控制点,后一线段的中点作为终点,用bezierCurveTo绘制。这里我分享一个最常用的快速方案,它的形状很自然,代码也不算复杂。假设有一组数据点data:

function drawSmoothLine(ctx, data) { ctx.beginPath(); ctx.moveTo(data[0].x, data[0].y); for (let i = 0; i < data.length - 1; i++) { const p1 = data[i]; const p2 = data[i + 1]; const midX = (p1.x + p2.x) / 2; const midY = (p1.y + p2.y) / 2; // 用 p1 作为控制点,画到中点 ctx.quadraticCurveTo(p1.x, p1.y, midX, midY); } // 画到最后一点 ctx.lineTo(data[data.length - 1].x, data[data.length - 1].y); ctx.stroke(); }

这个写法的效果是:曲线会非常平滑地穿过所有的中点,数值的波动通过控制点的“引力”保留下来,视觉上不会丢失趋势。如果你发现曲线在某些地方“过冲”(超过了数据点的高度),可能是控制点权重太大,可以考虑调整曲线类型或采样间隔。

4.3 页面背景的波浪动效,会了贝塞尔就随手能写

很多网站和 H5 的首页背景有一种“波浪起伏”的效果,看起来很高大上,其实就是几条用三阶贝塞尔曲线拼接而成的波浪线,加上时间参数不停地改变控制点坐标。

核心思路:把画布分成几个波峰波谷周期,每个周期内用bezierCurveTo画一个波浪,然后整体用正弦函数控制控制点的 y 坐标随时间上下浮动。我贴一个简版思路:

function drawWave(ctx, offsetY, color) { ctx.beginPath(); ctx.moveTo(0, offsetY); const waveHeight = 20; const waveWidth = 50; for (let x = 0; x < canvas.width; x += waveWidth) { // 通过正弦方式控制控制点高度 const controlY1 = offsetY + Math.sin(x * 0.05 + offsetY * 0.01) * waveHeight; const controlY2 = offsetY + Math.cos(x * 0.05 + offsetY * 0.02) * waveHeight; ctx.bezierCurveTo( x + waveWidth / 3, controlY1, x + (2 * waveWidth) / 3, controlY2, x + waveWidth, offsetY ); } ctx.strokeStyle = color; ctx.stroke(); ctx.fill(); // 如果需要填充波浪底部的水域,再配合 lineTo 到底部即可 }

时间变量加进去,比如全局的time每秒增加 0.1,然后把它加到controlY的计算里,波浪就动起来了。这个效果我实测下来帧率非常稳定,只要控制好波浪数量,在移动端不卡。

5. 菜鸟最容易踩的坑,和我的独家排查技巧

最后这一程,我把自己摔过的地方全摊开给你看。这些问题你在文档里绝对学不到,但一旦面试官问你,或者你在项目里碰上,就会明白“一文不值钱,一片值万金”是什么意思。

5.1 锯齿和模糊:canvas 宽高 vs CSS 宽高的世纪难题

先回头检查你写的<canvas>标签上的width和height属性,是不是canvas.getBoundingClientRect()拿到的尺寸。如果不是,你直接用 CSS 把 canvas 撑大了,那高 DPI 屏幕下所有线条都会糊得没法看。

还要提一个细节:Canvas 绘制时,坐标是浮点数。如果你的线条宽度为奇数,比如 3px,并且你画了一条在 x=100.5 上的竖线,那它会在 100 和 101 两个像素上都画半个像素,于是颜色变淡、看起来发虚。解决办法很简单:如果你要画奇数宽度线条,就给坐标加上 0.5,也就是x + 0.5。

5.2 路径状态与 beginPath 的顺序问题

这个 bug 非常隐蔽,我在刚写 Canvas 的时候被折磨了一整晚。现象是:画完第一笔后,第二笔开始莫名其妙地多了一条线,或者整个画面越来越乱。原因就是调用beginPath()之前,你没清空上一次的路径队列,而stroke()会把当前路径中的所有子路径都重新描一遍。

代码层面要注意:

// 错误示范 ctx.moveTo(10, 10); ctx.lineTo(100, 100); ctx.stroke(); // 第二次绘制,忘记 beginPath ctx.moveTo(200, 200); ctx.lineTo(300, 300); ctx.stroke(); // 前面那条线会被再描一遍

画完一笔之后,记得ctx.beginPath()重新开启新路径。这个习惯一旦养成,能帮你躲掉 90% 的绘制错乱问题。

5.3 性能问题:requestAnimationFrame 别乱用 setInterval

做动画的时候,新手往往习惯 setInterval 每 10 毫秒重绘一次,结果写出来的动画又卡又吃 CPU。正确的做法是使用requestAnimationFrame,浏览器会根据显示器的刷新率来合理安排绘制时机,走的是 GPU 优化通道,性能完全不同。这个 API 会自动处理帧率,你只管在回调里持续画下一帧。

如果遇到极高频次的拖拽重绘,可以在pointermove里加一个简单的节流判断,比如每两帧才重绘一次,避免计算量爆炸影响 UI 交互。

5.4 常见问题排查速查表

问题现象根本原因解决办法
画出来的线是折线、不光滑直接用了lineTo连接采样点改用中点法quadraticCurveTo平滑
曲线不经过我指定的控制点误解了控制点的作用记住曲线不经过控制点,只受它的“引力”影响
整个画面发虚、模糊canvas 尺寸和 CSS 尺寸不一致用 HTML 属性设置尺寸,CSS 只用来布局,不要缩放
第二笔开始画面变乱忘了beginPath()每次重新绘制前先调用beginPath()
手机端画到一半断触没有捕获 pointer capture调用canvas.setPointerCapture(e.pointerId)
数据点太多导致卡顿数组无限增长,每次重绘离屏 Canvas 固化,笔迹结束后清空数组

这张表是我敲键盘敲出来的真实经验积累,建议你截图存一份,遇到问题先来查一下,能省不少时间。

这几次实践下来,我最大的感受就是:贝塞尔曲线没有传说中那么高冷,它就是一个帮你“拐弯”的工具。我在很多项目里用中点法做了画笔,用三阶贝塞尔做了后台数据图表的平滑曲线,效果都相当稳定。你上手的时候不用贪多,先把二阶的quadraticCurveTo和三分法练透,再琢磨三阶的bezierCurveTo,一层一层往深走。你要是真的把这两兄弟玩转了,以后遇到产品提的“手写板”、“签名板”、“动效路径”之类的需求,都能做得又快又精致,再也不会被“优化一下曲线平滑度”这句话打回工位折腾到深夜了。

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

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

立即咨询