前端知库

页面水印怎样实现?怎样防止被删除?

前端水印通常用 canvas 画出带用户名和时间的倾斜文字,转成图片铺满一个覆盖全屏、pointer-events 为 none 的图层;再用 MutationObserver 监听水印节点被删除或样式被改,发现后重新加上。但页面代码运行在用户的浏览器里,前端防删只能增加难度,真正可靠的是服务端在图片或文件里加水印。

参考答案

简答

公司的内部系统,页面上铺满了一行行斜着的浅灰色文字,写着你的名字和工号。万一有人截图外传,一看就知道是谁泄露的。这就是页面水印。

做法是用 canvas 画一小块带文字的倾斜图案,转成图片地址,设成一个全屏图层的重复背景。图层放在最上面,加上 pointer-events: none,点击会穿透到下面的页面,不影响正常操作。

防删的常用手段是 MutationObserver:监听水印节点有没有被移除、style 有没有被改,发现了就重新插入或恢复。

但要说清楚,页面代码跑在用户自己的浏览器里,用户可以禁用脚本、在开发者工具里删节点、拦截请求、用插件改页面。前端防删只能提高门槛,防不住懂行的人。真正要追责的场景,水印要由服务端加在图片、PDF 或导出的文件里,或者用肉眼看不见的暗水印。

水印图案怎样生成

用 canvas 画一块图案,内容一般包括用户名、工号、时间,便于追溯:

JavaScript
function createWatermark(text) {
  const canvas = document.createElement('canvas');
  canvas.width = 240;
  canvas.height = 160;
  const ctx = canvas.getContext('2d');
  ctx.translate(120, 80);
  ctx.rotate(-Math.PI / 9); // 倾斜约 20 度
  ctx.font = '14px sans-serif';
  ctx.fillStyle = 'rgba(0, 0, 0, 0.12)';
  ctx.textAlign = 'center';
  ctx.fillText(text, 0, 0);
  return canvas.toDataURL(); // 不传格式时导出 PNG
}

toDataURL 返回一个 data URL。MDN 说明不指定格式时导出 image/png,PNG 支持透明,水印以外的地方是透明的。也可以用 toBlob 拿到 Blob 再用 URL.createObjectURL 生成地址,图案很小时两者差别不大。

不想用 canvas 的话,也可以直接写一段 SVG 字符串,转成 data URL 当背景,效果一样。

水印图层怎样铺满又不挡操作

JavaScript
const layer = document.createElement('div');
layer.style.cssText = `
  position: fixed; inset: 0; z-index: 9999;
  pointer-events: none;
  background-image: url(${createWatermark('张三 10086 2026-10-29')});
  background-repeat: repeat;
`;
document.body.append(layer);

position: fixed 加 inset: 0 让图层盖满视口,滚动时也不动。pointer-events: none 是关键:MDN 说明这个属性决定元素能否成为指针事件的目标,设为 none 后,点击和悬停都落到它下面的元素上,用户感觉不到这一层。

怎样发现水印被删了

用户打开开发者工具,删掉这个 div 或者把它改成 display: none,水印就没了。MutationObserver 可以监听 DOM 变化:MDN 介绍它用来观察 DOM 树的修改,可以配置监听子节点增删(childList)、属性变化(attributes)和整个子树(subtree)。

JavaScript
const observer = new MutationObserver(() => {
  if (!layer.isConnected) document.body.append(layer); // 被移除了,加回去
  if (layer.style.display === 'none' || layer.style.opacity === '0') {
    layer.style.display = '';
    layer.style.opacity = '';
  }
});
observer.observe(document.body, { childList: true });
observer.observe(layer, { attributes: true });

在 headless Chrome 里实测:调用 layer.remove() 后,监听 body 子节点的回调收到一条变更记录,把节点加回去后 isConnected 又是 true;改 layer.style.opacity,监听这个节点属性的回调收到 type 为 attributes、attributeName 为 style 的记录。

要注意,回调里把节点加回去,本身也是一次 DOM 变化,会再触发一次回调。所以回调里要先判断“确实被删了”再动手,否则会来回触发。

更进一步,可以把水印放进封闭模式的 Shadow DOM。MDN 说明 attachShadow({ mode: 'closed' }) 创建的影子树,外部通过 shadowRoot 属性拿到的是 null,脚本不能直接查到里面的元素。但在开发者工具里仍然能看到和编辑,作用同样只是增加麻烦。

为什么前端防删一定防不住

这些手段都依赖页面里的脚本在运行。用户可以直接禁用 JavaScript,MutationObserver 就不存在了;可以在开发者工具里给水印的样式加一条优先级更高的规则,或者给它的上层元素加 display: none;可以先暂停脚本执行再删节点;可以用浏览器插件在页面加载时注入脚本,把水印逻辑整个替换掉;还可以用打印、截图工具的裁剪绕过。

所以前端水印的定位是:提醒用户这些内容有归属、对普通用户形成威慑、让截图外传的人更容易被找到。它不是安全措施,也不能替代权限控制。

真正需要追责的场景,水印要在服务端加。比如图片、PDF、导出的 Excel,在服务端生成时就把用户信息写进内容里,用户拿到的文件本身就带着水印,前端改不掉。对截图外传更隐蔽的做法是暗水印:把信息以肉眼难以察觉的方式编码进图片像素或页面背景里,需要时用专门的工具提取。

常见追问

水印会不会影响页面性能?

图层只是一个重复的背景图,画一次就完事,开销很小。要注意的是 MutationObserver 不要监听整个文档的所有属性变化,页面更新频繁时回调会被大量触发。只监听水印节点本身和它的父节点就够了。

页面里的图片和视频也要加水印吗?

覆盖层盖在最上面,截图时图片和视频上也会有水印。但用户如果直接下载图片原文件或者拿到视频地址,覆盖层就不起作用了。需要保护的图片和视频,要在服务端处理原文件。

水印里为什么要带时间?

只有用户名的话,能知道是谁,但不知道是什么时候截的。带上时间或者会话标识,可以对照访问日志,确认是哪一次访问泄露的。