手撸一个多功能 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 可以轻松帮助我们实现窗口自适应。

参考资料

相关推荐
喵叔哟2 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特43 分钟前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解44 分钟前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django