本文已参与「新人创作礼」活动,一起开启掘金创作之路。 点击查看活动详情
写在前面
最近看了好几篇万字长文的canvas
教程,除了介绍了canvas
是干嘛的,无一例外都是调了一下canvas
的api
画了几个矩形、圆形、线段什么的,看完之后能用canvas
做点啥还是没有概念,所以打算用canvas
做几个小应用,实际的感受一下它的威力。
一个简单的流程图
拖拽节点,编辑节点信息,绑定自定义数据,通过连线元素连接节点
一个雪碧图工具
选择图片拖拽位置合成图,生成样式代码和配置信息,通过配置信息可以再次编辑
功能点
不同于常规的前端开发,canvas
是没有提供事件机制和自动更新机制的,也就是说开发者要自行实现这些功能(虽然很多库都提供能了这样的功能),如果没有这些基础的功能,在canvas
上绘制的图形既不能交互,也不能更新。
- 舞台刷新
- 事件(
mouseup
,mousedown
,mousemove
) - 自定义事件(可以给画布上的元素添加自定义事件)
- 添加/移除/更新元素
- 组合容器(把几个元素组合起来,看成一个整体进行操作)
舞台更新
在常规开发中,我们更改了一个dom
的信息或者样式,除了该dom
本身的样式更新,浏览器也会根据dom
树和css
来处理其他的dom
节点,使得整个应用显示正常,但是在canvas
中,它真的就是一块布,想更新其中某个元素,只能把它擦除了再绘制一个新的,而且在擦除的过程中还有可能擦除别的元素,所以实际上canvas
的应用方式大多是不停的把canvas全部擦除,再把所有的元素绘制一遍,包括更新的和没更新的,也就是说我们需要记录画布上所有元素的信息。
// 舞台
class Stage {
constructor() {
// 初始化
}
// 清除整块画布
clear() {
this.ctx.clear(0, 0, this.canvas.width + 1, this.canvas.height + )
}
render() {
// 以浏览器刷新的频率更新舞台
requestAnimationFrame(Stage.prototype.render.bind(this));
this.clear()
// 再绘制所有元素
}
}
事件
同样的,绘制的元素也需要有交互,我们没办法直接给画布上的元素添加事件,只能通过canvas
的交互事件传递给元素。
当canvas
触发mousedown
的时候,我们找到画布上在这个范围类最顶层的元素,认为点击到了这个元素,此时添加mousemove
事件
当canvas
触发mousemove
的时候,把move
信息同时传递给点击元素
当canvas
触发mouseup
的时候,传递mouseup
信息,同时清除mousemove
事件
// 添加mousedown事件
this.canvas.addEventListener("mousedown", (e) => {
/*
根据鼠标所在的xy找到这个位置且最上层的元素
每个类型都实现一个判断是否点击到自身的方法,这个类需要自行定义,如矩形,除了绘制之外,还添加额外的判断
例如一个矩形,this.x <= x && this.y <= y && this.x + this.w >= x && this.y + this.h >= y;
*/
let clickElements = this.children.filter((item) => {
return item.pointInElement && item.pointInElement(e.offsetX, e.offsetY, this.ctx);
});
// 在找到zindex最大的那个,我们在添加元素的时候,会给每个元素一个递增的zindex,后加的认为在上层
let target = clickElements.find((item) =>
item.zindex == Math.max(...clickElements.map((item) => item.zindex)));
// 如果找到了,那么就触发这个元素的mousedown事件,否则就是点击了canvas空白处
if(target) {
this.target = target;
this.target.dispatchEvent && this.target.dispatchEvent("click");
// 处理mousemove
this.canvas.addEventListener("mousemove", this.mouseMove, false);
}
});
// 添加mousemove事件
mouseMove = (e) => {
// 触发元素的mousemove事件
// 每个类除了定义自身的绘制,还会定义一个更新自身xy的方法,在move的时候触发
this.target.updatePosition &&
this.target.updatePosition(this.clickX + moveX - this.targetDx, this.clickY + moveY - this.targetDy)
}
// 处理mouseup
document.addEventListener("mouseup", () => {
this.target && this.target.dispatchEvent && this.target.dispatchEvent("mouseup");
this.canvas.removeEventListener("mousemove", this.mouseMove, false);
});
这样就把canvas的点击事件都传递给画布上的元素上,这个元素不是原生的canvas api绘制的,而是进行了一层封装,除了绘制之外,还要增加一点逻辑来响应Stage,因为我们要记住每个实例的信息,用来重绘,如矩形Rect
class Rect {
constructor(options) {
this.x = options.x
this.y = options.y
this.w = options.w
this.h = options.h
this.offsetX = options.offsetX;
this.offsetY = options.offsetY;
this.color = options.color
}
draw(ctx) {
ctx.beginPath();
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.w, this.h);
}
// 判断点是否在矩形内,用来响应点击事件
pointInElement(x, y) {
return this.x <= x && this.y <= y && this.x + this.w >= x && this.y + this.h >= y;
}
// 更新
updatePosition(x, y) {
this.x = x + this.offsetX;
this.y = y + this.offsetY;
}
}
自定义事件
前面已经把canvas的事件都传递到元素上了,接下来只需要在元素实例化的时候添加自定义的处理就可以了,比如一个简单的console
// rect响应Stage的自定义事件
class Rect {
// 省略
addEvent(key, fn) {
this.event[key] = this.event[key] || [];
this.event[key].push(fn);
}
dispatchEvent(key) {
this.event[key] && this.event[key].forEach((item) => item(this));
}
}
let r = new Rect({
x: 0,
y: 0,
w: 100,
h: 100,
color: "red"
})
r.addEvent("click", (t) => {
console.log("你点击了rect")
})
添加/移除/更新元素
由于我们设定了Stage
的刷新机制,所以所有的元素都交给Stage
来管理,直接调用canvas api
绘制的内容是不是呈现在canvas
上的
class Stage {
// 省略
add(child) {
this.children.push(child);
child.parent = this;
this.render();
}
remove(child) {
let index = this.children.findIndex((item) => item.id == child.id)
index != -1 && this.children.splice(index, 1);
}
}
组合容器
重点来了,很多时候我们要描述的内容并不是一个孤零零的对象,而是一个复杂的组合体,比如一个简单的文字板,是有一个文本和一个背景板组成的,当我们移动它的时候,指的是同时移动背景板和文字。 所以设计一个组合容器,可以往里面添加元素,各元素之间基于容器保持相对位置,那么操作这个组合就操作了里面所有的元素了。
// 组合容器
class Container {
constructor(options) {
// 初始化xywh等信息
}
// 添加元素到容器里
add(child) {
child.parent = this;
this.children.push(child);
}
draw(ctx) {
// 绘制容器里的所有元素
this.children.forEach(item => {
// 容器里的元素根据相对位置更新xy
item.updatePosition(this.x, this.y);
// 调用元素本身的绘制
item.draw(ctx);
})
}
}
实现一个文字板
- 一个文本元素Text
- 一个矩形背景框Rect
- 一个矩形边框Rect
- 一个删除按钮Icon
- 删除按钮点击销毁整个组件
import Container from "./Container"; // 组合容器
import Icon from "./Icon"; // 图片类
import Rect from "./Rect"; // 矩形类
import Stage from "./Stage"; // 舞台
import Word from "./Word"; // 文字类
class TextElm {
constructor(option) {
this.container = new Container({
x: option.x,
y: option.y,
w: option.w,
h: option.h
});
this.bg = new Rect({
x: 0,
y: 0,
w: option.w,
h: option.h,
offsetX: 0,
offsetY: 0,
color: "pink"
})
this.icon = new Icon({
offsetX: option.w - 20,
offsetY: 0,
w: 20,
h: 20,
src: "./close.png"
}); // icon固定在容器右上角
this.icon.addEvent("click", (t) => {
this.container.destory();
});
this.word = new Word({
text: option.text,
offsetX: 0,
offsetY: option.h / 2,
color: "blue"
}); // 文本垂直居中
this.container.add(this.bg)
this.container.add(this.icon);
this.container.add(this.word);
return this.container
}
}
使用
import Stage from "./Stage";
import TextElm from "./TextElm";
// 初始化一个800 * 700的舞台
let s2 = new Stage(document.getElementById("stage"));
// 在 (50, 50)的位置初始化一个200 * 50的文字板
let t1 = new TextElm({
text: "hello 解决1",
x: 350,
y: 250,
w: 200,
h: 50,
color: "blue",
parent: s2
});
s2.add(t1);
// 在 (300, 300)的位置初始化一个200 * 50的文字板
let t2 = new TextElm({
text: "world 哈哈2",
x: 300,
y: 400,
w: 200,
h: 50,
color: "blue",
parent: s2
});
s2.add(t2);
// 在 (300, 100)的位置初始化一个200 * 50的文字板
let t3 = new TextElm({
text: "多连线3",
x: 300,
y: 100,
w: 200,
h: 50,
color: "blue",
parent: s2[代码片段](https://code.juejin.cn/pen/7130565445010063391)
});
s2.add(t3);
这样,一个简单的canvas库就完成了,通过组合容器和自定义事件,封装好基础的元素,可以只关注业务逻辑的实现和交互。