ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Canvas贝塞尔曲线从入门到实战:手写板平滑与动画动效全攻略

Canvas贝塞尔曲线从入门到实战:手写板平滑与动画动效全攻略 写这篇文章之前我特意翻了下后台的留言发现关于 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 idcurveCanvas width800 height500/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绘制。这里我分享一个最常用的快速方案它的形状很自然代码也不算复杂。假设有一组数据点datafunction 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并且你画了一条在 x100.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一层一层往深走。你要是真的把这两兄弟玩转了以后遇到产品提的“手写板”、“签名板”、“动效路径”之类的需求都能做得又快又精致再也不会被“优化一下曲线平滑度”这句话打回工位折腾到深夜了。
返回列表