六芒星听起来像个自带神秘光环的符号但在前端眼里它说白了就是一个几何题两个正三角形叠在一起其中一个旋转 60 度。我之前给某个系统的加载动画做品牌图形时设计师丢来一张六芒星参考图要求用 Canvas 原生绘制、支持旋转发光、还能跟随鼠标互动明确不用图片素材。当时我以为这就一个beginPath加几个lineTo的事真写起来才发现坐标的起始角度、顶点的连接顺序、填充规则、高分屏适配每一个细节都能让图形翻车。这篇文章就把我从零手写 Canvas 六芒星的完整过程拆开讲一遍适合刚接触 Canvas、想搞懂图形绘制原理的前端新手也适合想快速拿一个可复用六芒星绘制方案去交差的同学。整篇不涉及任何框架纯原生 JavaScript 加 Canvas 2D API理解高中数学里的三角函数就足够了。1. 项目目标与方案选型为什么偏偏用 Canvas1.1 先把需求讲清楚这个练习看起来简单实际需求拆开之后有四个硬性要求。第一图形必须由代码动态生成不能贴一张静态图片。因为图形要跟着品牌色走主题切换时要换颜色图片素材做不到。第二要支持持续旋转加载页里它会作为中心装饰循环转动。第三要有发光效果视觉上要有点科技感不能只是一根干巴巴的线条。第四还要响应鼠标用户移动指针时六芒星的顶点要朝向指针方向整个过程要顺滑流畅。这四个要求凑到一起基本就排除了用 SVG 写死路径和用 CSS 拼边框这两条路。SVG 当然也能做动画和交互但涉及每一帧的坐标计算、渐变、发光这些操作时Canvas 的像素级控制更直接。CSS 做三角形拼六芒星撑死是个静态图形想旋转、发光、跟踪鼠标写法会绕到怀疑人生。用 Canvas 从零画正好把几何计算、路径绘制、动画循环、事件交互这些基本功一次性练完。1.2 为什么不是 CSS、SVG 或者直接抠图我见过有人用三个元素旋转拼六芒星也能做出来但稍微一变方向、一换尺寸所有样式都要重算可维护性很差。还有人直接用设计师给的 PNG最省事但改颜色、做动效、适配不同尺寸时就会发现一张位图根本经不起折腾。拿 SVG 和 Canvas 对比SVG 的优势是每个节点都是 DOM绑事件方便、缩放不模糊但它的坐标计算同样得自己写而且复杂图形叠加阴影和动态渐变时性能不一定比 Canvas 好。Canvas 的优势在于它是一个立即模式的绘图表面每一帧你都能完全控制画什么、怎么画、画完再覆盖什么做粒子、做渐变、做逐帧动画都顺理成章。这个项目的核心是动态生成 逐帧更新所以我选了 Canvas 2D。它不需要任何外部依赖浏览器原生支持同一个接口在各类前端框架里都能无缝使用。1.3 这套方案的适用范围如果你只是想做一张静态装饰图这篇文章的方法不是最优解但你要做的是加载动画、品牌 Logo 动效、数据可视化里的装饰图形、或者游戏里的技能图标这套参数化绘制 动画循环 鼠标交互的套路就能直接迁移过去。因为绘制逻辑全部基于圆心、半径、起始角度这三个参数只要会改参数就能画出一堆变体不同大小、不同朝向、不同颜色的六芒星。这也是我把整个过程写成代码而不是画死坐标的原因。参数化之后它不再是一个图形而是一个可以反复使用的图形工厂。2. 六芒星几何原理圆上六等分点与双三角形结构2.1 先看本质六芒星其实就是两个正三角形很多新手看到六芒星的第一反应是这是一个复杂的多边形实际上它的结构非常朴素两个完全相同的正三角形共用一个中心点其中一个绕中心旋转 60 度后叠放在另一个上面。画出来之后因为两条边相互交叉视觉上会形成中心一个正六边形、外围六个尖角的图案。理解这一点非常重要它直接决定了你的绘制策略。你完全不用去算六芒星这个复合图形的每个顶点只需要算出六个点的坐标然后按隔一个点连一条线的方式连出两个三角形即可。这个思路一旦建立哪怕以后让你画五芒星、八芒星你也能照葫芦画瓢。2.2 六个顶点怎么算从圆到三角函数的坐标公式画任何正多边形都可以走同一条路先找一个外接圆然后在圆上均匀取点。正三角形的三个顶点在圆上相隔 120 度六芒星需要六个顶点所以每两个相邻点之间相隔 60 度。Canvas 里的角度单位是弧度而不是角度。为什么要用弧度因为 JavaScript 的Math.cos和Math.sin接收的参数就是弧度制。一个完整圆是 2π 弧度即 360 度。所以每个点相隔 60 度写出来就是Math.PI / 3。给定圆心(cx, cy)和外接圆半径R第i个顶点的坐标公式如下x cx R * Math.cos(startAngle i * Math.PI / 3) y cy R * Math.sin(startAngle i * Math.PI / 3)这里startAngle是起始角度它决定了整个图形朝哪个方向。我习惯用-Math.PI / 2也就是 -90 度这样第一个顶点会落在圆的正上方六芒星的一个尖角朝上这是最常用的展示方向。如果这里用 0 作为起始角画出来的六芒星会是横着的第一个尖角朝右视觉效果差很多。以R 100、圆心在原点为例六个点的坐标是这样分布的索引角度度XY0-900-1001-3086.6-5023086.65039001004150-86.6505210-86.6-50这六个点连起来是一个正六边形但六芒星并不是连接相邻点而是隔一个点连接。索引 0、2、4 连成一个三角形索引 1、3、5 连成另一个三角形两个三角形一叠加六芒星就出来了。2.3 连接顺序和绕行方向最容易翻车的一步很多初学者在这里踩坑是因为下意识按 0、1、2、3、4、5 的顺序连接结果画出一个正六边形完全不是想要的形状。正确的思路是理解两个三角形的成员构成。按我的索引方案向上的三角形顶点是索引 0、2、4向下的三角形顶点是索引 1、3、5。用路径绘制时代码写起来就是这个样子// 三角形一0 - 2 - 4 ctx.moveTo(points[0].x, points[0].y); ctx.lineTo(points[2].x, points[2].y); ctx.lineTo(points[4].x, points[4].y); ctx.closePath(); // 三角形二1 - 3 - 5 ctx.moveTo(points[1].x, points[1].y); ctx.lineTo(points[3].x, points[3].y); ctx.lineTo(points[5].x, points[5].y); ctx.closePath();注意我用了closePath()。这个方法的作用是把最后一个点和起始点连起来形成闭合路径。如果不调用Canvas 默认不会帮你自动收尾线条在尖角处就会留下一个缺口看起来像断了一样。这个细节在你只描边不填充时尤其明显。还有一点容易被忽略两个三角形的绕行方向。我上面写的连接顺序两个三角形都是顺时针方向绕行。这个方向在后面会直接影响填充规则的结果。先记住这个结论绕行方向一致时默认的nonzero填充规则会把中心区域填成实心如果把三角形的连接顺序改成反向填充结果会完全不同。3. 核心代码实现一步步画出一个标准六芒星3.1 初始化画布第一步就决定清晰度HTML 里只需要一个 Canvas 元素其他全靠 JavaScript。不过这里有个很重要的前置操作高分屏适配。如果你在 MacBook 这类高分屏上直接写canvas.width 400会发现画出来的线条边缘发虚、模糊。这是因为设备物理像素和 CSS 像素不是一比一。正确做法是读取window.devicePixelRatio把画布的物理尺寸乘以这个倍数再通过ctx.scale把坐标系缩放回去。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / titleCanvas 六芒星基础版/title style body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #1a1a2e; } canvas { border-radius: 12px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5); } /style /head body canvas idstar/canvas script const canvas document.getElementById(star); const boxSize 400; const dpr window.devicePixelRatio || 1; canvas.width boxSize * dpr; canvas.height boxSize * dpr; canvas.style.width boxSize px; canvas.style.height boxSize px; const ctx canvas.getContext(2d); ctx.scale(dpr, dpr); /script /body /html这段代码把画布的物理分辨率提升到boxSize * dpr同时用 CSS 尺寸把它显示成boxSize大小。之后所有绘制代码都在缩放后的坐标系里进行画出来的线条就是清晰的。3.2 顶点计算函数参数化是一切扩展的基础我的习惯是把顶点计算单独抽成一个函数。这样不管后续要画多大、转多少度都能复用同一套逻辑。function calcHexagramPoints(cx, cy, R, startAngle -Math.PI / 2) { const points []; for (let i 0; i 6; i) { const angle startAngle i * Math.PI / 3; points.push({ x: cx R * Math.cos(angle), y: cy R * Math.sin(angle) }); } return points; }参数解释一下cx和cy是圆心坐标R是外接圆半径startAngle是起始角度默认 -90 度让第一个尖角朝上。函数里用循环生成六个点存成数组返回。这套设计的核心价值在于圆心、半径、起始角度全部变成参数之后你可以在不改动逻辑的前提下通过传不同参数画出任意大小、任意朝向的六芒星。后面做旋转动画也只需要不断修改startAngle就可以了。3.3 路径绘制描边和填充要用不同策略描边模式的绘制函数如下function strokeHexagram(ctx, points) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); ctx.lineTo(points[2].x, points[2].y); ctx.lineTo(points[4].x, points[4].y); ctx.closePath(); ctx.moveTo(points[1].x, points[1].y); ctx.lineTo(points[3].x, points[3].y); ctx.lineTo(points[5].x, points[5].y); ctx.closePath(); ctx.stroke(); }填充模式可以走同一条路径但要知道fill方法有一个隐藏参数填充规则。默认是nonzero也就是非零环绕规则另一个选项是evenodd也就是奇偶规则。function fillHexagram(ctx, points, rule nonzero) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); ctx.lineTo(points[2].x, points[2].y); ctx.lineTo(points[4].x, points[4].y); ctx.closePath(); ctx.moveTo(points[1].x, points[1].y); ctx.lineTo(points[3].x, points[3].y); ctx.lineTo(points[5].x, points[5].y); ctx.closePath(); ctx.fill(rule); }这里有个非常实用的小技巧当两个三角形以相同方向绕行时nonzero规则会让中心区域填充次数为 2仍然算内部所以整体是实心的六芒星而如果用evenodd规则中心区域被覆盖两次算作偶数次会被当成外部结果就变成了一个中心镂空的六芒星。想让中心镂空不需要额外计算任何几何数据只要把填充规则换成evenodd即可。这在做徽章、印章、科技感边框装饰时非常有用。3.4 完整可运行的基础版把上面的函数组合起来加上样式参数就是一个可以直接打开的基础版const cx boxSize / 2; const cy boxSize / 2; const R 150; const points calcHexagramPoints(cx, cy, R); ctx.lineWidth 4; ctx.strokeStyle #ffd166; ctx.lineJoin round; ctx.lineCap round; strokeHexagram(ctx, points);lineJoin设为round之后三角形的尖角处线条转折会更圆润不会出现生硬的折角。lineCap设为round也能避免线条端头露出尖锐的小尾巴。这些小参数在粗线条描边时特别重要能明显提升图形的精致度。3.5 进阶算内六边形做双色填充如果你想把中心的正六边形区域单独填成另一种颜色就需要额外算一下内六边形的坐标。经过前面的分析内六边形的六个顶点其实就落在每个三角形边长的三分之一和三分之二处更准确地说取每条边三分之二处的点正好是六个交点。function lerp(a, b, t) { return { x: a.x (b.x - a.x) * t, y: a.y (b.y - a.y) * t }; } function calcInnerHexagon(points, cx, cy) { const edges [[0, 2], [2, 4], [4, 0], [1, 3], [3, 5], [5, 1]]; const inner edges.map(([i, j]) lerp(points[i], points[j], 2 / 3)); return inner.sort((a, b) { return Math.atan2(a.y - cy, a.x - cx) - Math.atan2(b.y - cy, b.x - cx); }); }这里先取六个边的三分之二处得到六个交点再按它们相对于圆心的极角排序保证连出来的是一个凸多边形。拿到内六边形坐标后先填充内六边形再描边整个六芒星就能做出中心一个色、外围一圈色的双色效果。4. 动效与交互进阶让六芒星活起来4.1 用 requestAnimationFrame 做旋转动画静态图形画完之后紧接着要处理的就是旋转。不要用setInterval做动画浏览器对页面性能的调度机制决定了requestAnimationFrame更适合逐帧绘制。它会等浏览器下一次重绘时执行回调帧率更平滑而且在页面切到后台时自动暂停省电又省性能。最简单的旋转实现如下let angle -Math.PI / 2; let lastTime 0; function drawFrame(timestamp) { const elapsed (timestamp - lastTime) / 1000; lastTime timestamp; angle elapsed * 0.8; ctx.clearRect(0, 0, boxSize, boxSize); const points calcHexagramPoints(cx, cy, R, angle); strokeHexagram(ctx, points); requestAnimationFrame(drawFrame); } requestAnimationFrame(drawFrame);这里我用了时间差来控制旋转速度每秒钟转过约 0.8 弧度。用时间差而不是每次固定加一个值是为了防止帧率波动导致动画忽快忽慢。如果每帧固定加0.01在 60 帧下是每秒钟 0.6 弧度但在低帧率设备上就会变慢动画节奏不一致。4.2 另一种旋转姿势ctx.rotate 改造坐标系如果你不想每帧去重新计算六个顶点的坐标还有一个更省事的方案用ctx.translate和ctx.rotate旋转整个坐标系然后在局部坐标系里画一个固定的六芒星。function drawRotatingHexagram(ctx, cx, cy, R, angle) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle); // 圆心已经平移到了原点直接以0, 0为中心取点 const points calcHexagramPoints(0, 0, R, -Math.PI / 2); strokeHexagram(ctx, points); ctx.restore(); }save和restore负责把坐标系状态保存和还原这样旋转操作只影响当前这次绘制不会污染后续内容。这种做法的好处是你只需要在初始化时算一次顶点坐标之后的每一帧旋转零计算开销。坏处是旋转中心始终是画布中心如果你要做绕某个任意点旋转的复杂效果还是手动算坐标更灵活。两种方案各有适用场景我通常根据动画复杂度来选。4.3 呼吸缩放和渐变发光旋转只是第一步。实际项目里一个干巴巴旋转的线条并不好看。我习惯叠加两种效果呼吸缩放和发光渐变。呼吸缩放就是用正弦函数让半径周期变化const R 150 20 * Math.sin(timestamp * 0.002);这样六芒星会像呼吸一样每几秒微微变大变小。视觉上增加了生命力代码只改了一行。渐变发光则分两部分。一是用线性渐变代替纯色描边二是用shadowBlur加光晕const grad ctx.createLinearGradient( cx - R, cy - R, cx R, cy R ); grad.addColorStop(0, #ffd166); grad.addColorStop(1, #ef476f); ctx.shadowBlur 30; ctx.shadowColor rgba(255, 209, 102, 0.8); ctx.strokeStyle grad;这里深色背景下发光的金色线条会显得特别有质感。不过要提醒一句shadowBlur是开销比较大的绘制属性用在每个帧的描边上图形较大时会明显掉帧。优化思路是把带阴影的静态图形先画到一个离屏 Canvas 上每一帧只用drawImage贴出来而不是每帧重新计算阴影。4.4 鼠标交互让顶点跟着指针走让六芒星跟随鼠标其实只需要算出一个角度。监听mousemove从事件坐标和圆心坐标算出一个相对角度把这个角度当作startAngle传进去第一个顶点就会始终朝向鼠标。let targetAngle -Math.PI / 2; canvas.addEventListener(mousemove, (e) { const rect canvas.getBoundingClientRect(); const mx e.clientX - rect.left - cx; const my e.clientY - rect.top - cy; targetAngle Math.atan2(my, mx); });Math.atan2接收从圆心到鼠标的水平和垂直偏移返回这个方向的弧度角。因为我的顶点计算函数里startAngle指向第一个顶点的方向所以直接把这个角度传进去尖角就跟着鼠标跑了。如果直接赋值转动会非常生硬。我一般会做一个匀速追角度的缓动function lerpAngle(current, target) { let diff target - current; while (diff Math.PI) diff - Math.PI * 2; while (diff -Math.PI) diff Math.PI * 2; return current diff * 0.08; }这个函数处理了两件事一是把角度差归一化到 -π 到 π 之间防止角度从 179 度转到 -179 度时绕一大圈二是用0.08这个系数做缓动让指针追着鼠标走而不是瞬间跳过去。视觉效果会顺滑很多。4.5 完整的进阶版组合把旋转、呼吸、渐变、鼠标跟踪组合起来整体结构非常清晰function drawFrame(timestamp) { ctx.clearRect(0, 0, boxSize, boxSize); const R 150 20 * Math.sin(timestamp * 0.002); angle lerpAngle(angle, targetAngle); const grad ctx.createLinearGradient(cx - R, cy - R, cx R, cy R); grad.addColorStop(0, #ffd166); grad.addColorStop(1, #ef476f); ctx.save(); ctx.shadowBlur 30; ctx.shadowColor rgba(255, 209, 102, 0.8); ctx.strokeStyle grad; ctx.lineWidth 4; ctx.lineJoin round; ctx.lineCap round; const points calcHexagramPoints(cx, cy, R, angle); strokeHexagram(ctx, points); ctx.restore(); requestAnimationFrame(drawFrame); } requestAnimationFrame(drawFrame);注意这里每帧都创建了一次渐变对象。理论上可以把它放到循环外面只创建一次但渐变和半径挂钩半径每帧在变所以这里必须重新创建。这是一个值得记住的性能取舍点动态渐变本身开销不大但如果你同时开多层阴影加多个动态渐变就要考虑离屏 Canvas 缓存了。5. 踩坑实录Canvas 绘制常见问题排查5.1 问题速查表我在实际写这个项目时前前后后踩了不少坑很多问题在网络教程里根本不会提。整理成一张速查表遇到问题可以对着查现象原因解决办法图形不在画布中央圆心用了 (0,0)圆心用 canvas 宽高的一半线条发虚、模糊没有适配 devicePixelRatiowidth/height 乘 dpr再 ctx.scale(dpr, dpr)顶点朝右而不是朝上起始角度用了 0startAngle 从 -Math.PI / 2 开始尖角处线条有缺口忘记 closePath每个三角形画完用 closePath() 闭合画出来是六边形按 0-1-2-3-4-5 连点用 0-2-4 和 1-3-5 分组连接半透明填充时中心颜色变深两个三角形中心重叠混色合并成一个 path用 nonzero 或 evenodd 填充中心想镂空但不会算不知道填充规则直接 fill(evenodd)旋转时画面残留拖影没有每帧 clearRect每帧先清空再绘制shadowBlur 后掉帧明显阴影每帧重复计算把带阴影的静态内容缓存到离屏 canvas鼠标追角度时会绕圈角度差没归一化用上面的 lerpAngle 处理差值5.2 高分屏模糊最常见的疑难杂症模糊这个问题太典型了值得单独展开说。很多人画出来的六芒星线条肉肉的、边缘半透明第一反应是Canvas 精度不够其实完全不是。问题出在你只设置了 CSS 尺寸没有设置画布的实际像素尺寸。canvas.width和canvas.height决定的是画布的物理像素缓冲区大小canvas.style.width决定的是它显示出来的 CSS 大小。在devicePixelRatio为 2 的设备上如果物理像素只有 CSS 大小的一半浏览器就只能把低分辨率画面拉伸放大于是线条模糊。解决方式就是我开头写的那段物理像素乘 dpr再ctx.scale(dpr, dpr)。这个操作应该在所有绘制开始前完成否则坐标系错乱会导致图形偏移。5.3 路径残留和绕向问题另外还有个我猜很多人都会遇到的诡异现象如果你在同一个 Canvas 上反复绘制但没有在每次绘制前beginPath()新的路径会叠加到旧路径上结果线条越来越粗、颜色越来越深甚至出现残影。正确习惯是每次绘制新图形前先beginPath()帧动画里每帧先clearRect清空画面再重新开始路径。绕向问题则更容易被忽略。Canvas 的填充规则依赖路径方向如果两个三角形一个顺时针、一个逆时针nonzero规则下中心区域的绕行值会被抵消为 0中心反而变成空心。这不算 bug但如果你发现别人代码里是实心我这里怎么是空心多半就是绕向问题。解决办法是检查两组lineTo的顶点顺序保持一致性。5.4 细线对齐与绘制精度最后分享一个绘制精度的细节。当lineWidth为 1 时线条如果落在非整数坐标上会因为抗锯齿变得发灰。比如画一条从 x50.5 到 x150.5 的直线它正好覆盖在两排像素中间呈现出来就是半透明状态。所以绘制细线条时我会手动把坐标对齐到 0.5 或整数位置。六芒星的坐标大多是三角函数算出来的无理数做不了绝对对齐所以我会通过Math.round把最终坐标取整或者干脆用 2 以上的线宽配合lineCap和lineJoin的圆角处理观感就会好很多。这是很多炫酷图形实际看起来干净的底层原因。6. 玩法扩展与设计思考6.1 从图形到品牌符号它能用在哪画出一个标准六芒星只是一个起点。我在实际项目里它的用途远超加载动画这一种。它可以做成按钮的悬停图标鼠标移入时六芒星旋转并高亮可以作为页面背景的装饰元素用半透明细线在角落缓慢旋转在游戏前端里它是技能图标的底座配合光效扫过。甚至可以把六芒星的顶点稍作修改把外接圆半径改成每个顶点用不同半径画出来的就不再是正六芒星而是带棱角的异形星视觉效果完全不同。这里的关键不是画六芒星这个具体目标而是你掌握了一套在圆上取点、按规则连接、参数化动态绘制的方法论。把6改成5角度间隔从Math.PI / 3改成Math.PI * 2 / 5你就得到了五芒星改成8得到八芒星。整个函数没有任何一行需要改只是传参变了。6.2 还能怎么玩粒子化与分形如果想让六芒星质感更丰富可以试试粒子化效果利用已经算出的六个顶点在每条边上生成一串均匀分布的点然后让这些点像粒子一样轻微颤动再用ctx.fillRect或ctx.arc绘制出来。这样六芒星就变成一个由数百个独立小点组成的动态图形旋转时每个点都在微微呼吸科技感会再上一个台阶。分形玩法也很有意思把六芒星的六个顶点作为新的圆心再各自画一个缩小版的六芒星循环递归两三层就能得到一个类似雪花结构的复杂图案。核心计算还是那一个calcHexagramPoints函数只是在循环里反复调用而已。6.3 一点个人心得画完这个六芒星我最大的感受是前端图形绘制最难的从来不是 API而是把视觉需求翻译成几何参数。设计师的一句要一个会转、会发光、追着鼠标跑的六芒星落到代码里无非就是三个数圆心、半径、角度。这个过程想明白了任何复杂图形都能拆成点、线、规则三件套。另外参数化的习惯真的值得养成。我在项目里吃过亏一开始图省事把所有坐标写死结果产品要换尺寸时我不得不手工重算所有数值。改成参数化之后你只需要面对几个变量其他一切自动响应。这个思路放到任何图形绘制里都适用包括柱状图、饼图、雷达图本质都是根据数据算坐标然后把坐标画出来。最后分享一个保存成果的小技巧动画跑起来之后如果想导出静态图可以加一个按钮在完成一帧绘制后用canvas.toDataURL(image/png)把当前帧转成图片下载。这样代码里的图形不只能看还能直接变成设计资产。做前端的过程有时候确实像个魔法师但剥开看所有魔法都建立在清晰的结构和扎实的基础之上。