合成中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>

浙公网安备 33010602011771号