Canvas 进阶篇

基础绘制会了之后,交互和动画才是 Canvas 真正费神的地方:坐标要自己换算,像素要自己改,跨域图还会把画布锁死。

Canvas 事件监听

需要给画布里某一块图形单独做点击吗?Canvas 没有「点到某个形状」的事件 API。只能监听整块画布,再自己判断点落在哪。

方式一:利用数学的力量

以圆为例:有圆心和半径后,用距离公式判断点击是否在圆内。拿到鼠标相对画布的坐标,再和每个圆做相交检测。

矩形、椭圆、多边形都有对应的几何判定。这种方式很常见。

方式二:模拟点击区域

原理:在隐藏画布上把每个图形画成唯一颜色,点击时读取该像素颜色,反查是哪一个图形。主画布上的可见颜色可以重复,隐藏画布上的颜色必须唯一。

每个图形画两次:一次在主画布(可见),一次在「点击」画布(可放在屏外,不必插入文档)。

点击主画布时,用同一坐标去隐藏画布 getImageData,用颜色做索引。

简单示例

generateCircleListdrawCircle 以及 ctx 的创建见 DEMO。鼠标坐标用 getBoundingClientRect,避免 offsetLeft 遇到滚动、嵌套定位时算错。

image-20230408182743759

<!-- 方式一:利用数学的力量 -->
<canvas id="canvas" width="500" height="300"></canvas>
<p class="tips"></p>
<script>
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const p = document.querySelector('p')
    const circleList = generateCircleList(5)
    drawCircle(ctx, circleList)
    canvas.addEventListener('mousedown', (event) => {
        const rect = canvas.getBoundingClientRect()
        const x = event.clientX - rect.left
        const y = event.clientY - rect.top
        circleList.forEach(e => {
            const dx = x - e.x
            const dy = y - e.y
            if (dx * dx + dy * dy <= e.radius * e.radius) {
                canvas.style.borderColor = e.fillColor
                p.innerHTML = e.fillColor
                p.style.color = e.fillColor
            }
        });
    }, false);
</script>

查看 Canvas 事件 DEMO

Canvas 动画

动画的基本步骤

可以通过以下的步骤来画出一帧:

  • 清空 canvas: 除非接下来要画的内容会完全盖住 canvas(例如背景图),否则需要先清空。最简单的做法是 clearRect
  • 保存 canvas 状态: 如果这一帧会改样式、变形等,又希望下一帧从原始状态开始,先 save()
  • 绘制动画图形(animated shapes): 这一步才是重绘动画帧。
  • 恢复 canvas 状态: 如果已经 save(),绘制后 restore(),再进入下一帧。

操控动画

在 canvas 上绘制内容要用它提供的方法或自己封装的方法。脚本一次跑完才把结果画上去,所以在 for 循环里把所有帧一口气画完,通常看不到中间过程。

因此要实现动画,需要按时间重复重绘。常见两种做法:

  • setIntervalsetTimeout 在设定的时间点重绘。
  • requestAnimationFrame 告诉浏览器:希望做动画,并在下一次重绘前调用指定函数更新画面。

注: requestAnimationFrame 跟显示器刷新对齐,通常约 60 次/秒,高刷新屏会更高;标签页在后台时浏览器往往会降频或暂停。比 setInterval 更合适做动画。

Canvas 的优化

  • 在离屏 canvas 上预渲染相似的图形或重复的对象: 如果每个动画帧都在重复同一套绘制,可以先画到屏幕外的画布上,再按需把离屏图像拷到主画布,而不必每帧重新生成。

  • 坐标尽量用整数,避免浮点坐标。

  • 不要在 drawImage 时顺便缩放图像。

  • 复杂场景可以用多层画布分别绘制。

  • 用 CSS 设置大的背景图。

  • 用 CSS transforms 缩放画布: CSS transforms 走 GPU,通常更快。更稳妥的是不直接缩放画布,或者用较小的画布再放大,而不是先画很大再缩小。

  • 关闭透明度: 若不需要透明,创建上下文时把 alpha 设为 false,便于浏览器内部优化。

    const ctx = canvas.getContext('2d', { alpha: false });
    

示例:动态绘制图形

<canvas id="canvas" width="500" height="300"></canvas>
<script>
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const circleList = generateCircleList(5)
    circleList.forEach((e, i) => {
        setTimeout(() => {
            ctx.beginPath();
            ctx.fillStyle = e.fillColor;
            ctx.strokeStyle = e.strokeColor;
            ctx.arc(e.x, e.y, e.radius, e.startAngle, e.endAngle, e.anticlockwise);
            ctx.fill();
            ctx.stroke();
        }, i * 1000)
    })
</script>

查看 动态绘制图形 DEMO

示例:循环全景照片

<canvas id="canvas" width="500" height="300"></canvas>
<script>
    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const canvasWidth = canvas.clientWidth;
    const canvasHeight = canvas.clientHeight;
    const speed = 30;
    const scale = 1.2
    const img = new Image();
    img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';

    let imageWidth = 0
    let imageHeight = 0
    let x = 0;
    let y = 0
    let dx = -1;
    let dy = -1

    img.onload = function () {
        img.width = img.width / img.height * canvasHeight * scale;
        img.height = canvasHeight * scale;
        imageWidth = img.width
        imageHeight = img.height
        return setInterval(draw, speed);
    }

    function draw() {
        ctx.clearRect(0, 0, canvasWidth, canvasHeight);
        ctx.drawImage(img, x, y, imageWidth, imageHeight);
        if (imageWidth !== canvasWidth) {
            if (dx < 0 && Math.abs(x) > imageWidth - canvasWidth) {
                dx = Math.abs(dx)
            }
            if (dx > 0 && x >= 0) {
                dx = Math.abs(dx) * -1
            }
            x += dx;
        }
        if (imageHeight !== canvasHeight) {
            if (dy < 0 && Math.abs(y) > imageHeight - canvasHeight) {
                dy = Math.abs(dy)
            }

            if (dy > 0 && y >= 0) {
                dy = Math.abs(dy) * -1
            }
            y += dy;
        }
    }
</script>

查看 循环全景照片 DEMO

示例:鼠标追踪动画

<canvas id="canvas" width="500" height="300"></canvas>
<script>
    function ColorLine(lineWidth, color, deg) {
        this.x = mousePoint.x;
        this.y = mousePoint.y;
        this.lineWidth = lineWidth;
        this.color = color;
        this.theta = Math.random() * Math.PI * 2;
        this.deg = deg;
        this.radius = Math.random() * 150;

        this.draw = function () {
            const point = {
                x: this.x,
                y: this.y
            };
            this.theta += this.deg;
            this.x = mousePoint.x + Math.cos(this.theta) * this.radius;
            this.y = mousePoint.y + Math.sin(this.theta) * this.radius;
            ctx.beginPath();
            ctx.lineWidth = this.lineWidth;
            ctx.strokeStyle = this.color;
            ctx.moveTo(point.x, point.y);
            ctx.lineTo(this.x, this.y);
            ctx.stroke();
            ctx.closePath();
        }
    }

    function beginDraw() {
        requestAnimationFrame(beginDraw);
        ctx.fillStyle = "rgba(0, 0, 0, 0.05)";
        ctx.fillRect(0, 0, canvas.clientWidth, canvas.clientHeight);
        colorLines.forEach(e => e.draw());
    }

    const canvas = document.querySelector('canvas')
    const ctx = canvas.getContext('2d')
    const mousePoint = {
        x: canvas.clientWidth / 2,
        y: canvas.clientHeight / 2
    };
    const colorLines = [];
    for (let i = 0; i < 100; i++) {
        colorLines.push(new ColorLine(5, `#${Math.random().toString(16).slice(2, 8)}`, Math.PI / 180));
    }

    beginDraw()
    window.onmousemove = function (e) {
        const rect = canvas.getBoundingClientRect()
        mousePoint.x = e.clientX - rect.left;
        mousePoint.y = e.clientY - rect.top;
    }
</script>

查看 鼠标追踪动画 DEMO

像素操作

ImageData 对象里存着 canvas 位图的像素。它包含以下只读属性:

  • width:图片宽度,单位是像素。
  • height:图片高度,单位是像素。
  • dataUint8ClampedArray,按 RGBA 存放,每个通道 0~255。
  • colorSpace:色彩空间,如 srgbdisplay-p3

规范里没有 storageFormat 这一项。

data 里每个像素占 4 项,依次是 RGBA:

  • R:红色(0~255)。
  • G:绿色(0~255)。
  • B:蓝色(0~255)。
  • A:alpha 通道(0~255;0 为透明,255 为完全不透明)。

color、alpha 按数组顺序排列:第一个像素在 data[0]data[3],第二个在 data[4]data[7]

createImageData

createImageData 用于在 Canvas 2D 里创建一块指定大小的空白 ImageData。新对象里所有像素都是透明黑,即 rgba 为 0, 0, 0, 0

ctx.createImageData(width, height);
ctx.createImageData(imagedata);

参数说明:

  • width:ImageData 新对象的宽度。
  • height:ImageData 新对象的高度。
  • imagedata:从现有的 ImageData 对象中,复制一个和其宽度和高度相同的对象。图像自身不允许被复制。
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const img = new Image();
img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';
img.onload = function () {
    img.height = img.height / img.width * canvas.clientWidth * 1.2;
    img.width = canvas.clientWidth * 1.2;
    ctx.drawImage(img, 0, 0, img.width, img.height);
    const imageData = ctx.createImageData(canvas.clientWidth, canvas.clientHeight);
    const data = imageData.data
    for (let i = 0; i < data.length; i += 4) {
        data[i + 0] = Math.random() * 255;
        data[i + 1] = Math.random() * 255;
        data[i + 2] = Math.random() * 255;
        data[i + 3] = Math.random() * 255;
    }
    ctx.putImageData(imageData, 10, 10, 0, 0, 100, 100);
}

getImageData

getImageData 返回一个 ImageData 对象,描述画布上矩形区域内的像素。起点为 sxsy,宽为 sw,高为 sh

ctx.getImageData(sx, sy, sw, sh);

参数说明:

  • sx:将要被提取的图像数据矩形区域的左上角 x 坐标。
  • sy:将要被提取的图像数据矩形区域的左上角 y 坐标。
  • sw:将要被提取的图像数据矩形区域的宽度。
  • sh:将要被提取的图像数据矩形区域的高度。
const imageData = ctx.getImageData(10, 10, 200, 300);
const data = imageData.data
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')
const img = new Image();
img.src = 'https://my-files-1259410276.cos.ap-chengdu.myqcloud.com/md_images/飞舞.png';

img.onload = function () {
    img.height = img.height / img.width * canvas.clientWidth * 1.2;
    img.width = canvas.clientWidth * 1.2;
    ctx.drawImage(img, 0, 0, img.width, img.height);
    const imageData = ctx.getImageData(0, 0, img.width, img.height);
    const data = imageData.data
    for (let i = 0; i < data.length; i += 4) {
        data[i + 3] = Math.random() * 255;
    }
    ctx.putImageData(imageData, 0, 0, 0, 0, img.width, img.height);
}

putImageData

putImageData 把已有 ImageData 写回位图。如果提供了脏矩形,则只写该矩形内的像素。此方法不受画布变换矩阵影响。

ctx.putImageData(imagedata, dx, dy);
ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);

参数说明:

  • imagedata:包含像素值的 ImageData 对象。
  • dx:源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)。
  • dy:源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)。
  • dirtyX(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(x 坐标)。
  • dirtyY(可选):在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(y 坐标)。
  • dirtyWidth(可选):在源图像数据中,矩形区域的宽度。默认是图像数据的宽度。
  • dirtyHeight(可选):在源图像数据中,矩形区域的高度。默认是图像数据的高度。

查看 像素操作 DEMO

数据转换

SVG 转 CANVAS

image-20230408182911607

const svgDom = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svgDom.setAttribute('version', 1.1)
svgDom.setAttribute('baseProfile', 'full')
svgDom.setAttribute('width', canvas.clientWidth)
svgDom.setAttribute('height', canvas.clientHeight)
svgDom.innerHTML = `
<rect width="100%" height="100%" fill="orange" />
<text x="250" y="150" font-size="55" text-anchor="middle" fill="white">SVG TO CANVAS</text>
`

const image = new Image();
image.src = 'data:image/svg+xml;charset=utf-8,' + new XMLSerializer().serializeToString(svgDom);
image.onload = function () {
    ctx.drawImage(image, 0, 0, image.width, image.height);
}
// 注意:以下方法,svg 标签一定要加属性 xmlns='http://www.w3.org/2000/svg',即
svgDom.setAttribute('xmlns', 'http://www.w3.org/2000/svg')

// svg 内容中可以有中文字符
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgDom.outerHTML);
image.src = 'data:image/svg+xml;base64,' + window.btoa(
    encodeURIComponent(svgDom.outerHTML).replace(/%([0-9A-F]{2})/g, (_, p) => String.fromCharCode('0x' + p))
);

// svg 内容中不能有中文字符
image.src = 'data:image/svg+xml,' + svgDom.outerHTML;
image.src = 'data:image/svg+xml;base64,' + window.btoa(svgDom.outerHTML);

查看 SVG-TO-CANVAS DEMO

CANVAS 转 base64

const base64Url = canvas.toDataURL("image/jpeg");
const a = document.createElement('a');
a.setAttribute('href', base64Url);
a.setAttribute('download', 'lizhao-download-canvas-image.jpg');
a.click();

base64 转图片文件

const base64Url = canvas.toDataURL("image/jpeg");
const bytes = window.atob(base64Url.split(',')[1]);
const arrayBuffer = new ArrayBuffer(bytes.length);
const uint8Array = new Uint8Array(arrayBuffer);
for (let i = 0; i < bytes.length; i++) {
    uint8Array[i] = bytes.charCodeAt(i);
}
const jpegBlob = new Blob([arrayBuffer], { type: 'image/jpeg' });
const jpegFile = new File([uint8Array], 'lizhao-base64-to-jpeg.jpeg', { type: 'image/jpeg' });

CANVAS 转 URL

const jpegUrl = URL.createObjectURL(jpegBlob);

图像合成(查看效果

在绘制图形时,不同的图形会因为绘制的先后而有了层级关系。如果新绘制的图形和原有内容有重叠部分,在默认情况下,新绘制的图形是会覆盖在原有内容之上。

globalCompositeOperation 设置绘制新形状时的合成方式。下面表格里前 12 个是 Porter-Duff 合成。现行规范还支持 CSS 混合模式,如 multiplyscreenoverlayplus-lighter 等。darker 从未进入现行规范,引擎已移除,仅作对照。

属性值 描述
source-over 默认。新图像覆盖老图像。
source-in 新图像覆盖老图像,且仅仅会绘制重叠的部分,其他区域都变成透明的。
source-out 只绘制不重叠的部分,老图像是透明的。
source-atop 新图像覆盖老图像,且仅仅会绘制重叠的部分,其他区域不变。
destination-over 老图像覆盖新图像。
destination-in 老图像覆盖新图像,且仅仅会绘制重叠的部分,其他区域都变成透明的。
destination-out 只绘制不重叠的部分,新图像是透明的。
destination-atop 老图像覆盖新图像,且仅仅会绘制重叠的部分,其他区域不变。
lighter 重叠部分作加色处理。
xor 重叠部分会变成透明。
copy 只保留新图像,老图像被清除。
darker 重叠部分作减色处理。从未进入现行规范,引擎已移除。

常见问题

图片的 crossorigin 属性

HTML5 里部分元素支持 CORS,例如 audioimglinkscriptvideo 都有跨域属性 crossorigin,用来配置获取数据时的 CORS 请求。

图片的 crossorigin 属性配合合适的 CORS 响应头,就可以在画布里使用跨域 img,效果与同源图像相同。

HTML 属性 crossorigin 的取值:

  • anonymous:对此元素的 CORS 请求将不设置凭据标志。
  • use-credentials:对此元素的 CORS 请求将设置凭证标志,请求会带上凭据。
  • "":空值,如 crossorigincrossorigin="",效果与 anonymous 相同。

无效关键字和空字符串也会被当成 anonymous

JavaScript 里对应的是 img.crossOrigin(驼峰)。只要属性值不是 use-credentials,都会解析为 anonymous,包括空字符串和类似 abc 这样的字符。

注意: canvas 使用跨域图片时,图片所在服务器必须允许跨域访问。

画布像素可能来自多种来源,包括其他主机上的图像或视频,因此会有安全限制。非同源图片仍可画到 canvas 上,但会污染画布,此后再读取像素就会抛出安全错误。

在被污染的画布上调用以下方法会抛出安全错误:

  • 调用 getImageData() 方法;
  • 调用 toBlob() 方法;
  • 调用 toDataURL() 方法。
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

const img = new Image();
img.onload = function () {
    context.drawImage(this, 0, 0);
    context.getImageData(0, 0, this.width, this.height);
};
img.src = 'https://diff.example.com/images/sidgie.png';

如果使用 canvas.toDataURL() 方法,则会报错:

Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported

解决方法就是给 img 加上 crossOrigin

img.crossOrigin = 'anonymous'

Canvas 图片渲染异常

封装一个方法 drawCardImage 用于将图片和一些文本绘制到 canvas,再导出为图片。在 HUAWEI P30(Harmony 3.0.0)下,连续两次调用该方法,报错:Tainted canvasses may not be exported

解决方法: 第二次调用放进定时器,例如 setTimeout(async () => { await drawCardImage() }, 100)备注: 未定位到具体原因。其他机型未复现,HUAWEI Mate 40 Pro(Harmony 3.0.0)、HUAWEI Mate 20 X(Harmony 3.0.0)正常。

Canvas 大小限制

HTML <canvas> 元素在现代和传统浏览器里都支持。元素本身不规定宽度上限,但每种浏览器和平台组合都有自己的尺寸上限,超过后画布会无法使用。

浏览器没有提供 API 来查询这些上限,创建一个不可用的画布后也不会给出明确反馈。这对需要支持多种浏览器和平台、又要处理大型画布的应用是个麻烦。

canvas-size 是一个 npm 包,提供了浏览器支持的 Canvas 元素的最大面积、高度和宽度,以及测试自定义画布尺寸的能力。

以下是 canvas-size 当时的测试结果,测试使用 BrowserStack 提供的虚拟设备(结果因实际硬件而异):

PC 端:

Browser (OS) Max Width Max Height Max Area (Total)
Chrome >= 73 (Mac, Win) 65,535 65,535 16,384 x 16,384 (268,435,456)
Chrome <= 72 (Mac, Win) 32,767 32,767 16,384 x 16,384 (268,435,456)
Edge >= 80 (Mac, Win) 65,535 65,535 16,384 x 16,384 (268,435,456)
Edge <= 18 (Win) 16,384 16,384 16,384 x 16,384 (268,435,456)
Firefox >= 60 (Mac, Win) 32,767 32,767 11,180 x 11,180 (124,992,400)
IE 11 (Win) 16,384 16,384 8,192 x 8,192 (67,108,864)
IE 9 - 10 (Win) 8,192 8,192 8,192 x 8,192 (67,108,864)
Safari >= 5 (Mac) 4,194,303 8,388,607 16,384 x 16,384 (268,435,456)

移动端: 同一平台、浏览器组合在不同移动设备上的测试结果会因硬件而异,尤其是性能较差的旧设备。

Browser (OS) Max Width Max Height Max Area (Total)
Chrome 91 (Android 8 - 11) 65,535 65,535 16,384 x 16,384 (268,435,456)
Chrome 91 (Android 7) 65,535 65,535 14,188 x 14,188 (201,299,344)
Chrome 91 (Android 6) 65,535 65,535 16,384 x 16,384 (268,435,456)
Chrome 91 (Android 5) 65,535 65,535 11,180 x 11,180 (124,992,400)
Chrome 68 (Android 7.1 - 9) 32,767 32,767 14,188 x 14,188 (201,299,344)
Chrome 68 (Android 6) 32,767 32,767 10,836 x 10,836 (117,418,896)
Chrome 68 (Android 5) 32,767 32,767 11,402 x 11,402 (130,005,604)
IE (Windows Phone 8.x) 4,096 4,096 4,096 x 4,096 (16,777,216)
Safari (iOS >= 9) 4,194,303 8,388,607 4,096 x 4,096 (16,777,216)

参考链接

MDN - canvas 教程 - 基本的动画

© lizhao all right reserved,powered by Gitbook文件修订时间: 2026-08-31 22:54:18

results matching ""

    No results matching ""