使用MutationObserver简单实现文本水印

avatar
FE @字节跳动

背景

为了防止信息泄露或知识产权被侵犯,在特定场景中对于页面和图片等增加水印处理是十分有必要的。 在web页面上增加水印,简单来说可以拆解为两个主要的部分:

  • 生成水印 - 准备一张水印图片并将其添加到页面中
  • 保护水印 - 防止用户恶意修改水印

生成水印

假设我们想要生成以下的水印:

  • 水印内容为文本
  • 水印与横轴的夹角为15°
  • 行间有错行效果

image.png

水印应该有的样式

在全屏水印的场景下,我们通常给水印设置为最高z-index的绝对定位元素,并用pointer-events: none实现点击穿透,不影响用户的正常使用。

position: absolute;
width: 100%;
height: 100%;
left: 0px;
top: 0px;
pointer-events: none;
z-index: 9999;

推荐是将水印设置在body元素下,如果水印没有直接设置在body元素下,需要保证父元素中没有使用position: relative;,防止水印只显示在了父元素内,没有实现全屏的效果。

目前前端生成页面水印方案主要有以下几种:

Canvas 水印

对于前端来说,要创建一个图片比较容易想到的可能是用canvas来实现。 canvas方案可以简述为以下步骤:

  1. 创建一个canvas画布实例
  2. 使用fillText将文本添加到画布中
  3. 通过canvas.toDataUrl输出为图片
  4. 将图片设置为全屏水印div的背景

如果不需要错行效果的话,生成水印处理较为简单,我们可以通过rotate方法将单个图片旋转到我们想要的角度,最后使用css的background-repeat属性将我们的图片重复地平铺到页面里即可。
如果需要错行效果,我们需要计算页面中每个水印的位置,并依次添加到画布中,生成整个页面的水印,这里可以用canvastransformrotate来实现旋转效果。需要注意的是canvas的transformrotate是针对于整个画布进行变换的。
由于canvas最后导出的是一张图片,所以在放大页面的时候,水印会稍微显得模糊一点.

SVG 水印

当我们的水印只需要文本时,用SVG来实现水印较为简单.
SVG方案可以简述为以下步骤:

  1. 创建一个svg标签
  2. svg标签内添加text标签,将文本设置为text标签的innerHtml
  3. 通过Base64.toBase64(new XMLSerializer().serializeToString(svgElement))将svg标签输出为base64格式图片
  4. 将图片设置为水印div的背景

在SVG中可以用transform属性来实现旋转效果,语法与css的transform不同,例如:

// x,y 代表该元素在svg坐标系里的位置
<text x={50} y={50} transform="rotate(-15,50,50)"}>水印文本</text>

这里的transform是针对于当前元素,我们可以方便地将旋转好的text标签填入svg中。

image.png
SVG描述的是矢量图形,所以放大页面的时候不会影响水印的清晰度。

Element 水印

使用元素生成水印就是将一个个的标签添加到水印div中,实现起来简单,不过会在页面内添加较多的标签。

保护水印

使用水印的场景是为了维护信息安全,如果不做任何防御措施的话,前端添加的水印可以轻松地被消除:

  • 删除全屏的水印div
  • 修改水印的样式,使之在页面中不可见
  • 修改或删除水印图片的内容
  • 禁用JavaScript
    • 当然禁用JavaScript后,页面内的正常的内容可能也显示不出来了

所以我们还需要有一种方式来防御对于水印的修改,这里主要会用到浏览器的MutaitionObserver

MutaitionObserver的作用主要是用于监听当前页面的dom变化情况,当所监听的dom发生变化时,可以通过回调函数做出响应,尤其适用于我们希望得知当前页面的dom元素在何时发生了什么样的变化,MutationObserver目前在各大浏览器的支持情况如下:

image.png

MutationObserver简单示例

// 选择需要观察变动的节点
const targetNode = document.getElementById('some-id');

// 当观察到变动时执行的回调函数
// mutaions 数据结构参考 https://developer.mozilla.org/zh-CN/docs/Web/API/MutationRecord
const callback = function(mutations, observer) {
    // handle mutations
};

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 观察器的配置(需要观察什么变动)
// https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserverInit
const config = { attributes: true, childList: true, subtree: true };

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 不需要时可停止观察
observer.disconnect();

具体来说,MutaitionObserver可以帮我们监听到以下的dom变化:

  • 指定目标节点上的所有属性变化
  • 指定目标节点上的所有子元素的增删
  • 指定目标节点或子节点树中节点所包含的字符数据

当变动发生时,MutaitionObserver实例会调用我们传入的回调函数,我们通常在回调函数里处理恢复水印。

防止删除水印div

需要注意的是,如果被监听的目标节点本身被删除的话,是不会触发MutaitionObserver回调的。
对于这种情况,我们可以监听水印div的父元素或body元素,当水印的父元素是页面主要的container时,只有删掉水印的父元素MutaitionObserver才可能失效,而这时可能页面内需要保护的内容也被删掉了,这时删除水印的意义也不大了,删除body元素同理。
对于水印div被删除的case,我们可以实现储存水印div的ref,用appendChild将被删除的元素重新添加到监听的目标节点(在这里是水印div的父元素)上:

 parentNode.appendChild(elementRef.current)

防止修改水印的样式

在不使用shadow DOM的情况下,我们的样式是暴露在控制台的,如果水印被设置了display:none等样式,也失去了保护作用。
对于这种情况,我们可以监听水印div的style属性,当属性发生变动时,通过setAttribute将正确的样式重新设置到元素上:

 elementRef.current?.setAttribute('style', DEFAULT_STYLE_STRING);

需要注意的是,这里我们用行内css的方式来储存水印的样式。如果用class来储存样式的话,在控制台修改class内的样式,是不会触发回调的。

防止修改或删除水印图片的内容

前面提到,MutationObserver可以帮助我们监听目标节点的所有属性的变化,但这一点对于水印图片内容来说处理会特殊一点:通常在页面resize的时候,我们需要重新计算水印图片,使其能够覆盖到整个页面,这时我们的代码也会触发修改水印图片的动作,这时代码的修改和手动恶意修改在MutationObserver里都会触发一次回调。

对于这种情况,我们要做的是区别代码的修改,这里提供一个思路:

  1. 在生成水印图片时,也同时根据计算的结果进行一次md5,
  2. 当触发MutationObserver回调的时候,将被修改后图片做一次md5,与生成图片时的md5做对比
    a. 如果对比结果相同,不做处理
    b. 如果对比结果不同,重新生成水印图片

当然防止修改水印样式和图片内容还有另一种方式,就是当触发了水印元素的MutationObserver回调的时候,不去做具体的判断和恢复逻辑,而是将这个水印div删掉并重新生成新的水印div,用这种方式也需要停止前一个MutationObserver,启动新的MutationObserver实例。