你知道前端水印功能是怎麼實現的嗎?
前言
前一段時間由於項目需要實現水印功能,於是去了解了相關的內容後,基於 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.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 接口提供對 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