在Web应用开发中,为敏感内容添加水印是常见的安全需求。本文基于Vue 3实现了一个可定制的水印组件,支持动态文本、样式配置和防篡改功能,适用于文档保护、数据安全等场景。
组件采用Canvas API进行水印绘制,相比DOM方案具有以下优势:
vue<template> <div ref="watermarkContainer" class="watermark-container" :style="containerStyle"> <slot /> </div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue' interface Props { text: string | string[] color?: string fontSize?: number | string fontStyle?: 'normal' | 'italic' | 'oblique' fontWeight?: 'normal' | 'bold' | 'bolder' | 'lighter' | number fontFamily?: string rotate?: number lineHeight?: number gapX?: number gapY?: number opacity?: number fullScreen?: boolean zIndex?: number preventTamper?: boolean } const props = withDefaults(defineProps<Props>(), { text: 'Vue3 Watermark', color: 'rgba(0, 0, 0, 0.1)', fontSize: 14, fontStyle: 'normal', fontWeight: 'normal', fontFamily: 'sans-serif', rotate: -15, lineHeight: 1.5, gapX: 100, gapY: 80, opacity: 1, fullScreen: false, zIndex: 9999, preventTamper: true }) const emit = defineEmits(['mounted', 'updated', 'error']) const watermarkContainer = ref<HTMLDivElement | null>(null) const watermarkElement = ref<HTMLDivElement | null>(null) const resizeObserver = ref<ResizeObserver | null>(null) const mutationObserver = ref<MutationObserver | null>(null) // 临时Canvas:用于计算文字真实宽高(不渲染到页面) const tempCanvas = document.createElement('canvas') const tempCtx = tempCanvas.getContext('2d') const containerStyle = computed(() => ({ position: props.fullScreen ? 'fixed' : 'relative', top: props.fullScreen ? 0 : 'inherit', left: props.fullScreen ? 0 : 'inherit', width: props.fullScreen ? '100vw' : '100%', height: props.fullScreen ? '100vh' : '100%', overflow: 'hidden', zIndex: props.fullScreen ? 'inherit' : props.zIndex })) /** * 计算文字的真实宽高(不旋转) * @param text 单段文字 * @returns {width: number, height: number} */ const calculateTextRect = (text: string): { width: number; height: number } => { if (!tempCtx) return { width: 0, height: 0 } const fontSize = typeof props.fontSize === 'number' ? props.fontSize : parseInt(props.fontSize) // 设置临时Canvas字体样式,与实际水印完全一致 tempCtx.font = `${props.fontStyle} ${props.fontWeight} ${fontSize}px ${props.fontFamily}` // 获取文字的包围盒信息 const textMetrics = tempCtx.measureText(text) const textWidth = textMetrics.width const textHeight = fontSize return { width: textWidth, height: textHeight } } /** * 计算旋转后的包围盒尺寸 * @param width 原始宽度 * @param height 原始高度 * @param angle 旋转角度(角度制) * @returns {width: number, height: number} */ const calculateRotatedRect = (width: number, height: number, angle: number): { width: number; height: number } => { const radian = (angle * Math.PI) / 180 const rotatedWidth = width * Math.abs(Math.cos(radian)) + height * Math.abs(Math.sin(radian)) const rotatedHeight = width * Math.abs(Math.sin(radian)) + height * Math.abs(Math.cos(radian)) return { width: rotatedWidth, height: rotatedHeight } } /** * 生成水印图片URL + Canvas尺寸(核心修改:返回对象而非单一URL) * @returns {url: string, canvasWidth: number, canvasHeight: number} */ const generateWatermarkUrl = (): { url: string; canvasWidth: number; canvasHeight: number } => { try { if (!tempCtx) throw new Error('Canvas 上下文获取失败') const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) throw new Error('Canvas 上下文获取失败') const textList = Array.isArray(props.text) ? props.text : [props.text] const fontSize = typeof props.fontSize === 'number' ? props.fontSize : parseInt(props.fontSize) const singleLineHeight = fontSize * props.lineHeight // 1. 计算所有文字中最长的宽度 let maxTextWidth = 0 textList.forEach(text => { const { width } = calculateTextRect(text) if (width > maxTextWidth) maxTextWidth = width }) // 2. 计算单段文字高度和总文本高度 const singleTextHeight = fontSize const totalTextHeight = textList.length * singleLineHeight // 3. 计算旋转后的包围盒尺寸 const { width: rotatedWidth, height: rotatedHeight } = calculateRotatedRect(maxTextWidth, totalTextHeight, props.rotate) // 4. 设置Canvas尺寸为旋转后的包围盒尺寸(兜底防止过小) const canvasWidth = Math.max(rotatedWidth, props.gapX / 2) const canvasHeight = Math.max(rotatedHeight, props.gapY / 2) canvas.width = canvasWidth canvas.height = canvasHeight ctx.clearRect(0, 0, canvas.width, canvas.height) // 5. 配置水印样式 ctx.fillStyle = props.color ctx.font = `${props.fontStyle} ${props.fontWeight} ${fontSize}px ${props.fontFamily}` ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.globalAlpha = props.opacity // 6. 平移+旋转:保证文字居中旋转 ctx.translate(canvas.width / 2, canvas.height / 2) ctx.rotate((props.rotate * Math.PI) / 180) ctx.translate(-canvas.width / 2, -canvas.height / 2) // 7. 绘制多段文字 textList.forEach((text, index) => { const x = canvas.width / 2 const y = (canvas.height / 2) - (totalTextHeight / 2) + (index + 0.5) * singleLineHeight ctx.fillText(text, x, y) }) // 核心修改:返回URL + Canvas宽高 return { url: canvas.toDataURL('image/png'), canvasWidth, canvasHeight } } catch (err) { const error = err as Error emit('error', error.message) console.error('水印图片生成失败:', error) // 异常时返回默认值,避免后续报错 return { url: '', canvasWidth: props.gapX, canvasHeight: props.gapY } } } // 渲染水印(核心修改:接收并使用Canvas尺寸) const renderWatermark = () => { nextTick(() => { if (!watermarkContainer.value) return // 移除旧水印 if (watermarkElement.value) { watermarkElement.value.remove() watermarkElement.value = null } // 获取水印URL和Canvas尺寸(核心修改) const { url: watermarkUrl, canvasWidth, canvasHeight } = generateWatermarkUrl() if (!watermarkUrl) return const watermark = document.createElement('div') watermarkElement.value = watermark Object.assign(watermark.style, { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', pointerEvents: 'none', background: `url(${watermarkUrl}) repeat`, zIndex: props.zIndex, userSelect: 'none', // 使用返回的Canvas尺寸,不再引用局部变量(核心修复) backgroundSize: `${canvasWidth}px ${canvasHeight}px` }) watermarkContainer.value.appendChild(watermark) emit('updated') }) } // 防篡改监听 const startTamperPrevent = () => { if (!watermarkContainer.value || !props.preventTamper) return const observerOptions = { childList: true, attributes: true, subtree: true, attributeFilter: ['style', 'class'] } mutationObserver.value = new MutationObserver((mutations) => { const isWatermarkChanged = mutations.some(mutation => mutation.target === watermarkElement.value || mutation.removedNodes[0] === watermarkElement.value ) if (isWatermarkChanged) renderWatermark() }) mutationObserver.value.observe(watermarkContainer.value, observerOptions) } // 尺寸监听 const startResizeObserve = () => { if (!watermarkContainer.value) return resizeObserver.value = new ResizeObserver(() => { renderWatermark() }) resizeObserver.value.observe(watermarkContainer.value) } // 停止所有监听 const stopAllObserve = () => { if (resizeObserver.value) { resizeObserver.value.disconnect() resizeObserver.value = null } if (mutationObserver.value) { mutationObserver.value.disconnect() mutationObserver.value = null } } // 生命周期 onMounted(() => { if (watermarkContainer.value) { renderWatermark() startResizeObserve() startTamperPrevent() emit('mounted') } }) onUnmounted(() => { stopAllObserve() if (watermarkElement.value) { watermarkElement.value.remove() watermarkElement.value = null } // 销毁临时Canvas,避免内存泄漏 tempCanvas.remove() }) // 监听Props变化,自动重绘水印 watch( () => [ props.text, props.color, props.fontSize, props.fontStyle, props.fontWeight, props.fontFamily, props.rotate, props.lineHeight, props.gapX, props.gapY, props.opacity, props.fullScreen, props.zIndex ], () => { renderWatermark() if (props.preventTamper) startTamperPrevent() }, { deep: true } ) </script> <style scoped> .watermark-container { box-sizing: border-box; } :slotted(*) { position: relative; z-index: 1; } </style>
vue<WaterMark :text="['我的水印', '2026-02-05']" :rotate="-20" fontSize="16" color="rgba(0, 128, 0, 0.2)" :gapX="200" :gapY="160"> <HelloWorld></HelloWorld> </WaterMark>



本文作者:繁星
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!