
一、背景
最近在做一个 AI 多模态项目,有个需求是在对话记录下载预览弹窗中加水印,还要要防止用户去掉我们的水印"。 我当时心想,这不就是加个半透明文字嘛,很简单啊。结果真动手的时候,才发现水还挺深的。
二、需求分析
添加水印功能可以拆解为以下 4 个核心维度:
- 视觉效果:半透明倾斜文字,铺满整个预览区域,密度需适中(不能太密也不能太疏)
- 用户体验:不影响内容阅读,不阻挡用户点击、输入等交互操作
- 防删除能力:用户通过 F12 开发者工具删除水印节点 / 修改样式后,水印需自动恢复
- 自适应布局:窗口大小改变或容器尺寸变化时,水印布局需实时调整,避免错位
2.1 技术方案选型
在前端防删除水印的实现上,最终敲定 Canvas + MutationObserver 组合方案,核心逻辑与优势如下:
技术模块 | 核心作用 |
---|---|
Canvas | 动态绘制水印图案(支持文字、透明度、旋转角度定制),并转译为 base64 格式 |
MutationObserver | 监听 DOM 树变化,捕捉水印节点删除、样式篡改等操作,触发自动恢复机制 |
方案核心优势
- 强安全性:通过「动态绘制 + 实时监控 + 自动恢复」机制,大幅提升水印防篡改能力
- 高性能:基于单 Canvas 节点渲染 + 精准监听配置(如 attributeFilter),降低性能损耗
- 高灵活性:支持水印文字、颜色、透明度、旋转角度、密度等样式的自由配置,适配多场景
三、核心技术实现与代码解析
3.1 工具函数封装:模块化设计
将水印逻辑封装为 createWatermark
工具函数,遵循「可配置、可销毁、低耦合」原则,支持多场景复用。代码结构如下:
javascript
/**
* 创建防删除水印
* @param {HTMLElement} container - 水印挂载容器
* @param {WatermarkOptions} options - 水印配置项
* @returns {() => void} 销毁函数(清理监听/定时器/节点)
*/
export function createWatermark(container, options = {}) {
// 1. 默认配置(支持外部覆盖)
const defaultOptions = {
text: '智能汇 AI', // 水印文字
color: '#000000', // 文字颜色
opacity: 0.08, // 透明度
fontSize: 18, // 字体大小(px)
rotate: -20, // 旋转角度(°)
density: 200, // 单个水印块尺寸(px),控制密度
};
const opts = { ...defaultOptions, ...options };
// 2. 状态管理(避免闭包污染,统一维护)
let watermarkDiv = null; // 水印DOM节点
let observer = null; // MutationObserver实例
let resizeTimer = null; // resize防抖定时器
// 3. 核心方法(下文拆解)
const createWatermarkElement = () => {}; // 生成水印节点
const initObserver = () => {}; // 初始化DOM监听
const handleResize = () => {}; // 处理窗口resize
// 4. 初始化执行
createWatermarkElement();
initObserver();
window.addEventListener('resize', handleResize);
// 5. 返回销毁函数(关键:避免内存泄漏)
return () => {
observer?.disconnect(); // 停止DOM监听
watermarkDiv?.parentNode?.removeChild(watermarkDiv); // 移除水印节点
window.removeEventListener('resize', handleResize); // 解绑resize事件
clearTimeout(resizeTimer); // 清理定时器
};
}
设计亮点
- 配置模式灵活:采用「默认配置 + 外部覆盖」模式,支持不同业务场景的样式定制;
- 资源清理明确:返回销毁函数,覆盖监听、定时器、DOM 节点的清理路径,解决前端常见的「资源残留」问题;
- 状态隔离安全 :状态变量(
watermarkDiv
/observer
等)通过闭包封装,避免全局变量污染。
3.2 Canvas 生成水印:视觉层实现
Canvas 是水印视觉效果的核心,通过绘制单个水印块并转为 base64,再以 background-repeat
平铺,兼顾性能与视觉效果。
javascript
const createWatermarkElement = () => {
// 1. 创建Canvas实例,设置单个水印块尺寸
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = opts.density; // 单个水印宽度 = 密度值
canvas.height = opts.density; // 单个水印高度 = 密度值
// 2. 配置文字样式(关键:透明度与旋转)
ctx.font = `${opts.fontSize}px sans-serif`; // 字体大小
ctx.fillStyle = opts.color; // 文字颜色
ctx.globalAlpha = opts.opacity; // 整体透明度(0-1)
ctx.textAlign = 'center'; // 文字水平居中
ctx.textBaseline = 'middle'; // 文字垂直居中
// 3. 旋转文字并绘制(弧度转换是关键)
ctx.save(); // 保存当前绘图状态
// 平移到Canvas中心(避免旋转后文字偏移)
ctx.translate(canvas.width / 2, canvas.height / 2);
// 角度转弧度:Math.PI * 角度 / 180
ctx.rotate((opts.rotate * Math.PI) / 180);
// 绘制文字(x=0,y=0对应平移后的中心)
ctx.fillText(opts.text, 0, 0);
ctx.restore(); // 恢复绘图状态
// 4. 转为base64,创建水印DOM节点
const dataURL = canvas.toDataURL('image/png'); // 转为图片格式
watermarkDiv = document.createElement('div');
watermarkDiv.className = 'watermark-layer';
watermarkDiv.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; // 关键:不拦截点击事件
z-index: 9999; // 确保在最上层(避免被遮挡)
background-image: url(${dataURL});
background-repeat: repeat; // 平铺整个容器
`;
// 5. 处理容器定位(关键:确保水印相对容器定位)
const containerPosition = window.getComputedStyle(container).position;
if (containerPosition === 'static') { // static无法作为absolute的包含块
container.style.position = 'relative';
}
// 6. 挂载水印节点
container.appendChild(watermarkDiv);
return watermarkDiv;
};
技术关键点解析
- Canvas 尺寸设计 :以
density
为单个水印块的宽高,通过background-repeat
实现平铺,避免绘制多个文字导致性能损耗; - 旋转与平移配合 :先
translate
到 Canvas 中心再rotate
,防止文字旋转后偏离预期位置; - 交互穿透处理 :
pointer-events: none
让水印层「穿透」,不拦截容器内按钮、输入框等元素的点击 / 输入事件; - 容器定位修正 :若容器为
static
(默认定位),自动改为relative
,确保水印相对于容器定位(而非 body)。
3.3 MutationObserver:防篡改实现
用户通过 F12 删除水印节点或修改其 style
/class
时,需通过 MutationObserver
监听 DOM 变化并自动恢复,核心是避免「监听 - 修改 - 恢复」的无限循环。
javascript
const initObserver = () => {
// 1. 创建Observer实例,定义变化处理逻辑
observer = new MutationObserver((mutations) => {
let needRestore = false; // 标记是否需要恢复水印
// 2. 遍历所有DOM变化记录
mutations.forEach((mutation) => {
// 场景1:子节点被删除(水印节点被移除)
if (mutation.type === 'childList' && mutation.removedNodes.length > 0) {
mutation.removedNodes.forEach((node) => {
// 匹配水印节点(直接节点或类名匹配)
if (node === watermarkDiv || (node.classList?.contains('watermark-layer'))) {
needRestore = true;
}
});
}
// 场景2:水印节点属性被修改(如style/class被篡改)
if (mutation.type === 'attributes' && mutation.target === watermarkDiv) {
// 仅监听关键属性(style/class),减少不必要的判断
if (['style', 'class'].includes(mutation.attributeName)) {
needRestore = true;
}
}
});
// 3. 延迟恢复(关键:避免无限循环)
if (needRestore) {
// 100ms延迟:让浏览器先处理完当前DOM变化,再执行恢复
setTimeout(() => {
// 双重判断:避免重复创建(如多次删除事件触发)
if (!container.querySelector('.watermark-layer')) {
createWatermarkElement(); // 重新生成水印
}
}, 100);
}
});
// 4. 配置监听选项(最小化监听范围,提升性能)
observer.observe(container, {
childList: true, // 监听子节点添加/删除
attributes: true, // 监听节点属性变化
subtree: true, // 监听后代节点(避免水印被嵌套删除)
attributeFilter: ['style', 'class'], // 仅监听关键属性,减少开销
});
};
防无限循环设计
- 延迟 100ms 恢复 :若立即恢复,
appendChild
会触发新的childList
变化,导致 Observer 再次执行,形成无限循环;100ms 延迟让 DOM 变化事件队列完成处理后再恢复; - 双重存在判断:恢复前先检查容器内是否存在水印节点,避免重复创建(如用户连续删除多次时,仅恢复一次)。
3.4 窗口自适应:resize 防抖处理
窗口 resize 时,容器尺寸变化会导致水印布局错位,需重新生成水印。但 resize
事件触发频率极高(每秒数十次),需通过防抖优化性能。
javascript
const handleResize = () => {
// 防抖逻辑:200ms内仅执行一次重绘
if (resizeTimer) {
clearTimeout(resizeTimer); // 清除未执行的定时器
}
resizeTimer = setTimeout(() => {
// 先移除旧水印,再创建新水印(避免叠加)
if (watermarkDiv && watermarkDiv.parentNode) {
watermarkDiv.parentNode.removeChild(watermarkDiv);
}
createWatermarkElement(); // 重新生成适配新尺寸的水印
}, 200); // 200ms:兼顾响应速度(无明显卡顿)与性能(减少重绘次数)
};
四、Vue 项目工程化落地
以 Element UI 的 el-dialog
(预览弹窗)为例,需解决「DOM 渲染时序」「组件生命周期」等问题,避免水印节点挂载失败。
4.1 核心问题:DOM 渲染时序
el-dialog
打开时存在 300ms 默认动画,动画结束后 DOM 才完全挂载。若在 visible
变化时直接创建水印,会因 previewRef.value
为 null
导致挂载失败。
4.2 最终实现代码
vue
<template>
<el-dialog v-model="visible" title="对话记录预览">
<div ref="previewRef" class="preview-container">
</div>
</el-dialog>
</template>
<script setup>
import { ref, watch, onUnmounted, nextTick } from 'vue';
import { createWatermark } from '@/utils/watermarkUtil';
// 1. 状态管理
const visible = ref(false); // 弹窗显示状态
const previewRef = ref(null); // 水印挂载容器ref
let destroyWatermark = null; // 水印销毁函数(全局维护)
// 2. 水印初始化(核心:处理DOM时序)
const initWatermark = async () => {
// 第一步:清理旧水印(避免重复创建)
if (destroyWatermark) {
destroyWatermark();
destroyWatermark = null;
}
// 第二步:等待Vue DOM更新(nextTick:确保ref已关联DOM)
await nextTick();
// 第三步:等待el-dialog动画结束(300ms:匹配组件默认动画时长)
setTimeout(() => {
// 双重判断:确保容器DOM存在
if (previewRef.value) {
destroyWatermark = createWatermark(previewRef.value, {
text: '智能汇 AI',
opacity: 0.08,
density: 200,
});
}
}, 300);
};
// 3. 监听弹窗状态变化
watch(visible, (isVisible) => {
if (isVisible) {
initWatermark(); // 弹窗打开:初始化水印
} else {
// 弹窗关闭:清理水印(释放资源)
destroyWatermark?.();
destroyWatermark = null;
}
});
// 4. 组件卸载:强制清理(避免内存泄漏)
onUnmounted(() => {
destroyWatermark?.();
destroyWatermark = null;
});
</script>
时序控制关键点
nextTick
确保 DOM 关联 :等待 Vue 响应式更新完成,确保previewRef.value
已指向真实 DOM;- 300ms 延迟匹配动画 :匹配
el-dialog
的 transition 动画时长(默认 300ms),动画结束后容器尺寸才稳定; - 多时机资源清理:弹窗关闭、组件卸载时均调用销毁函数,覆盖所有资源释放场景,避免内存泄漏。
五、总结与技术思考
- 需求拆解的重要性:从「加个水印」到「防删除、自适应、无感知」,需将模糊需求转化为可量化的技术指标(如透明度≤0.1、恢复延迟≤100ms),避免开发偏差;
- 时序控制是前端难点 :Vue 的
nextTick
、组件库的动画生命周期、DOM 事件循环,需精准把控各环节的执行顺序,否则易出现「DOM 未挂载」「节点重复创建」等问题; - 资源清理是工程化基础 :
MutationObserver
、resize
事件、定时器等必须在合适时机销毁,尤其是在 Vue/React 等框架中,需结合组件生命周期完善清理逻辑; - 封装复用的设计思维:工具函数需兼顾「可配置性」与「低耦合性」,通过返回销毁函数、抽离默认配置等方式,降低业务接入成本,适配多场景复用。
本方案已在 AI 多模态项目中稳定运行,支持对话记录、报告预览等多个弹窗场景,可直接复用或根据业务需求调整配置项。