• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

很多网页会加上水印,用于版权标识、防止盗用等。

比如这样:

ant design 和 arco design 都提供了 Watermark 水印组件。

这种水印是咋实现的呢?

调试下就知道了:

arco design 的:

ant desigin 的:

首先,有一个 div 覆盖在需要加水印的区域,宽高 100%,绝对定位,设置 pointer-events:none 也就是不响应鼠标事件。

然后 background 设置 repeat,用 background image 平铺。

那这个 image 是什么呢?

点击这个 data url:

是个包含文字的图片。

而我们并没有传入图片作为参数:

所以说要用 canvas 画出来,做一些旋转,并导出 base64 的图片,作为这个 div 的背景就好了。

当然,也可以传入图片作为水印:

同样是用 canvas 画出来。

那怎么画呢?

根据传入的参数来画:

上面是 antd 的 Watermark 组件的参数。

可以传入宽高、旋转角度、字体样式、水印间距、水印偏移等。

虽然参数很多,但只是一些细节。

arco design 的 Watermark 组件画出的图片是上面的样子,所以 repeat 之后是这样的:

如果仔细看你会发现 ant design 的 Watermark 组件是这样的:

交错排列的。

这是因为它 canvas 画的内容就是交错的 2 个:

整体思路是很清晰的:用 canvas 把文字或者图片画出来,导出 base64 的 data url 设置为 div 的重复背景,这个 div 整个覆盖在需要加水印的元素上,设置 pointer-events 是 none。

此外,上节还讲过通过 MutationObserver 监听 dom 的修改,改了之后重新添加水印。

antd 就是这么做的:

思路理清了,我们来写下代码:

npx create-vite

去掉 index.css 和 StrictMode:

然后写下 Watermark/index.tsx

import { useRef, PropsWithChildren, CSSProperties, FC } from "react";

export interface WatermarkProps extends PropsWithChildren {
    style?: CSSProperties;
    className?: string;
    zIndex?: string | number;
    width?: number;
    height?: number;
    rotate?: number;
    image?: string;
    content?: string | string[];
    fontStyle?: {
        color?: string;
        fontFamily?: string;
        fontSize?: number | string;
        fontWeight?: number | string;
    };
    gap?: [number, number];
    offset?: [number, number];
    getContainer?: () => HTMLElement;
}

const Watermark: FC<WatermarkProps> = (props) => {
    const {
        className,
        style,
        zIndex,
        width,
        height,
        rotate,
        image,
        content,
        fontStyle,
        gap,
        offset,
    } = props;

    const containerRef = useRef<HTMLDivElement>(null);

    return props.children ? (
        <div className={className} style={style} ref={containerRef}>
            {props.children}
        </div>
    ) : null;
};

export default Watermark;

style、className 就不用解释了。

width、height、rotate、offset、gap 等都是水印的参数:

gap 是两个水印之间的空白距离。

offset 是水印相对于 container 容器的偏移量,也就是左上角的空白距离。

然后我们封装个 useWatermark 的自定义 hook 来绘制水印:

import {
    useRef,
    PropsWithChildren,
    CSSProperties,
    FC,
    useCallback,
    useEffect,
} from "react";
import useWatermark from "./useWatermark";

export interface WatermarkProps extends PropsWithChildren {
    style?: CSSProperties;
    className?: string;
    zIndex?: string | number;
    width?: number;
    height?: number;
    rotate?: number;
    image?: string;
    content?: string | string[];
    fontStyle?: {
        color?: string;
        fontFamily?: string;
        fontSize?: number | string;
        fontWeight?: number | string;
    };
    gap?: [number, number];
    offset?: [number, number];
    getContainer?: () => HTMLElement;
}

const Watermark: FC<WatermarkProps> = (props) => {
    const {
        className,
        style,
        zIndex,
        width,
        height,
        rotate,
        image,
        content,
        fontStyle,
        gap,
        offset,
    } = props;

    const containerRef = useRef<HTMLDivElement>(null);

    const getContainer = useCallback(() => {
        return props.getContainer
            ? props.getContainer()
            : containerRef.current!;
    }, [containerRef.current, props.getContainer]);

    const { generateWatermark } = useWatermark({
        zIndex,
        width,
        height,
        rotate,
        image,
        content,
        fontStyle,
        gap,
        offset,
        getContainer,
    });

    useEffect(() => {
        generateWatermark({
            zIndex,
            width,
            height,
            rotate,
            image,
            content,
            fontStyle,
            gap,
            offset,
            getContainer,
        });
    }, [
        zIndex,
        width,
        height,
        rotate,
        image,
        content,
        JSON.stringify(props.fontStyle),
        JSON.stringify(props.gap),
        JSON.stringify(props.offset),
        getContainer,
    ]);

    return props.children ? (
        <div className={className} style={style} ref={containerRef}>
            {props.children}
        </div>
    ) : null;
};

export default Watermark;

getContainer 默认用 containerRef.current,或者传入的 props.getContainer。

调用 useWatermark,返回 generateWatermark 方法。

然后当参数变化的时候,重新调用 generateWatermark 绘制水印。

getContainer 我们加了 useCallback 避免每次都变,对象参数(fontSize)、数组参数(gap、offset)用 JSON.stringify 序列化后再放到 deps 数组里:

然后来实现 useWatermark 的 hook。

新建 Watermark/useWatermark.ts

import { useEffect, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
    WatermarkProps,
    "className" | "style" | "children"
>;

export default function useWatermark(params: WatermarkOptions) {
    const [options, setOptions] = useState(params || {});

    function drawWatermark() {}

    useEffect(() => {
        drawWatermark();
    }, [options]);

    return {
        generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
            setOptions(merge({}, options, newOptions));
        },
        destroy: () => {},
    };
}

参数就是 WatermarkProps 去了 style、className、children。

把传入的参数保存到 options 的 state,根据它渲染。

调用返回的 generateWatermark 的时候设置 options 触发重绘。

这里用到了 lodash-es 包的 merge 来合并参数。

安装下:

npm install --save lodash-es

npm i --save-dev @types/lodash-es

然后来处理下 options,和默认 options 做下合并:

这里的 toNumber 会把第一个参数转为 number,如果不是数字的话就返回第二个参数的默认值:

具体的合并逻辑是这样的:

image.png

先合并传入的 options

然后如果没有传入的会用默认值。

fontStyle 是默认 fontStyle 和传入的 fontStyle 的合并

width 的默认值,如果是图片就用默认 width,否则 undefined,因为后面文字宽度是动态算的。

offset 的默认值是 0。

因为处理完之后肯定是有值的,所以断言为 Required<WatermarkOptions> 类型。

这个 Required 是去掉可选用的,相对的,Partial 是给属性添加可选修饰。

合并完之后,就拿到绘制的 options 了。

import { useEffect, useRef, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
    WatermarkProps,
    "className" | "style" | "children"
>;

export function isNumber(obj: any): obj is number {
    return (
        Object.prototype.toString.call(obj) === "[object Number]" && obj === obj
    );
}

const toNumber = (value?: string | number, defaultValue?: number) => {
    if (value === undefined) {
        return defaultValue;
    }
    if (isNumber(value)) {
        return value;
    }
    const numberVal = parseFloat(value);
    return isNumber(numberVal) ? numberVal : defaultValue;
};

const defaultOptions = {
    rotate: -20,
    zIndex: 1,
    width: 100,
    gap: [100, 100],
    fontStyle: {
        fontSize: "16px",
        color: "rgba(0, 0, 0, 0.15)",
        fontFamily: "sans-serif",
        fontWeight: "normal",
    },
    getContainer: () => document.body,
};

const getMergedOptions = (o: Partial<WatermarkOptions>) => {
    const options = o || {};

    const mergedOptions = {
        ...options,
        rotate: options.rotate || defaultOptions.rotate,
        zIndex: options.zIndex || defaultOptions.zIndex,
        fontStyle: { ...defaultOptions.fontStyle, ...options.fontStyle },
        width: toNumber(
            options.width,
            options.image ? defaultOptions.width : undefined
        ),
        height: toNumber(options.height, undefined)!,
        getContainer: options.getContainer!,
        gap: [
            toNumber(options.gap?.[0], defaultOptions.gap[0]),
            toNumber(
                options.gap?.[1] || options.gap?.[0],
                defaultOptions.gap[1]
            ),
        ],
    } as Required<WatermarkOptions>;

    const mergedOffsetX = toNumber(mergedOptions.offset?.[0], 0)!;
    const mergedOffsetY = toNumber(
        mergedOptions.offset?.[1] || mergedOptions.offset?.[0],
        0
    )!;
    mergedOptions.offset = [mergedOffsetX, mergedOffsetY];

    return mergedOptions;
};

export default function useWatermark(params: WatermarkOptions) {
    const [options, setOptions] = useState(params || {});

    const mergedOptions = getMergedOptions(options);

    function drawWatermark() {}

    useEffect(() => {
        drawWatermark();
    }, [options]);

    return {
        generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
            setOptions(merge({}, options, newOptions));
        },
        destroy: () => {},
    };
}

有了 options,接下来创建 dom,开始绘制:

用 useRef 保存水印元素的 dom。

调用 getCanvasData 方法来绘制,返回 base64Url、width、height 这些信息。

生成水印的 dom 元素,挂载到 container 下,设置 style。

注意 background-size 是 gap + width、gap + height 算出的。

接下来只要实现 getCanvasData 方法,用 cavas 画出水印就好了。

import { useEffect, useRef, useState } from "react";
import { WatermarkProps } from ".";
import { merge } from "lodash-es";

export type WatermarkOptions = Omit<
    WatermarkProps,
    "className" | "style" | "children"
>;

export function isNumber(obj: any): obj is number {
    return (
        Object.prototype.toString.call(obj) === "[object Number]" && obj === obj
    );
}

const toNumber = (value?: string | number, defaultValue?: number) => {
    if (!value) {
        return defaultValue;
    }
    if (isNumber(value)) {
        return value;
    }
    const numberVal = parseFloat(value);
    return isNumber(numberVal) ? numberVal : defaultValue;
};

const defaultOptions = {
    rotate: -20,
    zIndex: 1,
    width: 100,
    gap: [100, 100],
    fontStyle: {
        fontSize: "16px",
        color: "rgba(0, 0, 0, 0.15)",
        fontFamily: "sans-serif",
        fontWeight: "normal",
    },
    getContainer: () => document.body,
};

const getMergedOptions = (o: Partial<WatermarkOptions>) => {
    const options = o || {};

    const mergedOptions = {
        ...options,
        rotate: options.rotate || defaultOptions.rotate,
        zIndex: options.zIndex || defaultOptions.zIndex,
        fontStyle: { ...defaultOptions.fontStyle, ...options.fontStyle },
        width: toNumber(
            options.width,
            options.image ? defaultOptions.width : undefined
        ),
        height: toNumber(options.height, undefined)!,
        getContainer: options.getContainer!,
        gap: [
            toNumber(options.gap?.[0], defaultOptions.gap[0]),
            toNumber(
                options.gap?.[1] || options.gap?.[0],
                defaultOptions.gap[1]
            ),
        ],
    } as Required<WatermarkOptions>;

    const mergedOffsetX = toNumber(mergedOptions.offset?.[0], 0)!;
    const mergedOffsetY = toNumber(
        mergedOptions.offset?.[1] || mergedOptions.offset?.[0],
        0
    )!;
    mergedOptions.offset = [mergedOffsetX, mergedOffsetY];

    return mergedOptions;
};

const getCanvasData = async (
    options: Required<WatermarkOptions>
): Promise<{ width: number; height: number; base64Url: string }> => {};

export default function useWatermark(params: WatermarkOptions) {
    const [options, setOptions] = useState(params || {});

    const mergedOptions = getMergedOptions(options);
    const watermarkDiv = useRef<HTMLDivElement>();

    const container = mergedOptions.getContainer();
    const { zIndex, gap } = mergedOptions;

    function drawWatermark() {
        if (!container) {
            return;
        }

        getCanvasData(mergedOptions).then(({ base64Url, width, height }) => {
            const wmStyle = `
      width:100%;
      height:100%;
      position:absolute;
      top:0;
      left:0;
      bottom:0;
      right:0;
      pointer-events: none;
      z-index:${zIndex};
      background-position: 0 0;
      background-size:${gap[0] + width}px ${gap[1] + height}px;
      background-repeat: repeat;
      background-image:url(${base64Url})`;

            if (!watermarkDiv.current) {
                const div = document.createElement("div");
                watermarkDiv.current = div;
                container.append(div);
                container.style.position = "relative";
            }

            watermarkDiv.current?.setAttribute("style", wmStyle.trim());
        });
    }

    useEffect(() => {
        drawWatermark();
    }, [options]);

    return {
        generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
            setOptions(merge({}, options, newOptions));
        },
        destroy: () => {},
    };
}

接下来实现 getCanvasData 方法。

创建个 canvas 元素,拿到画图用的 context。

封装 drawText、drawImage 两个方法,优先绘制 image。

然后封装个 configCanvas 方法,用来设置 canvas 的宽高、rotate、scale:

宽高同样是 gap + width、gap + height。

用 tanslate 移动中心点到 宽高的一半的位置再 schale、rotate。

因为不同屏幕的设备像素比不一样,也就是 1px 对应的物理像素不一样,所以要在单位后面乘以 devicePixelRatio。

我们设置了 scale 放大 devicePixelRatio 倍,这样接下来绘制尺寸就不用乘以设备像素比了。

const getCanvasData = async (
    options: Required<WatermarkOptions>
): Promise<{ width: number; height: number; base64Url: string }> => {
    const { rotate, image, content, fontStyle, gap } = options;

    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d")!;

    const ratio = window.devicePixelRatio;

    const configCanvas = (size: { width: number; height: number }) => {
        const canvasWidth = gap[0] + size.width;
        const canvasHeight = gap[1] + size.height;

        canvas.setAttribute("width", `${canvasWidth * ratio}px`);
        canvas.setAttribute("height", `${canvasHeight * ratio}px`);
        canvas.style.width = `${canvasWidth}px`;
        canvas.style.height = `${canvasHeight}px`;

        ctx.translate((canvasWidth * ratio) / 2, (canvasHeight * ratio) / 2);
        ctx.scale(ratio, ratio);

        const RotateAngle = (rotate * Math.PI) / 180;
        ctx.rotate(RotateAngle);
    };

    const drawText = () => {};

    function drawImage() {}

    return image ? drawImage() : drawText();
};

先来实现 drawImage:

function drawImage() {
    return new Promise<{ width: number; height: number; base64Url: string }>(
        (resolve) => {
            const img = new Image();
            img.crossOrigin = "anonymous";
            img.referrerPolicy = "no-referrer";

            img.src = image;
            img.onload = () => {
                let { width, height } = options;
                if (!width || !height) {
                    if (width) {
                        height = (img.height / img.width) * +width;
                    } else {
                        width = (img.width / img.height) * +height;
                    }
                }
                configCanvas({ width, height });

                ctx.drawImage(img, -width / 2, -height / 2, width, height);
                return resolve({
                    base64Url: canvas.toDataURL(),
                    width,
                    height,
                });
            };
            img.onerror = () => {
                return drawText();
            };
        }
    );
}

new Image 指定 src 加载图片。

onload 的时候,对于没有设置 width 或 height 的时候,根据图片宽高比算出另一个值。

然后调用 configCanvas 修改 canvas 的宽高、缩放、旋转。

之后在中心点绘制一张图片,返回 base64 的结果。

当加载失败时,onerror 里绘制文本。

这里的 crssOrign 设置 anonymous 是跨域的时候不携带 cookie,而 refererPolicy 设置 no-referrer 是不携带 referer,都是安全相关的。

然后实现 drawText:

const drawText = () => {
    const { fontSize, color, fontWeight, fontFamily } = fontStyle;
    const realFontSize = toNumber(fontSize, 0) || fontStyle.fontSize;

    ctx.font = `${fontWeight} ${realFontSize}px ${fontFamily}`;
    const measureSize = measureTextSize(ctx, [...content], rotate);

    const width = options.width || measureSize.width;
    const height = options.height || measureSize.height;

    configCanvas({ width, height });

    ctx.fillStyle = color!;
    ctx.font = `${fontWeight} ${realFontSize}px ${fontFamily}`;
    ctx.textBaseline = "top";

    [...content].forEach((item, index) => {
        const { height: lineHeight, width: lineWidth } =
            measureSize.lineSize[index];

        const xStartPoint = -lineWidth / 2;
        const yStartPoint =
            -(options.height || measureSize.originHeight) / 2 +
            lineHeight * index;

        ctx.fillText(
            item,
            xStartPoint,
            yStartPoint,
            options.width || measureSize.originWidth
        );
    });
    return Promise.resolve({ base64Url: canvas.toDataURL(), height, width });
};

fontSize 转为 number。

如果没有传入 width、height 就自己计算,这个 measureTextSize 待会实现。

设置 textBaseline 为 top,顶部对齐。

然后依次绘制文字。

绘制文字要按照坐标来,在 measureTextSize 里计算出每一行的 lineSize,也就是行高、行宽。

在行宽的一半的地方开始绘制文字,行内每个文字的位置是行高的一半 * index。

然后实现 measureTextSize 方法:

const measureTextSize = (
    ctx: CanvasRenderingContext2D,
    content: string[],
    rotate: number
) => {
    let width = 0;
    let height = 0;
    const lineSize: Array<{ width: number; height: number }> = [];

    content.forEach((item) => {
        const {
            width: textWidth,
            fontBoundingBoxAscent,
            fontBoundingBoxDescent,
        } = ctx.measureText(item);

        const textHeight = fontBoundingBoxAscent + fontBoundingBoxDescent;

        if (textWidth > width) {
            width = textWidth;
        }

        height += textHeight;
        lineSize.push({ height: textHeight, width: textWidth });
    });

    const angle = (rotate * Math.PI) / 180;

    return {
        originWidth: width,
        originHeight: height,
        width: Math.ceil(
            Math.abs(Math.sin(angle) * height) +
                Math.abs(Math.cos(angle) * width)
        ),
        height: Math.ceil(
            Math.abs(Math.sin(angle) * width) +
                Math.abs(height * Math.cos(angle))
        ),
        lineSize,
    };
};

ctx.measureText 是用来测量文字尺寸的。

fontBoudingAscent 是 baseline 到顶部的距离,而 fontBoundingBoxDescent 是到底部的距离:

加起来就是行高。

然后如果有旋转的话,要用 sin、cos 函数算出旋转后的宽高。

这样经过计算和绘制,文字和图片的水印就都完成了。

我们测试下:

改下 App.tsx

import Watermark from "./Watermark";

const App = () => {
    return (
        <Watermark content={["测试水印", "神说要有光"]}>
            <div style={{ height: 800 }}>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
            </div>
        </Watermark>
    );
};

export default App;

把 gap 设为 0:

import Watermark from "./Watermark";

const App = () => {
    return (
        <Watermark
            content={["测试水印", "神说要有光"]}
            gap={[0, 0]}
            fontStyle={{
                color: "green",
            }}>
            <div style={{ height: 800 }}>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
            </div>
        </Watermark>
    );
};

export default App;

也没问题:

只是现在 offset 还没有支持,也就是左上角的空白距离。

这个就是改下 left、top 的值就好了,当然,width、height 也要从 100% 减去这块距离。

const offsetLeft = mergedOptions.offset[0] + "px";
const offsetTop = mergedOptions.offset[1] + "px";

const wmStyle = `
width:calc(100% - ${offsetLeft});
height:calc(100% - ${offsetTop});
position:absolute;
top:${offsetTop};
left:${offsetLeft};
bottom:0;
right:0;
pointer-events: none;
z-index:${zIndex};
background-position: 0 0;
background-size:${gap[0] + width}px ${gap[1] + height}px;
background-repeat: repeat;
background-image:url(${base64Url})`;

测试下:

import Watermark from "./Watermark";

const App = () => {
    return (
        <Watermark
            content={["测试水印", "神说要有光"]}
            gap={[0, 0]}
            offset={[50, 100]}
            fontStyle={{
                color: "green",
            }}>
            <div style={{ height: 800 }}>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
                <p>
                    Lorem ipsum dolor, sit amet consectetur adipisicing elit.
                    Quos quod deserunt quidem quas in rem ipsam ut nesciunt
                    asperiores dignissimos recusandae minus, eaque, harum
                    exercitationem esse sapiente? Eveniet, id provident!
                </p>
            </div>
        </Watermark>
    );
};

export default App;

这样水印组件就完成了。

但现在的水印组件作用并不大,因为只要打开 devtools 就能轻易删掉。

我们要加上防删功能,前面讲过,用 MutationObserver:

创建完水印节点后,首先 disnonnect 去掉之前的 MutationObserver 的监听,然后创建新的 MutationObserver 监听 container 的变动。

export default function useWatermark(params: WatermarkOptions) {
    const [options, setOptions] = useState(params || {});

    const mergedOptions = getMergedOptions(options);
    const watermarkDiv = useRef<HTMLDivElement>();
    const mutationObserver = useRef<MutationObserver>();

    const container = mergedOptions.getContainer();
    const { zIndex, gap } = mergedOptions;

    function drawWatermark() {
        if (!container) {
            return;
        }

        getCanvasData(mergedOptions).then(({ base64Url, width, height }) => {
            const offsetLeft = mergedOptions.offset[0] + "px";
            const offsetTop = mergedOptions.offset[1] + "px";

            const wmStyle = `
      width:calc(100% - ${offsetLeft});
      height:calc(100% - ${offsetTop});
      position:absolute;
      top:${offsetTop};
      left:${offsetLeft};
      bottom:0;
      right:0;
      pointer-events: none;
      z-index:${zIndex};
      background-position: 0 0;
      background-size:${gap[0] + width}px ${gap[1] + height}px;
      background-repeat: repeat;
      background-image:url(${base64Url})`;

            if (!watermarkDiv.current) {
                const div = document.createElement("div");
                watermarkDiv.current = div;
                container.append(div);
                container.style.position = "relative";
            }

            watermarkDiv.current?.setAttribute("style", wmStyle.trim());

            if (container) {
                mutationObserver.current?.disconnect();

                mutationObserver.current = new MutationObserver((mutations) => {
                    const isChanged = mutations.some((mutation) => {
                        let flag = false;
                        if (mutation.removedNodes.length) {
                            flag = Array.from(mutation.removedNodes).some(
                                (node) => node === watermarkDiv.current
                            );
                        }
                        if (
                            mutation.type === "attributes" &&
                            mutation.target === watermarkDiv.current
                        ) {
                            flag = true;
                        }
                        return flag;
                    });
                    if (isChanged) {
                        watermarkDiv.current = undefined;
                        drawWatermark();
                    }
                });

                mutationObserver.current.observe(container, {
                    attributes: true,
                    subtree: true,
                    childList: true,
                });
            }
        });
    }

    useEffect(() => {
        drawWatermark();
    }, [options]);

    return {
        generateWatermark: (newOptions: Partial<WatermarkOptions>) => {
            setOptions(merge({}, options, newOptions));
        },
        destroy: () => {},
    };
}

上节讲过,MutationObserver 可以监听子节点的变动和节点属性变动:

所以我们判断水印是否删除是通过判断是否修改了 watermark 节点的属性,是否增删了 watermark 节点:

是的话,就把 watermarkDiv.current 置空然后重新绘制。

测试下:

现在修改节点属性,或者删掉水印节点的时候,就会绘制一个新的。

这样,就达到了防止删除水印的功能。

案例代码上传了小册仓库

总结

这节我们实现了 Watermark 水印组件。

水印的实现原理就是加一个和目标元素宽高一样的 div 覆盖在上面,设置 pointer-events:none 不响应鼠标事件。

然后背景用水印图片 repeat 实现。

这个水印图片是用 canvas 画的,传入文字或者图片,会计算 gap、文字宽高等,在正确的位置绘制出来。

之后转成 base64 之后设置为 background-image。

此外,还要支持防删除功能,也就是用 MutationObserver 监听水印节点的属性变动、节点删除等,有变化就重新绘制一个。

这样,我们就实现了有防删功能的 Watermark 水印组件。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
17.浏览器的5种Observer
Next
19.手写react-lazyload