前言:当一个“不可能”的需求摆在面前
那天下午,阳光正好,我正戴着耳机,沉浸在重构一个陈年API的快乐中。突然,PM(项目经理)“哐”地一下拍在我的桌上,表情凝重。
“大佬,救火!🔥”
他指着屏幕上刚发给我的一段视频,那是一个效果炫酷的会员等级展示页面:一条优美的金色弧线,能根据用户的“成长值”平滑地向前延伸,沿途的等级节点还有细腻的双层光环和状态联动。
“这次的项目是在微信小程序里,”她解释道,“前端小哥折腾了一天,做出来的东西要么是静态的,要么就是各种诡异的渲染bug。他说根本实现不了,想要提桶跑路了”
“甲方爸爸就认准了这个视频效果,现在项目组都快炸锅了...” PM顿了顿,压低了声音,“大佬,尾款就靠这个了!”
我凑近屏幕,仔细看了看。确实,在小程序这个受限的环境里,想用常规Web技术栈完美复刻,几乎是不可能的。但这也恰好把路指向了唯一的、也是最强大的解决方案——Canvas。
“别慌。” 我摘下耳机,对PM说,“这活儿,我瞅瞅。”
这篇文章,我将以第一人称视角,完整复盘如何从0到1,用原生Canvas API一步步拆解需求、攻克难点,最终实现这个动态效果的全过程。
主体:庖丁解牛,一步步构建动画
项目提供的是 HTML + JS + CSS 方案(早期Demo) 并非最终提供给客户的 Taro + React + Typescript代码 避免可能存在的风险 所以下面的讲解 / 图解都是基于 浏览器环境
要完美复刻这个效果,我们必须像做外科手术一样,精确地拆解需求,选择最佳技术路径,然后一步步实现。
第一步:技术选型与架构设计
为什么在小程序中,Canvas是唯一选择?
-
SVG的局限性 (在小程序中):SVG的优点是声明式、对SEO和无障碍友好。但在微信小程序这个特定的环境中,它的动态能力被极大地削弱了。我们无法像在Web浏览器中那样,随心所欲地通过JS和CSS来驱动复杂的SVG动画。
-
Canvas (画布):它就像一块画板,我们用JavaScript可以完全控制上面每一个像素的绘制。在小程序中,Canvas的API支持非常完善,性能表现也极其出色。它让我们绕开了小程序对SVG的种种限制,通过底层的绘图API,我们可以完全掌控每一个像素,实现任何我们想要的视觉效果。
所以,我们的技术核心就是:JavaScript Canvas API。
在动手之前,先在脑子里画出架构图,事半功倍:
事件驱动是基于demo讲解。实际业务场景应该替换为接口获取用户等级并进行渲染
graph TD
A["页面加载"] --> B{"DOMContentLoaded 事件触发"};
B --> C["1、 初始化"];
C --> D["设置 Canvas 尺寸与DPR适配"];
C --> E["定义曲线/等级数据"];
C --> F["绑定事件监听器"];
C --> G["启动首次动画"];
subgraph "事件驱动"
F --> H["用户拖动滑块"];
F --> I["浏览器窗口大小改变"];
end
H --> J{"更新目标进度 targetProgress"};
I --> K{"重新计算画布尺寸和曲线坐标"};
J --> L["启动/重启动画循环"];
K --> M["重新绘制画布"];
G --> L;
subgraph "核心动画循环 (animate)"
L --> N{"动画是否结束?"};
N -- "否" --> O["计算下一帧进度 currentProgress"];
O --> P["调用 draw() 绘制该帧"];
P --> Q["requestAnimationFrame(animate)"];
N -- "是" --> R["停止循环"];
end
subgraph "核心绘制函数 (draw)"
P --> S["清空画布"];
S --> T["绘制灰色背景轨道"];
T --> U["绘制金色进度条"];
U --> V["绘制进度条端点"];
V --> W["绘制所有等级节点"];
end
第二步:搭建骨架 (HTML & CSS)
这部分很简单,我们需要一个容器,一个<canvas>元素,和一些控制组件。
<!-- HTML 结构 -->
<div class="membership-container">
<div class="card-header"></div>
<canvas id="membership-canvas"></canvas>
<div class="controls">
<div class="score-display" id="score-display">...</div>
<input type="range" ... id="score-slider">
</div>
</div>
CSS负责美化,这里不赘述,重点在JS。
第三步:Canvas初始化与高清屏适配
这是经常被新手忽略,但却至关重要的一步。现在的手机都是高清屏(Retina),它们的设备像素比(DPR)通常是2或3。如果直接在375px宽的画布上画,图像会被拉伸,导致模糊。
正确的做法是:将画布的物理像素尺寸放大DPR倍,然后用CSS把它“缩”回原来的逻辑尺寸。
// --- 1. 初始化与响应式处理 ---
function setupAndResize() {
// ... 获取容器宽度等 ...
// 获取设备像素比,用于高分屏适配
const dpr = window.devicePixelRatio || 1;
// 设置画布的物理像素尺寸 (例如 375 * 2 = 750)
canvas.width = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
// 设置画布的CSS显示尺寸 (在页面上看起来还是375)
canvas.style.width = `${logicalWidth}px`;
canvas.style.height = `${logicalHeight}px`;
// 对画布上下文进行缩放,后续所有绘图操作都会自动放大
ctx.scale(dpr, dpr);
// ...
}
这样操作后,我们画的1px的线,实际上会用2个物理像素去渲染,效果瞬间清晰锐利!
第四步:绘制灵魂——贝塞尔曲线
视频里的弧线不是正圆,而是一条顶部平缓、两边弧度优美的曲线。这正是三次贝塞尔曲线的用武之地。它由4个点定义:1个起点(P0),2个控制点(P1, P2),1个终点(P3)。
在Canvas中,我们用 bezierCurveTo() 方法来绘制它。
// --- 核心绘图函数 ---
function drawCurve(points, style, width) {
ctx.beginPath();
// 移动到起点
ctx.moveTo(points.p0.x, points.p0.y);
// 绘制曲线
ctx.bezierCurveTo(points.p1.x, points.p1.y, points.p2.x, points.p2.y, points.p3.x, points.p3.y);
// 设置样式
ctx.strokeStyle = style;
ctx.lineWidth = width;
ctx.lineCap = 'round'; // 关键:让线条末端变圆
ctx.stroke();
}
第五步:让它动起来!动画循环与缓动
要实现流畅的动画,我们不能用setInterval,因为它不稳定。最佳实践是 requestAnimationFrame,它会告诉浏览器在下一次重绘之前执行回调,保证动画与屏幕刷新率同步。
为了让动画不死板,我们还需要一个缓动(Easing)算法,让进度变化由快到慢,更符合物理直觉。
// --- 3. 动画循环 ---
function animate() {
// 使用缓动算法,让动画具有“渐入渐出”的平滑效果
const easingFactor = 0.08;
const diff = targetProgress - currentProgress;
// 每一帧都重绘画布
draw();
// 如果当前进度与目标进度非常接近,则停止动画
if (Math.abs(diff) < 0.001) {
currentProgress = targetProgress;
cancelAnimationFrame(animationFrameId);
} else {
// 否则,更新当前进度,并请求下一帧
currentProgress += diff * easingFactor;
animationFrameId = requestAnimationFrame(animate);
}
}
第六步:攻克核心难点——平滑的局部曲线
这是之前方案翻车的关键点:如何只画出曲线的一部分,并且让末端保持平滑的圆形?
错误示范:使用 clip() 裁剪。这会导致进度条的末端像被剪刀垂直剪断一样,非常生硬。
正确解法:回到数学的本源!我们需要一个函数,它能根据进度t(0到1之间),在数学上将一条贝塞尔曲线分割成两段,并返回第一段曲线的所有新的点(起点、控制点、终点)。
这个魔法就是 splitBezier 函数,它依赖于线性插值(lerp)和德卡斯特里奥算法(De Casteljau's algorithm)。
// --- 5. 辅助数学函数 ---
// 线性插值函数:计算两点之间t位置的点
function lerp(p1, p2, t) {
return { x: p1.x + (p2.x - p1.x) * t, y: p1.y + (p2.y - p1.y) * t };
}
// 分割贝塞尔曲线的函数,返回一个新的、代表部分曲线的点集
function splitBezier(t, p0, p1, p2, p3) {
const p01 = lerp(p0, p1, t), p12 = lerp(p1, p2, t), p23 = lerp(p2, p3, t);
const p012 = lerp(p01, p12, t), p123 = lerp(p12, p23, t);
const p0123 = lerp(p012, p123, t);
// 返回的新曲线点集
return { p0: p0, p1: p01, p2: p012, p3: p0123 };
}
有了它,我们就可以在 draw() 函数里,先计算出部分曲线,再把它画出来,问题迎刃而解!
第七步:精雕细琢,像素级还原
现在主体功能都有了,剩下的就是疯狂堆细节,把质感拉满:
- 进度条端点:在绘制完部分曲线后,获取其终点坐标,再画一个实心小圆。
- 双层光环节点:先画一个大的、半透明的圆作为外圈,再在同样的位置画一个小的、不透明的实心圆作为内圈。
- 文字内置:计算出内圈圆心的坐标,使用
ctx.fillText()并设置好textAlign和textBaseline,就能把文字完美居中。 - 响应式布局:将所有坐标和尺寸的计算都与画布的逻辑宽度
logicalWidth挂钩,并在resize事件中重新调用初始化函数,即可实现完美的响应式。
最终,我们将所有这些逻辑组合在 draw() 函数中,由 animate() 循环驱动,一个像素级还原、高度动态、细节满满的动画就诞生了。
总结
从一个棘手的需求,到一个像素级还原的成品,我们经历了从技术选型、架构设计,到攻克核心算法、精雕细节的全过程。
这次的经历再次证明了一个道理:面对看似复杂的UI挑战,不要畏惧,更不要轻易说“不”。 很多时候,当我们习惯的“上层”工具无法满足需求时,不妨回归基础,深入理解底层的渲染原理(无论是DOM、SVG还是Canvas)。回到数学和算法的本源,往往能找到最优雅、最强大的解决方案。
当你能用代码在画板上随心所欲地“创造”时,那种掌控感和成就感,正是作为一名工程师最纯粹的快乐。希望这次的分享,能对你有所启发。