手撸一个多功能 Canvas 水印

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

实现原理

将 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) 需要指定四个参数,分别表示渐变线段的开始和结束点。这里,我们仅需水平方向上的渐变,所以 y0y1 都设为 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);
        }
    }
}

我们做了三件事:

  1. 指定 Canvas 的初始默认值 defaultCanvasAttrs
  2. title(水印名)、container(水印容器)、wrapper(挂载节点)、canvasAttrs(Canvas 属性) 提取出来,作为公共部分维护;
  3. 在 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() 做如下整合:

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

参考资料

相关推荐
计算机魔术师1 分钟前
旅途中用手机远程控制电脑?电脑不在身边,ToDesk/UU远程/TeamViewer移动端深度实测
前端
风月说与山鬼15 分钟前
三、Tailwind CSS——排版与文字样式
前端·css·tailwind css
eokred29 分钟前
图片预览组件的另一种设计:Headless、可组合、开箱即用
前端·react
南雨北斗34 分钟前
解决web开发中前后端跨域通信(session失效问题)
前端
Y38153266243 分钟前
body-upload-js
前端·javascript·浏览器·深拷贝·数据克隆
Apifox1 小时前
Apifox 8 月更新|调试、权限与协作体验持续优化
前端·后端·测试
杉氧1 小时前
React 灵魂:深入剖析 Hooks 与副作用(Side Effects)陷阱
android·前端·react native
Json____1 小时前
java-宿舍安全卫生检查系统项目源码
java·前端·javascript·课程设计·it学习·wwwoop.com
lichenyang4531 小时前
鸿蒙ArkUI 键盘避让终极踩坑指南:RESIZE 无效、Column 布局遮挡、折叠屏适配全解
前端