深度解析:从0到1,用原生Canvas“丝滑”复刻动态会员弧线

89 阅读8分钟

前言:当一个“不可能”的需求摆在面前

那天下午,阳光正好,我正戴着耳机,沉浸在重构一个陈年API的快乐中。突然,PM(项目经理)“哐”地一下拍在我的桌上,表情凝重。

“大佬,救火!🔥”

b231f7772d7fe220afe1f8676145cb66.webp

他指着屏幕上刚发给我的一段视频,那是一个效果炫酷的会员等级展示页面:一条优美的金色弧线,能根据用户的“成长值”平滑地向前延伸,沿途的等级节点还有细腻的双层光环和状态联动。

“这次的项目是在微信小程序里,”她解释道,“前端小哥折腾了一天,做出来的东西要么是静态的,要么就是各种诡异的渲染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() 并设置好 textAligntextBaseline,就能把文字完美居中。
  • 响应式布局:将所有坐标和尺寸的计算都与画布的逻辑宽度 logicalWidth 挂钩,并在 resize 事件中重新调用初始化函数,即可实现完美的响应式。

最终,我们将所有这些逻辑组合在 draw() 函数中,由 animate() 循环驱动,一个像素级还原、高度动态、细节满满的动画就诞生了。

总结

从一个棘手的需求,到一个像素级还原的成品,我们经历了从技术选型、架构设计,到攻克核心算法、精雕细节的全过程。

这次的经历再次证明了一个道理:面对看似复杂的UI挑战,不要畏惧,更不要轻易说“不”。 很多时候,当我们习惯的“上层”工具无法满足需求时,不妨回归基础,深入理解底层的渲染原理(无论是DOM、SVG还是Canvas)。回到数学和算法的本源,往往能找到最优雅、最强大的解决方案。

当你能用代码在画板上随心所欲地“创造”时,那种掌控感和成就感,正是作为一名工程师最纯粹的快乐。希望这次的分享,能对你有所启发。

成果展示

lrQb5CPDAtle