离屏画布、Promise 队列、requestAnimationFrame,这套方案帮你彻底解决 Canvas 旋转过程中画质模糊、旋转错乱、卡顿延迟等常见问题。
目录
🌟 效果演示
注:暂不支持任意角度(非90°倍数)的连续旋转。
1️⃣ 常规写法痛点
常规旋转代码:
ctx.rotate(angle)
ctx.drawImage(img, x, y)
实际踩坑:
❌ 宽高不适配导致图片截断
❌ 瞬间旋转频繁触发闪烁
真实项目中旋转效果👇
-
图片锯齿严重
-
页面一顿一顿的
本套高性能 Canvas 旋转方案,核心亮点:
✅ 离屏画布快照
✅ 自动计算旋转尺寸
✅ Promise 队列避免多次触发冲突
✅ requestAnimationFrame 丝滑渲染
✅支持动态角度旋转,旋转后图像清晰
2️⃣ 方案设计思路
核心原则:
先拍快照 -> 计算旋转尺寸 -> 离屏旋转 -> 主线程绘制
整体思路:
-
snapshot 离屏 Canvas 负责记录当前画布;
-
offscreen 离屏 Canvas 负责旋转后结果;
-
Promise 队列 确保多个旋转顺序执行;
-
requestAnimationFrame 避免旋转阻塞渲染。
3️⃣ 核心方案完整实现
直接上代码,按需 copy👇
3.1 核心类封装
export default class CanvasRotationHelper {
constructor() {
this.snapshot = document.createElement("canvas")
this.snapshotCtx = this.snapshot.getContext("2d")
this.offscreen = document.createElement("canvas")
this.offscreenCtx = this.offscreen.getContext("2d")
this._rotationQueue = Promise.resolve()
}
}
3.2 核心旋转逻辑
-
队列控制:
_rotationQueue.then()
保证串行执行旋转,防抖 + 防错乱
rotateCanvas(canvas, degrees) {
this._rotationQueue = this._rotationQueue.then(() => {
return new Promise((resolve, reject) => {
try {
} catch (e) {
reject(e)
}
})
})
return this._rotationQueue
}
-
快照记录:
const ow = canvas.width
const oh = canvas.height
this.snapshot.width = ow
this.snapshot.height = oh
this.snapshotCtx.setTransform(1, 0, 0, 1, 0, 0)
this.snapshotCtx.clearRect(0, 0, ow, oh)
this.snapshotCtx.drawImage(canvas, 0, 0)
-
旋转尺寸计算:
const radians = (degrees * Math.PI) / 180
const sin = Math.abs(Math.sin(radians))
const cos = Math.abs(Math.cos(radians))
const nw = Math.ceil(ow * cos + oh * sin)
const nh = Math.ceil(ow * sin + oh * cos)
-
离屏旋转绘制:
this.offscreen.width = nw
this.offscreen.height = nh
const offCtx = this.offscreenCtx
offCtx.setTransform(1, 0, 0, 1, 0, 0)
offCtx.clearRect(0, 0, nw, nh)
offCtx.translate(nw / 2, nh / 2)
offCtx.rotate(radians)
offCtx.drawImage(this.snapshot, -ow / 2, -oh / 2)
-
主线程绘制:
requestAnimationFrame(() => {
canvas.width = nw
canvas.height = nh
const ctx = canvas.getContext("2d")
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, nw, nh)
ctx.drawImage(this.offscreen, 0, 0)
})
3.3 快速重置
clear() {
// 重置 snapshot
this.snapshotCtx.setTransform(1, 0, 0, 1, 0, 0)
this.snapshotCtx.clearRect(0, 0, this.snapshot.width, this.snapshot.height)
this.snapshot.width = this.snapshot.height = 0
// 重置 offscreen
this.offscreenCtx.setTransform(1, 0, 0, 1, 0, 0)
this.offscreenCtx.clearRect(0, 0, this.offscreen.width, this.offscreen.height)
this.offscreen.width = this.offscreen.height = 0
}
随时一键清理,避免内存残留 ✅
4️⃣ Bonus:下载旋转后图片(调试神器)
支持直接导出旋转结果:
base64ToBlob(base64, mimeType) { ... }
downloadBlob(blob, filename) { ... }
实战调试特别方便,旋转后立即导出文件验证效果 ✅
📝 整体优势总结
方案 | 优点 |
---|---|
离屏快照 | 避免画质损耗 |
动态宽高 | 不丢画面,不出现黑边 |
Promise 队列 | 彻底解决多次旋转乱序 |
requestAnimationFrame | 平滑不卡顿 |
一键清理 | 内存友好 |
完整源码
- 核心使用
this.rotationHelper.rotateCanvas(this.$refs.canvas, degrees);
- demo 代码示例
<template>
<div class="rotation-demo">
<h2>图片旋转工具</h2>
<div class="upload-section">
<input type="file" accept="image/*" @change="onUpload" />
</div>
<div class="button-group">
</div>
<div class="button-group">
<input
type="number"
v-model.number="customAngle"
placeholder="输入角度"
min="1"
max="359"
style="width: 100px;"
/>
<button @click="rotate(-customAngle)" :disabled="!image">
逆时针旋转 {{ customAngle }}°
</button>
<button @click="rotate(customAngle)" :disabled="!image">
顺时针旋转 {{ customAngle }}°
</button>
<button @click="rotate(-90)" :disabled="!image">← 左转 90°</button>
<button @click="rotate(90)" :disabled="!image">右转 90° →</button>
<button @click="rotate(180)" :disabled="!image">↻ 旋转 180°</button>
<button @click="reset" :disabled="!image">重置</button>
</div>
<div class="canvas-wrapper" v-show="image">
<canvas ref="canvas"></canvas>
</div>
</div>
</template>
<script>
import CanvasRotationHelper from "./utils/CanvasRotationHelper";
export default {
name: "CanvasRotationDemo",
data() {
return {
rotationHelper: null,
image: null,
rotation: 0,
customAngle: null,
};
},
mounted() {
this.rotationHelper = new CanvasRotationHelper();
},
methods: {
onUpload(e) {
const file = e.target.files[0];
if (!file) return;
const img = new Image();
img.onload = () => {
this.image = img; // 先赋值,canvas 显示
const canvas = this.$refs.canvas;
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
};
img.src = URL.createObjectURL(file);
},
rotate(degrees) {
if (!this.image) return;
this.rotation += degrees;
this.rotationHelper.rotateCanvas(this.$refs.canvas, degrees);
},
reset() {
if (!this.image) return;
const canvas = this.$refs.canvas;
canvas.width = this.image.width;
canvas.height = this.image.height;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(this.image, 0, 0);
this.rotationHelper.clear();
this.rotation = 0;
},
},
};
</script>
<style scoped>
.rotation-demo {
max-width: 1200px;
margin: 40px auto;
padding: 20px 30px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 20px rgb(0 0 0 / 0.1);
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
color: #333;
user-select: none;
display: flex;
flex-direction: column;
align-items: center;
}
h2 {
margin-bottom: 24px;
font-weight: 600;
color: #222;
}
.upload-section input[type="file"] {
border: 1.5px solid #3498db;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.upload-section input[type="file"]:hover {
background-color: #e6f0fb;
}
.button-group {
margin: 20px 0 30px;
display: flex;
gap: 16px;
}
.button-group button {
background-color: #3498db;
border: none;
color: white;
padding: 10px 18px;
border-radius: 6px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
box-shadow: 0 3px 6px rgb(52 152 219 / 0.4);
transition: background-color 0.25s ease, box-shadow 0.25s ease;
}
.button-group button:disabled {
background-color: #a5c9f3;
cursor: not-allowed;
box-shadow: none;
}
.button-group button:not(:disabled):hover {
background-color: #217dbb;
box-shadow: 0 5px 12px rgb(33 125 187 / 0.6);
}
.canvas-wrapper {
width: 100%;
max-height: 450px;
overflow: auto;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: inset 0 0 8px rgb(0 0 0 / 0.05);
display: flex;
justify-content: center;
align-items: center;
background: #fafafa;
}
canvas {
max-width: 100%;
max-height: 440px;
border-radius: 8px;
background: transparent;
}
.tips {
color: #777;
font-style: italic;
margin-top: 30px;
font-size: 16px;
}
</style>
- 工具类源代码
export default class CanvasRotationHelper {
constructor() {
this.snapshot = document.createElement("canvas")
this.snapshotCtx = this.snapshot.getContext("2d")
this.offscreen = document.createElement("canvas")
this.offscreenCtx = this.offscreen.getContext("2d")
this._rotationQueue = Promise.resolve()
}
rotateCanvas(canvas, degrees) {
this._rotationQueue = this._rotationQueue.then(() => {
return new Promise((resolve, reject) => {
try {
const radians = (degrees * Math.PI) / 180
const ow = canvas.width
const oh = canvas.height
this.snapshot.width = ow
this.snapshot.height = oh
this.snapshotCtx.setTransform(1, 0, 0, 1, 0, 0)
this.snapshotCtx.clearRect(0, 0, ow, oh)
this.snapshotCtx.drawImage(canvas, 0, 0)
// const mimeType = "image/png";
// const blob = this.base64ToBlob(this.snapshot.toDataURL(mimeType), mimeType);
// this.downloadBlob(blob, "snapshot.png");
const sin = Math.abs(Math.sin(radians))
const cos = Math.abs(Math.cos(radians))
const nw = Math.ceil(ow * cos + oh * sin)
const nh = Math.ceil(ow * sin + oh * cos)
this.offscreen.width = nw
this.offscreen.height = nh
const offCtx = this.offscreenCtx
offCtx.setTransform(1, 0, 0, 1, 0, 0)
offCtx.clearRect(0, 0, nw, nh)
offCtx.translate(nw / 2, nh / 2)
offCtx.rotate(radians)
offCtx.drawImage(this.snapshot, -ow / 2, -oh / 2)
// const blob1 = this.base64ToBlob(this.offscreen.toDataURL(mimeType), mimeType);
// this.downloadBlob(blob1, "offscreen.png");
requestAnimationFrame(() => {
canvas.width = nw
canvas.height = nh
const ctx = canvas.getContext("2d")
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, nw, nh)
ctx.drawImage(this.offscreen, 0, 0)
// const blob2 = this.base64ToBlob(canvas.toDataURL(mimeType), mimeType);
// this.downloadBlob(blob2, "canvas.png");
resolve()
})
} catch (e) {
reject(e)
}
})
})
return this._rotationQueue
}
clear() {
// 重置 snapshot
this.snapshotCtx.setTransform(1, 0, 0, 1, 0, 0)
this.snapshotCtx.clearRect(0, 0, this.snapshot.width, this.snapshot.height)
this.snapshot.width = this.snapshot.height = 0
// 重置 offscreen
this.offscreenCtx.setTransform(1, 0, 0, 1, 0, 0)
this.offscreenCtx.clearRect(0, 0, this.offscreen.width, this.offscreen.height)
this.offscreen.width = this.offscreen.height = 0
}
// #region test
base64ToBlob(base64, mimeType) {
// 解码 Base64 字符串
const byteString = atob(base64.split(",")[1])
// 创建一个 `Uint8Array` 数组并将 `byteString` 的每个字符复制到数组中
const arrayBuffer = new ArrayBuffer(byteString.length)
const uint8Array = new Uint8Array(arrayBuffer)
for (let i = 0; i < byteString.length; i++) {
uint8Array[i] = byteString.charCodeAt(i)
}
// 创建并返回 Blob 对象
return new Blob([uint8Array], { type: mimeType })
}
// 下载 Blob
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// #endregion
}
注:若需要对画布进行背景填充,对于离屏画布(offscreen)进行样式赋值即可实现。
offCtx.fillStyle = "#fff"
offCtx.fillRect(0, 0, nw, nh)