合成中loading效果

知识点梳理

高清屏适配(DPR 处理)

核心原理:Canvas 的 width/height 属性是像素缓冲区大小,CSS 的 width/height 是显示大小。在 Retina 屏(dpr=2/3)上,需要让缓冲区大小 = 显示大小 × dpr,才能避免模糊。组件中所有绘图参数都乘了 dpr。

const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;

路径绘制:圆角矩形

原生圆角矩形 APIroundRect是从左上角开始起的,这个需求是要从右上角开始,所以用直线+二次贝塞尔曲线 手动绘制:

ctx.beginPath();
ctx.moveTo(x + width - radius, y);                              // 起点:右上角圆弧起始
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);      // 右上角圆弧
ctx.lineTo(x + width, y + height - radius);                      // 右边直线
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); // 右下角圆弧
ctx.lineTo(x + radius, y + height);                              // 底边直线
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);    // 左下角圆弧
ctx.lineTo(x, y + radius);                                       // 左边直线
ctx.quadraticCurveTo(x, y, x + radius, y);                      // 左上角圆弧
ctx.closePath();

知识点:quadraticCurveTo(cpx, cpy, x, y)

  • 二次贝塞尔曲线,1 个控制点 + 1 个终点
  • 控制点就是矩形的角点,终点是圆弧结束位置
  • 效果等同于 CSS 的 border-radius

圆角矩形周长估算公式:

const perimeter = 2 * w + 2 * h + 2 * Math.PI * r - 8 * r;

即四条边的长度 + 四个 90° 圆弧的总弧长(4 × 2πr/4 = 2πr),减去被圆角截掉的 8 段直线长度(每个角截掉 2r→ 共 8r)。

描边技巧:setLineDash 实现跑马灯

ctx.setLineDash([dashLength, perimeter]);
ctx.stroke();

核心原理:setLineDash([实线长, 间隔长]) 定义虚线模式。当间隔设为整个周长时,只有第一段实线可见,其余全是空白。通过动态改变 dashLength(= 周长 × progress),就实现了进度条从 0 到满的增长效果。

配合属性:

  • ctx.lineWidth — 描边宽度
  • ctx.lineCap = 'round' — 线段两端为圆头,视觉更柔和

渐变系统

圆锥渐变 createConicGradient(主渐变)

strokeGradient = ctx.createConicGradient(rotation, width / 2, height / 2);
strokeGradient.addColorStop(0, '#7E8BFD');
strokeGradient.addColorStop(0.3, '#7896EE');
strokeGradient.addColorStop(0.6, '#B071FC');
strokeGradient.addColorStop(1, '#7E8BFD');
  • createConicGradient(startAngle, cx, cy):围绕中心点的角度渐变
  • rotation 参数不断变化 → 渐变不断旋转 → 实现描边颜色的流动效果
  • 首尾颜色相同,保证旋转时无突变

降级方案:对不支持 createConicGradient 的浏览器,降级为 createLinearGradient

径向渐变 createRadialGradient(起点光斑)

从线段起点位置向外扩散的白色光斑,中心最亮、边缘透明。

const fadeGradient = ctx.createRadialGradient(startX, startY, 0, startX, startY, fadeLength);
fadeGradient.addColorStop(0, 'rgba(255, 255, 255, 0.92)');
fadeGradient.addColorStop(0.45, 'rgba(255, 255, 255, 0.36)');
fadeGradient.addColorStop(1, 'rgba(255, 255, 255, 0)');

合成模式 globalCompositeOperation

ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = fadeGradient;
ctx.arc(startX, startY, fadeLength, 0, Math.PI * 2);
ctx.fill();

destination-out 的含义:新绘制的形状会「擦除」已有内容。

  • 效果:在进度条起点处画一个径向渐变的圆,把已有描边从中心往外逐渐擦掉
  • 视觉上产生了起点淡出/光斑消散的效果
    这是 Canvas 合成模式的经典应用——用一种图形去「挖掉」另一种图形。

动画系统:requestAnimationFrame

旋转动画(无限循环)

const animateRotation = (now: number) => {
  state.rotation = ((elapsed % ROTATION_DURATION) / ROTATION_DURATION) * Math.PI * 2;
  draw();
  rotationFrameId = requestAnimationFrame(animateRotation);
};
  • 每 3000ms 旋转一周(2π)
  • 用 % 取模实现无限循环
  • 驱动圆锥渐变的角度变化

进度动画(有限,跑到 99% 停止)

const animateProgress = (now: number) => {
  const t = elapsed / PROGRESS_DURATION;
  // 反比例缓动:前快后慢,趋近 0.99
  const eased = MAX_PROGRESS * (1 - 1 / (1 + 4 * t));
  state.progress = Math.min(MAX_PROGRESS, eased);
  draw();
  if (state.progress < MAX_PROGRESS) {
    progressFrameId = requestAnimationFrame(animateProgress);
  }
};

缓动函数 1 - 1/(1+4t):

  • 这是一个反比例函数缓动(hyperbolic easing)
  • 特点:开始快速增长,之后越来越慢,无限趋近于 1 但永远到不了
  • 非常适合 loading 场景——给用户"一直在进步但还没完"的心理感受
  • 最终在 12 秒内缓动到 99%

自适应:ResizeObserver

const resizeObserver = new ResizeObserver(updateSize);
resizeObserver.observe(canvas);

Canvas 不像 DOM 元素能自动缩放内容,尺寸变化时需要手动更新 canvas.width/height 并重绘。用 ResizeObserver 监听容器尺寸变化,比监听 window.resize 更精准。

ctx.save() / ctx.restore() 状态管理

ctx.save();
ctx.lineWidth = lineWidth;
ctx.lineCap = 'round';
// ... 绑定操作 ...
ctx.stroke();
ctx.restore();

Canvas 的绘图状态(lineWidth、strokeStyle、globalCompositeOperation 等)是全局的。save/restore 形成一个状态栈,确保局部修改不会污染后续绘制。本组件在描边和光斑绘制中各用了一次 save/restore。

原代码

<style>
    :root {
    --page-bg-start: #b8dde1;
    --page-bg-end: #dbeed9;
    --panel-bg: #efefef;
    --panel-border: #547bf6;
    --panel-border-soft: rgba(84, 123, 246, 0.18);
    --panel-radius: 16px;
    --title-color: #202020;
    --desc-color: rgba(32, 32, 32, 0.48);
    --title-size: 18px;
    --desc-size: 13px;
    --progress-width: 3px;
    }

    * {
    box-sizing: border-box;
    }

    html,
    body {
    width: 100%;
    min-height: 100%;
    margin: 0;
    font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display",
        "PingFang SC", "Segoe UI", sans-serif;
    color: var(--title-color);
    }

    .loading-container {
    width: 300px;
    height: 400px;
    display: flex;
    align-items: center;
    justify-content: center;
    overflow: hidden;
    background: linear-gradient(
        135deg,
        var(--page-bg-start),
        var(--page-bg-end)
    );
    border-radius: 12px;
    }

    .page-shell {
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 16px;
    }

    .loading-panel {
    position: relative;
    width: 100%;
    height: 100%;
    border-radius: var(--panel-radius);
    background: var(--panel-bg);
    overflow: hidden;
    box-shadow: 0 16px 32px rgba(77, 98, 112, 0.12),
        inset 0 0 0 1px rgba(255, 255, 255, 0.35);
    }

    .panel-progress-canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    pointer-events: none;
    }

    .panel-inner-glow {
    position: absolute;
    inset: 6px;
    border-radius: calc(var(--panel-radius) - 6px);
    box-shadow: inset 0 0 0 1px rgba(84, 123, 246, 0.06);
    pointer-events: none;
    }

    .loading-content {
    position: absolute;
    inset: 0;
    z-index: 2;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 24px 16px;
    text-align: center;
    }

    .loading-message {
    position: relative;
    width: 100%;
    min-height: 80px;
    display: flex;
    align-items: center;
    justify-content: center;
    }

    .message-line {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 10px;
    opacity: 0;
    transform: translateY(20px);
    transition: opacity 400ms ease, transform 400ms ease;
    will-change: opacity, transform;
    }

    .message-line.is-visible {
    opacity: 1;
    transform: translateY(0);
    }

    .message-line.is-leaving {
    opacity: 0;
    transform: translateY(-20px);
    transition-duration: 300ms;
    }

    .message-title {
    margin: 0;
    font-size: var(--title-size);
    line-height: 1.3;
    font-weight: 700;
    letter-spacing: -0.02em;
    }

    .message-desc {
    margin: 0;
    font-size: var(--desc-size);
    line-height: 1.5;
    font-weight: 400;
    color: var(--desc-color);
    letter-spacing: 0.01em;
    }
</style>
<div class="loading-container">
    <div class="page-shell">
    <section class="loading-panel" aria-label="照片生成中">
        <canvas class="panel-progress-canvas" id="progressCanvas"></canvas>
        <div class="panel-inner-glow"></div>
        <div class="loading-content">
        <div class="loading-message" id="loadingMessage"></div>
        </div>
    </section>
    </div>
</div>

<script>
    const LOADING_MESSAGES = [
    {
        title: "正在生成专属纪念照...",
        desc: "大约需要 20 秒",
        tip: "纪念照细节正在生成中",
    },
    {
        title: "正在优化人物细节...",
        desc: "为你调整五官、光影与清晰度",
        tip: "人物细节精修中",
    },
    {
        title: "正在匹配画面氛围...",
        desc: "同步完善背景、色彩与纪念感",
        tip: "整体氛围统一中",
    },
    {
        title: "马上就完成了...",
        desc: "请稍等,正在输出最终结果",
        tip: "即将完成最终出图",
    },
    ];

    const loadingMessage = document.getElementById("loadingMessage");
    const canvas = document.getElementById("progressCanvas");
    const ctx = canvas.getContext("2d");

    let messageTimer = null;
    let progressFrameId = null;
    let finishFrameId = null;
    let rotationFrameId = null;
    let restartTimer = null;
    let resizeObserver = null;
    let currentMessageIndex = 0;
    let progressStartTime = 0;
    let rotationStartTime = 0;

    const canvasState = {
    progress: 0,
    rotation: 0,
    width: 0,
    height: 0,
    dpr: window.devicePixelRatio || 1,
    };

    function createMessageElement(message) {
    const element = document.createElement("div");
    element.className = "message-line";
    element.innerHTML = `
            <p class="message-title">${message.title}</p>
            <p class="message-desc">${message.desc}</p>
        `;
    return element;
    }

    function showInitialMessage() {
    loadingMessage.innerHTML = "";
    currentMessageIndex = 0;
    const firstMessage = createMessageElement(LOADING_MESSAGES[0]);
    loadingMessage.appendChild(firstMessage);

    requestAnimationFrame(() => {
        firstMessage.classList.add("is-visible");
    });
    }

    function startMessageAnimation() {
    stopMessageAnimation();
    showInitialMessage();

    messageTimer = window.setInterval(() => {
        currentMessageIndex += 1;
        if (currentMessageIndex >= LOADING_MESSAGES.length) {
        window.clearInterval(messageTimer);
        messageTimer = null;
        return;
        }

        const oldMessage = loadingMessage.querySelector(".message-line");
        const newMessage = createMessageElement(
        LOADING_MESSAGES[currentMessageIndex]
        );
        loadingMessage.appendChild(newMessage);

        if (oldMessage) {
        oldMessage.classList.remove("is-visible");
        oldMessage.classList.add("is-leaving");
        window.setTimeout(() => {
            oldMessage.remove();
        }, 320);
        }

        window.setTimeout(() => {
        newMessage.classList.add("is-visible");
        }, 150);
    }, 3000);
    }

    function stopMessageAnimation() {
    if (messageTimer) {
        window.clearInterval(messageTimer);
        messageTimer = null;
    }
    }

    function traceRoundRect(pathContext, x, y, width, height, radius) {
    pathContext.beginPath();
    pathContext.moveTo(x + width - radius, y);
    pathContext.quadraticCurveTo(x + width, y, x + width, y + radius);
    pathContext.lineTo(x + width, y + height - radius);
    pathContext.quadraticCurveTo(
        x + width,
        y + height,
        x + width - radius,
        y + height
    );
    pathContext.lineTo(x + radius, y + height);
    pathContext.quadraticCurveTo(x, y + height, x, y + height - radius);
    pathContext.lineTo(x, y + radius);
    pathContext.quadraticCurveTo(x, y, x + radius, y);
    pathContext.lineTo(x + width - radius, y);
    pathContext.closePath();
    }

    function updateCanvasSize() {
    const rect = canvas.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    canvas.width = rect.width * dpr;
    canvas.height = rect.height * dpr;
    canvasState.width = canvas.width;
    canvasState.height = canvas.height;
    canvasState.dpr = dpr;
    drawCanvas();
    }

    function setupCanvas() {
    if (resizeObserver) {
        resizeObserver.disconnect();
    }
    resizeObserver = new ResizeObserver(updateCanvasSize);
    resizeObserver.observe(canvas);
    updateCanvasSize();
    }

    function drawCanvas() {
    const { width, height, dpr, progress, rotation } = canvasState;
    if (!width || !height) {
        return;
    }

    ctx.clearRect(0, 0, width, height);

    const lineWidth =
        parseFloat(
        getComputedStyle(document.documentElement).getPropertyValue(
            "--progress-width"
        )
        ) * dpr;
    const margin = lineWidth / 2 + 8 * dpr;
    const panelRadius =
        parseFloat(
        getComputedStyle(document.documentElement).getPropertyValue(
            "--panel-radius"
        )
        ) * dpr;
    const w = width - margin * 2;
    const h = height - margin * 2;
    const r = Math.max(0, panelRadius - 8 * dpr);
    const perimeter = 2 * w + 2 * h + 2 * Math.PI * r - 8 * r;
    const dashLength = perimeter * progress;

    ctx.save();
    ctx.lineWidth = lineWidth;
    ctx.lineCap = "round";
    ctx.strokeStyle = "rgba(84, 123, 246, 0.12)";
    traceRoundRect(ctx, margin, margin, w, h, r);
    ctx.stroke();
    ctx.restore();

    let strokeGradient;
    if (ctx.createConicGradient) {
        strokeGradient = ctx.createConicGradient(
        rotation,
        width / 2,
        height / 2
        );
        strokeGradient.addColorStop(0, "#8eb0ff");
        strokeGradient.addColorStop(0.2, "#547bf6");
        strokeGradient.addColorStop(0.5, "#3f67ea");
        strokeGradient.addColorStop(0.8, "#7fa2ff");
        strokeGradient.addColorStop(1, "#8eb0ff");
    } else {
        strokeGradient = ctx.createLinearGradient(0, 0, width, height);
        strokeGradient.addColorStop(0, "#8eb0ff");
        strokeGradient.addColorStop(0.5, "#547bf6");
        strokeGradient.addColorStop(1, "#3f67ea");
    }

    ctx.save();
    ctx.lineWidth = lineWidth;
    ctx.lineCap = "round";
    ctx.setLineDash([dashLength, perimeter]);
    ctx.strokeStyle = strokeGradient;
    traceRoundRect(ctx, margin, margin, w, h, r);
    ctx.stroke();
    ctx.restore();

    if (progress > 0.01) {
        const startX = margin + w - r;
        const startY = margin;
        const fadeLength = Math.min(perimeter * 0.05, dashLength * 0.28);
        const fadeGradient = ctx.createRadialGradient(
        startX,
        startY,
        0,
        startX,
        startY,
        fadeLength
        );

        fadeGradient.addColorStop(0, "rgba(255, 255, 255, 0.92)");
        fadeGradient.addColorStop(0.45, "rgba(255, 255, 255, 0.36)");
        fadeGradient.addColorStop(1, "rgba(255, 255, 255, 0)");

        ctx.save();
        ctx.globalCompositeOperation = "destination-out";
        ctx.fillStyle = fadeGradient;
        ctx.beginPath();
        ctx.arc(startX, startY, fadeLength, 0, Math.PI * 2);
        ctx.fill();
        ctx.restore();
    }
    }

    function animateRotation(now) {
    if (!rotationStartTime) {
        rotationStartTime = now;
    }
    const elapsed = now - rotationStartTime;
    canvasState.rotation = ((elapsed % 1200) / 1200) * Math.PI * 2;
    drawCanvas();
    rotationFrameId = window.requestAnimationFrame(animateRotation);
    }

    function animateProgress(now) {
    if (!progressStartTime) {
        progressStartTime = now;
    }
    const elapsed = now - progressStartTime;
    const t = elapsed / 5000;
    const easedProgress = 0.99 * (1 - 1 / (1 + 9 * t));
    canvasState.progress = Math.min(0.99, easedProgress);
    drawCanvas();

    if (canvasState.progress < 0.99) {
        progressFrameId = window.requestAnimationFrame(animateProgress);
    } else {
        progressFrameId = null;
        finishProgressAnimation();
    }
    }

    function finishProgressAnimation() {
    const startValue = canvasState.progress;
    const duration = 600;
    let startTime = 0;

    if (finishFrameId) {
        window.cancelAnimationFrame(finishFrameId);
        finishFrameId = null;
    }

    const step = (now) => {
        if (!startTime) {
        startTime = now;
        }
        const elapsed = now - startTime;
        const ratio = Math.min(elapsed / duration, 1);
        const eased = 1 - Math.pow(1 - ratio, 2);
        canvasState.progress = startValue + (1 - startValue) * eased;
        drawCanvas();

        if (ratio < 1) {
        finishFrameId = window.requestAnimationFrame(step);
        return;
        }

        finishFrameId = null;
        restartTimer = window.setTimeout(restartDemo, 1200);
    };

    finishFrameId = window.requestAnimationFrame(step);
    }

    function cancelAnimationFrames() {
    if (progressFrameId) {
        window.cancelAnimationFrame(progressFrameId);
        progressFrameId = null;
    }
    if (finishFrameId) {
        window.cancelAnimationFrame(finishFrameId);
        finishFrameId = null;
    }
    if (rotationFrameId) {
        window.cancelAnimationFrame(rotationFrameId);
        rotationFrameId = null;
    }
    if (restartTimer) {
        window.clearTimeout(restartTimer);
        restartTimer = null;
    }
    }

    function restartDemo() {
    cancelAnimationFrames();
    stopMessageAnimation();
    canvasState.progress = 0;
    canvasState.rotation = 0;
    progressStartTime = 0;
    rotationStartTime = 0;
    drawCanvas();
    startMessageAnimation();
    rotationFrameId = window.requestAnimationFrame(animateRotation);
    progressFrameId = window.requestAnimationFrame(animateProgress);
    }

    setupCanvas();
    restartDemo();

    window.addEventListener("beforeunload", () => {
    stopMessageAnimation();
    cancelAnimationFrames();
    if (resizeObserver) {
        resizeObserver.disconnect();
    }
    });
</script>
posted @ 2026-05-13 16:28  懒懒同学不懒  阅读(5)  评论(0)    收藏  举报