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

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

公众号新闻

推荐关注↓

作者:熊的猫

https://juejin.cn/post/7132620574198595597

前言

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

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

vue 中的自定义指令

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

是什么?

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

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

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

  • 自定义指令[4]:主要是为了重用涉及普通元素的底层 DOM 访问的逻辑

因此,自定义指令本身也是一种代码逻辑复用的方式,只是着重点在 对底层 DOM 的访问和操作 上。

指令钩子

VueReactWebpackVite 等中都会存在着对应的 钩子,而这些 钩子 本质上就是一些在 特定时机 会被执行的 函数/方法 而已,那么在 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:之前的旧值,仅在 beforeUpdateupdated 中可用,无论值是否更改都可用

    • arg:传递给指令的参数 (如果有的话),如在 v-my-directive:foo 中,参数是 "foo"

    • modifiers:一个包含修饰符的对象 (如果有的话),如在 v-my-directive.foo.bar 中,修饰符对象是 { foo: true, bar: true }

    • instance:使用该指令的组件实例

    • dir:指令的定义对象

  • vnode:代表绑定元素的底层 VNode

  • prevNode:之前的渲染中代表指令所绑定元素的 VNode,仅在 beforeUpdateupdated 钩子中可用

注意】除了 el 外(因为需要操作 DOM),其他参数都是只读的,不建议更改,如果需要在不同的钩子间共享信息,推荐通过元素的 dataset[6] 属性实现

实现水印功能

几种实现方案

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

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

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

基于 DOM 实现水印效果(前端)

自定义指令钩子非常多,但实际上能使用到的不多,比如最常用的就是 mountedupdated,在这我们只需要通过 mounted 即可实现对应的功能,并且核心代码比较简单。

核心内容

  • 创建一个 watermarkDOM 节点,即 div 元素,用于包裹对应的 img 便于展示水印内容

  • 在创建一个waterbgDOM节点,即div元素

    • waterbg 节点作为 watermark子节点,并进行 绝对定位 保证 waterbg最上层显示

    • 将对应的 水印标记 作为 waterbg 节点的 背景图片 展示

    • waterbg 节点设置 pointer-events: none; 实现 点击穿透

  • watermark 节点通过 insertBefore(...) 插入到 img 标签的前一个位置

  • 再将 img 标签移动到 watermark 节点节中,这样就保证了新创建的 watermark 节点的位置一定是在原本 img 挂载的位置

效果和代码如下

// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import directives from './directives'

createApp(App)
    .use(directives)
    .mount('#app');


// src/directives/index.ts
import type { App } from 'vue'
import watermark from './waterMark'

export default function installDirective(app: App{
    app.directive(watermark.name, watermark.directives);


// src/directives/waterMark.ts
import 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);
        }
    }
}


export default {
    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);
    };
  },
};

export default {
  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");
};

// 设置水印
const setWaterMark = (el: HTMLElement, binding: any) => {
  const { parentElement } = el;

  // 获取对应的 canvas 画布相关的 base64 url
  const 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);
  },
};

export default {
  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 中的条件写得不正确的话会导致死循环.

/********* 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");
};

const setWaterMark = (el: HTMLElement, binding: any = {}) => {
  const { parentElement } = el;

  // 获取对应的 canvas 画布相关的 base64 url
  const 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 变化
const createObserver = (el: HTMLElement, binding: any) => {
  const waterMarkEl = el.parentElement.querySelector(".water-mark");

  const observer = new MutationObserver((mutationsList) => {
    if (mutationsList.length) {
      const { removedNodes, type, target } = mutationsList[0];
      const currStyle = waterMarkEl.getAttribute("style");

      // 证明被删除了
      if (removedNodes[0] === waterMarkEl) {
        observer.disconnect();
        init(el, binding);
      } else if (
        type === "attributes" &&
        target === waterMarkEl &&
        currStyle !== style
      ) {
        waterMarkEl.setAttribute("style", style);
      }
    }
  });

  observer.observe(el.parentElement, {
    childListtrue,
    attributestrue,
    subtreetrue,
  });
};

// 初始化
const init = (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);
  },
};

export default {
  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]

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

[6]

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

[7]

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

- EOF -




推荐阅读  点击标题可跳转

0、极客专属:几十款程序员秒懂的卫衣

1、没有几十年功力,写不出这一行“看似无用”的代码!!

2、Linus 已决定将 Rust 语言加入 Linux 内核

3、虾皮光速大裁员!上个厕所的功夫,瞬间查无此人...


关注「程序员的那些事」加星标,不错过圈内事

点赞和在看就是最大的支持❤️

微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
眼周长小疙瘩是怎么回事?有一种情况可能是扁平疣密室杀人案,残杀4名大学生的嫌犯是怎样被发现的?海湾百货与太平洋铁路你知道“寒露”用英语怎么说吗?ACM MM2022|腾讯基于自适应区域选择和通道参考的视频盲水印,高效对抗各类攻击天天都在 CRUD,你知道数据库如何工作的吗?女副区长情爱故事,是怎么发现的?教育随笔(110)点燃课堂教学的亮点诗歌|不知道前头是归宿是流浪,我没说一句留你的话还记得你毕业后的第一份薪水是怎么花的吗?Spring Boot+Netty+Websocket实现后台向前端推送信息特斯拉在国内还未实现的功能,它做到了PS帮你去除3种顽固水印!你知道怎么写“好的”文书吗?快来看看这些小技巧吧!为什么实体企业要重视数智化?你知道《哈利·波特》的作者小时候最喜欢看谁的书吗?这套世界经典故事书你家有吗?利用静电吸引物体的特性可以创作出巨幅画,这是怎么实现的呢?毛泽东对三线建设的总构想方针“93段视频,176张照片,27.5G”,还记得李宗瑞是怎么玩翻车的吗?Spring 多线程异步上传图片、处理水印、缩略图在英国剪头发,你知道“修一修”的英文怎么说吗?光知道用【Doge】表情,你知道Doge是什么狗么?也不是太难!纯前端实现「羊了个羊」小游戏你知道“冬至”用英语怎么说吗?GRE考试中反复出现的超高频短语,你知道几个?这个东西大半个健身圈都在用,可是你知道怎么选吗?Python 教你快速批量下载抖音去水印短视频歪果仁也会穿秋裤?你知道“秋裤”的英语怎么说吗?人体的最佳运动时间孤女命运被董建华他爸的一句话改变如果你的梦想是移民火星,你准备怎么实现?哥大跌至18名,纽大CMU卷疯了!US News全美排名真不愧是端水大师!史景迁告诉你:你知道的太平天国可能是假的网站都变成灰色,是怎么实现的?成都两位副区长的情爱故事,是怎么被发现的?
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。