1

前端水印

为什么要有水印的存在?

  1. 保护知识产权,防止未经允许被随意盗用。比如淘宝美团的图片,背后都有水印。
  2. 保护公司机密信息,防止有心之人泄密

通常来说,前后端都可以实现水印的添加。

  • 前端水印适用场景:资源不跟某一个单独的用户绑定,而是一份资源,多个用户查看,需要在每一个用户查看的时候添加用户特有的水印,多用于某些机密文档或者展示机密信息的页面,水印的目的在于文档外流的时候可以追究到责任人
  • 服务端水印使用场景:资源为某个用户独有,一份原始资源只需要做一次处理,将其存储之后就无需再次处理,水印的目的在于标示资源的归属人

从前端的角度来说,有哪些实现方案

DOM覆盖

利用div来做水印,需要两个关键css属性。
use-select:nonepointer-events,不让用户选中我这个水印,以及让用户穿透我这个水印遮罩。
然后利用想要出现水印区域的宽高以及水印块的宽高,计算出我需要生成多少个水印块,然后铺开。

initDivWaterMark(userId: string) {
    const waterHeight = 100
    const waterWidth = 100
    const { clientWidth, clientHeight } =
      document.documentElement || document.body
    const column = Math.ceil(clientWidth / waterWidth)
    const rows = Math.ceil(clientHeight / waterHeight)
    for (let i = 0; i < column * rows; i++) {
      const wrap = document.createElement('div')
      wrap.setAttribute('class', 'watermark-item')
      wrap.style.width = waterWidth + 'px'
      wrap.style.height = waterHeight + 'px'
      wrap.textContent = userId
      this.box.appendChild(wrap)
    }
}

ok,可以看到,我们的水印出现了。但是有一个问题是,使用dom重复生成的话,还不停的append的话,感觉不优雅。而且一下就被人看到了,所以也可以用shadowdom

shadowdom ShadowDom MDN

Web components 的一个重要属性是封装——可以将标记结构、样式和行为隐藏起来,并与页面上的其他代码相隔离,保证不同的部分不会混在一起,可使代码更加干净、整洁。其中,Shadow DOM 接口是关键所在,它可以将一个隐藏的、独立的 DOM 附加到一个元素上。说白了就是隔离。

可以使用 Element.attachShadow() 方法来将一个 shadow root 附加到任何一个元素上。它接受一个配置对象作为参数,该对象有一个 mode 属性,值可以是 open 或者 closed

let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

open 表示可以通过页面内的 JavaScript 方法来获取 Shadow DOM,例如使用 Element.shadowRoot 属性:

let myShadowDom = myCustomElem.shadowRoot;

如果你将一个 Shadow root 附加到一个 Custom element 上,并且将 mode 设置为 closed,那么就不可以从外部获取 Shadow DOM 了, myCustomElem.shadowRoot 将会返回 null。浏览器中的某些内置元素就是如此,例如<video>,包含了不可访问的 Shadow DOM

所以为了简单,我们用了closed

initShadowdomWaterMark(userId: string) {
    const shadowRoot = this.box.attachShadow({ mode: 'closed' })

    const waterHeight = 100
    const waterWidth = 100
    const { clientWidth, clientHeight } =
      document.documentElement || document.body
    const column = Math.ceil(clientWidth / waterWidth)
    const rows = Math.ceil(clientHeight / waterHeight)
    for (let i = 0; i < column * rows; i++) {
      const wrap = document.createElement('div')
      wrap.setAttribute('class', 'watermark-item')
      //   const styleStr = `
      //             color: #f20;
      //             text-align: center;
      //             transform: rotate(-30deg);
      //         `
      //   wrap.setAttribute('style', styleStr)

      //   wrap.setAttribute('part', 'watermark')
      wrap.style.width = waterWidth + 'px'
      wrap.style.height = waterHeight + 'px'
      wrap.textContent = userId
      shadowRoot.appendChild(wrap)
    }
}

可有看到,这样写,样式没有了,其实就是因为 shadowdom 起到了隔离的作用,微前端里很重要的一个点就是沙箱隔离,其中像qiankun这样的框架的css隔离就是用了shadowdom

使用内联或者使用特殊的:伪类也可以解决,这里就先直接内联了。 Css part

canvas/svg背景图

可以看到,不管是使用dom还是shadowdom,都避免不了的进行for循环来进行添加元素,还需要计算。依然不是那么的优雅。所以我们可以考虑用canvas输出一个背景图,然后通过background-repeat: repeat来实现。

getCanvasUrl(userId: string) {
    const angle = -30
    const txt = userId
    this.canvas = document.createElement('canvas')
    this.canvas.width = 100
    this.canvas.height = 100
    this.ctx = this.canvas.getContext('2d')!
    this.ctx.clearRect(0, 0, 100, 100)
    this.ctx.fillStyle = '#f20'
    this.ctx.font = `14px`
    this.ctx.rotate((Math.PI / 180) * angle)
    this.ctx.fillText(txt, 0, 50)
    return this.canvas.toDataURL()
}

可以看到,我们只用了一个标签,以及背景图的方式,来实现了一个水印,但是呢,如果你是一个有心之人,我们只需要动动手指,打开F12,把这个标签删了,或者修改它的背景,都可以把水印去掉。那我们应该怎么办呢?那就需要用到MutationObserver了,说到观察者,现在使用的频率也是越来越高了。

MutationObserverMDN

MutationObserver应用的方式还挺多的

  1. 如我们上周提到的guide mask的解决方案问题。我们可以通过mutationObserver对它要笼罩的父级节点进行监控,并设置一个超时时间disconnect,在时间内对它进行矫正。我曾经有做过一个锚点的功能,也利用到了它来进行矫正操作。
  2. 我们可以通过MutationObserver来对真正的可用性能进行监控,通过判断节点的增加趋势,来获得真正可以使用的时间点。
  3. Vue nexttick的实现原理,利用MutationObserver是个micro task,来进行下一tick的通知。当然是promise.then不好使的情况下,模拟实现如下

    function myNextTick(func) {
         var textNode = document.createTextNode('0'); //新建文本节点
         var callback = (mutationsList, observer) => {
             func.call(this);
         };
         var observer = new MutationObserver(callback);
         observer.observe(textNode, { characterData: true });
         textNode.data = '1'; //修改文本信息,触发dom更新
     }
    
  4. 监控我们的水印节点是否被变更,我们也可以针对性的进行恢复。
initObserver() {
    // 观察器的配置
    const config = { attributes: true, childList: true, subtree: true }
    // 当观察到变动时执行的回调函数
    const callback: MutationCallback = (mutationsList, observer) => {
      for (const mutation of mutationsList) {
        mutation.removedNodes.forEach((item) => {
          if (item === this.box) {
            this.warnningTargetChanged = true
            // 省事,直接添加在body上了
            document.body.appendChild(this.box)
          }
        })
      }
      if (this.warnningTargetChanged) {
        this.warnningTargetChanged = false
        console.log(`用户${this.userId}的水印变动了,可能涉嫌违规操作!!!`)
      }
    }
    // 监听元素
    const targetNode = document.body
    // 创建一个观察器实例并传入回调函数
    const observer = new MutationObserver(callback)
    // 以上述配置开始观察目标节点
    observer.observe(targetNode, config)
} 

当然,并不是说这样就万无一失了,因为我们还可以通过 disabled javascript来解决。像有的网站,开启F12就会无限循环debugger,也可以解决。

暗水印

那如果说,就是有这样的人存在,统统都能搞定呢?这时候就需要隐藏水印的出现了。比如大众点评上面的图片,其实也都是有隐藏版权的水印存在的。如果是商用盗用,都是能被查到的。

暗水印的生成方式有很多,常见的为通过修改RGB 分量值的小量变动、DWT、DCT 和 FFT 等方法。DFT、DCT和DWT的联系和区别

前端实现主要看RGB 分量值的小量变动。

我们都知道图片都是有一个个像素点构成的,每个像素点都是由 RGB 三种元素构成。当我们把其中的一个分量修改,人的肉眼是很难看出其中的变化,甚至是像素眼的设计师也很难分辨出。

那么,我们只要能获取到一张图片上每个像素点上的具体信息,就可以再RGB上动动手脚,就可以把我们想要的信息藏进去。那如何获取像素点的信息呢?
就需要用到 canvas 的 CanvasRenderingContext2D.getImageData() 了,这个方法会返回一个 ImageData对象,其中就包含了像素的信息数组。所以我们应该可以利用这个方法,来做取色器

这个一维数组存储了所有的像素信息,一共有 256 256 4 = 262144 个值。其中 4 个值一组,为什么呢?在浏览器中解析图片,除了 RGB 值外,每组第 4 个值为透明度值,即像素信息实际为大家熟知的 rgba 值。

pic

以我们想要藏的文字信息为例

    const txt = '测试点'
    this.canvas = document.createElement('canvas')
    this.canvas.width = 10
    this.canvas.height = 10
    this.ctx = this.canvas.getContext('2d')
    this.ctx.clearRect(0, 0, 10, 10)
    this.ctx.font = `14px`

    this.ctx.fillText(txt, 0, 0)
    const textData = this.ctx.getImageData(0, 0, 10, 10).data

把上面代码复制到控制台可以发现,字体的数据基本都是0,0,0,xx。

那现在我们有了文字数据和图片数据,我们就可以设计一个算法。以R通道为例子,这个是红色通道,(255, 0, 0, 255) 就代表的是纯红色。

我们遍历图片数据,检查它的每一个R点位,如果这个点位的文字数据是有的,也就是alpha 值不为0,那我们就强行把当前图片信息的这个点的值改成奇数,如果这个点位没有数字,就把它改成偶数。

那么最后,这个图片数据里奇数的部分,就是有文字盖着的部分。而偶数部分,就是无关紧要的了。那最后想要找到我们的目标文案的时候,只需要把奇数部分的值变成255,把其他通道以及偶数部分的,全都改成0。 文字就出现了。

// 加密核心方法
 for (let i = 0; i < oData.length; i++) {
      if (i % 4 === bit) {
        // 如果文字在这里没有数据,并且图片R通道是奇数,那我们把它改成偶数。
        if (newData[i + offset] === 0 && oData[i] % 2 === 1) {
          // 没有信息的像素,该通道最低位置0,但不要越界
          if (oData[i] === 255) {
            oData[i]--
          } else {
            oData[i]++
          }
          // 如果文字在这里是有数据的,并且图片R通道是偶数,那我们把它改成奇数。
        } else if (newData[i + offset] !== 0 && oData[i] % 2 === 0) {
          oData[i]++
        }
        // 也就是说,如果是奇数,说明一定是有文字压在上面的
      }
}

// 解密核心方法
for (let i = 0; i < data.length; i++) {
      // R通道
      if (i % 4 === 0) {
        // 目标分量,把偶数的关闭。因为文字没有数据在这里。
        if (data[i] % 2 === 0) {
          data[i] = 0
        } else {
          data[i] = 255
          data[i + 3] = 255
        }
      } else if (i % 4 === 3) {
        continue
      } else {
        data[i] = 0
      }
}

但是我们实际想要见到的隐藏水印的形式,肯定不是局限在图片上的。我们希望的是给我们整体的文章之类的打上隐藏水印。那这怎么做呢?

其实我们可以把之前cavans的水印,把颜色换成黑色,旋转去掉,透明度降低到0.005,为啥是这个值,因为0.005 * 255=1.27。基本算是最小透明度了。但是因为我们是高层级,所以截图的时候,一定会把这信息包含进去。那么我是(0,0,0,1)叠加到原来的图片上,至少会影响到原图的颜色,也就是说它的R通道,起码会动个1。我编不下去了。我也不知道为啥。但是确实可以。大家可以一起探讨下。


jansen
130 声望16 粉丝

学习不能止步,学习就是兴趣!终生学习是目标