深度解析:从0到1,用原生Canvas“丝滑”复刻动态会员弧线
前言:当一个“不可能”的需求摆在面前
那天下午,阳光正好,我正戴着耳机,沉浸在重构一个陈年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。
在动手之前,先在脑子里画出架构图,事半功倍:
第二步:搭建骨架 (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)。回到数学和算法的本源,往往能找到最优雅、最强大的解决方案。
当你能用代码在画板上随心所欲地“创造”时,那种掌控感和成就感,正是作为一名工程师最纯粹的快乐。希望这次的分享,能对你有所启发。
成果展示
完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>会员等级弧线动画</title>
<style>
/*
* 全局样式和布局
*/
body {
margin: 0;
background-color: #121212;
color: #e0e0e0;
}
.membership-container {
width: 100%;
background: linear-gradient(145deg, #2e2e30, #212123);
padding: 20px 0 30px;
text-align: center;
position: relative;
overflow: hidden;
}
.card-header {
height: 140px;
background-color: #000;
background-size: auto;
background-position: center;
opacity: 0.15;
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 0;
mix-blend-mode: overlay;
}
/* Canvas 元素样式 */
#membership-canvas {
display: block;
position: relative;
z-index: 1;
}
/* 控制区域样式 */
.controls {
margin-top: 30px;
padding: 0 20px;
position: relative;
z-index: 1;
color: #ccc;
}
.controls .score-display {
font-size: 18px;
font-weight: bold;
color: #e4c891;
margin-bottom: 15px;
}
.controls .score-display span {
font-size: 14px;
font-weight: normal;
color: #888;
}
/* 滑块样式 */
.slider {
-webkit-appearance: none;
appearance: none;
width: 80%;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
outline: none;
opacity: 0.7;
transition: opacity 0.2s;
}
.slider:hover {
opacity: 1;
}
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
background: #e4c891;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 5px #e4c891;
}
.slider::-moz-range-thumb {
width: 20px;
height: 20px;
background: #e4c891;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 0 5px #e4c891;
}
</style>
</head>
<body>
<div class="membership-container">
<div class="card-header"></div>
<canvas id="membership-canvas"></canvas>
<div class="controls">
<div class="score-display" id="score-display">
2000 <span>/ 4000 成长值</span>
</div>
<input
type="range"
min="0"
max="4000"
value="1200"
class="slider"
id="score-slider"
/>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
// --- DOM元素获取 ---
const container = document.querySelector(".membership-container");
const canvas = document.getElementById("membership-canvas");
const ctx = canvas.getContext("2d");
const slider = document.getElementById("score-slider");
const scoreDisplay = document.getElementById("score-display");
// --- 全局配置与状态变量 ---
const logicalHeight = 180; // 画布的逻辑高度 (固定)
let logicalWidth = 0; // 画布的逻辑宽度 (动态计算)
let curve = {}; // 存储贝塞尔曲线的四个点坐标
// 等级与成长值的映射关系
const scoreMap = {
V1: 800,
V2: 2000,
V3: 3200,
};
const maxScore = 4000; // 轨道的总成长值
// 动画状态变量
let currentProgress = 0; // 当前动画所在的进度 (0到1)
let targetProgress = 0; // 动画需要到达的目标进度 (0到1)
let animationFrameId; // 用于控制动画循环的ID
// --- 1. 初始化与响应式处理 ---
/**
* 设置并重置画布尺寸,以适应不同屏幕宽度和高分屏(DPR)。
* 同时,根据新的宽度动态计算贝塞尔曲线的坐标。
*/
function setupAndResize() {
// 获取容器当前宽度作为画布的逻辑宽度
logicalWidth = container.clientWidth;
// 动态定义贝塞尔曲线的坐标,确保它能铺满整个画布宽度
curve = {
p0: { x: 0, y: 140 }, // 起点 (左边缘)
p1: { x: logicalWidth * 0.25, y: 40 }, // 控制点1
p2: { x: logicalWidth * 0.75, y: 40 }, // 控制点2
p3: { x: logicalWidth, y: 140 }, // 终点 (右边缘)
};
// 获取设备像素比,用于高分屏适配
const dpr = window.devicePixelRatio || 1;
// 设置画布的物理像素尺寸
canvas.width = logicalWidth * dpr;
canvas.height = logicalHeight * dpr;
// 设置画布的CSS显示尺寸
canvas.style.width = `${logicalWidth}px`;
canvas.style.height = `${logicalHeight}px`;
// 对画布上下文进行缩放,后续所有绘图操作都会自动放大
ctx.scale(dpr, dpr);
// 尺寸变化后,立即重绘一次以更新显示
draw();
}
// --- 2. 核心绘图函数 ---
/**
* 绘制一条完整的贝塞尔曲线。
* @param {object} points - 包含p0, p1, p2, p3四个点的对象。
* @param {string|CanvasGradient} style - 描边的颜色或渐变。
* @param {number} width - 描边的宽度。
*/
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();
}
/**
* 绘制所有的等级节点(V1, V2, V3)。
* @param {number} progress - 当前的动画进度,用于判断节点是否激活。
* @param {number} currentScore - 当前的成长值,用于判断哪个是当前最高等级。
*/
function drawMarkers(progress, currentScore) {
// 首先确定当前已达到的最高等级是哪个
let currentHighestLevel = "V0";
for (const level in scoreMap) {
if (currentScore >= scoreMap[level]) {
currentHighestLevel = level;
}
}
// 遍历所有等级并绘制节点
for (const level in scoreMap) {
const levelProgress = scoreMap[level] / maxScore;
const isActive = progress >= levelProgress;
const isCurrentHighest = level === currentHighestLevel;
const point = getBezierPoint(
levelProgress,
curve.p0,
curve.p1,
curve.p2,
curve.p3
);
// V2节点比其他节点稍大
const baseRadius = level === "V2" ? 8 : 7;
// 当前最高等级的节点会再放大一点
const radius = isCurrentHighest ? baseRadius * 1.2 : baseRadius;
// 绘制外层光环
ctx.beginPath();
ctx.arc(point.x, point.y, radius + 4, 0, 2 * Math.PI);
ctx.fillStyle = isActive
? "rgba(228, 200, 145, 0.3)"
: "rgba(128, 128, 128, 0.15)";
ctx.fill();
// 绘制内层实心圆
ctx.beginPath();
ctx.arc(point.x, point.y, radius, 0, 2 * Math.PI);
ctx.fillStyle = isActive ? "#E4C891" : "#555";
ctx.fill();
// 绘制内置文字
ctx.fillStyle = isActive ? "#FFFFFF" : "#888";
ctx.font = `bold ${radius * 0.9}px sans-serif`; // 字体大小与半径关联
ctx.textAlign = "center";
ctx.textBaseline = "middle"; // 垂直居中
ctx.fillText(level, point.x, point.y + 1);
}
}
/**
* 主绘制函数,每一帧都会被调用以重绘整个画布。
*/
function draw() {
// 清空整个画布
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 步骤1: 绘制2px的灰色背景轨道
drawCurve(curve, "rgba(255, 255, 255, 0.1)", 2);
// 步骤2: 如果进度大于0,则绘制金色进度条
if (currentProgress > 0.001) {
// 创建金色渐变
const gradient = ctx.createLinearGradient(
curve.p0.x,
0,
curve.p3.x,
0
);
gradient.addColorStop(0, "#A88734");
gradient.addColorStop(1, "#E4C891");
// 计算出当前进度对应的部分曲线
const partialCurve = splitBezier(
currentProgress,
curve.p0,
curve.p1,
curve.p2,
curve.p3
);
// 绘制4px的金色进度条
drawCurve(partialCurve, gradient, 4);
// 在进度条尽头绘制一个更明显的圆点
const endPoint = partialCurve.p3;
ctx.beginPath();
ctx.arc(endPoint.x, endPoint.y, 3, 0, 2 * Math.PI); // 半径为3px (直径6px)
ctx.fillStyle = "#E4C891"; // 使用渐变的亮色
ctx.fill();
}
// 步骤3: 绘制所有等级节点
const currentScore = currentProgress * maxScore;
drawMarkers(currentProgress, currentScore);
}
// --- 3. 动画循环 ---
/**
* 动画的核心循环函数,使用requestAnimationFrame以获得最佳性能。
*/
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);
}
}
// --- 4. 事件处理 ---
// 监听滑块的输入事件
slider.addEventListener("input", (e) => {
const newScore = parseInt(e.target.value, 10);
// 更新显示的成长值
scoreDisplay.innerHTML = `${newScore} <span>/ ${maxScore} 成长值</span>`;
// 设置新的目标进度
targetProgress = newScore / maxScore;
// 启动动画
cancelAnimationFrame(animationFrameId);
animate();
});
// 监听浏览器窗口的尺寸变化事件,以实现响应式
window.addEventListener("resize", setupAndResize);
// --- 5. 辅助数学函数 ---
// 线性插值函数
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 };
}
// 根据t(0到1)获取贝塞尔曲线上点的坐标
function getBezierPoint(t, p0, p1, p2, p3) {
const u = 1 - t;
const tt = t * t,
uu = u * u;
const uuu = uu * u,
ttt = tt * t;
let p = { x: 0, y: 0 };
p.x = uuu * p0.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * p3.x;
p.y = uuu * p0.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * p3.y;
return p;
}
// --- 6. 启动 ---
// 首次加载时,初始化画布
setupAndResize();
// 延迟一小段时间后,播放初始动画
setTimeout(() => {
const initialScore = parseInt(slider.value, 10);
targetProgress = initialScore / maxScore;
animate();
}, 500);
});
</script>
</body>
</html>