likes
comments
collection
share

【源码阅读】【万字长文预警】🔍水印保卫战

作者站长头像
站长
· 阅读数 30

什么是水印

【源码阅读】【万字长文预警】🔍水印保卫战

概念

水印技术是一种在数字内容中嵌入隐蔽或半隐蔽信息的方法,这些信息可以是文本、图像或其他标识符。

作用

  1. 版权保护:水印可以帮助内容创作者保护其作品的版权,防止他人未经许可复制或传播其作品。
  2. 来源追踪:通过在内容中嵌入特定的标识,水印可以帮助追踪内容的传播路径,尤其是在内容被非法分发时。
  3. 所有权证明:水印可以作为证明内容所有权的法律证据。
  4. 防伪:在某些情况下,水印还可以用于验证内容的真实性,防止伪造。
  5. 访问控制:水印可以用来标记不同的访问级别或分发渠道,从而对内容的访问和使用进行控制。

组成

在我看来,将要展示的信息作为印花,放到一张画布上,再结合一些样式设置,就能得到覆盖页面的「水印」组件了。

【源码阅读】【万字长文预警】🔍水印保卫战

我司网页的水印

考虑到隐私性,仅截图局部做展示,如下: 【源码阅读】【万字长文预警】🔍水印保卫战

如果我们在「元素标签页」直接删除与「水印」相关的块级元素。

【源码阅读】【万字长文预警】🔍水印保卫战

我们会发现可以成功删除。此时我司系统的这个页面就是一个没有水印的页面了。

(其实,除了直接删除块级元素之外,我们还可以通过修改样式,将水印隐藏,比如设置: visibility: hidden)

【源码阅读】【万字长文预警】🔍水印保卫战

Antd 5.x的水印

我们继续用同样的手法,试着删除antd的水印。

【源码阅读】【万字长文预警】🔍水印保卫战

会发现删除失败。与其说是删除失败,不如说是按下「backspace」后好像一点反应都没有。

这里是一个Demo链接,大家可以亲自尝试下。

【源码阅读】【万字长文预警】🔍水印保卫战

Antd一对比,我们不难发现,我司系统页面上的水印可以被轻而易举地删除😣。

这不由得让人心中响起一声呐喊:

水印保护我们,但是来保护水印?

保护水印

就让我们一起来阅读antd 5.x的源码,看看它为了「保护水印」都做了什么

Watermark组件的git仓库链接

【源码阅读】【万字长文预警】🔍水印保卫战

思维导图

【源码阅读】【万字长文预警】🔍水印保卫战

源码分析

useWatermark

import * as React from 'react';

import { getStyleStr } from './utils';

// Base size of the canvas, 1 for parallel layout and 2 for alternate layout
// Only alternate layout is currently supported

export const BaseSize = 2;
export const FontGap = 3;

// Prevent external hidden elements from adding accent styles
const emphasizedStyle = {
  visibility: 'visible !important',
};

export type AppendWatermark = (
  base64Url: string,
  markWidth: number,
  container: HTMLElement,
) => void;

export default function useWatermark(
  markStyle: React.CSSProperties,
): [
  appendWatermark: AppendWatermark,
  removeWatermark: (container: HTMLElement) => void,
  isWatermarkEle: (ele: Node) => boolean,
] {
  const [watermarkMap] = React.useState(() => new Map<HTMLElement, HTMLDivElement>());

  const appendWatermark = (base64Url: string, markWidth: number, container: HTMLElement) => {
    if (container) {
      if (!watermarkMap.get(container)) {
        const newWatermarkEle = document.createElement('div');
        watermarkMap.set(container, newWatermarkEle);
      }

      const watermarkEle = watermarkMap.get(container)!;

      watermarkEle.setAttribute(
        'style',
        getStyleStr({
          ...markStyle,
          backgroundImage: `url('${base64Url}')`,
          backgroundSize: `${Math.floor(markWidth)}px`,
          ...(emphasizedStyle as React.CSSProperties),
        }),
      );
      // Prevents using the browser `Hide Element` to hide watermarks
      watermarkEle.removeAttribute('class');

      if (watermarkEle.parentElement !== container) {
        container.append(watermarkEle);
      }
    }

    return watermarkMap.get(container);
  };

  const removeWatermark = (container: HTMLElement) => {
    const watermarkEle = watermarkMap.get(container);

    if (watermarkEle && container) {
      container.removeChild(watermarkEle);
    }

    watermarkMap.delete(container);
  };

  const isWatermarkEle = (ele: any) => Array.from(watermarkMap.values()).includes(ele);

  return [appendWatermark, removeWatermark, isWatermarkEle];
}

注意第29行

const [watermarkMap] = React.useState(() => new Map<HTMLElement, HTMLDivElement>());

为什么这里要在useState里面使用() => new Map(),而不是new Map()去创建对象?

因为采用new Map()的写法,会在每次重新渲染时,都得到一个新的对象实例,这会造成不必要的性能开销。

我们可以看下React官方文档中有关useState的内容:

【源码阅读】【万字长文预警】🔍水印保卫战

【源码阅读】【万字长文预警】🔍水印保卫战

// 我们可以将antd中的() => new Map()当作是和例子中createInitialTodos一样的声明

function createInitialMap() {
  return new Map();
}
// 于是,antd源码也可以写成这样2种方式
const [watermarkMap] = React.useState(createInitialMap);// = React.useState(() => new Map());
const [watermarkMap] = React.useState(createInitialMap());// = React.useState(new Map());

那么,为什么useWatermark不需要在每次重新渲染时,都生成一个新的watermarkMap实例呢?

我们简单回顾一下React组件渲染的几条规则,假设我们现在有个组件A,其中引入了组件B。 什么情况下作为hook使用的组件B会重新渲染:

  • 组件B是否使用了useState或useContext:如果组件B中使用了useStateuseContext,并且它们的依赖发生了变化,那么组件B将会重新渲染。这是因为React的hook机制会在依赖变化时触发组件的重新渲染。

  • 组件B是否接收了来自组件A的props:如果组件A向组件B传递了props,并且这些props发生了变化,那么组件B将会重新渲染。这是因为React组件会在其接收的props发生变化时重新渲染。

  • 组件B是否使用了useContext:如果组件B使用了useContext,那么当上下文发生变化时,组件B会重新渲染。

  • 组件B是否是纯组件或使用了React.memo:如果组件B是一个纯组件(PureComponent)或使用了React.memo进行包装,那么组件B的重新渲染会取决于其props是否发生了变化。React.memo会对props进行浅比较,只有当props发生变化时,组件才会重新渲染。

OK,我们先看useWatermark自己的代码, 组件内容只使用了一次useState,并且这个useState并没有返回setState的函数方法。因为我们可以得出第1条结论

useWatermark自己内部的state并不会触发重新渲染。

接着我们看下useWatermark的props,它从外部接收了一个 markStyle 参数,这是一个 React CSS 属性对象,用于定义水印的样式。因此我们可以得出第2条结论:

只有 markStyle发生变化,才会导致useWatermark的重新渲染。

至此,我们要讨论的问题就从

“为什么useWatermark每次重新渲染不需要生成一个新的watermarkMap实例”

转变成了

“为什么 markStyle发生变化,useWatermark不需要生成一个新的watermarkMap实例”

我们临时看下useWatermarkindex.tsx文件中的使用方式


  const markStyle = React.useMemo(() => {
    const mergedStyle: React.CSSProperties = {
      zIndex,
      position: 'absolute',
      left: 0,
      top: 0,
      width: '100%',
      height: '100%',
      pointerEvents: 'none',
      backgroundRepeat: 'repeat',
    };

    /** Calculate the style of the offset */
    let positionLeft = offsetLeft - gapXCenter;
    let positionTop = offsetTop - gapYCenter;
    if (positionLeft > 0) {
      mergedStyle.left = `${positionLeft}px`;
      mergedStyle.width = `calc(100% - ${positionLeft}px)`;
      positionLeft = 0;
    }
    if (positionTop > 0) {
      mergedStyle.top = `${positionTop}px`;
      mergedStyle.height = `calc(100% - ${positionTop}px)`;
      positionTop = 0;
    }
    mergedStyle.backgroundPosition = `${positionLeft}px ${positionTop}px`;

    return mergedStyle;
  }, [zIndex, offsetLeft, gapXCenter, offsetTop, gapYCenter]);



  // ============================= Effect =============================
  // Append watermark to the container
  const [appendWatermark, removeWatermark, isWatermarkEle] = useWatermark(markStyle);


markStyle是通过使用React.Memo计算得出的。这就意味着只有zIndexoffsetLeftgapXCenteroffsetTopgapYCenter发生变化时,markStyle才会发生变化。

至此,我们的问题转换成最终形态:

为什么zIndexoffsetLeftgapXCenteroffsetTopgapYCenter发生变化,useWatermark不需要生成一个新的watermarkMap实例?

其实倒也不必把问题想得如此复杂,思考问题的角度是可以转换的。只不过聊到了重新渲染,咱们就赶巧回顾一下知识点

换个角度来看其实问题很简单:

每次重新渲染时,创建一个新的watermarkMap实例,这是否真的有必要?

让我们回看appendWatermark,此函数方法内部也是通过get的方法去找到原先container对应的水印对象,然后通过setAttribute,实现数据的更新。

 watermarkEle.setAttribute(
        'style',
        getStyleStr({
          ...markStyle,
          backgroundImage: `url('${base64Url}')`,
          backgroundSize: `${Math.floor(markWidth)}px`,
          ...(emphasizedStyle as React.CSSProperties),
        }),
      );

因此,就算markStyle,即:zIndexoffsetLeftgapXCenteroffsetTopgapYCenter发生变化,也无需通过生成一个新的watermarkMap实例去更新水印的信息。

我们期望watermarkMap应该是跨渲染持久化的,而不是每次渲染都重新创建,从而避免不必要的性能开销

这里的性能开销,考虑到篇幅问题,会在下一篇文章,通过一个demo(后续会上传至CodeSandbox),结合浏览器调试工具performance,给大家直观地展示一下性能上的差异。

至此,我们花了很长的篇幅去聊了一下【关于在useState中该如何合理地进行初始化赋值】这个问题,也顺便回顾了React组件触发重新渲染的几种场景。

接下来,我们继续往下分析源码。

appendWatermark

appendWatermark 函数用于将水印添加到指定的容器元素。它接受三个参数:

  • base64Url:水印图片的 base64 编码字符串。

  • markWidth:水印图片的宽度。

  • container:要添加水印的容器元素。

  const appendWatermark = (base64Url: string, markWidth: number, container: HTMLElement) => {
    if (container) {
      if (!watermarkMap.get(container)) {
        const newWatermarkEle = document.createElement('div');
        watermarkMap.set(container, newWatermarkEle);
      }

      const watermarkEle = watermarkMap.get(container)!;

      watermarkEle.setAttribute(
        'style',
        getStyleStr({
          ...markStyle,
          backgroundImage: `url('${base64Url}')`,
          backgroundSize: `${Math.floor(markWidth)}px`,
          ...(emphasizedStyle as React.CSSProperties),
        }),
      );
      // Prevents using the browser `Hide Element` to hide watermarks
      watermarkEle.removeAttribute('class');

      if (watermarkEle.parentElement !== container) {
        container.append(watermarkEle);
      }
    }

    return watermarkMap.get(container);
  };
  1. 函数首先检查 watermarkMap(用于存储容器元素和对应的水印元素的关联关系。) 中是否已经有了对应容器的水印元素,如果没有,则创建一个新的 div 元素作为水印元素,并将其添加到 watermarkMap 中。

  2. 然后,设置水印元素的样式,包括背景图片、背景大小和一些强调样式(即emphasizedStyle),以防止水印被隐藏。

// Prevent external hidden elements from adding accent styles
const emphasizedStyle = {
  visibility: 'visible !important',
};
  1. 最后,如果水印元素尚未添加到容器中,将其添加到容器中。

注意,由于watermarkMap作为state变量是个对象,因此在此函数方法内调用watermarkMap自身的set()方法并不会触发re-render,因为该state变量的地址一直没有发生改变。

removeWatermark

removeWatermark 函数用于从指定的容器元素中移除水印。它接受一个参数:

  • container:需要移除水印的容器元素。
  const removeWatermark = (container: HTMLElement) => {
    const watermarkEle = watermarkMap.get(container);

    if (watermarkEle && container) {
      container.removeChild(watermarkEle);
    }

    watermarkMap.delete(container);
  };

不知道各位看到container.removeChild(watermarkEle)watermarkMap.delete(container)同时出现,是否会有如下疑惑:

为什么不只写一行watermarkMap.delete(container)

回答这个问题,我们可以用反推的方式。假设这里只写了一行watermarkMap.delete(container),那么在DOM(即container)中仍然存在未被移除的水印元素。这将导致DOM状态和watermarkMap状态不一致,引起一些问题,比如:

  • 尝试在同一个容器上再次添加水印时,由于watermarkMap中已经没有该容器的键名,它会认为容器还没有水印元素并尝试添加一个新的,从而导致DOM中存在重复的水印元素。
  • watermarkMap是一个Map对象,watermarkMap.delete(container)只是从Map中移除一个键值对,并不会影响到实际的HTMLDivElement对象。也就是说,DOM里还是保留着这个水印元素,删了等于没删。

因此,通过先调用container.removeChild(watermarkEle),可以确保水印元素首先从DOM中被删除。然后,再通过调用watermarkMap.delete(container),从Map中移除对应的映射关系。这样,DOM的实际内容和watermarkMap的当前状态就保持一致了,我们成功地彻底地删除了水印,并且也不会影响到下一次对此DOM进行添加水印的操作。

isWatermarkEle

isWatermarkEle 函数用于检查一个元素是否是水印元素。它接受一个参数:

  • ele:需要检查的元素。
  const isWatermarkEle = (ele: any) => Array.from(watermarkMap.values()).includes(ele);

函数通过检查 watermarkMap 的值中是否包含这个元素来判断它是否是水印元素。

useClips

import type { WatermarkProps } from '.';

export const FontGap = 3;

function prepareCanvas(
  width: number,
  height: number,
  ratio: number = 1,
): [
  ctx: CanvasRenderingContext2D,
  canvas: HTMLCanvasElement,
  realWidth: number,
  realHeight: number,
] {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d')!;

  const realWidth = width * ratio;
  const realHeight = height * ratio;
  canvas.setAttribute('width', `${realWidth}px`);
  canvas.setAttribute('height', `${realHeight}px`);
  ctx.save();

  return [ctx, canvas, realWidth, realHeight];
}

/**
 * Get the clips of text content.
 * This is a lazy hook function since SSR no need this
 */
export default function useClips() {
  // Get single clips
  function getClips(
    content: NonNullable<WatermarkProps['content']> | HTMLImageElement,
    rotate: number,
    ratio: number,
    width: number,
    height: number,
    font: Required<NonNullable<WatermarkProps['font']>>,
    gapX: number,
    gapY: number,
  ): [dataURL: string, finalWidth: number, finalHeight: number] {
    // ================= Text / Image =================
    const [ctx, canvas, contentWidth, contentHeight] = prepareCanvas(width, height, ratio);

    if (content instanceof HTMLImageElement) {
      // Image
      ctx.drawImage(content, 0, 0, contentWidth, contentHeight);
    } else {
      // Text
      const { color, fontSize, fontStyle, fontWeight, fontFamily, textAlign } = font;
      const mergedFontSize = Number(fontSize) * ratio;

      ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${height}px ${fontFamily}`;
      ctx.fillStyle = color;
      ctx.textAlign = textAlign;
      ctx.textBaseline = 'top';
      const contents = Array.isArray(content) ? content : [content];
      contents?.forEach((item, index) => {
        ctx.fillText(item ?? '', contentWidth / 2, index * (mergedFontSize + FontGap * ratio));
      });
    }

    // ==================== Rotate ====================
    const angle = (Math.PI / 180) * Number(rotate);
    const maxSize = Math.max(width, height);
    const [rCtx, rCanvas, realMaxSize] = prepareCanvas(maxSize, maxSize, ratio);

    // Copy from `ctx` and rotate
    rCtx.translate(realMaxSize / 2, realMaxSize / 2);
    rCtx.rotate(angle);
    if (contentWidth > 0 && contentHeight > 0) {
      rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);
    }

    // Get boundary of rotated text
    function getRotatePos(x: number, y: number) {
      const targetX = x * Math.cos(angle) - y * Math.sin(angle);
      const targetY = x * Math.sin(angle) + y * Math.cos(angle);
      return [targetX, targetY];
    }

    let left = 0;
    let right = 0;
    let top = 0;
    let bottom = 0;

    const halfWidth = contentWidth / 2;
    const halfHeight = contentHeight / 2;
    const points = [
      [0 - halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 + halfHeight],
      [0 - halfWidth, 0 + halfHeight],
    ];
    points.forEach(([x, y]) => {
      const [targetX, targetY] = getRotatePos(x, y);
      left = Math.min(left, targetX);
      right = Math.max(right, targetX);
      top = Math.min(top, targetY);
      bottom = Math.max(bottom, targetY);
    });

    const cutLeft = left + realMaxSize / 2;
    const cutTop = top + realMaxSize / 2;
    const cutWidth = right - left;
    const cutHeight = bottom - top;

    // ================ Fill Alternate ================
    const realGapX = gapX * ratio;
    const realGapY = gapY * ratio;
    const filledWidth = (cutWidth + realGapX) * 2;
    const filledHeight = cutHeight + realGapY;

    const [fCtx, fCanvas] = prepareCanvas(filledWidth, filledHeight);

    function drawImg(targetX = 0, targetY = 0) {
      fCtx.drawImage(
        rCanvas,
        cutLeft,
        cutTop,
        cutWidth,
        cutHeight,
        targetX,
        targetY,
        cutWidth,
        cutHeight,
      );
    }
    drawImg();
    drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
    drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);

    return [fCanvas.toDataURL(), filledWidth / ratio, filledHeight / ratio];
  }

  return getClips;
}

prepareCanvas

prepareCanvas函数用于创建一个新的<canvas>元素,并对其进行配置,以便在不同的设备像素比下保持清晰的渲染。它接受三个参数:

  • width:画布的宽度。
  • height:画布的高度。
  • ratio:设备像素比,默认为1。
function prepareCanvas(
  width: number,
  height: number,
  ratio: number = 1,
): [
  ctx: CanvasRenderingContext2D,
  canvas: HTMLCanvasElement,
  realWidth: number,
  realHeight: number,
] {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d')!;

  const realWidth = width * ratio;
  const realHeight = height * ratio;
  canvas.setAttribute('width', `${realWidth}px`);
  canvas.setAttribute('height', `${realHeight}px`);
  ctx.save();

  return [ctx, canvas, realWidth, realHeight];
}
  1. 函数首先通过document.createElement('canvas')创建了一个新的<canvas>元素。然后通过getContext()方法获取此元素的渲染上下文

渲染上下文可以用来绘制和处理canvas要展示的内容。——MDN

  1. 接着,根据传入的宽度、高度、设备像素比来计算实际的宽度(realWidth)和高度(realHeight)。并通过canvas.setAttribute()方法设置<canvas>元素的宽度和高度(这里是以像素为单位的)。

  2. 在完成基本配置后,使用了渲染上下文(ctx)提供的save()方法,保存当前的绘图状态。

及时使用ctx.save()是个好习惯,尤其是在进行一系列变换操作之前。它通常与ctx.restore()(它是将<canvas>恢复到最近的保存状态的方法)配对使用。当我们对<canvas>进行了多次变换或者其他操作后,可能需要回到之前的状态,这时候save()restore()方法就非常有用。

  1. 在函数的最后,它返回一个数组,包含2D渲染上下文、<canvas>元素、实际的宽度和高度。

渲染上下文具备一些确定的实例属性,并且提供了一系列的实例方法,在继续阅读源码之前,大家可以访问下方链接,提前了解一下,为之后的理解奠定基础。 CanvasRenderingContext2D

【源码阅读】【万字长文预警】🔍水印保卫战

getClips

getClips函数用于创建一个包含水印(文本或图像)的<canvas>元素,对水印进行旋转,并返回一个包含水印图像数据的dataURL以及最终宽度和高度的数组。它接受八个参数:

  • content:水印内容。
  • rotate:旋转角度。
  • ratio:设备像素比。
  • width:宽度。
  • height:高度。
  • font:字体属性。
  • gapX:X方向的间隙。
  • gapY:Y方向的间隙。
  function getClips(
    content: NonNullable<WatermarkProps['content']> | HTMLImageElement,
    rotate: number,
    ratio: number,
    width: number,
    height: number,
    font: Required<NonNullable<WatermarkProps['font']>>,
    gapX: number,
    gapY: number,
  ): [dataURL: string, finalWidth: number, finalHeight: number] {
    // ================= Text / Image =================
    const [ctx, canvas, contentWidth, contentHeight] = prepareCanvas(width, height, ratio);

    if (content instanceof HTMLImageElement) {
      // Image
      ctx.drawImage(content, 0, 0, contentWidth, contentHeight);
    } else {
      // Text
      const { color, fontSize, fontStyle, fontWeight, fontFamily, textAlign } = font;
      const mergedFontSize = Number(fontSize) * ratio;

      ctx.font = `${fontStyle} normal ${fontWeight} ${mergedFontSize}px/${height}px ${fontFamily}`;
      ctx.fillStyle = color;
      ctx.textAlign = textAlign;
      ctx.textBaseline = 'top';
      const contents = Array.isArray(content) ? content : [content];
      contents?.forEach((item, index) => {
        ctx.fillText(item ?? '', contentWidth / 2, index * (mergedFontSize + FontGap * ratio));
      });
    }

    // ==================== Rotate ====================
    const angle = (Math.PI / 180) * Number(rotate);
    const maxSize = Math.max(width, height);
    const [rCtx, rCanvas, realMaxSize] = prepareCanvas(maxSize, maxSize, ratio);

    // Copy from `ctx` and rotate
    rCtx.translate(realMaxSize / 2, realMaxSize / 2);
    rCtx.rotate(angle);
    if (contentWidth > 0 && contentHeight > 0) {
      rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);
    }

    // Get boundary of rotated text
    function getRotatePos(x: number, y: number) {
      const targetX = x * Math.cos(angle) - y * Math.sin(angle);
      const targetY = x * Math.sin(angle) + y * Math.cos(angle);
      return [targetX, targetY];
    }

    let left = 0;
    let right = 0;
    let top = 0;
    let bottom = 0;

    const halfWidth = contentWidth / 2;
    const halfHeight = contentHeight / 2;
    const points = [
      [0 - halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 + halfHeight],
      [0 - halfWidth, 0 + halfHeight],
    ];
    points.forEach(([x, y]) => {
      const [targetX, targetY] = getRotatePos(x, y);
      left = Math.min(left, targetX);
      right = Math.max(right, targetX);
      top = Math.min(top, targetY);
      bottom = Math.max(bottom, targetY);
    });

    const cutLeft = left + realMaxSize / 2;
    const cutTop = top + realMaxSize / 2;
    const cutWidth = right - left;
    const cutHeight = bottom - top;

    // ================ Fill Alternate ================
    const realGapX = gapX * ratio;
    const realGapY = gapY * ratio;
    const filledWidth = (cutWidth + realGapX) * 2;
    const filledHeight = cutHeight + realGapY;

    const [fCtx, fCanvas] = prepareCanvas(filledWidth, filledHeight);

    function drawImg(targetX = 0, targetY = 0) {
      fCtx.drawImage(
        rCanvas,
        cutLeft,
        cutTop,
        cutWidth,
        cutHeight,
        targetX,
        targetY,
        cutWidth,
        cutHeight,
      );
    }
    drawImg();
    drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
    drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);

    return [fCanvas.toDataURL(), filledWidth / ratio, filledHeight / ratio];
  }
  1. 函数首先使用prepareCanvas()方法创建一个<canvas>元素,并获取其2D渲染上下文(ctx),以及根据设备像素比调整后的实际宽度和高度。
  2. 判断从外部接收的content是否是HTMLImageElement实例,如果是,则直接将其绘制到canvas上;如果不是,则将其作为文本处理,根据从外部接收的font参数来设置字体样式,并绘制到canvas上。
  • fillStyle 是 Canvas 2D API 使用内部方式描述颜色和样式的属性。默认值是 #000 (黑色)。
  • textAlign 是 Canvas 2D API 描述绘制文本时,文本的对齐方式的属性。注意,该对齐是基于 CanvasRenderingContext2D.fillText 方法的 x 的值。所以如果 textAlign="center",那么该文本将画在 x-50%*width。
  • textBaseline 是 Canvas 2D API 描述绘制文本时,当前文本基线的属性(决定文字垂直方向的对齐方式。)。
  •  fillText()  是 Canvas 2D API 的一部分,它在指定的坐标上绘制文本字符串,并使用当前的 fillStyle 对其进行填充。存在一个可选参数,其指定了渲染文本的最大宽度,用户代理将通过压缩文本或使用较小的字体大小来实现。

我们再仔细看下当content是文本时,最后这几行代码的处理:

  const contents = Array.isArray(content) ? content : [content];
  contents?.forEach((item, index) => {
        ctx.fillText(item ?? '', contentWidth / 2, index * (mergedFontSize + FontGap * ratio));
      });

这段代码首先将content进行数组化处理,这是为了统一处理单个文本和多行文本的情况。而后对每一个数组成员都使用fillText()方法将其在<canvas>进行绘制。

注意这里调用此方法时的入参:

contentWidth / 2表示文本的水平起始位置被设置为<canvas>宽度的一半,这样文本会在<canvas>中心水平居中。

index * (mergedFontSize + FontGap * ratio)表示文本的垂直位置根据当前索引、字体大小和字体间隙计算得出,确保多行文本不会重叠,并且有适当的间隔。

总的来说,最后的这几行代码确保了无论是单个文本还是多个文本,都可以正确地在<canvas>上居中绘制,并且多行文本之间会有适当的间距,灵活地处理不同的输入内容。

3.使用prepareCanvas函数创建一个新的<canvas>元素(rCtx, rCanvas),用于旋转水印。

  • translate() 方法是对当前网格添加平移变换的方法。将<canvas>按原始 x 点的水平方向、原始的 y 点垂直方向进行平移变换。
 rCtx.translate(realMaxSize / 2, realMaxSize / 2);

通过传入realMaxSize / 2, realMaxSize / 2两个参数,将<canvas>坐标系原点(左上角)移动到<canvas>的中心。

【源码阅读】【万字长文预警】🔍水印保卫战

  • rotate() 方法是 Canvas 2D API 在变换矩阵中增加旋转的方法。角度变量表示一个顺时针旋转角度并且用弧度表示。
rCtx.rotate(angle);

这行代码将<canvas>的坐标系按照指定的角度进行旋转。angle是旋转的角度,通常是以度为单位的值,但在这里已经转换为弧度(const angle = (Math.PI / 180) * Number(rotate))。

旋转操作将会围绕新的原点(即<canvas>的中心)进行,这意味着绘制的内容将会围绕其中心点旋转。

【源码阅读】【万字长文预警】🔍水印保卫战

  • drawImage() 方法提供了多种在画布(<canvas>)上绘制图像的方式。
  if (contentWidth > 0 && contentHeight > 0) {
      rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);
    }

首选通过if (contentWidth > 0 && contentHeight > 0)检查contentWidthcontentHeight是否都大于0,确保有内容可以绘制。

rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);

这行代码将原始canvas上的内容绘制到旋转后的rCanvas上。参数-contentWidth / 2-contentHeight / 2指定了在旋转后的rCanvas上的绘制起点。由于之前已经将坐标系原点移动到中心,这里的负值将会把内容绘制到原点的正方向,从而保持内容在旋转后的rCanvas居中

【源码阅读】【万字长文预警】🔍水印保卫战

(蓝色圆圈即为canvasrCanvas上的绘制起点。)

  1. 计算旋转后的文本边界,并在一个新的<canvas>上绘制填充和交替排列的水印。

为什么要计算旋转后的文本边界,并且还得再在一个新的<canvas>上继续绘制?

下方是我用简单的demo模拟出来的效果,为了更直观地看出差异,我还将<canvas>设置了背景色(即绘制了一个和画布大小相同的紫色矩形盖住)。

【源码阅读】【万字长文预警】🔍水印保卫战

可以看到,当原来的画布被放置在坐标系旋转过后的新画布上时,新画布(即灰色矩形)会露出来部分区域,新画布并不会被原来的画布完全遮盖。因此,咱们要再开一张画布,这张画布将从旋转画布(rCanvas)中截取包含旋转后的文本内容的区域,展示在自身上面。

好,我们继续阅读源码,看看Antd是怎么去绘制这最后一张画布(fCanvas)的。

getRotatePos(x: number, y: number)这个函数接受一个坐标(x,y)作为参数,并返回这个点绕原点旋转指定角度后的新的坐标(targetX,targetY)

    let left = 0;
    let right = 0;
    let top = 0;
    let bottom = 0;

这四行代码初始化了四个变量,用于存储旋转后文本的最小和最大边界。接下来的几行就是计算这里提到的最小和最大边界了。

 const points = [
      [0 - halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 - halfHeight],
      [0 + halfWidth, 0 + halfHeight],
      [0 - halfWidth, 0 + halfHeight],
    ];

这个数组points包含了文本边界四个角的坐标,这些坐标是相对于文本中心的。

    points.forEach(([x, y]) => {
      const [targetX, targetY] = getRotatePos(x, y);
      left = Math.min(left, targetX);
      right = Math.max(right, targetX);
      top = Math.min(top, targetY);
      bottom = Math.max(bottom, targetY);
    });

之后对这个数组进行遍历,针对数组中的每一个点,使用getRotatePos(x: number, y: number)函数计算它在旋转后的新坐标。在循环中,通过比较计算出的新坐标,更新leftrighttopbottom变量的值,以确定旋转后文本的最小和最大边界

【源码阅读】【万字长文预警】🔍水印保卫战 (蓝色点代表更新后的各个点位的最小、最大值。)

    const cutLeft = left + realMaxSize / 2;
    const cutTop = top + realMaxSize / 2;

紧接着,这两行代码计算出了旋转后的文本在rCanvas(也就是上文提到的第二次咱们新建的坐标系旋转后的<canvas>)上的左上角坐标。

    const cutWidth = right - left;
    const cutHeight = bottom - top;

这两行代码计算了旋转后文本的宽度和高度。

【源码阅读】【万字长文预警】🔍水印保卫战 至此,我们完成了前置的计算工作,接下来就是根据当前计算出的文本的真实坐标,确定补偿画布上的文本与其之间的间隙,以及相对位置的坐标。

   const realGapX = gapX * ratio;
   const realGapY = gapY * ratio;

这两行代码根据设备像素比ratio计算了实际的X和Y方向间隙。

  const filledWidth = (cutWidth + realGapX) * 2;
  const filledHeight = cutHeight + realGapY;

这两行代码计算了填充和交替排列后fCanvas的最终宽度和高度。

再完成这最后一步前置计算的工作后,我们终于要开始真正地去绘制最后一张画布了(fCanvas)

    const [fCtx, fCanvas] = prepareCanvas(filledWidth, filledHeight);

    function drawImg(targetX = 0, targetY = 0) {
      fCtx.drawImage(
        ctx,
        cutLeft,
        cutTop,
        cutWidth,
        cutHeight,
        targetX,
        targetY,
        cutWidth,
        cutHeight,
      );
    }
    drawImg();
    drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
    drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);

在开始绘制前,我们再回顾一下drawImage()的各个参数的定义,这将有助于后续我们的理解。

drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);
  • image: 绘制到上下文的元素。允许任何的画布图像源,例如:HTMLImageElementSVGImageElementHTMLVideoElementHTMLCanvasElementImageBitmapOffscreenCanvas 或 VideoFrame

  • sx 可选:需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的左上角 X 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。

  • sy 可选:需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的左上角 Y 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。

  • sWidth 可选: 需要绘制到目标上下文中的,image 的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的 sx 和 sy 开始,到 image 的右下角结束。可以使用 3 参数或 5 参数语法来省略这个参数。使用负值将翻转这个图像。

  • sHeight 可选:需要绘制到目标上下文中的,image的矩形(裁剪)选择框的高度。使用负值将翻转这个图像。

  • dximage 的左上角在目标画布上 X 轴坐标。

  • dyimage 的左上角在目标画布上 Y 轴坐标。

  • dWidthimage 在目标画布上绘制的宽度。允许对绘制的 image 进行缩放。如果不说明,在绘制时 image 宽度不会缩放。注意,这个参数不包含在 3 参数语法中。

  • dHeightimage 在目标画布上绘制的高度。允许对绘制的 image 进行缩放。如果不说明,在绘制时 image 高度不会缩放。注意,这个参数不包含在 3 参数语法中。

【源码阅读】【万字长文预警】🔍水印保卫战

看完详细的参数定义,以及图片示例后,我们再回看源码的处理:

     fCtx.drawImage(
        rCanvas,
        cutLeft,
        cutTop,
        cutWidth,
        cutHeight,
        targetX,
        targetY,
        cutWidth,
        cutHeight,
      );
  • rCanvas是旋转后的rCanvas,这是我们要从中提取图像的部分。
  • cutLeftcutTop是文本在旋转后图像左上角的坐标。
  • cutWidthcutHeight是旋转后文本的宽度和高度。
  • targetXtargetY是我们要在fCanvas上绘制图像的新位置的x和y坐标。
  • cutWidthcutHeight是我们要在fCanvas上绘制的图像的宽度和高度

ok,这下我们明白了

【源码阅读】【万字长文预警】🔍水印保卫战

用之前的demo展示的话,就是类似这样

【源码阅读】【万字长文预警】🔍水印保卫战

那么问题又来了,为什么源码里要drawImg()3次?

    drawImg();
    drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
    drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);

我略微改造了一下源码,使用3种不同颜色代表3次用于绘制的<canvas>,方便大家进行辨识。

  • drawImg(): 红色
  • drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2):黄色
  • drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2):蓝色

当文本印花的间距是默认的100时,

const DEFAULT_GAP_X = 100;
const DEFAULT_GAP_Y = 100;

【源码阅读】【万字长文预警】🔍水印保卫战 则此组件展示如下: 【源码阅读】【万字长文预警】🔍水印保卫战 可以观察到,好像第二次绘制时用黄色表示的文本印花并没有显示出来,这是为什么,难道这行代码是多余的? 那么我们是不是可以将源码改成这样↓

    drawImg();
    // drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
    drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);

注释、甚至删除掉第二行看起来无用的代码,顺便还能给Antd提一个PR,成为开源项目的贡献者,岂不美哉?

【源码阅读】【万字长文预警】🔍水印保卫战

然而现实是....当我们尝试将文本印花的间距由默认的100改为1时,

const DEFAULT_GAP_X = 1;
const DEFAULT_GAP_Y = 1;

并且我们再注释掉中间那一行的代码,取消黄色表示的文本印花的绘制,再看一下组件,效果如下: 【源码阅读】【万字长文预警】🔍水印保卫战

水印残缺了,这显然不是我们想要的,于是我们再将注释掉的中间那一行代码恢复,再看一下组件,效果如下:

【源码阅读】【万字长文预警】🔍水印保卫战

原来如此,原来中间那行的绘制代码也是无可替代的,它负责在间距变小时,补全水印印花的内容。 看来并不能轻易地去对开源项目的代码下判断,定义“多余”,而是要多尝试,多理解,否则就搞出技术乌龙了😓。

(看来这次是不能成为开源项目的贡献者了,😔)

【源码阅读】【万字长文预警】🔍水印保卫战

如果注释掉第三行的话,也是同理,水印会残缺,效果如下:

【源码阅读】【万字长文预警】🔍水印保卫战

至此,我们直观地明白了为什么要绘制黄、蓝两份水印印花的必要性,那么将必要性作为前提,我们继续一起学习一下这么做的巧妙性~

  1. drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);:

    • 我们往drawImg传递了两个参数。
    • cutWidth + realGapXfCanvas上的图像的新x坐标。这个值是从旋转后图像的宽度(cutWidth)加上水平间隙(realGapX)计算得出的。
    • -cutHeight / 2 - realGapY / 2fCanvas上的图像的新y坐标。这个值是从旋转后图像的高度(cutHeight)减去一半的高度(cutHeight / 2)再减去垂直间隙(realGapY)的一半计算得出的。
    • 因此,这个调用会在fCanvas右侧下方绘制旋转后的图像。
  2. drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);:

    • 我们也往drawImg传递了两个参数。
    • cutWidth + realGapXfCanvas上的图像的新x坐标。这个值是从旋转后图像的宽度(cutWidth)加上水平间隙(realGapX)计算得出的。
    • +cutHeight / 2 + realGapY / 2fCanvas上的图像的新y坐标。这个值是从旋转后图像的高度(cutHeight)加上一半的高度(cutHeight / 2)再加上垂直间隙(realGapY)的一半计算得出的。
    • 因此,这个调用会在fCanvas右侧上方绘制旋转后的图像。

这样,我们就创建出了一个交替排列的水印效果,其中水印在水平方向上保持一致,而在垂直方向上交替出现。

【源码阅读】【万字长文预警】🔍水印保卫战 【源码阅读】【万字长文预警】🔍水印保卫战

好像有哪里不对劲??

你会不会有疑问,为什么最终生成的水印图片只有这么一小块,而我们实际看到的页面是被水印图片全覆盖的?它到底是如何覆盖整个页面的?

回忆下appendWatermark的代码

 watermarkEle.setAttribute(
        'style',
        getStyleStr({
          ...markStyle,
          backgroundImage: `url('${base64Url}')`,
          backgroundSize: `${Math.floor(markWidth)}px`,
          ...(emphasizedStyle as React.CSSProperties),
        }),
      );

其实是巧妙用了backgroundImage的缘故。让我们去background-image - CSS:层叠样式表 | MDN (mozilla.org)回忆一下这个CSS属性吧。

【源码阅读】【万字长文预警】🔍水印保卫战

如果background-image的属性值是url(),它会加载并显示相应的图像。如果图像元素的尺寸小于背景内容区域,图像元素会重复多次以填满整个背景内容区域。

这也就是为什么我们虽然只生成了1张小水印,但是却可以铺满整个页面的原因。

提供了一个demo ,大家也可以玩一下 【源码阅读】【万字长文预警】🔍水印保卫战

useRafDebounce

import React from 'react';
import { useEvent } from 'rc-util';
import raf from 'rc-util/lib/raf';

/**
 * Callback will only execute last one for each raf
 */
export default function useRafDebounce(callback: VoidFunction) {
  const executeRef = React.useRef(false);
  const rafRef = React.useRef<number>();

  const wrapperCallback = useEvent(callback);

  return () => {
    if (executeRef.current) {
      return;
    }

    executeRef.current = true;
    wrapperCallback();

    rafRef.current = raf(() => {
      executeRef.current = false;
    });
  };
}

看完水印绘制的巧妙,咱们换换口味,一起来品一品性能优化🧐

useRafDebounce是一个自定义的React Hook,它接受一个参数:

  • callback:被期望进行防抖设置的回调函数,VoidFunction 是 TypeScript 中的一个泛型,它代表没有返回值的函数。。

useRafDebounce的作用是防止在短时间内多次触发同一个回调函数。它通过请求动画帧(requestAnimationFrame,简称raf)来限制回调函数的执行,确保在每一帧内回调函数只被执行一次,从而达到防抖的效果。

防抖(Debounce)是一种常见的优化手段,用于避免在短时间内频繁触发某个函数,比如窗口大小改变、滚动事件等。如果没有防抖,这些事件可能会在短时间内被大量触发,导致性能问题。

和前文阅读源码的方式相同,我们也对useRafDebounce进行逐行分析:

  1. executeRef是一个React的useRef对象,用于存储一个布尔值,表示回调函数是否已经在当前帧内执行过。
  2. rafRef也是一个useRef对象,用于存储raf返回的ID,以便在需要时取消未执行的帧。
  3. wrapperCallback是通过useEvent包装的回调函数,它可以确保回调函数在组件的不同渲染之间保持稳定,避免不必要的重渲染。

useEvent做了什么?为什么要使用useEvent去包一层callback?

本着知其然,知其所以然的主旨,让我们先来看看useEvent的源码

import * as React from 'react';

export default function useEvent<T extends Function>(callback: T): T {
  const fnRef = React.useRef<any>();
  fnRef.current = callback;

  const memoFn = React.useCallback<T>(
    ((...args: any) => fnRef.current?.(...args)) as any,
    [],
  );

  return memoFn;
}
  const fnRef = React.useRef<any>();
  • 这里使用 React.useRef 创建了一个 ref 对象 fnRef。这个 ref 对象的目的是在组件的不同渲染之间保持一个最新的引用。初始值设置为 any 类型,这意味着它可以存储任何类型的值。
  fnRef.current = callback;
  • 在每次渲染时,将传入的 callback 函数赋值给 fnRef.current
  const memoFn = React.useCallback<T>(
    ((...args: any) => fnRef.current?.(...args)) as any,
    [],
  );
  return memoFn;

  • 使用 React.useCallback 创建了一个记忆化的函数 memoFn。这个函数接受任意数量的参数 args,并调用 fnRef.current 上的同名方法,传入这些参数。由于每次渲染时fnRef.current都会更新为最新传入的callback,这确保了在调用memoFn时,能够将args传递给最新的callback函数。[] 作为依赖项数组,这里并不存在任何依赖项,因此 memoFn 只会在组件挂载时创建一次。

  • 最后,函数返回了 memoFn。由于 memoFn 的引用在组件的多次渲染之间保持不变,因此可以安全地将其作为事件处理函数传递给子组件,而不用担心由于引用变化导致的不必要的重渲染。

总的来说,useEvent 通过结合 useRef 和 useCallback,确保了传递给它的函数在组件的多次渲染之间保持相同的引用,同时总是能够访问到最新的状态(即每次memoFn在调用时,对应的callback也一定是最新的)。这在处理事件处理器或其他需要稳定引用的场景中非常有用。

至此,通过阅读useEvent的源码,我们了解了useEvent到底做了什么,以及是怎么做到的。接下来我们继续阅读useRafDebounce的源码

  1. 返回的函数是防抖包装后的函数。

  return () => {
    if (executeRef.current) {
      return;
    }

    executeRef.current = true;
    wrapperCallback();

    rafRef.current = raf(() => {
      executeRef.current = false;
    });
  };

当你调用这个函数时,它会检查executeRef的值。

  • 如果executeRef.currenttrue,说明在当前帧内已经执行过此函数,因此直接返回,不做任何处理。
  • 如果为false,则设置executeRef.currenttrue,立即执行回调函数,并通过raf设置一个定时器,在下一帧开始时将executeRef.current重置为false

通过这种方式,useRafDebounce确保了在每一帧内,即使防抖包装的函数被多次调用,也只会执行一次回调函数,从而提高了性能和响应的稳定性。

utils

/** converting camel-cased strings to be lowercase and link it with Separato */
export function toLowercaseSeparator(key: string) {
  return key.replace(/([A-Z])/g, '-$1').toLowerCase();
}

export function getStyleStr(style: React.CSSProperties): string {
  return Object.keys(style)
    .map((key) => `${toLowercaseSeparator(key)}: ${style[key as keyof React.CSSProperties]};`)
    .join(' ');
}

/** Returns the ratio of the device's physical pixel resolution to the css pixel resolution */
export function getPixelRatio() {
  return window.devicePixelRatio || 1;
}

/** Whether to re-render the watermark */
export const reRendering = (mutation: MutationRecord, isWatermarkEle: (ele: Node) => boolean) => {
  let flag = false;
  // Whether to delete the watermark node
  if (mutation.removedNodes.length) {
    flag = Array.from<Node>(mutation.removedNodes).some((node) => isWatermarkEle(node));
  }
  // Whether the watermark dom property value has been modified
  if (mutation.type === 'attributes' && isWatermarkEle(mutation.target)) {
    flag = true;
  }
  return flag;
};

这个utils模块包含了一些工具函数,每个函数都有其特定的作用:

  1. toLowercaseSeparator函数: 这个函数的作用是在驼峰命名法的字符串中,将连字符-加在大写字母前方,然后再将所有字母转换为小写。
    • ([A-Z]):这是一个捕获组(capturing group),括号内的表达式会匹配一个从A到Z的任意大写字母,并且将匹配到的字符作为一个分组进行捕获,以便可以在替换文本中使用。

    • g:这是一个修饰符,表示全局匹配(global match),即替换所有匹配的子串,而不是只替换第一个匹配的子串。

    • -$1: 这是replace方法的第二个参数,也就是替换文本。在这个表达式中:

      • $1: 表示第一个捕获组的内容,也就是正则表达式中括号内匹配到的内容(在这里指大写字母)。
      • -: 这是一个连字符,它会加在捕获到的大写字母前面。
// 举个例子
toLowercaseSeparator('backgroundColor')
//输出:background-color

主要是用于CSS样式属性转换,因为CSS属性通常使用连字符分隔的小写字母表示。

  1. getStyleStr函数: 这个函数接受一个React的CSSProperties对象,将其转换为CSS字符串。它首先使用toLowercaseSeparator函数将样式对象的键(CSS属性名)转换为符合CSS规范的格式,然后将属性名和值组合成CSS声明,最后将所有声明拼接成一个完整的CSS字符串。
// 举个例子,假设有一个块级元素的行内样式是这样的
<div style={{backgroundColor:'white',backgroundSize:'100px'}}>demo</div>
// 调用getStyleStr函数
getStyleStr({backgroundColor:'white',backgroundSize:'100px'})
// 输出: `background-color:'white';background-size:'100px';`

(也可以通过回顾useWatermark的源码,直观地看到它的使用) 【源码阅读】【万字长文预警】🔍水印保卫战

  1. getPixelRatio函数: 这个函数返回设备的物理像素分辨率与CSS像素分辨率的比例。这个比例对于高分辨率显示屏尤其重要,因为它们可能有超过一个物理像素对应一个CSS像素。这个函数的返回值可以用于调整<canvas>或图像的大小,以确保它们在不同的设备上具有一致的视觉效果。

  2. reRendering函数: 这个函数用于判断是否需要重新渲染水印。它接受两个参数:

    • mutation:一个MutationRecord对象,它包含了DOM变化的详细信息。

    • isWatermarkEle:一个函数,用于判断一个节点是否是水印元素。

      函数的工作原理如下:

      • 检查mutation.removedNodes,如果水印节点被删除,则返回true,表示需要重新渲染水印。
      • 如果变化类型是attributes,并且变化的节点是水印元素,则返回true,表示需要重新渲染水印。
      • 如果以上条件都不满足,则返回false,表示不需要重新渲染水印。

这个utils模块中的函数都是通用的工具函数,主要用于处理CSS样式、设备像素比和监测DOM变化。

index

300多行,处于篇幅考虑,这里和上面的章节不一样,起手就不贴源码了,大家可以点开这里的链接,一边对照源码一边结合本章内容使用👍。

在之前的章节里,我们把实现水印组件依赖的Hooks和工具函数都介绍了一遍,并且深挖了实现原理。这就好比我们准备好了发动机、底盘、车身、电气设备,接下来就是讨论如何将它们组装成一辆汽车并且能够开上路

【源码阅读】【万字长文预警】🔍水印保卫战 index.tsx文件是水印组件的主入口,它定义了一个名为Watermark的React组件,该组件用于在页面上生成水印效果。

为了方便理解,我们对此文件也进行一下结构拆分

思维导图

【源码阅读】【万字长文预警】🔍水印保卫战

Props

当我们选择从组件库中使用某个组件时,我们最先关注的一定是这个组件的API。同理,当我们分析一个组件时,最先要做的也是掌握它的Props

export interface WatermarkProps {
  zIndex?: number;
  rotate?: number;
  width?: number;
  height?: number;
  image?: string;
  content?: string | string[];
  font?: {
    color?: CanvasFillStrokeStyles['fillStyle'];
    fontSize?: number | string;
    fontWeight?: 'normal' | 'light' | 'weight' | number;
    fontStyle?: 'none' | 'normal' | 'italic' | 'oblique';
    fontFamily?: string;
    textAlign?: CanvasTextAlign;
  };
  style?: React.CSSProperties;
  className?: string;
  rootClassName?: string;
  gap?: [number, number];
  offset?: [number, number];
  children?: React.ReactNode;
  inherit?: boolean;
}

倒是不像其他UI组件一样具备某些特定功能,这个组件的大部分Props都是和样式相关,故不作赘述,直接贴一张官网的API介绍图

【源码阅读】【万字长文预警】🔍水印保卫战

Content

循序渐进,先来聊一聊Content的部分,它定义了如何生成水印内容以及更新水印内容。

  1. const getMarkSize = (ctx: CanvasRenderingContext2D) => { ... };:

    • 用于返回水印的宽度和高度。
    • 函数内部,它首先定义了默认的宽度和高度。
    • 然后,它检查Watermark组件的Props是否传入了图像(image)和水印内容(content)。如果传入的是图像,它会使用默认的宽度和高度值。
    • 如果传入的是文本内容,它会计算文本内容的宽度和高度。
      ctx.font = `${Number(fontSize)}px ${fontFamily}`;
      const contents = Array.isArray(content) ? content : [content];
      // 这行代码使用map方法遍历contents数组,并计算元素的宽度和高度
      const sizes = contents.map((item) => {
      // `ctx.measureText(item!)`用于测量文本的宽度
      const metrics = ctx.measureText(item!);
      
      return [metrics.width, metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent];
      });
      // 这行代码计算sizes数组中所有文本宽度的最大值,并将其赋值给`defaultWidth`。
      defaultWidth = Math.ceil(Math.max(...sizes.map((size) => size[0])));
      // 这行代码计算sizes数组中所有文本高度的最大值,并将其乘以`contents`数组的长度,然后加上`(contents.length - 1) * FontGap`,最后赋值给`defaultHeight`
      // 这么做是为了确保水印的高度足够容纳所有的文本行,并提供了足够的间隙,
      defaultHeight =
      Math.ceil(Math.max(...sizes.map((size) => size[1]))) * contents.length +
      (contents.length - 1) * FontGap;
      
    • 最后,它返回最终计算得到的宽度和高度。
  2. const getClips = useClips();:

    • 使用上文分析过的Hook,用于生成能够作为background-iamge的水印内容的dataUrl。
  3. const [watermarkInfo, setWatermarkInfo] = React.useState<[base64: string, contentWidth: number]>(null!);:

    • 创建了一个状态变量watermarkInfo,它是一个包含base64字符串(也就是dataUrl)和content宽度(水印宽度)的数组。这个数组的内容恰好就是appendWatermark所需要的参数。
    • null!表示即使watermarkInfonull,也会将其强制转换为非null,这里这么写感觉纯纯是为了解决ts error,忽略一下报错😄。
  4. const renderWatermark = () => { ... };:

    • 用于生成新的水印内容。
    • 函数内部,它创建了一个新的canvas元素和2D渲染上下文ctx
    • 它通过getMarkSize函数计算了水印的宽度和高度,并使用这些值来绘制水印。
    • 如果存在图像(image),它会加载图像并等待图像加载完成后绘制水印。
    • 如果图像加载失败,它会使用文本内容(content)来绘制水印。
    • 绘制完成后,它会更新watermarkInfo状态变量,以存储水印的base64字符串(即dataUrl)和内容宽度。
  5. const syncWatermark = useRafDebounce(renderWatermark);:

    • 使用useRafDebounce创建了一个函数syncWatermark,它将renderWatermark函数的执行延迟一段时间。这样做可以减少水印的生成频率,从而提高性能(即防抖)。

总之,这段代码确保了水印内容的生成是符合期望的以及高效的。它通过useStateuseClips来管理和更新水印的内容(随着watermarkInfo的变化而调用useClips来更新水印的内容),并通过useRafDebounce优化水印的生成频率。

Effect

决定何时将水印附加到目标元素上。

  1. 首先,从我们的老朋友useWatermark中解构出3个方法,拿到能够实现添加水印的appendWatermark
  2. 然后通过useEffect将水印添加到目标元素上。在其副作用函数中,先判断watermarkInfo是否不为null,只有当水印信息准备就绪后,才会调用appendWatermark,将水印添加到目标元素上。

总之,这段代码确保了当水印内容准备好时,它会将水印附加到目标元素上。同时,它还确保了当目标元素列表发生变化时,水印会被重新添加。

Observe

负责监听DOM的变化,并根据发生的变化来执行一些操作。

(这里也使用了useEvent,可以回顾一下它的作用~)

  const onMutate = useEvent((mutations: MutationRecord[]) => {
    mutations.forEach((mutation) => {
      if (reRendering(mutation, isWatermarkEle)) {
        syncWatermark();
      } else if (mutation.target === container && mutation.attributeName === 'style') {
        // We've only force container not modify.
        // Not consider nest case.
        const keyStyles = Object.keys(fixedStyle);

        for (let i = 0; i < keyStyles.length; i += 1) {
          const key = keyStyles[i];
          const oriValue = (mergedStyle as any)[key];
          const currentValue = (container.style as any)[key];

          if (oriValue && oriValue !== currentValue) {
            (container.style as any)[key] = oriValue;
          }
        }
      }
    });
  });

  useMutateObserver(targetElements, onMutate);

  useEffect(syncWatermark, [
    rotate,
    zIndex,
    width,
    height,
    image,
    content,
    color,
    fontSize,
    fontWeight,
    fontStyle,
    fontFamily,
    textAlign,
    gapX,
    gapY,
    offsetLeft,
    offsetTop,
  ]);
  1. 首先,先聊一下onMutate。它接受一个参数:

    • mutations:一个包含多个MutationRecord对象的数组,每个MutationRecord对象代表一个DOM元素的变化。

    【源码阅读】【万字长文预警】🔍水印保卫战

    【源码阅读】【万字长文预警】🔍水印保卫战 函数内部先循环遍历这个mutations,根据utils提供的工具函数reRendering的结果,判断是否要重新生成水印,如果reRendering返回的是true,则调用syncWatermark(),重新生成水印。

    如果不需要重新生成水印,但是mutation中的所影响的节点是container并且attributeName还是style时(即修改了container的style),强制container使用固定的样式(即如果某个样式与mergedStyle中的不同,重置为mergedStyle中定义的值)。这样一来,用户就不能通过修改visibility: 'hidden'来实现隐藏水印了。

    const keyStyles = Object.keys(fixedStyle);
    
        for (let i = 0; i < keyStyles.length; i += 1) {
          const key = keyStyles[i];
          const oriValue = (mergedStyle as any)[key];
          const currentValue = (container.style as any)[key];
    
          if (oriValue && oriValue !== currentValue) {
            (container.style as any)[key] = oriValue;
          }
        }
    
  2. 然后,看这一行useMutateObserver(targetElements, onMutate);,先聊一下useMutateObserver

    import canUseDom from 'rc-util/lib/Dom/canUseDom';
    import * as React from 'react';
    
    const defaultOptions: MutationObserverInit = {
      subtree: true,
      childList: true,
      attributeFilter: ['style', 'class'],
    };
    
    export default function useMutateObserver(
      nodeOrList: HTMLElement | HTMLElement[],
      callback: MutationCallback,
      options: MutationObserverInit = defaultOptions,
    ) {
      React.useEffect(() => {
        if (!canUseDom() || !nodeOrList) {
          return;
        }
    
        let instance: MutationObserver;
    
        const nodeList = Array.isArray(nodeOrList) ? nodeOrList : [nodeOrList];
    
        if ('MutationObserver' in window) {
          instance = new MutationObserver(callback);
    
          nodeList.forEach(element => {
            instance.observe(element, options);
          });
        }
        return () => {
          instance?.takeRecords();
          instance?.disconnect();
        };
      }, [options, nodeOrList]);
    }
    

    我们也逐行分析一下它的源码:

    1. const defaultOptions: MutationObserverInit = { ... };:

    • 这行代码定义了一个默认的MutationObserverInit对象,它包含了MutationObserver的一些初始化选项,如subtreechildListattributeFilter

    1. export default function useMutateObserver( ... ) { ... };:

    • 这行代码定义了useMutateObserver函数。

    • 它接受三个参数:nodeOrList(要监听的DOM元素或元素数组)、callback(变化发生时的回调函数)和optionsMutationObserver的初始化选项)。

    1. React.useEffect(() => { ... }, [options, nodeOrList]);:

    • 副作用函数首先检查是否可以操作DOM(通过canUseDom())以及要监听的DOM元素或元素数组是否为空(!nodeOrList)来决定接下来的执行逻辑。

    • 如果条件满足,它创建一个MutationObserver实例,并设置回调函数为callback。遍历nodeList,对每个DOM元素,执行实例方法instance.observe,即调用之前设置好的callback。这也是为什么onMutate(callback)能对container(DOM元素)生效的原因

    • 副作用函数的依赖项是optionsnodeOrList,这意味着当这些依赖项中的任何一个发生变化时,副作用函数将被重新执行。

    1. return () => { ... };:

    • 这行代码定义了副作用函数的返回值,它是一个清理函数。
    • 清理函数首先调用instance?.takeRecords(),这会停止观察MutationObserver实例并返回所有记录的变化。
    • 然后,它调用instance?.disconnect(),这会完全停止观察并释放MutationObserver实例。

    useMutateObserver通过创建一个MutationObserver实例,并设置回调函数来监听DOM元素的变化。当依赖项发生变化时,它会重新创建或更新MutationObserver实例。当组件卸载时,它会清理MutationObserver实例,以避免内存泄漏。

  3. 这块内容的最后一行代码就是一个useEffect,很好理解,当此useEffect的依赖项发生变化时(如:字体大小变化、旋转角度变化等等),就通过syncWatermark()重新生成水印,不再赘述。

Context

定义了一个名为watermarkContext的上下文对象,它用于管理水印的添加和移除。

  const watermarkContext = React.useMemo<WatermarkContextProps>(
    () => ({
      add: (ele) => {
        setSubElements((prev) => {
          const clone = new Set(prev);
          clone.add(ele);
          return getSizeDiff(prev, clone);
        });
      },
      remove: (ele) => {
        removeWatermark(ele);

        setSubElements((prev) => {
          const clone = new Set(prev);
          clone.delete(ele);

          return getSizeDiff(prev, clone);
        });
      },
    }),
    [],
  );
  1. 首先,在讲它的源码之前,我们先看一下getSizeDiff这个函数
/**
 * Only return `next` when size changed.
 * This is only used for elements compare, not a shallow equal!
 */
function getSizeDiff<T>(prev: Set<T>, next: Set<T>) {
  return prev.size === next.size ? prev : next;
}

getSizeDiff的作用是判断两个集合(Set)的大小是否发生了变化。如果集合的大小没有变化,它将返回第一个集合;如果集合的大小发生了变化,它将返回第二个集合。这个函数的目的是在比较两个集合时,仅当集合的大小发生变化时才返回新的集合。

  1. Ok,接下来我们再回过头来去看看,在watermarkContext中,为什么要用getSizeDiff

    • watermarkContext的上下文对象中,有一个add方法,它用于添加新的子元素到水印管理中。这个方法调用了setSubElements函数,这个函数使用getSizeDiff函数来比较新旧集合的大小。如果新集合的大小与旧集合的大小相同,它将返回旧集合;如果新集合的大小与旧集合的大小不同,它将返回新集合。

    • watermarkContext的上下文对象中,还有一个remove方法,它用于从水印管理中移除指定的子元素。这个方法同样调用了setSubElements函数,使用getSizeDiff函数来比较新旧集合的大小。

通过使用getSizeDiff函数,watermarkContext确保了在添加或移除子元素时,集合的大小不会被意外地重置回旧集合的大小,从而保持了集合的完整性。

Render

决定了是否在上下文提供者中渲染子节点,以及如何合并样式和类名。

  const childNode = inherit ? (
    <WatermarkContext.Provider value={watermarkContext}>{children}</WatermarkContext.Provider>
  ) : (
    children
  );
  1. 首先,声明了一个变量childNode,使用了三元表达式来决定对此变量的赋值内容。

    • 如果inherit变量为true,它将创建一个WatermarkContext.Provider组件,并将watermarkContext作为值传递给上下文。{children}是组件的子节点,它们将被包裹在上下文提供者中。

    • 如果inherit变量为false,它将直接渲染子节点,不使用上下文提供者。

 return (
   <div ref={setContainer} className={classNames(className, rootClassName)} style={mergedStyle}>
     {childNode}
   </div>
 );

  1. 返回一个包裹childNode变量的块级元素。
    • ref={setContainer}:可以看成是ref={(ref) => setContainer(ref)},即创建了一个回调函数作为ref的值,这个回调函数会在组件挂载时被调用,并传入当前的DOM元素,从而将通过useState声明的container的值置为此DOM元素。
    • className={classNames(className, rootClassName)}:将classNamerootClassName合并为一个类名,并传递给div元素。
    • style={mergedStyle}:这个属性将mergedStyle对象传递给div元素,用于设置元素的样式。
    • {childNode}:这表示将之前声明的childNode变量渲染到div元素中。

总之,这段代码确保了子节点在inherit变量为true时被包裹在上下文提供者中,以便它们能够访问上下文提供的功能。同时,它合并了样式和类名,并将父容器信息赋值给container,这样咱们的appendWatermark函数才能将水印加到父容器上。

阶段性小结

我们仔细地分析了antd 5.x的Watermark组件的源码,基本了解了它是如何生成一个水印组件的。在阅读的过程中,我们也知晓了它是利用什么原理去达成「保护水印」的目标——在水印被删除时,重新生成水印。在水印的样式被修改时,将此修改回滚。

不仅如此,我们还顺带回顾了各种各样的知识(useState声明变量的方式、CSS属性等等),在阅读组件源码的基础上,又进一步阅读了组件所依赖的某些第三方库函数的源码(useEvent,useMutateObserver等等)。

动手,try it

Talk is cheap, show me the code.

考虑到篇幅限制,将会和上文提到的性能对比一起,放到下一篇文章中,先接触理论,再一起动手实践,敬请期待~

(到时候更新了,会在这里贴一个文章链接。)

防君子不防“小人”

虽然咱们分析了antd的源码,了解了它是如何实现保护水印的,但是如果遇到有心之人,点开浏览器的【设置】页面的话......

【源码阅读】【万字长文预警】🔍水印保卫战

假如他开启了「停用JavaScript」,那么利用检测Dom变化来实现水印保护的方式就白忙活了😢

antd倒下了

【源码阅读】【万字长文预警】🔍水印保卫战

Man, What can I say ?Ant design, Out!

结语

这是我第一次写这样一篇有关源码阅读的长文,感谢你能看到这里,真心希望你能从本文中有所收获。

第一次的尝试我想并不会那么顺利,假如你看完本文之后有任何的意见或者建议,欢迎在评论区留言,我一定会看的,并且尝试做出改进。

扩展

转载自:https://juejin.cn/post/7382892875103682575
评论
请登录