Skip to content

深度解析:从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
<!-- 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把它“缩”回原来的逻辑尺寸。

javascript
// --- 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() 方法来绘制它。

javascript
// --- 核心绘图函数 ---
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)算法,让进度变化由快到慢,更符合物理直觉。

javascript
// --- 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)

javascript
// --- 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

完整代码

html
<!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>