今天我们来实现一个常用的水印功能,效果如下图所示。

实现原理
将 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 可以轻松帮助我们实现窗口自适应。