你知道前端水印功能是怎麼實現的嗎?

前言

前一段時間由於項目需要實現水印功能,於是去了解了相關的內容後,基於 Vue 的實現了一個 v-watermark 指令完成了對應的功能,其實整體內容並不複雜!

那麼接下來先來簡單瞭解一些和 Vue 自定義指令相關的內容,作爲前置知識的鋪墊,然後在逐步完成對應的功能。

vue 中的自定義指令

以下的內容其實簡單瞭解即可,甚至可以直接跳過,遇到不認識的內容在回頭來查看都是可以的,甚至可以直接查看 官方文檔  

是什麼?

Vue3複用代碼 有如下三種方式:

因此,自定義指令本身也是一種代碼邏輯複用的方式,只是着重點在 對底層 DOM 的訪問和操作 上。

指令鉤子

VueReactWebpackVite 等中都會存在着對應的 鉤子,而這些 鉤子 本質上就是一些在 特定時機 會被執行的 函數 / 方法 而已,那麼在 Vue 自定義指令 中就存在如下鉤子:

注意】以上這些鉤子與 vue2 中的自定義鉤子是有些不同的,具體可以點擊對應鏈接比對查看.

鉤子參數

其實直接看參數的命名方式,相信你也能知道大部分的參數代表什麼:

注意】除了 el 外(因爲需要操作 DOM),其他參數都是隻讀的,不建議更改,如果需要在不同的鉤子間共享信息,推薦通過元素的 dataset 屬性實現

實現水印功能

幾種實現方案

基於原圖生成水印圖片(後端)

這種方案就是將 原圖片 添加水印之後生成了 新圖片,後續在前端頁面進行展示是後端接口不返回原圖片,而是返回帶有水印的圖片即可。

這種方式最大的優點就是安全,因爲 水印圖片 是後端生成的,前端只需要負責展示即可,不需考慮多餘的問題,且即便在前端頁面保存對應圖片,拿到的仍然不是原圖片。

基於 DOM 實現水印效果(前端)

自定義指令鉤子非常多,但實際上能使用到的不多,比如最常用的就是 mountedupdated,在這我們只需要通過 mounted 即可實現對應的功能,並且核心代碼比較簡單。

核心內容

效果和代碼如下

// 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
}

優化實現方式

在上述的實現方式中,實際上至少有兩點可優化的點:

優化後核心代碼如下:

/********* 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 實現方式的優點就在於能夠動態的設置水印內容,相比於上一種基於固定背景圖片的方式更靈活,這種方式也是 語雀 在使用的方式,具體效果如下:

核心步驟

效果和代碼如下

/********* 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 優化

以上提到的兩種前端實現方案,都存在一個問題很明顯的問題,那就是用於只要用戶通過 開發者調試工具 來稍微操作一,舊能夠導致水印失效:

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");
};
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, {
    childList: true,
    attributes: true,
    subtree: true,
  });
};
// 初始化
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 都沒加呢 ~~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/xYKf_qBjpPfyG1WLtOfWag