手撸一个多功能 Canvas 水印

1,036 阅读3分钟

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

watermark.gif

实现原理

将 Canvas 转化成一个包含 PNG 图片展示的 data URI,再将其作为容器元素的背景图片。

开始绘画

有了指导思想,我们以整个 document.body 为背景,画个水印出来。在此之前,先实现一个 Canvas 转化成 data URI 的方法。

createDataURL() 图片生成函数

Canvas 元素通过调用 getContext('2d') 来获取 CanvasRenderingContext2D 上下文。而 CanvasRenderingContext2D 接口作为 Canvas API 的一部分,用来完成实际的图像绘制。

CanvasRenderingContext2D 拥有很多的属性和方法,稍后会对用到的几种做出解析,以下是代码实现:

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() 来实现。

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() 初始化函数

此函数用于处理容器、渲染水印、挂载节点。用户通过它来初始化水印。

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() 来稍微调整下。

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,并支持参数可调:

function createDataURL() {
    // other code ...
    if (ctx) {
        const { globalAlpha = 0.7 } = restAttrs;
        ctx.globalAlpha = globalAlpha;
        // other code ...
    }
}

支持阴影

控制阴影,需要用到 shadowBlur(模糊效果程度)、shadowColor(阴影颜色)、shadowOffsetX(阴影水平偏移距离)、shadowOffsetY(阴影垂直偏移距离) 四种属性。

需要注意,想绘制阴影,shadowColor 必须设置。

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) 需要指定四个参数,分别表示渐变线段的开始和结束点。这里,我们仅需水平方向上的渐变,所以 y0y1 都设为 0,x1 设为 Canvas 的宽。让它在自己宽度范围内渐变。

createLinearGradient() 返回一个线性 CanvasGradient 对象。该对象只有一个 addColorStop 方法,专门用来添加一个由偏移值和颜色值指定的断点到渐变。

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 表示绘制一个从绿色到白色水平渐变的长方形:

gradient.png

最后,想要应用这个渐变,还得把线性对象赋值给 fillStyle

回到 createDataURL(),我们支持传入一个类型为 { value: number; color: string }lineGradient 数组,用来接收多个渐变:

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 将他们聚合到一起,让它们更好的紧密合作。

维护公共部分

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);
        }
    }
}

我们做了三件事:

  1. 指定 Canvas 的初始默认值 defaultCanvasAttrs
  2. title(水印名)、container(水印容器)、wrapper(挂载节点)、canvasAttrs(Canvas 属性) 提取出来,作为公共部分维护;
  3. 在 Class 实例化期间合并所有配置项。

重新整理方法

针对 createDataURL() 做如下整合:

  • 所有 Canvas 属性都从 this.canvasAttrs 中获取;
  • 所有 Canvas 扩展功能都在此实现。
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 没有变化,此时就需要强制渲染。
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() 做如下整合:

  • containerwrapper 都直接从 this 中获取。
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() 中备份一份初始配置。

export default class Watermark {
    private _initCanvasAttrs: CanvasAttributes = {};

    constructor(options: WatermarkOptions) {
        if (isObject(canvasAttrs)) {
            // other code ...
            this._initCanvasAttrs = clone(initCanvasAttrs);
        }
    }
}

然后在调用时重新赋值给 canvasAttrs 并渲染。

reset() {
    this.canvasAttrs = clone(this._initCanvasAttrs);
    this.render({ forceRender: true });
}

clear() 表示删除水印,移除所有配置项并初始化成默认值。一旦删除,就得重新执行 new Watermark()

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,并给水印添加窗口自适应功能。

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

参考资料