你知道前端水印功能是怎么实现的吗?

由于项目需要实现水印功能,于是去了解了相关的内容后,基于 Vue 的实现了一个 v-watermark 指令完成了对应的功能,其实整体内容并不复杂!

那么接下来先来简单了解一些和 Vue 自定义指令相关的内容,作为前置知识的铺垫,然后在逐步完成对应的功能。

vue 中的自定义指令

以下的内容其实简单了解即可,甚至可以直接跳过,遇到不认识的内容在回头来查看都是可以的,甚至可以直接查看 官方文档[1]

是什么?

在 Vue3 中 复用代码 有如下三种方式:

组件[2]:组件是主要的构建模块

组合式函数[3]:合式函数则侧重于有状态的逻辑

自定义指令[4]:主要是为了重用涉及普通元素的底层 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[5] 中的自定义钩子是有些不同的,具体可以点击对应链接比对查看.

钩子参数

其实直接看参数的命名方式,相信你也能知道大部分的参数代表什么:

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[6] 属性实现

实现水印功能

几种实现方案

基于原图生成水印图片(后端)

这种方案就是将 原图片 添加水印之后生成了 新图片,后续在前端页面进行展示是后端接口不返回原图片,而是返回带有水印的图片即可。

这种方式最大的优点就是安全,因为 水印图片 是后端生成的,前端只需要负责展示即可,不需考虑多余的问题,且即便在前端页面保存对应图片,拿到的仍然不是原图片。

基于 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.ts

import{ createApp }from'vue'

importAppfrom'./App.vue'

importdirectivesfrom'./directives'

createApp(App)

.use(directives)

.mount('#app');

// src/directives/index.ts

importtype { App }from'vue'

importwatermarkfrom'./waterMark'

exportdefaultfunctioninstallDirective(app: App){

app.directive(watermark.name, watermark.directives);

}

// src/directives/waterMark.ts

importwaterBgImgfrom'../assets/water-bg.png'

constdirectives: any = {

mounted(el: HTMLElement) {

el.onload =()=>{

const{ clientWidth, clientHeight, parentElement } = el;

constwaterMark: HTMLElement =document.createElement('div');

constwaterBg: 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 ***********/

constdirectives: any = {

mounted(el: HTMLElement) {

el.onload =()=>{

const{ clientWidth, clientHeight, parentElement } = el;

constwaterMark: 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 ,避免重复创建(单例模式)

constglobalCanvas =null;

constglobalWaterMark =null;

// 获取 toDataURL 的结果

constgetDataUrl = ({

font ="16px normal",

fillStyle ="rgba(180, 180, 180, 0.3)",

textAlign,

textBaseline,

text ="请勿外传",

}) => {

constrotate =-20;

constcanvas = globalCanvas ||document.createElement("canvas");

constctx = 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);

returncanvas.toDataURL("image/png");

};

// 设置水印

constsetWaterMark =(el: HTMLElement, binding: any) =>{

const{ parentElement } = el;

// 获取对应的 canvas 画布相关的 base64 url

consturl = getDataUrl(binding);

// 创建 waterMark 父元素

constwaterMark = globalWaterMark ||document.createElement("div");

waterMark.className =`water-mark`;// 方便自定义展示结果

waterMark.setAttribute("style",`background-image: url(${url});`);

// 将对应图片的父容器作为定位元素

parentElement.setAttribute("style","position: relative;");

// 将图片元素移动到 waterMark 中

parentElement.appendChild(waterMark);

};

constdirectives: 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[7] 接口提供对 DOM 树监听的能力,它能够监听 DOM 树属性、节点本身、子节点等的变化,于是优化的思路就是使用 MutationObserver 去监听外部对应 water-mark 节点的操作,只要监听到了就重新渲染水印效果即可。

效果和代码

注意】这里最容易踩坑的点就是 MutationObserver 中的条件写得不正确的话会导致死循环.

need-to-insert-img

/********* src/directives/waterMark.ts  ***********/

// 全局保存 canvas 和 div ,避免重复创建(单例模式)

constglobalCanvas =null;

constglobalWaterMark =null;

// watermark 样式

letstyle =`

display: block;

overflow: hidden;

position: absolute;

left: 0;

top: 0;

width: 100%;

height: 100%;

background-repeat: repeat;

pointer-events: none;`

;

constgetDataUrl = ({

font ="16px normal",

fillStyle ="rgba(180, 180, 180, 0.3)",

textAlign,

textBaseline,

text ="请勿外传",

}) => {

constrotate =-20;

constcanvas = globalCanvas ||document.createElement("canvas");

constctx = 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);

returncanvas.toDataURL("image/png");

};

constsetWaterMark =(el: HTMLElement, binding: any = {}) =>{

const{ parentElement } = el;

// 获取对应的 canvas 画布相关的 base64 url

consturl = getDataUrl(binding);

// 创建 waterMark 父元素

constwaterMark = 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) =>{

constwaterMarkEl = el.parentElement.querySelector(".water-mark");

constobserver =newMutationObserver((mutationsList) =>{

if(mutationsList.length) {

const{ removedNodes, type, target } = mutationsList[0];

constcurrStyle = 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);

};

// 定义指令配置项

constdirectives: any = {

mounted(el: HTMLElement,binding: any) {

el.onload = init.bind(null, el, binding);

},

};

exportdefault{

name:"watermark",

directives,

};

最后

上述过程中我们做了那么多优化,最终的结果看起来比较还算是可以接受,但实际上前端的实现方案终归是不够完美的,比如有心人直接复制图片对应的地址怎么办?又或者是通过开发者调试工具选择禁用 JavaScript 又怎么办呢?

因此,总结下来前端的实现方案是属于:防君子不防小人的方案,不过也不必过于纠结这一点,毕竟 语雀 这样的网站连 MutationObserver 都没加呢 ~~

参考资料

[1]

官方文档:https://cn.vuejs.org/guide/reusability/custom-directives.html

[2]

组件:https://cn.vuejs.org/guide/essentials/component-basics.html

[3]

组合式函数:https://cn.vuejs.org/guide/reusability/composables.html

[4]

自定义指令:https://cn.vuejs.org/guide/reusability/custom-directives.html

[5]

vue2:https://v2.cn.vuejs.org/v2/guide/custom-directive.html

[6]

dataset:https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/dataset

[7]

MutationObserver:https://developer.mozilla.org/zh-CN/docs/Web/API/MutationObserver

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,692评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,482评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,995评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,223评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,245评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,208评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,091评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,929评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,346评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,570评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,739评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,437评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,037评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,677评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,833评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,760评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,647评论 2 354

推荐阅读更多精彩内容

  • 导语:前段时间做某系统审核后台,出现了审核人员截图把内容外泄露的情况,虽然截图内容不是特别敏感,但是安全问题还是不...
    李亚_45be阅读 1,190评论 0 0
  • 问题: 项目需要将系统加水印,从网上找到的方法都是动态生成dom,根据屏幕宽高,计算dom的定位。但在页面resi...
    过好这普通的一生阅读 2,093评论 0 1
  • 前端开发不断创新,其中之一是实现水印效果。水印可以用于保护内容的版权,提供额外信息,或者美化页面。本文将深入探讨几...
    码农说阅读 3,682评论 0 1
  • 前言 前端实现的水印基本都是不安全的,可被破解的. 水印 水印(watermark)是一种容易识别、被夹于纸内,能...
    追星人小豪阅读 1,118评论 0 5
  • 关键词:前端 明水印 暗水印 明水印和暗水印的区别 前端水印可以分为明水印和暗水印两种类型。它们的区别如下: 明水...
    晴小篆阅读 344评论 0 0