话不多说,先上效果!!
没错,就是水印。今天就来【step by step】手撸一个纯前端实现水印的方案。
本次纯前端实现的水印方案原理非常简单,就是把想要的水印文字或图片画到canvas上,然后把canvas添加到页面中就ok了,同时还加了一点点“魔法”, 就是防水印删除的能力。下面让我们一步一步把它写出来。
这里使用canvas, 就一个元素,所以就使用纯函数的方式,手动去创建canvas, 因此,是可以适用任何前端框架的。
我们使用class的方式来封装代码:
export class WaterMark {
// ...
constructor(option?: WaterMarkOption) {
// init params
this.id = option?.id || 'water-mark-id';
this.text = option?.text;
this.image = option?.image;
this.angle = option?.angle;
this.scale = option?.scale || 1;
this.fontColor = option?.fontColor || DEFAULT_COLOR;
this.fontSize = option?.fontSize || 16;
this.antiErase = option?.antiErase ?? true;
this.sparseness = option?.sparseness || 'normal';
this.initGap(option?.gapX, option?.gapY, this.sparseness);
}
}
private initGap(gapX?: number, gapY?: number, sparseness?: Sparseness) {
// ...
}
在构造函数中,我们仅仅是初始化一些参数。将创建和渲染的能力延迟处理。这样的好处之一是减轻了耦合度,同时也可以避免一些元素挂载错误的情况,因为水印的挂载点时是可以指定的(挂载到指定元素容器上或挂载到body上,也就是全屏水印)。
接口WaterMarkOption
如下:
// 水印稀疏程度: normal: 常规 sparse: 稀疏 compressed: 紧凑
export type Sparseness = 'normal' | 'sparse' | 'compressed';
export interface WaterMarkOption {
id?: string; // 水印容器id,默认为water-mark-id, 建议填写唯一id
text?: string; // 水印文字 (优先级高于图片) 默认为watermark
fontColor?: string; // 文字水印颜色
fontSize?: number; // 文字水印字体大小
image?: string; // 图片水印
scale?: number; // 图片缩放比例
angle?: number; // 水印旋转角度
gapX?: number; // 水印水平间距
gapY?: number; // 水印垂直间距
sparseness?: Sparseness;
antiErase?: boolean; // 是否开启防止擦除, 默认开启
}
必要的参数已经在构造函数初始化好了,下面就需要创建水印了:
export class WaterMark {
// ...
public create(el?: HTMLElement) {
this.el = el || document.body;
this.isInit = true;
this.canvas = this.addCanvas(el);
this.ctx = this.canvas.getContext('2d')!;
if (this.antiErase && this.isInit) {
this.antiEraseObserver();
}
return this;
}
}
create方法主要做了三件事,
- 确定挂载节点,如果缺省,就挂载到body上
- 添加canvas画布,并把画布添加到节点上
- 防水印删除功能
这里重点说添加canvas画布函数,addCanvas
, addCanvas会根据挂载节点的不同,设置不同的style信息。然后就是初始化canvas, initCanvas
, initCanvas函数做了适配高清屏的处理:
export class WaterMakr {
// ...
private addCanvas(el?: HTMLElement | null) {
const canvas = (document.getElementById(this.id) as HTMLCanvasElement) || document.createElement('canvas');
canvas.setAttribute('id', this.id);
if (!el) {
canvas.setAttribute('style', 'position:fixed; z-index: 9999; inset: 0; pointer-events:none;');
} else {
canvas.setAttribute('style', 'position:absolute; z-index: 9999; inset: 0; pointer-events:none;');
}
const width = this.el!.offsetWidth;
const height = this.el!.offsetHeight;
this.initCanvas({ canvas, width, height });
this.el!.appendChild(canvas);
return canvas;
}
}
private initCanvas(option: InitCanvasOption) {
const { canvas, width, height } = option;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
const ctx = canvas.getContext('2d')!;
ctx.imageSmoothingEnabled = true;
ctx.scale(dpr, dpr);
}
我们可以发现,create函数做的也就是初始化了canvas,画布上仍然有水印,画水印的能力会在render
函数中实现。为什么这么做呢?前面提到过,构造函数中渲染canvas可能会造成挂载节点不存在(如果指定了挂载节点)而导致挂载错误,把功能下放到create函数中,就可以在自由地在任何生命周期环节创建, 例如在react中,可以在组件渲染完成后再去create,这样就能保证挂载节点不会错误,同理,把渲染能分离也是同样道理(例如有时我们修改了水印的文字,只需要重新渲染,这样就可以在合适的时机调用render函数就可以了)。
render
函数做的就是把文字或图片渲染到canvas上:
render() {
if (!this.image) {
this.renderText(this?.text || 'watermark', this.angle);
} else {
this.renderImage(this.image, this.angle);
}
}
这里render函数会根据是否有图片参数来渲染图片还是文字,因为我们的水印是支持设置角度angle的,因此要对canvas进行旋转,这里有点小道道, 以渲染图片为例:
private renderImage(image: string, angle?: number) {
const img = new Image();
img.src = image;
img.crossOrigin = 'anonymous';
img.onload = () => {
if (!this.canvas || !this.ctx) return;
const width = img.width;
const height = img.height;
const rows = Math.floor(this.canvas.width / width) * window.devicePixelRatio;
const columns = Math.floor(this.canvas.height / height) * window.devicePixelRatio;
this.ctx.save();
this.ctx.rotate((angle || 0 * Math.PI) / 180);
this.ctx.translate(-this.canvas.width * this.scale, -this.canvas.height * this.scale);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < columns; c++) {
this.ctx.drawImage(
img,
r * this.gapX * width * this.scale,
c * this.gapY * height * this.scale,
width * this.scale,
height * this.scale,
);
}
}
this.ctx.restore();
};
}
canvas渲染图片,需要等到图片加载完成才能去处理,因此主要逻辑都在img.onload函数中。计算canvas的宽高和图片的宽高,以及水印之间的gap和缩放参数scale, 可以通过两个for循环,就可以把水印一行一行的画到canvas上。注意:这里要先save一下,然后才能通过ctx.rotate函数去旋转,最后渲染完成后再restore, 这样就实现了水印旋转。this.ctx.rotate((angle || 0 * Math.PI) / 180)
这里做了角度和弧度的转换,所以我们使用时只需要传入我们熟悉的角度值就可以了。
renderText的方法同理。
自此,我们在create完成后,调用render函数,就可以在页面上看到水印了。
不过,还有一个问题,canvas也是DOM元素,我可以F12打开控制面板,把这个元素删了,不就没有水印了吗?
所以,我们就加个防删除的功能,这个功能主要是通过MutationObserver
实现的, MutationObserver接口提供了监视对 DOM 树所做更改的能力,具体内容可以看一下MDN上的解释。
我们在create中已经添加的防删除功能,这个功能默认是开启的,当然也可以通过antiErase
参数关闭它。
private antiEraseObserver() {
const config = { attributes: true, childList: true, subtree: true };
const observer = new MutationObserver(() => {
const watermarkEl = document.getElementById(this.id);
if (!watermarkEl) {
this.create(this.el);
this.render();
}
});
observer.observe(document.body, config);
}
构建MutationObserver的实例,通过observer.observe(document.body, config)
来监听父元素,注意在新版的chrome浏览器中,监听canvas元素是无效的,只能监听它的父元素,config配置是观察子元素的那些属性,这里配置了监听属性,子节点的增删(childList), 子节点的元素属性变化(subtree)。
这里写的比较简单,仅观察canvas节点是否存在,不如果不存在,说明被移除了,就重新创建和渲染。
但是讲真,这个防删除的方法并不是万能的,想删除它而不出发重新绘制有很多方法,有兴趣的朋友可以自行研究。
到此,带有基础防删除功能,可定制的纯水印就完成了,为了方便使用,我特意暴露了一个函数watermarkFn
, 它会直接返回WaterMark实例,传入配置参数即可:
export function watermarkFn(option?: WaterMarkOption): WaterMark {
const waterMarkInstance = new WaterMark(option);
return waterMarkInstance;
}
当然,你也可以自己导入WaterMark类,创建多个实例去使用。
一个简单的react版本使用Demo:
import { FC, useEffect, useRef } from 'react';
// 一个轻量的mock数据的工具库
import TinyLorem from 'tiny-lorem';
import { watermarkFn } from 'filigrana';
const lorem = new TinyLorem();
const Home: FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const watermark = watermarkFn({
id: 'watermark-demo',
text: '水印-1234567890',
angle: 45,
fontColor: 'rgba(255,0,0,0.5)',
fontSize: 12,
sparseness: 'compressed',
});
useEffect(() => {
if (ref.current) {
watermark.create(ref.current).render();
}
});
return (
<div className="p-8">
<div ref={ref} className="p-2 border text-[#333] border-gray-300 rounded-sm relative overflow-hidden">
{lorem.texts.paragraph({ language: 'cn', range: 10 })}
</div>
</div>
);
};
export default Home;
最后放上github仓库,github.com/CiroLee/fil…
如果对有帮助请多多star哦~~
(ps:为什么是filigrana这个名字呢,因为watermark相关的包名都被占用了...😭😭)