【Web】HTML5 Canvas 2D绘图的封装

概述

(原文写自2024年10月9日,整理笔记所以现在发出)

HTML5 Canvas并不是一项很新的技术了,但是作为一名业余程序员 ,我是不需要考虑新旧技术和投入实际生产的问题,相反,我只需要考虑有趣,什么有趣搞什么。在Godot中玩味了一圈CanvasItem的绘图函数,也慢慢补齐了一点三角函数、向量和线性变换的基础之后,发现绘图才是程序中最有意思的内容

关于Canvas的2D绘图指令,确实没有必要重复讲述,有很好的文章和在线文档讲述这些内容。相反,一些除绘图指令之外的用法是非常值得进阶学习的,因为我有Godot中的一些经验,理解和运用这些内容也变得非常容易,这大概就叫做"触类旁通"吧。

本文部分参考《HTML5 Canvas核心技术------图形、动画与游戏开发》(下文简称《HC开发》)一书,MDN文档、菜鸟教程和其他各处博文等。

本文的主要目标是试图精炼Canvas 2D绘图的一些高级和核心内容,并将其封装为一个自定义类的方法,从而简化原来的大量基础绘图代码,并且作为后续高级应用开发的基础。你可以看到我糅合了很多Godot中的思路和做法,之前封装JS版本的Vector2类就是我无法忘掉从Godot中学习到的内容。

另外推荐渡一教育的几个Canvas视频,我觉得是目前讲的最牛最清晰的一个,而且也是最接近《HC开发》一书内容的,可以作为速通和辅助学习视频。渡一教育的视频在B站、小红书等社交账号都可以找到。

本文的最大特点,一个是业余,一个就是会讲述整个自定义类逐渐添加和封装Canvas核心功能的过程和思路。并且会以小tip的形式补充大量JavaScript的基础知识点(毕竟我的JavaScript基础也不是很牢固)。

动态创建canvas

在2D绘制方面,最重要的是下面两句:

javascript 复制代码
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");

封装为ES6类


JavaScript中的类

ECMAScript(简称ES)是一种由Ecma International通过ECMA-262标准定义的脚本语言规范。ES5和ES6是这个规范的两个版本,分别代表JavaScript语言的两个不同的发展阶段。

在ES5中,JavaScript并没有原生的类(class)概念,但是可以通过构造函数和原型链(prototype)来模拟面向对象编程中的类。而在ES6中,JavaScript正式引入了类(class)的概念,提供了一种更简洁和直观的方式来实现面向对象编程,但是其本质还是通过构造函数和原型链(prototype)实现。


可以看到,很多脚本和语言都有趋同化的设计,如果你将JavaScript和Python以及GDSCript放在一起,会发现很多相似的东西,毕竟思路和用途都大差不差。

以下是一个简单的ES6风格的类定义形式和实例化用法:

javascript 复制代码
class 类名{
    constructor(参数列表){
       this.属性A = 参数1
       this.属性B = 参数2
      //...
    }
    方法(参数){
      //...
    }
}

let 实例 = new 类名(参数列表);
  • 类名一般首字母大写
  • constructor()是类的构造函数,可以传入一些参数,为属性进行初始的赋值
  • 构造函数和一般的方法都不需要带function关键字
  • new 类名(参数列表)而不是类名.new(),习惯了GDSCript,很容易写错

我们依照ES6风格的类定义形式,定义一个初步的·Canvas2D类型如下:

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    constructor(width,height,p_node = document.body){
        this.canvas = document.createElement("canvas");
        this.ctx = this.canvas.getContext("2d");
        this.canvas.width = width;
        this.canvas.height = height;
        p_node.append(this.canvas);
    }
}

其中:

  • Canvas2D的构造函数,有三个参数:
    • widthheight分别指定<canvas>的画布宽度和高度
    • p_node指定<canvas>标签的父元素,默认为document.body

如上定义后,我们只需要在测试代码中new一个Canvas2D的实例,并传入宽高和父元素,就可以自定在测试页面的<body>标签或其他元素中添加一个<canvas>标签,Canvas2D实例会在其canvas属性中存储对<canvas>标签实例的引用。

javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);

因为需要再浏览器中使用和测试,所以我们搭建如下的测试页面:

html 复制代码
<!-- test.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Canvas2D测试</title>
    <script src="Canvas2D.js"></script>
    <style>
        canvas{
            box-shadow: 1px 1px 5px #ccc;
        }
    </style>
</head>
<body>

</body>
<script src="draw.js"></script>
</html>

其中:

  • <head>部分引入Canvas2D.js,并且用<style>直接定义所有canvas标签的统一样式,为其添加一个box-shadow,用于在HTML页面中与白色背景区分
  • <body>外,引入draw.js,作为测试代码

测试效果:

使用Getter和Setter

使用setget关键字,可以定义属性的Getter和Setter方法,用于更细节的控制属性的读写操作。

这里我们将Canvas2Dwidthheight属性设定为读写Canvas2D.canvaswidthheight属性,以简化代码:

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    constructor(width,height,p_node = document.body){...}
    set width(val){
        this.canvas.width = val;
    }
    get width(){
        return this.canvas.width;
    }
    set height(val){
        this.canvas.height = val;
    }
    get height(){
        return this.canvas.height;
    }
}

这样我们就可以直接像下面这样重新定义和读取canvas的尺寸:

javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);

// 通过height和width属性重新设定canvas的尺寸
canvas.height = 400;
canvas.width = 300;

// 读取canvas的尺寸
console.log(canvas.height);
console.log(canvas.width);

编写绘图方法

在搞定canvas的实例化之后,我们开始正式封装一些绘图方法。

draw_circle()

以绘制圆为例,封装一个方法如下:

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    //...
    // ================= 方法 =================
    draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        ctx.beginPath();
        // 样式设定
        ctx.strokeStyle = border;      // 轮廓样式
        ctx.lineWidth = border_width;  // 轮廓线宽
        ctx.fillStyle = fill;          // 填充样式
        if(dash != null && dash.length>0){   //虚线样式
            ctx.setLineDash(dash);
        }
        // 主体路径
        ctx.arc(cx,cy,radius,0,Math.PI * 2);
        // 填充和轮廓绘制
        ctx.fill();
        ctx.stroke();
    }
}

测试:

javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_circle(100,100,50);
javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_circle(100,100,50,"#444","#eee",2);
javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_circle(100,100,50,"#FF5722","#F0F4C3",2,[5,10]);

对draw_circle()的改进

可以看到对轮廓和填充样式的设定,以及调用fill()stroke()进行绘制,对于每个绘图函数封装都是必须且重复的,所以可以将代码提炼出来,作为单独的方法。

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    // ...
    // ================= 方法 =================
    // 设定绘图样式
    set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        ctx.beginPath();
        // 样式设定
        ctx.strokeStyle = border;      // 轮廓样式
        ctx.lineWidth = border_width;  // 轮廓线宽
        ctx.fillStyle = fill;          // 填充样式
        if(dash != null && dash.length>0){   //虚线样式
            ctx.setLineDash(dash);
        }
    }
    // 填充和轮廓绘制
    stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){
        let ctx =  this.ctx;
        if(border != null && border_width > 0){
            ctx.stroke();
        }
        if(fill != null){
            ctx.fill();
        }
    }
    draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        // 设定绘图样式
        this.set_draw_style(border,fill,border_width,dash);
        // 主体路径
        ctx.arc(cx,cy,radius,0,Math.PI * 2);
        // 填充和轮廓绘制
        this.stroke_and_fill(border,fill,border_width)
    }
}

进一步的还可以改进为:

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    // ...
    // ================= 方法 =================
    // 设定绘图样式
    set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        ctx.beginPath();
        // 样式设定
        ctx.strokeStyle = border;      // 轮廓样式
        ctx.lineWidth = border_width;  // 轮廓线宽
        ctx.fillStyle = fill;          // 填充样式
        if(dash != null && dash.length>0){   //虚线样式
            ctx.setLineDash(dash);
        }
    }
    // 填充和轮廓绘制
    stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){
        let ctx =  this.ctx;
        if(border != null && border_width > 0){
            ctx.stroke();
        }
        if(fill != null){
            ctx.fill();
        }
    }
    draw_circle(cx,cy,radius){
        let ctx =  this.ctx;
        // 主体路径
        ctx.arc(cx,cy,radius,0,Math.PI * 2);
    }
}

两种方式各有利弊吧,第二种形式更像是将原来的设定样式工作和填充和轮廓绘制工作整体封装起来,绘图函数的参数也可以更加简化。

我更倾向于第一种设计,因为每绘制一个图形只需要调用一个方法,而不是两个。

绘制折线和多边形


Javascript不定参数函数设计

在ES6之前,JavaScript中处理不定参数的常用方法是使用arguments对象,ES6引入了Rest参数,用来创建更清晰和简洁的代码。Rest参数通过在参数名前加上...来表示,它将所有剩余的参数收集到一个数组中。


javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
     // 直线和折线
    draw_polyline(points,close = false,border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        if(points.length>3 && points.length % 2 == 0){  // 至少有2个点,而且的数量是2的整数倍
            // 设定绘图样式
            this.set_draw_style(border,fill,border_width,dash);
            // 绘图线段
            for(let i=0;i<points.length;i++){
                if(i % 2 == 0){ //偶数项
                    let x = points[i];
                    let y = points[i+1];
                    this.ctx.lineTo(x,y);
                }
            }
            // 设定路径是否闭合
            if (close == true){this.ctx.closePath();}
             // 填充和轮廓绘制
            this.stroke_and_fill(border,fill,border_width)
        }

    }
    // 多边形
    draw_polygon(points,border = "#000",fill = "#fff",border_width = 1,dash = null){
        this.draw_polyline(points,true,border,fill,border_width,dash);
    }
}
javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_polyline([50,50,100,100,20,80])
javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_polyline([50,50,100,100,20,80],false,"#000","#abc",3,[5])
javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_polyline([50,50,100,100,20,80],true,"#000","#abc",3,[5])

也可以直接使用draw_polygon

javascript 复制代码
// draw.js
var canvas = new Canvas2D(200,200);
canvas.draw_polygon([50,50,100,100,20,80],"#000","#abc",3,[5])

鼠标事件监听

可以使用以下两种方式监听鼠标事件:

javascript 复制代码
canvas.canvas.onmousedown = function(e){
    // 事件处理代码
}
canvas.canvas.addEventListener("mousedown",function(e){
    // 事件处理代码
})

clientX和clientY

Event对象有clientX和clientY两个属性:

javascript 复制代码
canvas.canvas.addEventListener("mousemove",function(e){
    console.log(e.clientX,e.clientY);
})

上面的代码,只有鼠标再canvas的矩形范围内才会在控制台输出,但是打印的clientX和clientY是基于整个页面的,而不是canvas自己的坐标,所以我们还需要转化一下。


获取HTML元素的矩形

Element.getBoundingClientRect() 方法返回一个 DOMRect 对象,其提供了元素的大小及其相对于视口的位置。

是包含整个元素的最小矩形(包括 paddingborder-width)。该对象使用 lefttoprightbottomxywidthheight 这几个以像素为单位的只读属性描述整个矩形的位置和大小。除了 widthheight 以外的属性是相对于视图窗口的左上角来计算的。


改成如下形式,就可以获取canvas内的局部坐标了:

javascript 复制代码
var canvas = new Canvas2D(200,200);

canvas.canvas.addEventListener("mousemove",function(e){
    var rect = canvas.canvas.getBoundingClientRect();
    console.log(e.clientX - rect.x,e.clientY - rect.y);
})

封装方法,简化代码:

javascript 复制代码
// 2D canvas 辅助类
class Canvas2D{
    constructor(width,height,p_node = document.body){...}
    // ================= 方法 =================
    get_rect(){ // 获取矩形边界框
        return this.canvas.getBoundingClientRect();
    }
    to_local(x,y){ // 将全局坐标转换为canvas局部坐标
        var rect = this.get_rect();
        return [x - rect.x,y - rect.y];
    }
    draw_circle(cx,cy,radius,border = "#000",fill = "#fff"){...}
    draw_polyline(border,fill,close = false,...positions){...}
    draw_polygon(border,fill,...positions){...}
}

测试代码改写为:

javascript 复制代码
var canvas = new Canvas2D(200,200);

canvas.canvas.addEventListener("mousemove",function(e){
    console.log(canvas.to_local(e.clientX,e.clientY));
})

画布清除

clearRect

javascript 复制代码
// 2D canvas 辅助类
class Canvas2D{
    constructor(width,height,p_node = document.body){...}
    // ================= 方法 =================
    get_rect(){...}
    to_local(x,y){...}
    clear(){ // 清空画布
        this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height)
    }
    draw_circle(cx,cy,radius,border = "#000",fill = "#fff"){...}
    draw_polyline(border,fill,close = false,...positions){...}
    draw_polygon(border,fill,...positions){...}
}

测试代码:

javascript 复制代码
var canvas = new Canvas2D(200,200);
canvas.draw_circle(100,100,50);

canvas.canvas.addEventListener("mousedown",function(e){
    canvas.clear();
})

初始绘制一个圆,点击画布后清空。

绘制水平和垂直间隔线

javascript 复制代码
var canvas = new Canvas2D(200,200);
canvas.draw_hlines(6);
javascript 复制代码
var canvas = new Canvas2D(200,200);
canvas.draw_hlines(6);
canvas.draw_vlines(6);

:::tips
Edge浏览器对canvas另存为和复制的支持

现在的Edge浏览器好像直接支持将canvas绘制的内容当做是类似img插入的图片,可以直接另存和复制。这相当于,你拥有了基于浏览器的图片创建能力。

:::

javascript 复制代码
var canvas = new Canvas2D(200,200);
canvas.draw_hlines(6,"#ccc");
canvas.draw_vlines(6,"#ccc");
javascript 复制代码
var canvas = new Canvas2D(200,200);
canvas.draw_hlines(30,"#ccc");
canvas.draw_vlines(30,"#ccc");
canvas.draw_hlines(6,"#444");
canvas.draw_vlines(6,"#444");

动态绘制辅助线

javascript 复制代码
var canvas = new Canvas2D(200,200);
draw(); //初始绘制

function draw(){ //主体绘制部分
    canvas.draw_hlines(30,"#ccc");
    canvas.draw_vlines(30,"#ccc");
    canvas.draw_hlines(6,"#444");
    canvas.draw_vlines(6,"#444");
}

// 鼠标移动
canvas.canvas.addEventListener("mousemove",function(e){
    canvas.clear();
    draw();
    canvas.draw_help_lines(e.clientX,e.clientY);
})

绘制图片

javascript 复制代码
// draw.js
// 创建并添加一个canvas
let canvas = document.createElement("canvas");
canvas.width = 1200;
canvas.height = 600;
document.body.appendChild(canvas);
let ctx = canvas.getContext("2d");


function draw(){
    const img = new Image();
    
    img.onload = function(){
        ctx.drawImage(img,20,20,300,300)
    }
    img.src = "godot.png";
}

draw();

全屏

requestAnimationFrame

前端 - 浅析requestAnimationFrame的用法与优化 - 个人文章 - SegmentFault 思否

javascript 复制代码
const animation = () => {
  // 绘制代码
  requestAnimationFrame(animation);  //结束后调用
}

requestAnimationFrame(animation);   // 第一次调用

基于requestAnimationFrame的动画

javascript 复制代码
// draw.js
// 创建并添加一个canvas
let canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 200;
document.body.appendChild(canvas);
let ctx = canvas.getContext("2d");

// 矩形的起点和宽高
let x= 0;
let y= 0;
let w= 50;
let h= 50;
var deltaX = 1;

// 绘制函数
function draw(){
    // 绘制逻辑
    ctx.clearRect(0,0,canvas.width,canvas.height); // 清除上一帧绘制内容
    if(x > canvas.width - w || x < 0){
        deltaX *= -1;
    }
    x += deltaX;
    ctx.fillRect(x,y,w,h)
    // 下一帧
    window.requestAnimationFrame(draw); // 调用绘制函数
}


window.requestAnimationFrame(draw);  // 调用绘制函数

实现了动画:

save()和restore()

canvas理解:一看就懂的save和restore_canvas save restore-CSDN博客

  • save()保存上下文的边线、填充以及线性变换状态,每次保存状态压入栈内
  • restore()弹出并恢复栈顶的上下文状态
javascript 复制代码
// draw.js
// 创建并添加一个canvas
let canvas = document.createElement("canvas");
canvas.width = 200;
canvas.height = 200;
document.body.appendChild(canvas);
let ctx = canvas.getContext("2d");

ctx.fillStyle = "red"
ctx.fillRect(0,0,100,100);
ctx.save(); // 保存状态


ctx.translate(50,50);
ctx.fillStyle = "yellow"
ctx.fillRect(0,0,100,100);

ctx.restore() //恢复状态
ctx.fillStyle = "green"
ctx.fillRect(0,0,50,50);
  • ctx.translate(50,50);是对画布的绘制位置 进行了偏移,类似于GDScript中CanvasItemset_tramsform()用法。

完整代码

javascript 复制代码
// Canvas2D.js
// 2D canvas 辅助类
class Canvas2D{
    constructor(width,height,p_node = document.body){
        this.canvas = document.createElement("canvas");
        this.ctx = this.canvas.getContext("2d");
        this.canvas.width = width;
        this.canvas.height = height;
        p_node.append(this.canvas);
    }
    set width(val){
        this.canvas.width = val;
    }
    get width(){
        return this.canvas.width;
    }
    set height(val){
        this.canvas.height = val;
    }
    get height(){
        return this.canvas.height;
    }
    // ================= 绘图样式 =================
    // 设定绘图样式
    set_draw_style(border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        ctx.beginPath();
        // 样式设定
        ctx.strokeStyle = border;      // 轮廓样式
        ctx.lineWidth = border_width;  // 轮廓线宽
        ctx.fillStyle = fill;          // 填充样式
        if(dash != null && dash.length>0){   //虚线样式
            ctx.setLineDash(dash);
        }else{
            ctx.setLineDash([]);
        }
    }
    // 填充和轮廓绘制
    stroke_and_fill(border = "#000",fill = "#fff",border_width = 1){
        let ctx =  this.ctx;
        if(border != null && border_width > 0){
            ctx.stroke();
        }
        if(fill != null){
            ctx.fill();
        }
    }
    // ================= 基础图形绘制 =================
    // 圆
    draw_circle(cx,cy,radius,border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        // 设定绘图样式
        this.set_draw_style(border,fill,border_width,dash);
        // 主体路径
        ctx.arc(cx,cy,radius,0,Math.PI * 2);
        // 填充和轮廓绘制
        this.stroke_and_fill(border,fill,border_width)
    }
    // 直线和折线
    draw_polyline(points,close = false,border = "#000",fill = "#fff",border_width = 1,dash = null){
        let ctx =  this.ctx;
        if(points.length>3 && points.length % 2 == 0){  // 至少有2个点,而且的数量是2的整数倍
            // 设定绘图样式
            this.set_draw_style(border,fill,border_width,dash);
            // 绘图线段
            for(let i=0;i<points.length;i++){
                if(i % 2 == 0){ //偶数项
                    let x = points[i];
                    let y = points[i+1];
                    this.ctx.lineTo(x,y);
                }
            }
            // 设定路径是否闭合
            if (close == true){this.ctx.closePath();}
             // 填充和轮廓绘制
            this.stroke_and_fill(border,fill,border_width)
        }

    }
    // 多边形
    draw_polygon(points,border = "#000",fill = "#fff",border_width = 1,dash = null){
        this.draw_polyline(points,true,border,fill,border_width,dash);
    }
    // ================= 矩形与坐标 =================
    get_rect(){ // 获取矩形边界框
        return this.canvas.getBoundingClientRect();
    }
    full_screen(){ // 全屏 - 设置canvas的尺寸为页面尺寸
       this.width =  window.innerWidth;
       this.height =  window.innerHeight;
    }
    to_local(x,y){ // 将全局坐标转换为canvas局部坐标
        var rect = this.get_rect();
        return [x - rect.x,y - rect.y];
    }
     // ================= 矩形与坐标 =================
    clear(){ // 清空画布
        this.ctx.clearRect(0,0,this.width,this.height)
    }
    // ================= 网格线 =================
    draw_hlines(num,border = "#000",border_width = 1,dash = null){  // 绘制水平间隔线
        const dh = this.height / num;
        const w = this.width;
        for(let i =0;i<num+1;i++){
            this.draw_polyline([0,dh * i,w,dh * i],false,border,null,border_width,dash)
        }
    }
    draw_vlines(num,border = "#000",border_width = 1,dash = null){  // 绘制垂直间隔线
        const dw = this.width / num;
        const h = this.height;
        for(let i =0;i<num+1;i++){
            this.draw_polyline([dw * i,0,dw * i,h],false,border,null,border_width,dash)
        }
    }
    // ================= 辅助线 =================
    draw_help_lines(x,y,border = "orange",border_width = 1,dash = null){ // 绘制水平和垂直辅助线
        const h = this.height;
        const w = this.width;
        const local = this.to_local(x,y);
        this.draw_polyline([0,local[1],w,local[1]],false,border,null,border_width,dash)
        this.draw_polyline([local[0],0,local[0],h],false,border,null,border_width,dash)
    }

}
相关推荐
磨十三4 分钟前
HTML 基础
前端·html
张志明45619 分钟前
2025-3-13 react中做样式穿透,在index.module.less中修改antd全局样式,不影响其他组件
前端
alpha_xiao19 分钟前
css 知识点整理
前端·css
江西谢霆锋19 分钟前
css -学习
前端·css·学习
qianmoQ23 分钟前
第一章:Tailwind CSS基础与项目设置 - 第一节:Tailwind CSS入门 - 核心理念与工作流
前端·css
江西谢霆锋26 分钟前
css3-学习
前端·学习·css3
猫老板的豆35 分钟前
npm、pnpm、cnpm、yarn、npx之间的区别
前端·npm·node.js
西柚i1 小时前
开发工具链的智能化重构
前端·javascript
MariaH1 小时前
JavaScript ES5 实现继承
前端
前端康师傅1 小时前
CSS基础教程-布局
前端·css