【step by step】手撸一个纯前端水印功能

580 阅读6分钟

话不多说,先上效果!! image.png

没错,就是水印。今天就来【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方法主要做了三件事,

  1. 确定挂载节点,如果缺省,就挂载到body上
  2. 添加canvas画布,并把画布添加到节点上
  3. 防水印删除功能

这里重点说添加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相关的包名都被占用了...😭😭)