IdbI

第18章—组件实战:Watermark防删除水印组件

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

比如这样:

ant designarco 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 水印组件。

返回上级