欢迎关注同名公众号《熊的猫》,文章会同步更新!
前言
前一段时间由于项目需要实现水印功能,于是去了解了相关的内容后,基于 **
Vue
** 的实现了一个 **
v-watermark
** 指令完成了对应的功能,其实整体内容并不复杂!
那么接下来先来简单了解一些和 **
Vue
** 自定义指令相关的内容,作为前置知识的铺垫,然后在逐步完成对应的功能。

vue 中的自定义指令
以下的内容其实简单了解即可,甚至可以直接跳过,遇到不认识的内容在回头来查看都是可以的,甚至可以直接查看 官方文档 
是什么?
在 **
Vue3
** 中 复用代码 有如下三种方式:
- 组件:组件是主要的构建模块
- 组合式函数:合式函数则侧重于有状态的逻辑
- 自定义指令:主要是为了重用涉及普通元素的底层 DOM 访问的逻辑
因此,自定义指令本身也是一种代码逻辑复用的方式,只是着重点在 对底层 DOM 的访问和操作 上。
指令钩子
在 **
Vue
、
React
、
Webpack
、
Vite
** 等中都会存在着对应的 钩子,而这些 钩子 本质上就是一些在 特定时机 会被执行的 函数/方法 而已,那么在 Vue 自定义指令 中就存在如下钩子:
- **
created(el, binding, vnode, prevVnode)**:在绑定元素的attribute前,或事件监听器应用前调用 - **
beforeMount(el, binding, vnode, prevVnode)**:在元素被插入到DOM前调用 - **
mounted(el, binding, vnode, prevVnode)**:在绑定元素的父组件,及其所有子节点都挂载完成后调用 - **
beforeUpdate(el, binding, vnode, prevVnode)**:绑定元素的父组件更新前调用 - **
updated(el, binding, vnode, prevVnode)**:在绑定元素的父组件,及他自己的所有子节点都更新后调用 - **
beforeUnmount(el, binding, vnode, prevVnode)**:绑定元素的父组件卸载前调用 - **
unmounted(el, binding, vnode, prevVnode)**:绑定元素的父组件卸载后调用
【注意】以上这些钩子与 vue2 中的自定义钩子是有些不同的,具体可以点击对应链接比对查看.
钩子参数
其实直接看参数的命名方式,相信你也能知道大部分的参数代表什么:
- **
el**:指令绑定到的元素,意味着可通过el直接操作DOM - **
binding**:一个对象,包含以下属性 - **value**:传递给指令的值,例如在v-my-directive="1 + 1"中,值是2- **oldValue**:之前的旧值,仅在beforeUpdate和updated中可用,无论值是否更改都可用- **arg**:传递给指令的参数 (如果有的话),如在v-my-directive:foo中,参数是"foo"- **modifiers**:一个包含修饰符的对象 (如果有的话),如在v-my-directive.foo.bar中,修饰符对象是{ foo: true, bar: true }- **instance**:使用该指令的组件实例- **dir**:指令的定义对象 - **
vnode**:代表绑定元素的底层VNode - **
prevNode**:之前的渲染中代表指令所绑定元素的VNode,仅在beforeUpdate和updated钩子中可用
【注意】除了 **
el** 外(因为需要操作
DOM),其他参数都是只读的,不建议更改,如果需要在不同的钩子间共享信息,推荐通过元素的 dataset 属性实现
实现水印功能
几种实现方案
基于原图生成水印图片(后端)
这种方案就是将 原图片 添加水印之后生成了 新图片,后续在前端页面进行展示是后端接口不返回原图片,而是返回带有水印的图片即可。

这种方式最大的优点就是安全,因为 水印图片 是后端生成的,前端只需要负责展示即可,不需考虑多余的问题,且即便在前端页面保存对应图片,拿到的仍然不是原图片。
基于 DOM 实现水印效果(前端)
自定义指令钩子非常多,但实际上能使用到的不多,比如最常用的就是
mounted
、
updated
,在这我们只需要通过
mounted
即可实现对应的功能,并且核心代码比较简单。
核心内容
- 创建一个
watermark的DOM节点,即div元素,用于包裹对应的img便于展示水印内容 - 在创建一个
waterbg的DOM节点,即div元素 - 将waterbg节点作为watermark的 子节点,并进行 绝对定位 保证waterbg在 最上层显示- 将对应的 水印标记 作为waterbg节点的 背景图片 展示- 为waterbg节点设置pointer-events: none;实现 点击穿透 - 将
watermark节点通过insertBefore(...)插入到img标签的前一个位置 - 再将
img标签移动到watermark节点节中,这样就保证了新创建的watermark节点的位置一定是在原本img挂载的位置
效果和代码如下

// main.tsimport{ createApp }from'vue'import App from'./App.vue'import directives from'./directives'createApp(App).use(directives).mount('#app');// src/directives/index.tsimport type { App }from'vue'import watermark from'./waterMark'exportdefaultfunctioninstallDirective(app: App){
app.directive(watermark.name, watermark.directives);}// src/directives/waterMark.tsimport waterBgImg from'../assets/water-bg.png'const directives: any ={mounted(el: HTMLElement){
el.onload=()=>{const{ clientWidth, clientHeight, parentElement }= el;const waterMark: HTMLElement = document.createElement('div');const waterBg: HTMLElement = document.createElement('div');
waterMark.className =`water-mark`;// 方便自定义展示结果// 创建 waterMark 父元素
waterMark.setAttribute('style',`
display: inline-block;
overflow: hidden;
position: relative;
width: ${clientWidth}px;
height: ${clientHeight}px;`);// 创建 waterBg 背景元素
waterBg.className =`water-mark-bg`;// 方便自定义展示结果
waterBg.setAttribute('style',`
position: absolute;
pointer-events: none;
width: 100%;
height: 100%;
opacity: 0.2;
background-image: url(${waterBgImg});
background-repeat: repeat;`);// 为 waterMark 添加对应的子元素
waterMark.appendChild(waterBg);// 将 waterMark 插入到对应的位置
parentElement?.insertBefore(waterMark, el);// 将图片元素移动到 waterMark 中
waterMark.appendChild(el);}}}exportdefault{
name:'watermark',
directives
}
优化实现方式
在上述的实现方式中,实际上至少有两点可优化的点:
- 所有的样式直接以 字符串 形式出现在
JavaScript代码中 - 可以将对应的静态样式部分处理在css中,由.water-mark来管理 - 多余的
waterBg被创建出来 - 其实完全没必要单独创建这个节点元素,可以直接基于.water-mark对应节点的伪类元素来实现
优化后核心代码如下:
/********* src/directives/waterMark.ts ***********/const directives: any ={mounted(el: HTMLElement){
el.onload=()=>{const{ clientWidth, clientHeight, parentElement }= el;const waterMark: HTMLElement = document.createElement("div");// 创建 waterMark 父元素
waterMark.setAttribute("style",`width: ${clientWidth}px; height: ${clientHeight}px;`);
waterMark.className =`water-mark`;// 方便自定义展示结果// 将 waterMark 插入到对应的位置
parentElement?.insertBefore(waterMark, el);// 将图片元素移动到 waterMark 中
waterMark.appendChild(el);};},};exportdefault{
name:"watermark",
directives,};/********* css 部分代码如下 ***********/.water-mark {
display: inline-block;
overflow: hidden;
position: relative;}.water-mark::after {
pointer-events: none;
position: absolute;
content:' ';
width:100%;
height:100%;
opacity:0.2;
background-image:url("../assets/water-bg.png");
background-repeat: repeat;}
基于 Canvas 实现水印效果(前端)
基于
Canvas
实现方式的优点就在于能够动态的设置水印内容,相比于上一种基于固定背景图片的方式更灵活,这种方式也是 语雀 在使用的方式,具体效果如下:

核心步骤
- 通过
canvas填充文本,并通过canvas.toDataURL("image/png");获取到对应的base64格式的图片 - 将这个
base64格式的图片作为类名为water-mark节点的背景图 - 利用background-repeat: repeat;让这个图重复填充背景即可- 为water-mark节点设置pointer-events: none;实现 点击穿透- 利用对应图片的父元素作为water-mark节点的相对定位节点,保证绝对定位的water-mark节点显式在对应图片之上
效果和代码如下

/********* src/directives/waterMark.ts ***********/// 全局保存 canvas 和 div ,避免重复创建(单例模式)const globalCanvas =null;const globalWaterMark =null;// 获取 toDataURL 的结果const getDataUrl =({
font ="16px normal",
fillStyle ="rgba(180, 180, 180, 0.3)",
textAlign,
textBaseline,
text ="请勿外传",})=>{const rotate =-20;const canvas = globalCanvas || document.createElement("canvas");const ctx = canvas.getContext("2d");// 获取画布上下文
ctx.rotate((rotate * Math.PI)/180);
ctx.font = font;
ctx.fillStyle = fillStyle;
ctx.textAlign = textAlign ||"left";
ctx.textBaseline = textBaseline ||"middle";
ctx.fillText(text, canvas.width /3, canvas.height /2);return canvas.toDataURL("image/png");};// 设置水印constsetWaterMark=(el: HTMLElement, binding: any)=>{const{ parentElement }= el;// 获取对应的 canvas 画布相关的 base64 urlconst url =getDataUrl(binding);// 创建 waterMark 父元素const waterMark = globalWaterMark || document.createElement("div");
waterMark.className =`water-mark`;// 方便自定义展示结果
waterMark.setAttribute("style",`background-image: url(${url});`);// 将对应图片的父容器作为定位元素
parentElement.setAttribute("style","position: relative;");// 将图片元素移动到 waterMark 中
parentElement.appendChild(waterMark);};const directives: any ={mounted(el: HTMLElement, binding: any){
el.onload =setWaterMark.bind(null, el, binding.value);},};exportdefault{
name:"watermark",
directives,};/********* css 部分 ***********/.water-mark {
display: inline-block;
overflow: hidden;
position: absolute;
left:0;
top:0;
width:100%;
height:100%;
pointer-events: none;
background-repeat: repeat;}
使用 MutationObserver 优化
以上提到的两种前端实现方案,都存在一个问题很明显的问题,那就是用于只要用户通过 开发者调试工具 来稍微操作一,旧能够导致水印失效:
- 删除对应
dom节点 - 设置对应
dom节点的css样式

MutationObserver 接口提供对
DOM
树监听的能力,它能够监听
DOM
树属性、节点本身、子节点等的变化,于是优化的思路就是使用 MutationObserver 去监听外部对应
water-mark
节点的操作,只要监听到了就重新渲染水印效果即可。
效果和代码
【注意】这里最容易踩坑的点就是 MutationObserver 中的条件写得不正确的话会导致死循环.

/********* src/directives/waterMark.ts ***********/// 全局保存 canvas 和 div ,避免重复创建(单例模式)const globalCanvas =null;const globalWaterMark =null;// watermark 样式let style =`
display: block;
overflow: hidden;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-repeat: repeat;
pointer-events: none;`;const getDataUrl =({
font ="16px normal",
fillStyle ="rgba(180, 180, 180, 0.3)",
textAlign,
textBaseline,
text ="请勿外传",})=>{const rotate =-20;const canvas = globalCanvas || document.createElement("canvas");const ctx = canvas.getContext("2d");// 获取画布上下文
ctx.rotate((rotate * Math.PI)/180);
ctx.font = font;
ctx.fillStyle = fillStyle;
ctx.textAlign = textAlign ||"left";
ctx.textBaseline = textBaseline ||"middle";
ctx.fillText(text, canvas.width /3, canvas.height /2);return canvas.toDataURL("image/png");};constsetWaterMark=(el: HTMLElement, binding: any ={})=>{const{ parentElement }= el;// 获取对应的 canvas 画布相关的 base64 urlconst url =getDataUrl(binding);// 创建 waterMark 父元素const waterMark = globalWaterMark || document.createElement("div");
waterMark.className =`water-mark`;// 方便自定义展示结果
style =`${style}background-image: url(${url});`;
waterMark.setAttribute("style", style);// 将对应图片的父容器作为定位元素
parentElement.setAttribute("style","position: relative;");// 将图片元素移动到 waterMark 中
parentElement.appendChild(waterMark);};// 监听 DOM 变化constcreateObserver=(el: HTMLElement, binding: any)=>{const waterMarkEl = el.parentElement.querySelector(".water-mark");const observer =newMutationObserver((mutationsList)=>{if(mutationsList.length){const{ removedNodes, type, target }= mutationsList[0];const currStyle = waterMarkEl.getAttribute("style");// 证明被删除了if(removedNodes[0]=== waterMarkEl){
observer.disconnect();init(el, binding);}elseif(
type ==="attributes"&&
target === waterMarkEl &&
currStyle !== style
){
waterMarkEl.setAttribute("style", style);}}});
observer.observe(el.parentElement,{
childList:true,
attributes:true,
subtree:true,});};// 初始化constinit=(el: HTMLElement, binding: any ={})=>{// 设置水印setWaterMark(el, binding.value);// 启动监控createObserver(el, binding.value);};// 定义指令配置项const directives: any ={mounted(el: HTMLElement, binding: any){
el.onload =init.bind(null, el, binding);},};exportdefault{
name:"watermark",
directives,};
最后
欢迎关注同名公众号《熊的猫》,文章会同步更新!
上述过程中我们做了那么多优化,最终的结果看起来比较还算是可以接受,但实际上前端的实现方案终归是不够完美的,比如有心人直接复制图片对应的地址怎么办?又或者是通过开发者调试工具选择禁用
JavaScript
又怎么办呢?
因此,总结下来前端的实现方案是属于:**
防君子不防小人
** 的方案,不过也不必过于纠结这一点,毕竟 语雀 这样的网站连
MutationObserver
都没加呢 ~~

版权归原作者 熊的猫 所有, 如有侵权,请联系我们删除。