【干货】Canvas 旋转最佳实践:离屏快照 + requestAnimationFrame 平滑方案

离屏画布、Promise 队列、requestAnimationFrame,这套方案帮你彻底解决 Canvas 旋转过程中画质模糊、旋转错乱、卡顿延迟等常见问题。

目录

🌟 效果演示

1️⃣ 常规写法痛点

2️⃣ 方案设计思路

3️⃣ 核心方案完整实现

3.1 核心类封装

3.2 核心旋转逻辑

3.3 快速重置

4️⃣ Bonus:下载旋转后图片(调试神器)

📝 整体优势总结

 完整源码


🌟 效果演示

注:暂不支持任意角度(非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)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值