今天我们来实现一个常用的水印功能,效果如下图所示。
实现原理
将 Canvas 转化成一个包含 PNG 图片展示的 data URI
,再将其作为容器元素的背景图片。
开始绘画
有了指导思想,我们以整个 document.body
为背景,画个水印出来。在此之前,先实现一个 Canvas 转化成 data URI
的方法。
createDataURL() 图片生成函数
Canvas 元素通过调用 getContext('2d')
来获取 CanvasRenderingContext2D
上下文。而 CanvasRenderingContext2D
接口作为 Canvas API 的一部分,用来完成实际的图像绘制。
CanvasRenderingContext2D
拥有很多的属性和方法,稍后会对用到的几种做出解析,以下是代码实现:
js
function createDataURL(title, canvasAttrs) {
const { width = 240, height = 160, ...restAttrs } = this.canvasAttrs;
// step1: 创建 Canvas 元素
const canvas = document.createElement('canvas');
Object.assign(canvas, { width, height });
// step2: 绘制 canvas
const ctx = canvas.getContext('2d');
if (ctx) {
const startPointX = width / 5;
const startPointY = height / 2;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = '15px Reggae One';
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
ctx.fillText(title, startPointX, startPointY);
}
// step3: 生成 data URI
return canvas.toDataURL('image/png');
}
第一步,创建 Canvas 元素,并设置其宽高;
第二步,获取 Canvas 上下文,绘制了传入的水印 title,用到以下属性和方法:
属性(方法) | 说明 |
---|---|
textAlign | 文本的对齐方式 |
textBaseline | 决定文字垂直方向的对齐方式 |
font | 字体样式 |
fillStyle | 设置颜色和样式 |
fillText(text, x, y, maxWidth) | 在指定的坐标上绘制文本字符串,并使用 fillStyle 填充 |
第三步,使用 toDataURL() 获取到包含图片的 data URI
。
render() 渲染函数
有了水印 URI,还需要一个装水印的容器,将 URI 作为容器的背景图片。
我们再实现一个 render()
渲染函数,所有与水印调整相关的操作都通过 render()
来实现。
js
function render(container, options = {}) {
const { title, containerWidth, containerHeight } = options;
if (container instanceof HTMLElement) {
// 调整容器大小
if (containerWidth) container.style.width = containerWidth + 'px';
if (containerHeight) container.style.height = containerHeight + 'px';
if (title) {
// 生成背景图片
const url = createDataURL(title);
container.style.background = `url(${url}) left top repeat`;
}
// other code ...
}
}
除了调整容器,后续修改 title、canvas 都可以往里塞。
set() 初始化函数
此函数用于处理容器、渲染水印、挂载节点。用户通过它来初始化水印。
js
function set(container) {
if (!(container instanceof HTMLElement)) {
const div = document.createElement('div');
div.id = 'watermark-dom';
div.style.pointerEvents = 'none';
div.style.top = '0px';
div.style.left = '0px';
div.style.position = 'absolute';
div.style.zIndex = '100000';
container = div;
}
document.bodystyle.position = 'relative';
render({
containerWidth: document.body.clientWidth,
containerHeight: document.body.clientHeight,
});
document.body.appendChild(container);
}
set()
支持传入 container
,但如果没有传递,它将自动创建一个 div
容器。
set()
内部会调用 render()
函数渲染水印,并最终将水印容器挂载到 document.body
上。
一个简单的全局水印就制作完成了。
多功能扩展
现在的水印功能还很单一,我们让它支持更多的功能。
支持旋转
rotate() 是 Canvas 2D API 在变换矩阵中增加旋转的方法。
它接收一个顺时针旋转的弧度参数。如果想通过角度值计算,可以使用公式:degree * Math.PI / 180
。
它的旋转中心点一直是 canvas 的起始点。这里我们需要通过 translate() 来稍微调整下。
js
function createDataURL() {
// other code ...
if (ctx) {
const startPointX = width / 5;
const startPointY = height / 2;
const { rotate } = restAttrs;
if (rotate) {
ctx.translate(-startPointX, startPointY);
ctx.rotate((-rotate * Math.PI) / 180);
}
}
ctx.fillText(title, startPointX, startPointY);
}
请注意: 确保 fillText()
总是最后调用。
支持透明度
globalAlpha 设置图形和图片透明度的属性。数值的范围从 0.0(完全透明)到 1.0(完全不透明)。
作为水印,本身就应该有一些透明度,我们默认为 0.7,并支持参数可调:
js
function createDataURL() {
// other code ...
if (ctx) {
const { globalAlpha = 0.7 } = restAttrs;
ctx.globalAlpha = globalAlpha;
// other code ...
}
}
支持阴影
控制阴影,需要用到 shadowBlur(模糊效果程度)、shadowColor(阴影颜色)、shadowOffsetX(阴影水平偏移距离)、shadowOffsetY(阴影垂直偏移距离) 四种属性。
需要注意,想绘制阴影,shadowColor
必须设置。
js
function createDataURL() {
// other code ...
if (ctx) {
const {
shadowColor = 'rgba(0, 0, 0, 0.7)',
shadowBlur = 0,
shadowOffsetX = 10,
shadowOffsetY = 5,
} = restAttrs;
if (shadowBlur) {
ctx.shadowBlur = shadowBlur;
ctx.shadowColor = shadowColor;
ctx.shadowOffsetX = shadowOffsetX;
ctx.shadowOffsetY = shadowOffsetY;
}
// other code ...
}
}
支持线性渐变
线性渐变稍微有点麻烦,需要先调用 createLinearGradient() 方法。
createLinearGradient(x0, y0, x1, y1)
需要指定四个参数,分别表示渐变线段的开始和结束点。这里,我们仅需水平方向上的渐变,所以 y0
和 y1
都设为 0,x1
设为 Canvas 的宽。让它在自己宽度范围内渐变。
createLinearGradient()
返回一个线性 CanvasGradient 对象。该对象只有一个 addColorStop 方法,专门用来添加一个由偏移值和颜色值指定的断点到渐变。
js
var ctx = canvas.getContext("2d");
var gradient = ctx.createLinearGradient(0, 0, 200, 0);
gradient.addColorStop(0, "green");
gradient.addColorStop(1, "white");
ctx.fillStyle = gradient;
ctx.fillRect(10, 10, 200, 100);
上述 demo 表示绘制一个从绿色到白色水平渐变的长方形:
最后,想要应用这个渐变,还得把线性对象赋值给 fillStyle
。
回到 createDataURL()
,我们支持传入一个类型为 { value: number; color: string }
的 lineGradient
数组,用来接收多个渐变:
js
function createDataURL() {
// other code ...
if (ctx) {
const { lineGradient } = restAttrs;
if (Array.isArray(lineGradient)) {
const gradient = ctx.createLinearGradient(0, 0, width, 0);
lineGradient.forEach(({ value, color }) => {
gradient.addColorStop(value, color);
});
ctx.fillStyle = gradient;
}
// other code ...
}
}
封装灵活的通用 Class 库
现在,这些几个函数还很零散,有些配置是写死的(比如只能给 body
添加水印)不够灵活,还有些配置(比如 title、container、Canvas 属性等)是共用的,完全可以抽取出来维护一份。
让我们通过 class
将他们聚合到一起,让它们更好的紧密合作。
维护公共部分
ts
import { clone } from 'lodash-es';
import { getElement, type Container } from './utils';
// 初始的 Canvas 属性
const defaultCanvasAttrs: CanvasAttributes = {
width: 240,
height: 160,
font: '15px Reggae One',
fillStyle: 'rgba(0, 0, 0, 0.4)',
};
export default class Watermark {
private readonly domSymbol = Symbol('watermark-dom');
title = '';
container: Element | null = null;
wrapper = document.body;
canvasAttrs = clone(defaultCanvasAttrs);
constructor(options: WatermarkOptions) {
const { title, container, wrapper, canvasAttrs } = options;
if (title) this.title = String(title);
const cont = getElement(container);
if (cont) this.container = cont;
const wrap = getElement(wrapper);
if (wrap) this.wrapper = wrap;
this.wrapper.style.position = 'relative';
if (isObject(canvasAttrs)) {
const initCanvasAttrs = Object.assign(this.canvasAttrs, canvasAttrs);
}
}
}
我们做了三件事:
- 指定 Canvas 的初始默认值
defaultCanvasAttrs
; - 将
title
(水印名)、container
(水印容器)、wrapper
(挂载节点)、canvasAttrs
(Canvas 属性) 提取出来,作为公共部分维护; - 在 Class 实例化期间合并所有配置项。
重新整理方法
针对 createDataURL()
做如下整合:
- 所有 Canvas 属性都从
this.canvasAttrs
中获取; - 所有 Canvas 扩展功能都在此实现。
ts
export default class Watermark {
// other code ...
createDataURL() {
const { width = 240, height = 160, ...restAttrs } = this.canvasAttrs;
const canvas = document.createElement('canvas');
Object.assign(canvas, { width, height });
const ctx = canvas.getContext('2d');
if (ctx) {
const startPointX = width / 5;
const startPointY = height / 2;
const {
font,
fillStyle,
globalAlpha = 0.7,
rotate = 30,
shadowBlur = 0,
shadowColor = 'rgba(0, 0, 0, 0.7)',
shadowOffsetX = 10,
shadowOffsetY = 5,
lineGradient,
} = restAttrs;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.font = font as string;
ctx.globalAlpha = globalAlpha;
ctx.fillStyle = fillStyle as string;
// 旋转
if (rotate) {
ctx.translate(-startPointX, startPointY);
ctx.rotate((-rotate * Math.PI) / 180);
}
// 阴影(shadowBlur 不为 0,才会绘制)
if (shadowBlur) {
ctx.shadowBlur = shadowBlur;
ctx.shadowColor = shadowColor;
ctx.shadowOffsetX = shadowOffsetX;
ctx.shadowOffsetY = shadowOffsetY;
}
// 线性渐变
if (isArray(lineGradient)) {
const gradient = ctx.createLinearGradient(0, 0, width, 0);
lineGradient.forEach(({ value, color }) => {
gradient.addColorStop(value, color);
});
ctx.fillStyle = gradient;
}
ctx.fillText(this.title, startPointX, startPointY);
}
return canvas.toDataURL('image/png');
}
}
针对 render()
做如下整合:
- 支持
title
(水印名)、containerWidth
(容器宽)、containerHeight
(容器高)、canvasAttrs
(Canvas 配置项)、forceRender
(强制更新),5 种属性; - 其中,
forceRender
允许title
没有改变或canvasAttrs
没有传入新值的情况下,继续调用createDataURL()
,强制重新渲染 Canvas,默认为 false。比如初始化set()
时,title
没有变化,此时就需要强制渲染。
ts
export default class Watermark {
// other code ...
render({
title,
containerWidth: width,
containerHeight: height,
canvasAttrs,
forceRender = false,
}: RenderOptions = {}) {
if (this.container instanceof HTMLElement) {
let isRender = forceRender;
// container 宽高铺满 wrapper 挂载节点
if (width) this.container.style.width = width + 'px';
if (height) this.container.style.height = height + 'px';
// 更新 title
if (title) {
// eslint-disable-next-line no-param-reassign
title = String(title);
if (title !== this.title) {
this.title = title;
isRender = true;
}
}
// 更新 canvasAttrs
if (isObject(canvasAttrs)) {
Object.assign(this.canvasAttrs, canvasAttrs);
isRender = true;
}
// 强制更新、新 title、新 canvasAttrs 三种情况下会渲染 canvas
if (isRender) {
const url = this.createDataURL();
this.container.style.background = `url(${url}) left top repeat`;
}
}
}
}
针对 set()
做如下整合:
container
和wrapper
都直接从this
中获取。
ts
export default class Watermark {
// other code ...
set() {
if (!(this.container instanceof HTMLElement)) {
const div = document.createElement('div');
// 设置 div 属性,与上文相同,此处略...
this.container = div;
}
this.render({
containerWidth: this.wrapper.clientWidth,
containerHeight: this.wrapper.clientHeight,
forceRender: true,
});
this.wrapper.appendChild(this.container);
}
}
添加 reset() 重置 和 clear() 清空
reset()
表示恢复成初始化时的样子。为此,我们需在 constructor()
中备份一份初始配置。
ts
export default class Watermark {
private _initCanvasAttrs: CanvasAttributes = {};
constructor(options: WatermarkOptions) {
if (isObject(canvasAttrs)) {
// other code ...
this._initCanvasAttrs = clone(initCanvasAttrs);
}
}
}
然后在调用时重新赋值给 canvasAttrs
并渲染。
ts
reset() {
this.canvasAttrs = clone(this._initCanvasAttrs);
this.render({ forceRender: true });
}
clear()
表示删除水印,移除所有配置项并初始化成默认值。一旦删除,就得重新执行 new Watermark()
。
ts
clear() {
if (this.container instanceof HTMLElement && this.wrapper.contains(this.container)) {
this.wrapper.removeChild(this.container);
this.container = null;
this.wrapper = document.body;
this.title = '';
this.canvasAttrs = clone(defaultCanvasAttrs);
this._initCanvasAttrs = {};
}
}
现在,你可以在任何地方自由使用水印了!
如需完整代码,可参考👉 watermark | @zerozhang/utils 欢迎 start 🤞❤️
自适应
以 Vue3 为例,我们在项目中使用 Watermark,并给水印添加窗口自适应功能。
tsx
<script setup lang="ts">
import { onMounted } from 'vue';
import { useEventListener, useDebounceFn } from '@vueuse/core';
import { Watermark, RenderOptions } from '@zerozhang/utils';
defineOptions({ name: 'Watermark' });
const globalWatermark = ref<Watermark | null>(null);
const init = () => globalWatermark.value?.set();
// 自适应函数
const resize = useDebounceFn(
() => {
if (globalWatermark.value) {
globalWatermark.value.render({
containerWidth: globalWatermark.value.wrapper.clientWidth,
containerHeight: globalWatermark.value.wrapper.clientHeight,
});
}
},
500,
{ maxWait: 3000 }
);
// 注册自适应事件
useEventListener(window, 'resize', resize);
onMounted(() => {
globalWatermark.value = new Watermark({
title: 'hahahaha',
});
});
</script>
<template>
<button @click="init">init</button>
</template>
运用 Vueuse 提供的两个 hooks:useEventListener 和 useDebounceFn 可以轻松帮助我们实现窗口自适应。