前端水印實現(xiàn)方案
大廠技術(shù) 堅持周更 精選好文
一、問題背景
為了防止信息泄露或知識產(chǎn)權(quán)被侵犯,在web的世界里,對于頁面和圖片等增加水印處理是十分有必要的,水印的添加根據(jù)環(huán)境可以分為兩大類,前端瀏覽器環(huán)境添加和后端服務(wù)環(huán)境添加,簡單對比一下這兩種方式的特點:
前端瀏覽器加水印:
減輕服務(wù)端的壓力,快速反應(yīng)
安全系數(shù)較低,對于掌握一定前端知識的人來說可以通過各種騷操作跳過水印獲取到源文件
適用場景:
資源不跟某一個單獨的用戶綁定,而是一份資源,多個用戶查看,需要在每一個用戶查看的時候添加用戶特有的水印,多用于某些機密文檔或者展示機密信息的頁面,水印的目的在于文檔外流的時候可以追究到責任人
當遇到大文件密集水印,或是復雜水印,占用服務(wù)器內(nèi)存、運算量,請求時間過長 安全性高,無法獲取到加水印前的源文件 適用場景:資源為某個用戶獨有,一份原始資源只需要做一次處理,將其存儲之后就無需再次處理,水印的目的在于標示資源的歸屬人 這里我們討論前端瀏覽器環(huán)境添加
二、收益分析
簡單介紹一下目前主流的前端加水印的方法,以后其他同學在用到的時候可以作為參考。
三、實現(xiàn)方案
1. 重復的dom元素覆蓋實現(xiàn)
從效果開始,要實現(xiàn)的效果是「在頁面上充滿透明度較低的重復的代表身份的信息」,第一時間想到的方案是在頁面上覆蓋一個position:fixed的div盒子,盒子透明度設(shè)置較低,設(shè)置pointer-events: none;樣式實現(xiàn)點擊穿透,在這個盒子內(nèi)通過js循環(huán)生成小的水印div,每個水印div內(nèi)展示一個要顯示的水印內(nèi)容,簡單實現(xiàn)了一下
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<style>
#watermark-box {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
font-size: 24px;
font-weight: 700;
display: flex;
flex-wrap: wrap;
overflow: hidden;
user-select: none;
pointer-events: none;
opacity: 0.1;
z-index: 999;
}
.watermark {
text-align: center;
}
</style>
</head>
<body>
<div>
<h2> 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- </h2>
<br />
<h2> 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- </h2>
<br />
<h2 onclick="alert(1)"> 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- 機密內(nèi)容- </h2>
<br />
</div>
<div id="watermark-box">
</div>
<script>
function doWaterMark(width, height, content) {
let box = document.getElementById("watermark-box");
let boxWidth = box.clientWidth,
boxHeight = box.clientHeight;
for (let i = 0; i < Math.floor(boxHeight / height); i++) {
for (let j = 0; j < Math.floor(boxWidth / width); j++) {
let next = document.createElement("div")
next.setAttribute("class", "watermark")
next.style.width = width + 'px'
next.style.height = height + 'px'
next.innerText = content
box.appendChild(next)
}
}
}
window.onload = doWaterMark(300, 100, '水印123')
</script>
</body>
</html>
頁面效果是有了,但是這種方案需要要在js內(nèi)循環(huán)創(chuàng)建多個dom元素,既不優(yōu)雅也影響性能,于是考慮可不可以不生成這么多個元素。
2. canvas輸出背景圖
第一步還是在頁面上覆蓋一個固定定位的盒子,然后創(chuàng)建一個canvas畫布,繪制出一個水印區(qū)域,將這個水印通過toDataURL方法輸出為一個圖片,將這個圖片設(shè)置為盒子的背景圖,通過backgroud-repeat:repeat;樣式實現(xiàn)填滿整個屏幕的效果,簡單實現(xiàn)的代碼。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="info" onclick="alert(1)" >
123
</div>
<script>
(function () {
function __canvasWM({
container = document.body,
width = '300px',
height = '200px',
textAlign = 'center',
textBaseline = 'middle',
font = "20px Microsoft Yahei",
fillStyle = 'rgba(184, 184, 184, 0.6)',
content = '水印',
rotate = '45',
zIndex = 10000
} = {}) {
const args = arguments[0];
const canvas = document.createElement('canvas');
canvas.setAttribute('width', width);
canvas.setAttribute('height', height);
const ctx = canvas.getContext("2d");
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.font = font;
ctx.fillStyle = fillStyle;
ctx.rotate(Math.PI / 180 * rotate);
ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2);
const base64Url = canvas.toDataURL();
const __wm = document.querySelector('.__wm');
const watermarkDiv = __wm || document.createElement("div");
const styleStr = `
position:fixed;
top:0;
left:0;
bottom:0;
right:0;
width:100%;
height:100%;
z-index:${zIndex};
pointer-events:none;
background-repeat:repeat;
background-image:url('${base64Url}')`;
watermarkDiv.setAttribute('style', styleStr);
watermarkDiv.classList.add('__wm');
if (!__wm) {
container.insertBefore(watermarkDiv, container.firstChild);
}
if (typeof module != 'undefined' && module.exports) { //CMD
module.exports = __canvasWM;
} else if (typeof define == 'function' && define.amd) { // AMD
define(function () {
return __canvasWM;
});
} else {
window.__canvasWM = __canvasWM;
}
})();
// 調(diào)用
__canvasWM({
content: '水印123'
});
</script>
</body>
</html>
3. svg實現(xiàn)背景圖
與canvas生成背景圖的方法類似,只不過是生成背景圖的方法換成了通過svg生成,canvas的兼容性略好于svg。兼容性對比:
canvas

svg

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="info" onclick="alert(1)">
123
</div>
<script>
(function () {
function __canvasWM({
container = document.body,
width = '300px',
height = '200px',
textAlign = 'center',
textBaseline = 'middle',
font = "20px Microsoft Yahei",
fillStyle = 'rgba(184, 184, 184, 0.6)',
content = '水印',
rotate = '45',
zIndex = 10000,
opacity = 0.3
} = {}) {
const args = arguments[0];
const svgStr = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${width}">
<text x="50%" y="50%" dy="12px"
text-anchor="middle"
stroke="#000000"
stroke-width="1"
stroke-opacity="${opacity}"
fill="none"
transform="rotate(-45, 120 120)"
style="font-size: ${font};">
${content}
</text>
</svg>`;
const base64Url = `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(svgStr)))}`;
const __wm = document.querySelector('.__wm');
const watermarkDiv = __wm || document.createElement("div");
const styleStr = `
position:fixed;
top:0;
left:0;
bottom:0;
right:0;
width:100%;
height:100%;
z-index:${zIndex};
pointer-events:none;
background-repeat:repeat;
background-image:url('${base64Url}')`;
watermarkDiv.setAttribute('style', styleStr);
watermarkDiv.classList.add('__wm');
if (!__wm) {
container.style.position = 'relative';
container.insertBefore(watermarkDiv, container.firstChild);
}
if (typeof module != 'undefined' && module.exports) { //CMD
module.exports = __canvasWM;
} else if (typeof define == 'function' && define.amd) { // AMD
define(function () {
return __canvasWM;
});
} else {
window.__canvasWM = __canvasWM;
}
})();
// 調(diào)用
__canvasWM({
content: '水印123'
});
</script>
</body>
</html>
但是,以上三種方法存在一個共同的問題,由于是前端生成dom元素覆蓋到頁面上的,對于有些前端知識的人來說,可以在開發(fā)者工具中找到水印所在的元素,將元素整個刪掉,以達到刪除頁面上的水印的目的,針對這個問題,我想到了一個很笨的辦法:設(shè)置定時器,每隔幾秒檢驗一次我們的水印元素還在不在,有沒有被修改,如果發(fā)生了變化則再執(zhí)行一次覆蓋水印的方法。網(wǎng)上看到了另一種解決方法:使用MutationObserver
MutationObserver是變動觀察器,字面上就可以理解這是用來觀察節(jié)點變化的。Mutation Observer API 用來監(jiān)視 DOM 變動,DOM 的任何變動,比如子節(jié)點的增減、屬性的變動、文本內(nèi)容的變動,這個 API 都可以得到通知。
但是MutationObserver只能監(jiān)測到諸如屬性改變、子結(jié)點變化等,對于自己本身被刪除,是沒有辦法監(jiān)聽的,這里可以通過監(jiān)測父結(jié)點來達到要求。監(jiān)測代碼的實現(xiàn):
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
if (MutationObserver) {
let mo = new MutationObserver(function () {
const __wm = document.querySelector('.__wm');
// 只在__wm元素變動才重新調(diào)用 __canvasWM
if ((__wm && __wm.getAttribute('style') !== styleStr) || !__wm) {
// 避免一直觸發(fā)
mo.disconnect();
mo = null;
__canvasWM(JSON.parse(JSON.stringify(args)));
}
});
mo.observe(container, {
attributes: true,
subtree: true,
childList: true
})
}
}
整體代碼
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="info" onclick="alert(1)">
123
</div>
<script>
(function () {
function __canvasWM({
container = document.body,
width = '300px',
height = '200px',
textAlign = 'center',
textBaseline = 'middle',
font = "20px Microsoft Yahei",
fillStyle = 'rgba(184, 184, 184, 0.6)',
content = '水印',
rotate = '45',
zIndex = 10000
} = {}) {
const args = arguments[0];
const canvas = document.createElement('canvas');
canvas.setAttribute('width', width);
canvas.setAttribute('height', height);
const ctx = canvas.getContext("2d");
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.font = font;
ctx.fillStyle = fillStyle;
ctx.rotate(Math.PI / 180 * rotate);
ctx.fillText(content, parseFloat(width) / 2, parseFloat(height) / 2);
const base64Url = canvas.toDataURL();
const __wm = document.querySelector('.__wm');
const watermarkDiv = __wm || document.createElement("div");
const styleStr = `
position:fixed;
top:0;
left:0;
bottom:0;
right:0;
width:100%;
height:100%;
z-index:${zIndex};
pointer-events:none;
background-repeat:repeat;
background-image:url('${base64Url}')`;
watermarkDiv.setAttribute('style', styleStr);
watermarkDiv.classList.add('__wm');
if (!__wm) {
container.style.position = 'relative';
container.insertBefore(watermarkDiv, container.firstChild);
}
const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
if (MutationObserver) {
let mo = new MutationObserver(function () {
const __wm = document.querySelector('.__wm');
// 只在__wm元素變動才重新調(diào)用 __canvasWM
if ((__wm && __wm.getAttribute('style') !== styleStr) || !__wm) {
// 避免一直觸發(fā)
mo.disconnect();
mo = null;
__canvasWM(JSON.parse(JSON.stringify(args)));
}
});
mo.observe(container, {
attributes: true,
subtree: true,
childList: true
})
}
}
if (typeof module != 'undefined' && module.exports) { //CMD
module.exports = __canvasWM;
} else if (typeof define == 'function' && define.amd) { // AMD
define(function () {
return __canvasWM;
});
} else {
window.__canvasWM = __canvasWM;
}
})();
// 調(diào)用
__canvasWM({
content: '水印123'
});
</script>
</body>
</html>
當然,設(shè)置了MutationObserver之后也只是相對安全了一些,還是可以通過控制臺禁用js來跳過我們的監(jiān)聽,總體來說在單純的在前端頁面上加水印總是可以通過一些騷操作來跳過的,防君子不防小人,防外行不防內(nèi)行

4. 圖片加水印
有時我們需要在圖片上加水印用來標示歸屬或者其他信息,在圖片上加水印的實現(xiàn)思路是,圖片加載成功后畫到canvas中,隨后在canvas中繪制水印,完成后通過canvas.toDataUrl()方法獲得base64并替換原來的圖片路徑
代碼實現(xiàn):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<div id="info" onclick="alert(1)">
<img />
</div>
<script>
(function() {
function __picWM({
url = '',
textAlign = 'center',
textBaseline = 'middle',
font = "20px Microsoft Yahei",
fillStyle = 'rgba(184, 184, 184, 0.8)',
content = '水印',
cb = null,
textX = 100,
textY = 30
} = {}) {
const img = new Image();
img.src = url;
img.crossOrigin = 'anonymous';
img.onload = function() {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.textAlign = textAlign;
ctx.textBaseline = textBaseline;
ctx.font = font;
ctx.fillStyle = fillStyle;
ctx.fillText(content, img.width - textX, img.height - textY);
const base64Url = canvas.toDataURL();
cb && cb(base64Url);
}
}
if (typeof module != 'undefined' && module.exports) { //CMD
module.exports = __picWM;
} else if (typeof define == 'function' && define.amd) { // AMD
define(function () {
return __picWM;
});
} else {
window.__picWM = __picWM;
}
})();
// 調(diào)用
__picWM({
url: './a.png',
content: '水印水印',
cb: (base64Url) => {
document.querySelector('img').src = base64Url
},
});
</script>
</body>
</html>
5. 拓展:圖片的隱性水印
對于圖片資源來說,顯性水印會破壞圖片的完整性,有些情況下我們想要在保留圖片原本樣式,這時可以添加隱藏水印。
簡單實現(xiàn)思路是:圖片的像素信息里存儲著 RGB 的色值,對于RGB 分量值的小量變動,是肉眼無法分辨的,不會影響對圖片的識別,我們可以對圖片的RGB以一種特殊規(guī)則進行小量的改動。
通過canvas.getImageData()可以獲取到圖片的像素數(shù)據(jù),首先在canvas中繪制出水印圖,獲取到其像素數(shù)據(jù),然后通過canvas獲取到原圖片的像素數(shù)據(jù),選定R、G、B其中一個如G,遍歷原圖片像素,將對應(yīng)水印像素有信息的像素的G都轉(zhuǎn)成奇數(shù),對應(yīng)水印像素沒有信息的像素都轉(zhuǎn)成偶數(shù),處理完后轉(zhuǎn)成base64并替換到頁面上,這時隱形水印就加好了,正常情況下看這個圖片是沒有水印的,但是經(jīng)過對應(yīng)規(guī)則(上邊例子對應(yīng)的解密規(guī)則是:遍歷圖片的像素數(shù)據(jù)中對應(yīng)的G,奇數(shù)則將其rgba設(shè)置為0,255,0,偶數(shù)則設(shè)置為0,0,0)的解密處理后就可以看到水印了。
這種方式下,當用戶采用截圖、保存圖片后轉(zhuǎn)換格式等方法獲得圖片后,圖片的色值可能是會變化的,會影響水印效果 加水印代碼實現(xiàn):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<canvas id="canvasText" width="256" height="256"></canvas>
<canvas id="canvas" width="256" height="256"></canvas>
<script>
var ctx = document.getElementById('canvas').getContext('2d');
var ctxText = document.getElementById('canvasText').getContext('2d');
var textData;
ctxText.font = '30px Microsoft Yahei';
ctxText.fillText('水印', 60, 130);
textData = ctxText.getImageData(0, 0, ctxText.canvas.width, ctxText.canvas.height).data;
var img = new Image();
var originalData;
img.onload = function() {
ctx.drawImage(img, 0, 0);
// 獲取指定區(qū)域的canvas像素信息
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
console.log(originalData);
mergeData(textData,'G')
console.log(document.getElementById('canvas').toDataURL())
};
img.src = './aa.jpeg';
var mergeData = function(newData, color){
var oData = originalData.data;
var bit, offset;
switch(color){
case 'R':
bit = 0;
offset = 3;
break;
case 'G':
bit = 1;
offset = 2;
break;
case 'B':
bit = 2;
offset = 1;
break;
}
for(var i = 0; i < oData.length; i++){
if(i % 4 == bit){
// 只處理目標通道
if(newData[i + offset] === 0 && (oData[i] % 2 === 1)){
// 沒有水印信息的像素,將其對應(yīng)通道的值設(shè)置為偶數(shù)
if(oData[i] === 255){
oData[i]--;
} else {
oData[i]++;
}
} else if (newData[i + offset] !== 0 && (oData[i] % 2 === 0)){
// 有水印信息的像素,將其對應(yīng)通道的值設(shè)置為奇數(shù)
if(oData[i] === 255){
oData[i]--;
} else {
oData[i]++;
}
}
}
}
ctx.putImageData(originalData, 0, 0);
}
</script>
</body>
</html>
顯示水印代碼實現(xiàn):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
</head>
<body>
<canvas id="canvas" width="256" height="256"></canvas>
<script>
var ctx = document.getElementById('canvas').getContext('2d');
var img = new Image();
var originalData;
img.onload = function() {
ctx.drawImage(img, 0, 0);
// 獲取指定區(qū)域的canvas像素信息
originalData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height);
console.log(originalData);
processData(originalData)
};
img.src = './a.jpg';
var processData = function(originalData){
var data = originalData.data;
for(var i = 0; i < data.length; i++){
if(i % 4 == 1){
if(data[i] % 2 === 0){
data[i] = 0;
} else {
data[i] = 255;
}
} else if(i % 4 === 3){
// alpha通道不做處理
continue;
} else {
// 關(guān)閉其他分量,不關(guān)閉也不影響答案,甚至更美觀 o(^▽^)o
data[i] = 0;
}
}
// 將結(jié)果繪制到畫布
ctx.putImageData(originalData, 0, 0);
}
</script>
</body>
</html>
這是一種比較簡單的實現(xiàn)方式,有興趣想要了解更多的可以參看https://juejin.cn/post/6917934964202242061
四、參考文檔
1.盲水印和圖片隱寫術(shù):https://juejin.cn/post/6917934964202242061
2.不能說的秘密-前端也能玩的圖片隱寫術(shù):http://www.alloyteam.com/2016/03/image-steganography/
3.前端水印生成方案(網(wǎng)頁水印+圖片水印):https://juejin.cn/post/6844903645155164174
