
如果你是从 HTML/CSS 转过来学前端的第一次打开 Canvas 大概率是懵的想画个方块发现坐标系跟数学课本里长得不一样想画个圆角度从右边开始转还往下走好不容易折腾出一个三角形它居然是躺着的。别慌这太正常了。我见过不少前端新人卡在这道坎上其实 Canvas 几何绘图的底层逻辑非常单纯。 这篇就从坐标系和弧度制讲起把“画正 N 边形”这件事彻底做成一个参数化函数——边数从 3 到 360 随便拉还能让图形旋转、嵌套、跟鼠标互动。前端菜鸟跟着走完大概率会从“懵圈”直接过渡到“上头”。面试时如果被问到 Canvas 图形绘制你也能聊出一些门道。1. 坐标系和角度单位先把 Canvas 的“底层脾气”摸透1.1 Canvas 是“状态驱动”的画板不是“一笔一划”的记事本接触 Canvas 的第一个认知误区是从 CSS 带过来的很多人以为ctx.fillStyle red只对下一次填充生效像 CSS 里color那样“这次设置、这次使用”。实际上 Canvas 更像一台寄存器机器fillStyle、strokeStyle、lineWidth都是持续生效的全局状态。你设成红色之后不改成其他颜色后面所有fill()都是红色。同理ctx.lineWidth 5设置一次后续所有描边都是 5 像素直到你再次修改它。我后来习惯把它想成“给画板拧旋钮”fillStyle是油漆桶的颜料选择旋钮lineWidth是笔尖粗细旋钮。每次画画前第一件事就是检查旋钮状态不然你会在不知情的情况下得到一种“看起来好像哪里不对”的图形。这个心智模型一旦建立后面调试颜色、描边、透明度相关的 bug 会省掉大量时间。1.2 为什么 0° 在“三点钟方向”并且角度越大越是顺时针接下来是坐标系。网页里的坐标系原点在左上角x 轴向右是正方向y 轴向下是正方向。你没理解错在 Canvas 世界里y 轴是“低头”的楼下方向才是正数。这带来一个反直觉的推论数学课本里的极坐标角度逆时针递增但在 Canvas 里由于 y 轴反转角度会顺时针递增。具体看弧度制弧度角度钟表位置00°12 点方向往右即 3 点钟π / 290°6 点钟方向π180°9 点钟方向3π / 2 或 -π / 2270°12 点钟方向所以当你计算cos(θ)和sin(θ)时θ 0的点在正右方θ π/2的点在正下方绕一圈下来是顺时针的。刚开始会不太习惯但我想说一个经验这个方向反而是符合人类直觉的毕竟你看钟表就是从 12 点往 3、6、9 点转的。新手最常犯的错是把Math.PI / 2当 90° 用却忘了 Canvas 里的 90° 是朝下的。画出来的点要么掉到图形下方要么方向完全反了。解决办法很简单画到哪一步都先在脑内过一遍“0 在右边顺时针转”然后多试几次就顺了。另外Canvas 的三角函数全部使用弧度制不要在代码里手动乘 180 再除 π直接背几个常用弧度值就够了。真正想“画对角度”你得把“弧度”两个字焊在脑子里。1.3 用钢笔比喻理解 path 三件套beginPath、moveTo、lineToCanvas 绘图最基础的操作是路径path它像拿钢笔在白纸上画画ctx.beginPath()把笔从纸上抬起来开始一段新路径。旧路径全部作废。ctx.moveTo(x, y)笔尖移动到某一点不画线。ctx.lineTo(x, y)从当前笔尖位置画一条直线到目标点。ctx.closePath()把最后一个点跟起点连起来形成闭合形状。ctx.stroke()沿着路径描边。ctx.fill()用当前填充色填满闭合区域。画正 N 边形只需要循环调用lineTo就够了。一个正方形从 (50, 50) 开始顺时针走一圈代码长这样const ctx canvas.getContext(2d); ctx.beginPath(); ctx.moveTo(50, 50); ctx.lineTo(150, 50); ctx.lineTo(150, 150); ctx.lineTo(50, 150); ctx.closePath(); ctx.stroke();看起来简单但很多新人会漏掉beginPath()结果就是第二次执行时上一次的路径还残留在纸上图形越画越脏线条颜色莫名变深。这个问题我会在第五章专门讲因为它的坑劲儿绝对值得单独立项。2. 正 N 边形的秘密它不是多边形是“切开的圆”2.1 核心几何洞察外接圆上均匀分布的 N 个点现在正式聊正 N 边形。直觉上大家会想正五边形怎么画5 条边、5 个内角相等那我是不是该算每条边的长度能算但非常绕而且边数一多就麻了。真正干净的做法是把正 N 边形看作“某个圆上的 N 个等分点按顺序连起来”。这个圆叫外接圆每个顶点都在圆上。你把这个圆理解成一个蛋糕想要正五边形就先把蛋糕切成 5 等份取蛋糕边缘的 5 个切点连起来就是正五边形。切蛋糕的每一刀都经过圆心所以刀与刀之间的夹角是 360° 除以 5也就是 72°换算成弧度是2π / 5。把这个关系推广对于正 N 边形相邻两个顶点和圆心之间形成的圆心角是圆心角 2π / N这是整个绘图的“锚点”后面所有坐标都是从它推导出来的。你不需要背边长公式不需要算内角只要拿着这个圆心角一路均匀转圈就行。2.2 三角函数把角度变成坐标有了角度怎么得到顶点的 x 和 y假设圆心坐标是(cx, cy)半径是r某个顶点相对圆心的偏移角度是θ。那这个点的坐标是x cx r * cos(θ) y cy r * sin(θ)为什么是这个公式三角函数定义在一个直角三角形里cos等于邻边除以斜边sin等于对边除以斜边。这里圆的半径r就是斜边r * cos(θ)表示 x 方向的水平偏移r * sin(θ)表示 y 方向的垂直偏移分别加到圆心上就得到顶点坐标。如果不放心可以手动验证一个小例子。圆心在(200, 200)半径100θ 0那顶点就是(200 100 * 1, 200 100 * 0)等于(300, 200)果然在圆心的正右方 100 像素处没毛病。在 Canvas 坐标系里因为 y 轴向下θ增大时点会从正右方往正下方转也就是顺时针。这不是 bug反而让“按循环顺序连线”变得很自然i 0, 1, 2, ..., N-1每个顶点都用i * 2π / N计算角度顺序就是一笔画。下面这张表是半径 100、圆心在 (200, 200)、从 0° 开始时正三角形、正方形、正五边形的前几个顶点坐标你可以拿计算器核对感受一下边数 N角度增量第 0 点角度第 0 点坐标第 1 点角度第 1 点坐标32π/3 ≈ 120°0(300, 200)120°(150, 286.6)4π/2 ≈ 90°0(300, 200)90°(200, 300)52π/5 ≈ 72°0(300, 200)72°(230.9, 295.1)看到没计算并不复杂只要把圆心角叠加到初始角度上逐个求出点然后lineTo一连就完事了。2.3 第一次实战画一个顶点朝上的正三角形现在动手写一个正三角形。这里有一个新手容易忽略的小细节初始角度设成多少如果从θ 0开始画第一个点会出现在圆心的正右方画出来的三角形是这样的它的一条边在右侧整个图形横躺着视觉上相当不“正”。绝大多数 UI 场合我们希望正多边形的顶点朝上像一个正放的奖牌。所以初始角度要设置成-π / 2也就是 12 点钟方向。代码是这样const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const cx 200; const cy 200; const radius 120; const sides 3; const rotation -Math.PI / 2; // 起始角度12 点钟方向 ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); for (let i 0; i sides; i) { const angle rotation (i * 2 * Math.PI) / sides; const x cx radius * Math.cos(angle); const y cy radius * Math.sin(angle); if (i 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.closePath(); ctx.strokeStyle #4f8cf7; ctx.lineWidth 3; ctx.stroke();运行一下你会看到一个笔直的正三角形顶点朝上另外两个顶点在下方左右对称分布。这个图形会打破你对 Canvas 几何绘制的恐惧因为你会发现它跟数学课本上的图长得一摸一样了。这段代码里唯一需要动脑的就是rotation (i * 2 * Math.PI) / sides。它看起来只是一个累加角度实际上承载了整个正多边形的几何逻辑初始方向定了剩下的顶点按照圆心角均匀分布。3. 封装成函数让 N 变成滑条上的参数3.1 写一个通用函数drawRegularPolygon把上面的代码提取成函数参数化设计非常关键。我的建议是至少支持这六个参数function drawRegularPolygon(ctx, cx, cy, sides, radius, rotation -Math.PI / 2) { ctx.beginPath(); for (let i 0; i sides; i) { const angle rotation (i * 2 * Math.PI) / sides; const x cx radius * Math.cos(angle); const y cy radius * Math.sin(angle); if (i 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } } ctx.closePath(); }这个函数只负责构建路径不负责描边和填充。这样设计的好处是调用方可以自由决定呈现方式先drawRegularPolygon建立起路径想描边就stroke()想填充就fill()想要两者叠加就都调用。参数里的rotation默认值我设成了-Math.PI / 2因为大多数场景都希望顶点朝上。如果你画的是正六边形想要“扁平”的糖葫芦造型两个顶点朝上下两端可以传 0就能让第一个顶点出现在正右方看起来像横向拉伸的六边形。这个参数带来的自由度后面玩动画时会变得非常迷人。3.2 拉一个滑块从三角形滑到圆的魔法光有函数还不够动态控制边数才能真正理解正 N 边形。一个滑块控制边数从 3 拖到 20瞬间重绘你会亲眼看到 3 变成 4、变成 5、变成 20最后趋向于一个圆。完整的示例 HTML 如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title动态正N边形/title style body { display: grid; place-items: center; min-height: 100vh; background: #1e1e2f; color: #eee; font-family: sans-serif; } canvas { background: #2d2d44; border-radius: 12px; } /style /head body div label 边数 input idsides typerange min3 max30 value5 / span idsidesValue5/span /label /div canvas idcanvas width500 height500/canvas script const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const input document.getElementById(sides); const show document.getElementById(sidesValue); function drawRegularPolygon(ctx, cx, cy, sides, radius, rotation -Math.PI / 2) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.beginPath(); for (let i 0; i sides; i) { const angle rotation (i * 2 * Math.PI) / sides; const x cx radius * Math.cos(angle); const y cy radius * Math.sin(angle); if (i 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.strokeStyle #5eead4; ctx.lineWidth 3; ctx.stroke(); } function render() { const sides Number(input.value); show.textContent sides; drawRegularPolygon(ctx, canvas.width / 2, canvas.height / 2, sides, 180); } input.addEventListener(input, render); render(); /script /body /html把边数拖到 12 时你会看到一个既不是圆也不是简单多边形的漂亮形状拖到 30肉眼几乎看不出棱角。这个过程比看任何静态教程都更能建立“多边形是圆在有限采样下的近似”这个直觉。clearRect是重绘的关键因为 Canvas 没有自动擦除旧内容的能力。每一帧开始先把画布清空再整个重画。这是动画的基础也是新手容易漏掉的第一行代码。3.3 为什么用“外接圆半径”定义尺寸而不是“边长”新手经常纠结为什么定义正多边形都用 radius外接圆半径而不是直接给边长两种方案都可以但外接圆半径在很多场景下有明显优势。对比一下定义方式优点缺点外接圆半径圆心直接成为对齐锚点缩放动画方便旋转时中心天然固定需要额外算边长才能知道自己画多大边长直观知道每条边多长中心坐标要额外计算缩放动画麻烦旋转时绕中心算偏移也绕如果你要做一个居中显示的图形面板外接圆半径直接除以画布宽度就能得到恰到好处的尺寸。而如果你用边长定义还得在画布中心反推半径多一层心算。另外旋转动画极其依赖“圆心固定”这件事。rotation不断累加时顶点坐标全部围绕同一个圆心变化圆心本身不用参与计算。如果改用边长定义你还得同时维护多边形中心的位置代码复杂度会直线上升。所以在实际工程里我几乎只用外接圆半径。函数的radius参数对应的是“这个多边形能装进多大的圆里”心智负担小得多。4. 上头时刻从静止图形到会动会玩的作品4.1 样式升级渐变、虚线、阴影几行代码让图形“贵”起来基础描边练熟之后可以开始玩视觉。Canvas 的样式 API 看起来多但常用的三件套非常简单渐变createLinearGradient(x0, y0, x1, y1)或createRadialGradient(...)然后把fillStyle设成渐变对象。虚线ctx.setLineDash([10, 5])表示虚线段 10 像素、空 5 像素数组越长你得到的虚线模式越复杂。阴影ctx.shadowBlur 12; ctx.shadowColor rgba(0,0,0,0.5)。一个漂亮的例子渐变填充 虚线描边 柔和阴影总共不超过 6 行额外代码const gradient ctx.createLinearGradient(cx - radius, cy - radius, cx radius, cy radius); gradient.addColorStop(0, #ff6b6b); gradient.addColorStop(1, #ffd93d); drawRegularPolygon(ctx, cx, cy, 6, 120); ctx.fillStyle gradient; ctx.fill(); ctx.setLineDash([6, 3]); ctx.strokeStyle #fff; ctx.lineWidth 2; ctx.stroke(); ctx.setLineDash([]); // 记得恢复实线否则后面全是虚线这里有个容易忽略的小细节setLineDash设置的是全局状态用完必须重置为空数组[]不然下一次绘制的所有图形都会带着虚线。这和第一节讲的状态机是同一个道理——Canvas 所有样式属性都是“旋钮”扭了就一直保持。阴影也有同样的坑shadowBlur不归零后续绘制都会被拖影影响性能。所以说Canvas 样式代码写起来简单但“用完还原”这件事很体现工程素养。4.2 旋转动画requestAnimationFrame 模板做动画的套路高度统一用一个变量保存当前角度。requestAnimationFrame回调里累加角度。clearRect擦掉旧画面。用新角度重画。一个持续旋转的正六边形核心代码let angle -Math.PI / 2; function tick() { angle 0.02; // 每次帧约 16ms0.02 弧度大约是每秒转 60 度左右 ctx.clearRect(0, 0, canvas.width, canvas.height); drawRegularPolygon(ctx, cx, cy, 6, 120, angle); ctx.strokeStyle #a78bfa; ctx.lineWidth 4; ctx.stroke(); requestAnimationFrame(tick); } requestAnimationFrame(tick);看着正六边形缓缓旋转你会发现它每隔 60° 完全重合一次因为 360° / 6 60°。这个现象非常直观地展示了“对称性”的数学含义——它就是旋转对称而 Canvas 让这种抽象概念变成了看得见的事实。性能上有个小经验fillStyle、strokeStyle如果不是每帧变化尽量在循环外设置。因为 Canvas 设置样式的开销比绘制路径本身更大虽然肉眼感知不到但在低端设备上大量绘制时差异会非常明显。4.3 交互玩法鼠标拖拽时的“追踪多边形”滑块控制边数是基础交互真正“上头”的是让鼠标位置直接控制多边形的旋转角度。思路用Math.atan2(dy, dx)计算鼠标相对于多边形中心的夹角然后把这个角度作为旋转参数传给drawRegularPolygon。canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mouseX e.clientX - rect.left; const mouseY e.clientY - rect.top; const angle Math.atan2(mouseY - cy, mouseX - cx); ctx.clearRect(0, 0, canvas.width, canvas.height); drawRegularPolygon(ctx, cx, cy, 7, 120, angle); ctx.strokeStyle #fbbf24; ctx.lineWidth 4; ctx.stroke(); });效果是无论鼠标移到哪个方向正七边形的一个顶点都会“看向”鼠标。这个玩法之所以有趣是因为它赋予了图形生命感你控制的不再只是一堆坐标而是一个有朝向的物体。Math.atan2是前端几何里被低估的宝藏函数。它接收纵向差值和横向差值直接返回弧度角自动处理四个象限的符号问题。如果你想实现任何“物体朝向鼠标”的效果优先想到它。还可以再接一个滚轮事件让sides随着wheel事件增减。滚轮向上滚边数增加图形从三角形变五边形再变圆向下滚边数减少又变回三角形。这种组合交互会让访客玩很久因为它把两个维度的“可控感”同时交到了用户手里。4.4 组合技顶点上再长小多边形分形世界的入口单个正 N 边形玩熟了可以把顶点坐标保存下来在每个顶点上再画一个更小的正 N 边形。这个递归思路是分形图形的起点。实现起来也不复杂function drawNested(ctx, cx, cy, sides, radius, depth) { if (depth 0) return; drawRegularPolygon(ctx, cx, cy, sides, radius); ctx.strokeStyle hsl(${depth * 40}, 70%, 60%); ctx.lineWidth 2; ctx.stroke(); const points []; for (let i 0; i sides; i) { const angle -Math.PI / 2 (i * 2 * Math.PI) / sides; points.push({ x: cx radius * Math.cos(angle), y: cy radius * Math.sin(angle), }); } for (const p of points) { drawNested(ctx, p.x, p.y, sides, radius * 0.4, depth - 1); } }当sides 5、depth 3时你会得到一层一层向外生发的五边形树视觉上像雪花又像分子结构图。每次递归创建 5 个新支点3 层就有 125 个五边形整个画面变得异常丰富。这个工程相当有成就感的部分在于所有复杂度都来自同一个函数只是把“当前位置”和“当前层级”作为参数递归传递。你会发现正 N 边形不再只是面试里的画图题而是可以生长成复杂视觉系统的起点。5. 画着画着就会踩到Canvas 的几个“反直觉”坑5.1 忘了 beginPath()图形为什么会“越画越脏”这个坑我在第一节提过现在讲具体现象。假设你画一个正三角形然后又画一个正方形如果不调用beginPath()两个形状会共享同一段路径历史。第二次stroke()时浏览器会把三角形和正方形的路径一起描边。表现出来就是图形变粗、颜色变深、甚至出现一条连接两个形状的“幽灵线”。原因是 Canvas 的路径是一种“墨迹累积”机制它不像 React 或 DOM 那样自动重建而是不断往当前的笔迹里追加内容。beginPath()的作用是“换一张新纸”把之前的笔迹归档不再影响后续绘制。我的习惯是任何一次独立绘制第一行都写ctx.beginPath()没有例外。在动画里每帧第一行写clearRect第二行写beginPath。这两个动作本质上都是“清空旧状态”是 Canvas 编程的基本纪律。5.2 rgba 的透明度叠加陷阱越描越黑跟颜色的直觉相反用半透明颜色描边时很多人会踩到一个诡异问题同一路径描边多次颜色变得超乎想象的深。比如ctx.strokeStyle rgba(0, 0, 255, 0.2)理论上应该是很淡的蓝色。但你循环描边 5 次之后它变成了接近实心的深蓝。原因很简单每描一次画布上就是一次新的半透明叠层透明度按乘法合成20% 的透明度叠 5 次等效不透明度接近1 - (0.8)^5也就是 67% 左右。这不是 bug是透明度合成的数学本质。如果你确实想要“淡蓝色”一次stroke()就够。如果非要描边多次那要么提高透明度数值要么改用一个整体不透明度后再统一绘制。动画里如果要对同一图形做多次叠加渲染优先考虑ctx.globalAlpha 0.2并只绘制一次而不是循环 5 次同路径。这个坑在绘制复杂星形、渐变线条效果时尤其常见经常有人以为是 Canvas 抽风其实是自己在跟透明度合成较劲。5.3 N 太大时正 N 边形和圆的边界在哪滑块拖到 30视觉上已经非常接近圆。拖到 100基本无法区分是正多边形还是圆。理论上N 360时每条边的夹角是 1°计算机画出来的边缘足够光滑可以冒充标准圆形。但别急着把sides拖到 10000。顶点数量上千Canvas 的路径点会急剧增多。每次stroke()都要处理上万次的坐标运算和渲染低端移动设备可能出现掉帧。同时角度计算用浮点数N 很大时浮点误差会累积闭合路径可能无法精确回到起点边缘出现极细微的缺口。工程上的建议视觉上需要“近似圆”的场景直接用ctx.arc()不要用正 N 边形替代。只有当你能明显感知到多边形棱角、或者需要多边形的边缘做递归和分支时才考虑用正 N 边形。工具选型永远是“哪个简单用哪个”不是“哪个看起来更数学”。5.4 Canvas 在服务端渲染环境中的崩溃document is not defined现代前端经常用 Next.js、Nuxt 这类服务端渲染框架。如果你在组件顶层直接写new Canvas()或者调用document.getElementById(canvas)服务端 Node.js 环境根本不存在document运行时会直接抛ReferenceError: document is not defined。这是非常典型的新手“框架感”不够造成的错误在 SSR 里组件代码不止在浏览器执行它先在服务器上跑一遍生成 HTML。一旦顶层代码里用了 DOM API服务端就崩了。解决办法也很简单所有 Canvas 初始化逻辑都丢进useEffectReact或onMountedVue这些生命周期只会在浏览器端执行。如果你必须要在服务端生成图形那就别用 Canvas改用 SVG 字符串或者用纯数学方式生成坐标数据流式传输到前端再画。这个经验价值极高因为 90% 的“Canvas 在框架项目里白屏”都是这个原因。不是 Canvas 本身有问题是你把它放到了不该执行的位置。5.5 高清屏模糊问题别忘了 devicePixelRatio最后一个坑是画面发虚。在 Retina 屏上CSS 里的1px实际上对应物理像素2px甚至3px。如果你的 Canvaswidth500在 CSS 里也是 500px那画布只有 500 个物理像素宽。在 2 倍屏上这 500 个逻辑像素会被拉伸到 1000 个物理像素于是起笔的线条看起来发糊。标准解法是让 Canvas 的物理分辨率跟上设备像素比const dpr window.devicePixelRatio || 1; canvas.width 500 * dpr; canvas.height 500 * dpr; canvas.style.width 500px; canvas.style.height 500px; ctx.scale(dpr, dpr);设置之后所有绘图坐标仍然按 500 的逻辑尺寸写但底层像素密度翻倍线条锐利度肉眼可见地提升。你可能会觉得这种细节离“画正 N 边形”很远但实际做可视化项目时这是第一个被检查的问题。画面糊不糊直接决定作品看起来是“demo”还是“作品”。我自己最上头的一次经历是把滑块拖到 360正 N 边形突然从多边形变成了完整的圆然后我在每个顶点上递归嵌套了更小的正 360 边形结果画面完全失控地华丽。回头再看起点不过是坐标轴上的一次cos和sin。Canvas 几何绘图就是这样数学部分只需要一点点参数化思路一旦打开能玩的空间却大得吓人。如果你刚入门不用贪多先把这个函数跑熟再慢慢往里面加交互、加递归你会发现自己离“会画图”这个目标其实只差一次从懵圈到上头的蜕变。