【源码阅读】【万字长文预警】🔍水印保卫战
什么是水印
概念
水印技术是一种在数字内容中嵌入隐蔽或半隐蔽信息的方法,这些信息可以是文本、图像或其他标识符。
作用
- 版权保护:水印可以帮助内容创作者保护其作品的版权,防止他人未经许可复制或传播其作品。
- 来源追踪:通过在内容中嵌入特定的标识,水印可以帮助追踪内容的传播路径,尤其是在内容被非法分发时。
- 所有权证明:水印可以作为证明内容所有权的法律证据。
- 防伪:在某些情况下,水印还可以用于验证内容的真实性,防止伪造。
- 访问控制:水印可以用来标记不同的访问级别或分发渠道,从而对内容的访问和使用进行控制。
组成
在我看来,将要展示的信息作为印花,放到一张画布上,再结合一些样式设置,就能得到覆盖页面的「水印」组件
了。
我司网页的水印
考虑到隐私性,仅截图局部做展示,如下:
如果我们在「元素标签页」
直接删除与「水印」
相关的块级元素。
我们会发现可以成功删除。此时我司系统的这个页面就是一个没有水印的页面了。
(其实,除了直接删除块级元素之外,我们还可以通过修改样式,将水印隐藏,比如设置: visibility: hidden
)
Antd 5.x的水印
我们继续用同样的手法,试着删除antd的水印。
会发现删除失败。与其说是删除失败,不如说是按下「backspace」后好像一点反应都没有。
这里是一个Demo链接,大家可以亲自尝试下。
和Antd
一对比,我们不难发现,我司系统页面上的水印可以被轻而易举地删除😣。
这不由得让人心中响起一声呐喊:
水印保护我们,但是谁来保护水印?
保护水印
就让我们一起来阅读antd 5.x的源码,看看它为了「保护水印」
都做了什么
思维导图
源码分析
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中使用了
useState
或useContext
,并且它们的依赖发生了变化,那么组件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
实例”
我们临时看下useWatermark
在index.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计算得出的。这就意味着只有zIndex
、offsetLeft
、gapXCenter
、offsetTop
和gapYCenter
发生变化时,markStyle
才会发生变化。
至此,我们的问题转换成最终形态:
为什么
zIndex
、offsetLeft
、gapXCenter
、offsetTop
和gapYCenter
发生变化,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
,即:zIndex
、offsetLeft
、gapXCenter
、offsetTop
和gapYCenter
发生变化,也无需通过生成一个新的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);
};
-
函数首先检查
watermarkMap
(用于存储容器元素和对应的水印元素的关联关系。) 中是否已经有了对应容器的水印元素,如果没有,则创建一个新的div
元素作为水印元素,并将其添加到watermarkMap
中。 -
然后,设置水印元素的样式,包括背景图片、背景大小和一些强调样式(即
emphasizedStyle
),以防止水印被隐藏。
// Prevent external hidden elements from adding accent styles
const emphasizedStyle = {
visibility: 'visible !important',
};
- 最后,如果水印元素尚未添加到容器中,将其添加到容器中。
注意,由于
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];
}
- 函数首先通过
document.createElement('canvas')
创建了一个新的<canvas>
元素。然后通过getContext()
方法获取此元素的渲染上下文。
渲染上下文可以用来绘制和处理
canvas
要展示的内容。——MDN
-
接着,根据传入的宽度、高度、设备像素比来计算实际的宽度(
realWidth
)和高度(realHeight
)。并通过canvas.setAttribute()
方法设置<canvas>
元素的宽度和高度(这里是以像素为单位的)。 -
在完成基本配置后,使用了渲染上下文(
ctx
)提供的save()
方法,保存当前的绘图状态。
及时使用
ctx.save()
是个好习惯,尤其是在进行一系列变换操作之前。它通常与ctx.restore()
(它是将<canvas>
恢复到最近的保存状态的方法)配对使用。当我们对<canvas>
进行了多次变换或者其他操作后,可能需要回到之前的状态,这时候save()
和restore()
方法就非常有用。
- 在函数的最后,它返回一个数组,包含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];
}
- 函数首先使用
prepareCanvas()
方法创建一个<canvas>
元素,并获取其2D渲染上下文(ctx
),以及根据设备像素比调整后的实际宽度和高度。 - 判断从外部接收的
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)
检查contentWidth
和contentHeight
是否都大于0,确保有内容可以绘制。
rCtx.drawImage(canvas, -contentWidth / 2, -contentHeight / 2);
这行代码将原始canvas
上的内容绘制到旋转后的rCanvas
上。参数-contentWidth / 2
和-contentHeight / 2
指定了在旋转后的rCanvas
上的绘制起点。由于之前已经将坐标系原点移动到中心,这里的负值将会把内容绘制到原点的正方向,从而保持内容在旋转后的rCanvas
上居中。
(蓝色圆圈即为canvas
在rCanvas
上的绘制起点。)
- 计算旋转后的文本边界,并在一个新的
<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)
函数计算它在旋转后的新坐标。在循环中,通过比较计算出的新坐标,更新left
、right
、top
和bottom
变量的值,以确定旋转后文本的最小和最大边界。
(蓝色点代表更新后的各个点位的最小、最大值。)
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
: 绘制到上下文的元素。允许任何的画布图像源,例如:HTMLImageElement
、SVGImageElement
、HTMLVideoElement
、HTMLCanvasElement
、ImageBitmap
、OffscreenCanvas
或VideoFrame
。 -
sx
可选:需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的左上角 X 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。 -
sy
可选:需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的左上角 Y 轴坐标。可以使用 3 参数或 5 参数语法来省略这个参数。 -
sWidth
可选: 需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的宽度。如果不说明,整个矩形(裁剪)从坐标的sx
和sy
开始,到image
的右下角结束。可以使用 3 参数或 5 参数语法来省略这个参数。使用负值将翻转这个图像。 -
sHeight
可选:需要绘制到目标上下文中的,image
的矩形(裁剪)选择框的高度。使用负值将翻转这个图像。 -
dx
:image
的左上角在目标画布上 X 轴坐标。 -
dy
:image
的左上角在目标画布上 Y 轴坐标。 -
dWidth
:image
在目标画布上绘制的宽度。允许对绘制的image
进行缩放。如果不说明,在绘制时image
宽度不会缩放。注意,这个参数不包含在 3 参数语法中。 -
dHeight
:image
在目标画布上绘制的高度。允许对绘制的image
进行缩放。如果不说明,在绘制时image
高度不会缩放。注意,这个参数不包含在 3 参数语法中。
看完详细的参数定义,以及图片示例后,我们再回看源码的处理:
fCtx.drawImage(
rCanvas,
cutLeft,
cutTop,
cutWidth,
cutHeight,
targetX,
targetY,
cutWidth,
cutHeight,
);
rCanvas
是旋转后的rCanvas
,这是我们要从中提取图像的部分。cutLeft
和cutTop
是文本在旋转后图像左上角的坐标。cutWidth
和cutHeight
是旋转后文本的宽度和高度。targetX
和targetY
是我们要在fCanvas
上绘制图像的新位置的x和y坐标。cutWidth
和cutHeight
是我们要在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;
并且我们再注释掉中间那一行的代码,取消黄色表示的文本印花的绘制,再看一下组件,效果如下:
水印残缺了,这显然不是我们想要的,于是我们再将注释掉的中间那一行代码恢复,再看一下组件,效果如下:
原来如此,原来中间那行的绘制代码也是无可替代的,它负责在间距变小时,补全水印印花的内容。 看来并不能轻易地去对开源项目的代码下判断,定义“多余”,而是要多尝试,多理解,否则就搞出技术乌龙了😓。
(看来这次是不能成为开源项目的贡献者了,😔)
如果注释掉第三行的话,也是同理,水印会残缺,效果如下:
至此,我们直观地明白了为什么要绘制黄、蓝两份水印印花的必要性,那么将必要性作为前提,我们继续一起学习一下这么做的巧妙性~
-
drawImg(cutWidth + realGapX, -cutHeight / 2 - realGapY / 2);
:- 我们往drawImg传递了两个参数。
cutWidth + realGapX
是fCanvas
上的图像的新x坐标。这个值是从旋转后图像的宽度(cutWidth
)加上水平间隙(realGapX
)计算得出的。-cutHeight / 2 - realGapY / 2
是fCanvas
上的图像的新y坐标。这个值是从旋转后图像的高度(cutHeight
)减去一半的高度(cutHeight / 2
)再减去垂直间隙(realGapY
)的一半计算得出的。- 因此,这个调用会在
fCanvas
的右侧下方绘制旋转后的图像。
-
drawImg(cutWidth + realGapX, +cutHeight / 2 + realGapY / 2);
:- 我们也往drawImg传递了两个参数。
cutWidth + realGapX
是fCanvas
上的图像的新x坐标。这个值是从旋转后图像的宽度(cutWidth
)加上水平间隙(realGapX
)计算得出的。+cutHeight / 2 + realGapY / 2
是fCanvas
上的图像的新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
进行逐行分析:
executeRef
是一个React的useRef
对象,用于存储一个布尔值,表示回调函数是否已经在当前帧内执行过。rafRef
也是一个useRef
对象,用于存储raf
返回的ID,以便在需要时取消未执行的帧。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
的源码
- 返回的函数是防抖包装后的函数。
return () => {
if (executeRef.current) {
return;
}
executeRef.current = true;
wrapperCallback();
rafRef.current = raf(() => {
executeRef.current = false;
});
};
当你调用这个函数时,它会检查executeRef
的值。
- 如果
executeRef.current
为true
,说明在当前帧内已经执行过此函数,因此直接返回,不做任何处理。 - 如果为
false
,则设置executeRef.current
为true
,立即执行回调函数,并通过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
模块包含了一些工具函数,每个函数都有其特定的作用:
toLowercaseSeparator
函数: 这个函数的作用是在驼峰命名法的字符串中,将连字符-
加在大写字母前方,然后再将所有字母转换为小写。-
([A-Z])
:这是一个捕获组(capturing group),括号内的表达式会匹配一个从A到Z的任意大写字母,并且将匹配到的字符作为一个分组进行捕获,以便可以在替换文本中使用。 -
g
:这是一个修饰符,表示全局匹配(global match),即替换所有匹配的子串,而不是只替换第一个匹配的子串。 -
-$1
: 这是replace方法的第二个参数,也就是替换文本。在这个表达式中:$1
: 表示第一个捕获组的内容,也就是正则表达式中括号内匹配到的内容(在这里指大写字母)。-
: 这是一个连字符,它会加在捕获到的大写字母前面。
-
// 举个例子
toLowercaseSeparator('backgroundColor')
//输出:background-color
主要是用于CSS样式属性转换,因为CSS属性通常使用连字符分隔的小写字母表示。
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
的源码,直观地看到它的使用)
-
getPixelRatio
函数: 这个函数返回设备的物理像素分辨率与CSS像素分辨率的比例。这个比例对于高分辨率显示屏尤其重要,因为它们可能有超过一个物理像素对应一个CSS像素。这个函数的返回值可以用于调整<canvas>
或图像的大小,以确保它们在不同的设备上具有一致的视觉效果。 -
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的部分,它定义了如何生成水印内容以及更新水印内容。
-
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;
- 最后,它返回最终计算得到的宽度和高度。
-
const getClips = useClips();
:- 使用上文分析过的Hook,用于生成能够作为
background-iamge
的水印内容的dataUrl。
- 使用上文分析过的Hook,用于生成能够作为
-
const [watermarkInfo, setWatermarkInfo] = React.useState<[base64: string, contentWidth: number]>(null!);
:- 创建了一个状态变量
watermarkInfo
,它是一个包含base64字符串(也就是dataUrl)和content宽度(水印宽度)的数组。这个数组的内容恰好就是appendWatermark
所需要的参数。 null!
表示即使watermarkInfo
是null
,也会将其强制转换为非null
,这里这么写感觉纯纯是为了解决ts error,忽略一下报错😄。
- 创建了一个状态变量
-
const renderWatermark = () => { ... };
:- 用于生成新的水印内容。
- 函数内部,它创建了一个新的
canvas
元素和2D渲染上下文ctx
。 - 它通过
getMarkSize
函数计算了水印的宽度和高度,并使用这些值来绘制水印。 - 如果存在图像(
image
),它会加载图像并等待图像加载完成后绘制水印。 - 如果图像加载失败,它会使用文本内容(
content
)来绘制水印。 - 绘制完成后,它会更新
watermarkInfo
状态变量,以存储水印的base64字符串(即dataUrl)和内容宽度。
-
const syncWatermark = useRafDebounce(renderWatermark);
:- 使用
useRafDebounce
创建了一个函数syncWatermark
,它将renderWatermark
函数的执行延迟一段时间。这样做可以减少水印的生成频率,从而提高性能(即防抖)。
- 使用
总之,这段代码确保了水印内容的生成是符合期望的以及高效的。它通过useState
和useClips
来管理和更新水印的内容(随着watermarkInfo
的变化而调用useClips
来更新水印的内容),并通过useRafDebounce
优化水印的生成频率。
Effect
决定何时将水印附加到目标元素上。
- 首先,从我们的老朋友
useWatermark
中解构出3个方法,拿到能够实现添加水印的appendWatermark
。 - 然后通过
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,
]);
-
首先,先聊一下
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; } }
-
然后,看这一行
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]); }
我们也逐行分析一下它的源码:
-
const defaultOptions: MutationObserverInit = { ... };
:
-
这行代码定义了一个默认的
MutationObserverInit
对象,它包含了MutationObserver
的一些初始化选项,如subtree
、childList
和attributeFilter
。
-
export default function useMutateObserver( ... ) { ... };
:
-
这行代码定义了
useMutateObserver
函数。 -
它接受三个参数:
nodeOrList
(要监听的DOM元素或元素数组)、callback
(变化发生时的回调函数)和options
(MutationObserver
的初始化选项)。
-
React.useEffect(() => { ... }, [options, nodeOrList]);
:
-
副作用函数首先检查是否可以操作DOM(通过
canUseDom()
)以及要监听的DOM元素或元素数组是否为空(!nodeOrList
)来决定接下来的执行逻辑。 -
如果条件满足,它创建一个
MutationObserver
实例,并设置回调函数为callback
。遍历nodeList
,对每个DOM元素,执行实例方法instance.observe
,即调用之前设置好的callback
。这也是为什么onMutate
(callback)能对container
(DOM元素)生效的原因 -
副作用函数的依赖项是
options
和nodeOrList
,这意味着当这些依赖项中的任何一个发生变化时,副作用函数将被重新执行。
-
return () => { ... };
:
- 这行代码定义了副作用函数的返回值,它是一个清理函数。
- 清理函数首先调用
instance?.takeRecords()
,这会停止观察MutationObserver
实例并返回所有记录的变化。 - 然后,它调用
instance?.disconnect()
,这会完全停止观察并释放MutationObserver
实例。
useMutateObserver
通过创建一个MutationObserver
实例,并设置回调函数来监听DOM元素的变化。当依赖项发生变化时,它会重新创建或更新MutationObserver
实例。当组件卸载时,它会清理MutationObserver
实例,以避免内存泄漏。 -
-
这块内容的最后一行代码就是一个
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);
});
},
}),
[],
);
- 首先,在讲它的源码之前,我们先看一下
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
)的大小是否发生了变化。如果集合的大小没有变化,它将返回第一个集合;如果集合的大小发生了变化,它将返回第二个集合。这个函数的目的是在比较两个集合时,仅当集合的大小发生变化时才返回新的集合。
-
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
);
-
首先,声明了一个变量
childNode
,使用了三元表达式来决定对此变量的赋值内容。-
如果
inherit
变量为true
,它将创建一个WatermarkContext.Provider
组件,并将watermarkContext
作为值传递给上下文。{children}
是组件的子节点,它们将被包裹在上下文提供者中。 -
如果
inherit
变量为false
,它将直接渲染子节点,不使用上下文提供者。
-
return (
<div ref={setContainer} className={classNames(className, rootClassName)} style={mergedStyle}>
{childNode}
</div>
);
- 返回一个包裹
childNode
变量的块级元素。ref={setContainer}
:可以看成是ref={(ref) => setContainer(ref)}
,即创建了一个回调函数作为ref
的值,这个回调函数会在组件挂载时被调用,并传入当前的DOM元素,从而将通过useState
声明的container
的值置为此DOM元素。className={classNames(className, rootClassName)}
:将className
和rootClassName
合并为一个类名,并传递给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