跳转到内容

水印

为全局或局部添加水印

useWatermark.ts

import type { Ref } from "vue"
import { debounce } from "lodash-es"

/** 默认配置 */
const DEFAULT_CONFIG = {
  /** 防御(默认开启,能防御水印被删除或隐藏,但可能会有性能损耗) */
  defense: true,
  /** 文本颜色 */
  color: "#c0c4cc",
  /** 文本透明度 */
  opacity: 0.5,
  /** 文本字体大小 */
  size: 16,
  /** 文本字体 */
  family: "serif",
  /** 文本倾斜角度 */
  angle: -20,
  /** 一处水印所占宽度(数值越大水印密度越低) */
  width: 300,
  /** 一处水印所占高度(数值越大水印密度越低) */
  height: 200
}

type DefaultConfig = typeof DEFAULT_CONFIG

interface Observer {
  watermarkElMutationObserver?: MutationObserver
  parentElMutationObserver?: MutationObserver
  parentElResizeObserver?: ResizeObserver
}

/** body 元素 */
const bodyEl = ref<HTMLElement>(document.body)

/**
 * @name 水印 Composable
 * @description 1. 可以选择传入挂载水印的容器元素,默认是 body
 * @description 2. 做了水印防御,能有效防御别人打开控制台删除或隐藏水印
 */
export function useWatermark(parentEl: Ref<HTMLElement | null> = bodyEl) {
  // 备份文本
  let backupText: string
  // 最终配置
  let mergeConfig: DefaultConfig
  // 水印元素
  let watermarkEl: HTMLElement | null = null
  // 观察器
  const observer: Observer = {
    watermarkElMutationObserver: undefined,
    parentElMutationObserver: undefined,
    parentElResizeObserver: undefined
  }

  // 设置水印
  const setWatermark = (text: string, config: Partial<DefaultConfig> = {}) => {
    if (!parentEl.value) return console.warn("请在 DOM 挂载完成后再调用 setWatermark 方法设置水印")
    // 备份文本
    backupText = text
    // 合并配置
    mergeConfig = { ...DEFAULT_CONFIG, ...config }
    // 创建或更新水印元素
    watermarkEl ? updateWatermarkEl() : createWatermarkEl()
    // 监听水印元素和容器元素的变化
    addElListener(parentEl.value)
  }

  // 创建水印元素
  const createWatermarkEl = () => {
    const isBody = parentEl.value!.tagName.toLowerCase() === bodyEl.value.tagName.toLowerCase()
    const watermarkElPosition = isBody ? "fixed" : "absolute"
    const parentElPosition = isBody ? "" : "relative"
    watermarkEl = document.createElement("div")
    watermarkEl.style.pointerEvents = "none"
    watermarkEl.style.top = "0"
    watermarkEl.style.left = "0"
    watermarkEl.style.position = watermarkElPosition
    watermarkEl.style.zIndex = "99999"
    const { clientWidth, clientHeight } = parentEl.value!
    updateWatermarkEl({ width: clientWidth, height: clientHeight })
    // 设置水印容器为相对定位
    parentEl.value!.style.position = parentElPosition
    // 将水印元素添加到水印容器中
    parentEl.value!.appendChild(watermarkEl)
  }

  // 更新水印元素
  const updateWatermarkEl = (
    options: Partial<{
      width: number
      height: number
    }> = {}
  ) => {
    if (!watermarkEl) return
    backupText && (watermarkEl.style.background = `url(${createBase64()}) left top repeat`)
    options.width && (watermarkEl.style.width = `${options.width}px`)
    options.height && (watermarkEl.style.height = `${options.height}px`)
  }

  // 创建 base64 图片
  const createBase64 = () => {
    const { color, opacity, size, family, angle, width, height } = mergeConfig
    const canvasEl = document.createElement("canvas")
    canvasEl.width = width
    canvasEl.height = height
    const ctx = canvasEl.getContext("2d")
    if (ctx) {
      ctx.fillStyle = color
      ctx.globalAlpha = opacity
      ctx.font = `${size}px ${family}`
      ctx.rotate((Math.PI / 180) * angle)
      ctx.fillText(backupText, 0, height / 2)
    }
    return canvasEl.toDataURL()
  }

  // 清除水印
  const clearWatermark = () => {
    if (!parentEl.value || !watermarkEl) return
    // 移除对水印元素和容器元素的监听
    removeListener()
    // 移除水印元素
    try {
      parentEl.value.removeChild(watermarkEl)
    } catch {
      // 比如在无防御情况下,用户打开控制台删除了这个元素
      console.warn("水印元素已不存在,请重新创建")
    } finally {
      watermarkEl = null
    }
  }

  // 刷新水印(防御时调用)
  const updateWatermark = debounce(() => {
    clearWatermark()
    createWatermarkEl()
    addElListener(parentEl.value!)
  }, 100)

  // 监听水印元素和容器元素的变化(DOM 变化 & DOM 大小变化)
  const addElListener = (targetNode: HTMLElement) => {
    // 判断是否开启防御
    if (mergeConfig.defense) {
      // 防止重复添加监听
      if (!observer.watermarkElMutationObserver && !observer.parentElMutationObserver) {
        // 监听 DOM 变化
        addMutationListener(targetNode)
      }
    } else {
      // 无防御时不需要 mutation 监听
      removeListener("mutation")
    }
    // 防止重复添加监听
    if (!observer.parentElResizeObserver) {
      // 监听 DOM 大小变化
      addResizeListener(targetNode)
    }
  }

  // 移除对水印元素和容器元素的监听,传参可指定要移除哪个监听,不传默认移除全部监听
  const removeListener = (kind: "mutation" | "resize" | "all" = "all") => {
    // 移除 mutation 监听
    if (kind === "mutation" || kind === "all") {
      observer.watermarkElMutationObserver?.disconnect()
      observer.watermarkElMutationObserver = undefined
      observer.parentElMutationObserver?.disconnect()
      observer.parentElMutationObserver = undefined
    }
    // 移除 resize 监听
    if (kind === "resize" || kind === "all") {
      observer.parentElResizeObserver?.disconnect()
      observer.parentElResizeObserver = undefined
    }
  }

  // 监听 DOM 变化
  const addMutationListener = (targetNode: HTMLElement) => {
    // 当观察到变动时执行的回调
    const mutationCallback = debounce((mutationList: MutationRecord[]) => {
      // 水印的防御(防止用户手动删除水印元素或通过 CSS 隐藏水印)
      mutationList.forEach(
        debounce((mutation: MutationRecord) => {
          switch (mutation.type) {
            case "attributes":
              mutation.target === watermarkEl && updateWatermark()
              break
            case "childList":
              mutation.removedNodes.forEach((item) => {
                item === watermarkEl && targetNode.appendChild(watermarkEl)
              })
              break
          }
        }, 100)
      )
    }, 100)
    // 创建观察器实例并传入回调
    observer.watermarkElMutationObserver = new MutationObserver(mutationCallback)
    observer.parentElMutationObserver = new MutationObserver(mutationCallback)
    // 以上述配置开始观察目标节点
    observer.watermarkElMutationObserver.observe(watermarkEl!, {
      // 观察目标节点属性是否变动,默认为 true
      attributes: true,
      // 观察目标子节点是否有添加或者删除,默认为 false
      childList: false,
      // 是否拓展到观察所有后代节点,默认为 false
      subtree: false
    })
    observer.parentElMutationObserver.observe(targetNode, {
      attributes: false,
      childList: true,
      subtree: false
    })
  }

  // 监听 DOM 大小变化
  const addResizeListener = (targetNode: HTMLElement) => {
    // 当 targetNode 元素大小变化时去更新整个水印的大小
    const resizeCallback = debounce(() => {
      const { clientWidth, clientHeight } = targetNode
      updateWatermarkEl({ width: clientWidth, height: clientHeight })
    }, 500)
    // 创建一个观察器实例并传入回调
    observer.parentElResizeObserver = new ResizeObserver(resizeCallback)
    // 开始观察目标节点
    observer.parentElResizeObserver.observe(targetNode)
  }

  // 在组件卸载前移除水印以及各种监听
  onBeforeUnmount(() => {
    clearWatermark()
  })

  return { setWatermark, clearWatermark }
}

使用

watermark.vue

<script lang="ts" setup>
import { useWatermark } from "@/common/useWatermark.ts"

const localRef = ref<HTMLElement | null>(null)
const { setWatermark, clearWatermark } = useWatermark(localRef)
const { setWatermark: setGlobalWatermark, clearWatermark: clearGlobalWatermark } = useWatermark()
</script>

<template>
  <div class="app-container p-16">
    <el-card shadow="never" class="card-w">
      功能:开启或关闭水印,
      支持局部、全局、自定义样式(颜色、透明度、字体大小、字体、倾斜角度等),并自带防御(防删、防隐藏)和自适应功能
    </el-card>
    <el-card header="示例" shadow="never" class="card-w">
      <div ref="localRef" class="local" />
      <template #footer>
        <el-button-group>
          <el-button type="primary" @click="setWatermark('局部水印', { color: '#409eff' })">
            创建局部水印
          </el-button>
          <el-button type="warning" @click="setWatermark('没有防御功能的局部水印', { color: '#e6a23c', defense: false })">
            创建无防御局部水印
          </el-button>
          <el-button type="danger" @click="clearWatermark">
            清除局部水印
          </el-button>
        </el-button-group>
        <el-button-group>
          <el-button type="primary" @click="setGlobalWatermark('全局水印', { color: '#409eff' })">
            创建全局水印
          </el-button>
          <el-button type="warning" @click="setGlobalWatermark('没有防御功能的全局水印', { color: '#e6a23c', defense: false })">
            创建无防御全局水印
          </el-button>
          <el-button type="danger" @click="clearGlobalWatermark">
            清除全局水印
          </el-button>
        </el-button-group>
      </template>
    </el-card>
  </div>
</template>

<style scoped>
.el-card {
  margin-bottom: 20px;
}

.local {
  height: 35vh;
  border: 2px dashed var(--el-color-primary);
}

.el-button-group {
  margin-right: 12px;
  margin-bottom: 5px;
}
</style>