Skip to content
霞露小伙 — HfWang
On this page

支持自定义水印

背景

同花顺手炒-资产分析页面,日活在 80w 以上,所以这个页面每天的分享次数十分巨大,所以有很多用户希望能提供自定义水印的功能。

开发方案

  1. 设置完成自定义水印后,直接将水印作为背景文字,然后脱离文档流,给定位到指定位置就行
  2. 使用 canvas 实现,作为特定位置的背景图片

需要注意的点:

  1. 分享到外部的链接,被可能被人手动删除 dom 节点,从而移除自定义水印
  2. 分享到外部的链接,水印节点可能被设置透明度为 0

所以综合下来,决定采用 canvas 实现,并结合 mutaionObserver 防止用户篡改水印

实现demo

实现效果图

通用功能抽离

具体实现

ts
export interface WaterMarkBaseOptions {
  /** 水印文案,默认 xialuxiaohuo */
  text?: string
  /** 水印字体大小,默认 12px */
  fontSize?: number
  /** 水印文字倾斜角度, 默认 -45deg */
  rotate?: number
  /** 水印之间的间距倍数,默认一倍 */
  gap?: number
  /** 水印文字颜色 */
  fontColor?: string
}
export type UseWaterMarkOptions = Required<WaterMarkBaseOptions>
/**
 * 获取水印图片信息
 * @param options 配置参数
 * @returns
 */
export function getWaterMark(options: UseWaterMarkOptions): WaterMarkOptions {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

  /** 当前设备像素密度 */
  const devicePixelRatio = window.devicePixelRatio || 1
  // 计算水印文案字体占据的像素宽度
  const { width } = ctx.measureText(options.text)

  const canvasSize = width + options.gap * devicePixelRatio
  const canvasTranslate = canvasSize / 2
  canvas.width = canvasSize
  canvas.height = canvasSize
  ctx.translate(canvasTranslate, canvasTranslate)
  ctx.rotate((options.rotate * Math.PI) / 180)
  ctx.font = `${options.fontSize * devicePixelRatio}px serif`
  ctx.fillStyle = options.fontColor
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(options.text, 0, 0)

  return {
    /** 水印图片,base64 格式 */
    base64: canvas.toDataURL(),
    /** 水印图片大小 */
    size: canvasSize,
    /** 水印图片宽度 */
    styleSize: canvasSize / devicePixelRatio,
  }
}
ts
/**
 * 监听使用节点被修改的情况然后重新设置水印
 * @param parentDom 水印挂载父节点
 * @param options 水印参数
 * @returns
 */
export function watchWaterMarkChange(
  parentDom: HTMLDivElement,
  watermarkDom: HTMLDivElement | null,
  options: WaterMarkOptions,
) {
  const ob = new MutationObserver((entries) => {
    for (const entry of entries) {
      // 遍历被删除的节点
      for (const dom of entry.removedNodes) {
        // 如果被删除的节点是水印节点,重新设置水印节点
        if (dom === watermarkDom) {
          setWatermark(parentDom, watermarkDom, options)
          return
        }
      }
      // 修改水印节点的属性,例如修改水印节点的 css样式,比如透明度设置为 0 的情况
      if (entry.target === watermarkDom) {
        setWatermark(parentDom, watermarkDom, options)
      }
    }
  })

  return ob
}
ts
/**
 * 设置水印到父节点上去
 * @param parentDom 水印挂载父节点
 * @param options 水印参数
 * @returns
 */
export function setWatermark(
  parentDom: HTMLDivElement,
  watermarkDom: HTMLDivElement | null,
  options: WaterMarkOptions,
) {
  if (!parentDom) return
  if (watermarkDom) {
    watermarkDom.remove()
  }
  const { base64, styleSize } = options
  watermarkDom = document.createElement('div')
  watermarkDom.style.backgroundImage = `url(${base64})`
  watermarkDom.style.backgroundSize = `${styleSize}px`
  watermarkDom.style.backgroundRepeat = 'repeat'
  watermarkDom.style.width = '100%'
  watermarkDom.style.height = '100%'
  watermarkDom.style.zIndex = '99999'
  watermarkDom.style.position = 'absolute'
  watermarkDom.style.pointerEvents = 'none'
  watermarkDom.style.inset = '0'
  parentDom.appendChild(watermarkDom)
}

通用功能完整代码

ts
export interface WaterMarkBaseOptions {
  /** 水印文案,默认 xialuxiaohuo */
  text?: string
  /** 水印字体大小,默认 12px */
  fontSize?: number
  /** 水印文字倾斜角度, 默认 -45deg */
  rotate?: number
  /** 水印之间的间距倍数,默认一倍 */
  gap?: number
  /** 水印文字颜色 */
  fontColor?: string
}

export interface WaterMarkOptions {
  /** 水印图片,base64 格式 */
  base64: string
  /** 水印图片大小 */
  size: number
  /** 水印图片宽度 */
  styleSize: number
  /** 水印id */
  waterMarkId: string
}

/**
 * 获取水印图片信息
 * @param options 配置参数
 * @returns
 */
export function getWaterMark(options: Required<WaterMarkBaseOptions>): WaterMarkOptions {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

  /** 当前设备像素密度 */
  const devicePixelRatio = window.devicePixelRatio || 1
  // 计算水印文案字体占据的像素宽度
  const { width } = ctx.measureText(options.text)

  const canvasSize = width + options.gap * devicePixelRatio
  const canvasTranslate = canvasSize / 2
  canvas.width = canvasSize
  canvas.height = canvasSize
  ctx.translate(canvasTranslate, canvasTranslate)
  ctx.rotate((options.rotate * Math.PI) / 180)
  ctx.font = `${options.fontSize * devicePixelRatio}px serif`
  ctx.fillStyle = options.fontColor
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(options.text, 0, 0)

  const waterMarkId = `watermark_${Date.now()}`

  return {
    /** 水印图片,base64 格式 */
    base64: canvas.toDataURL(),
    /** 水印图片大小 */
    size: canvasSize,
    /** 水印图片宽度 */
    styleSize: canvasSize / devicePixelRatio,
    /** 水印id */
    waterMarkId,
  }
}

/**
 * 设置水印到父节点上去
 * @param parentDom 水印挂载父节点
 * @param options 水印参数
 * @returns
 */
export function setWatermark(parentDom: HTMLDivElement, options: WaterMarkOptions) {
  if (!parentDom) return
  const { base64, styleSize, waterMarkId } = options
  const watermarkDom = document.getElementById(waterMarkId)
  if (watermarkDom) {
    watermarkDom.remove()
  }
  const div = document.createElement('div')
  div.style.backgroundImage = `url(${base64})`
  div.style.backgroundSize = `${styleSize}px`
  div.style.backgroundRepeat = 'repeat'
  div.style.width = '100%'
  div.style.height = '100%'
  div.style.zIndex = '99999'
  div.style.position = 'absolute'
  div.style.pointerEvents = 'none'
  div.style.inset = '0'
  div.id = waterMarkId
  parentDom.appendChild(div)
}

/**
 * 监听使用节点被修改的情况然后重新设置水印
 * @param parentDom 水印挂载父节点
 * @param options 水印参数
 * @returns
 */
export function watchWaterMarkChange(parentDom: HTMLDivElement, options: WaterMarkOptions) {
  const { waterMarkId } = options
  const watermarkDom = document.getElementById(waterMarkId)

  const ob = new MutationObserver((entries) => {
    for (const entry of entries) {
      // 遍历被删除的节点
      for (const dom of entry.removedNodes) {
        // 如果被删除的节点是水印节点,重新设置水印节点

        if (dom === watermarkDom) {
          setWatermark(parentDom, options)
          return
        }
      }
      // 修改水印节点的属性,例如修改水印节点的 css样式,比如透明度设置为 0 的情况
      if (entry.target === watermarkDom) {
        setWatermark(parentDom, options)
      }
    }
  })

  return ob
}

组件实现

tsx
import { memo, useCallback, useEffect, useRef } from 'react'
import { WaterMarkBaseOptions, WaterMarkOptions, getWaterMark, setWatermark, watchWaterMarkChange } from './utils'

type WaterMarkProps = WaterMarkBaseOptions & {
  /** 水印覆盖内容 */
  children: React.ReactNode
  /** 自定义水印样式 */
  style?: React.CSSProperties
  /** 自定义水印类名 */
  classname?: string
}

function WaterMark(props: WaterMarkProps) {
  const { text = 'xialuxiaohuo', fontSize = 16, rotate = -45, gap = 10, fontColor = '#999', children } = props
  const parentRef = useRef<HTMLDivElement>(null)
  const ob = useRef<MutationObserver>()
  const watermarkOptions = useRef<WaterMarkOptions>()

  useEffect(() => {
    getWatermarkOptions()
    resetWatermark()
    ob.current = watchWaterMarkChange(parentRef.current as HTMLDivElement, watermarkOptions.current as WaterMarkOptions)

    ob.current.observe(parentRef.current as HTMLDivElement, {
      attributes: true,
      childList: true,
      subtree: true,
    })
    return () => {
      ob.current?.disconnect()
      const id = watermarkOptions.current?.waterMarkId || ''
      if (id) {
        document.getElementById?.(id)?.remove()
      }
    }
  }, [text, fontSize, rotate, gap, fontColor])

  const getWatermarkOptions = useCallback(() => {
    watermarkOptions.current = getWaterMark({
      fontSize,
      rotate,
      text,
      gap,
      fontColor,
    })
  }, [text, fontSize, rotate, gap, fontColor])

  const resetWatermark = useCallback(() => {
    setWatermark(parentRef.current as HTMLDivElement, watermarkOptions.current as WaterMarkOptions)
  }, [])

  return (
    <div
      ref={parentRef}
      className={`watermark-contain ${props?.classname}`}
      style={{ position: 'relative', ...props?.style }}>
      {children}
    </div>
  )
}

export default memo(WaterMark)
vue
<template>
  <div ref="parentRef" :class="classname" :style="style">
    <slot></slot>
  </div>
</template>

<script setup lang="ts">
import { CSSProperties, HTMLAttributes, computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
  WaterMarkBaseOptions,
  WaterMarkOptions,
  getWaterMark,
  setWatermark,
  watchWaterMarkChange,
} from './utils';

type WaterMarkProps = WaterMarkBaseOptions & {
  /** 自定义水印样式 */
  style?: CSSProperties;
  /** 自定义水印类名 */
  class?: string;
};
const props = withDefaults(defineProps<WaterMarkProps>(), {
  text: 'xialuxiaohuo',
  fontSize: 16,
  rotate: -45,
  gap: 10,
  fontColor: '#999',
  class: '',
  style: () => ({}),
})

const style = computed<HTMLAttributes['style']>(() => {
  return {
    position: 'relative',
    ...props?.style,
  }
})
const classname = computed(() => {
  return `watermark-contain ${props?.class}`
})

const watermarkOptions = ref<WaterMarkOptions>()
const parentRef = ref<HTMLDivElement>();
const ob = ref<MutationObserver>();

const getWatermarkOptions = () => {
  const { fontSize,
    rotate,
    text,
    gap,
    fontColor, } = props;
  watermarkOptions.value = getWaterMark({
    fontSize,
    rotate,
    text,
    gap,
    fontColor,
  });
};

const resetWatermark = () => setWatermark(
  parentRef.value as HTMLDivElement,
  watermarkOptions.value as WaterMarkOptions
);

onMounted(() => {
  observe()
})

const observe = () => {
  getWatermarkOptions();
  resetWatermark();
  ob.value = watchWaterMarkChange(
    parentRef.value as HTMLDivElement,
    watermarkOptions.value as WaterMarkOptions
  );
  ob.value?.observe(parentRef.value as HTMLDivElement, {
    attributes: true,
    childList: true,
    subtree: true,
  });
}


const disconnect = () => {
  ob.value?.disconnect();
  const id = watermarkOptions.value?.waterMarkId || '';
  if (id) {
    document.getElementById?.(id)?.remove();
  }
}

onUnmounted(() => disconnect())

watch(() => [
  props.fontSize,
  props.rotate,
  props.text,
  props.gap,
  props.fontColor,
], () => {
  disconnect()
  observe()
})
</script>

<style scoped></style>

本站中引用到的其他资料,如有侵权,请联系本人删除