天天看點

前端水印實作方案

大廠技術  堅持周更  精選好文

一、問題背景

為了防止資訊洩露或知識産權被侵犯,在web的世界裡,對于頁面和圖檔等增加水印處理是十分有必要的,水印的添加根據環境可以分為兩大類,前端浏覽器環境添加和後端服務環境添加,簡單對比一下這兩種方式的特點:

前端浏覽器加水印:

  • 減輕服務端的壓力,快速反應
  • 安全系數較低,對于掌握一定前端知識的人來說可以通過各種騷操作跳過水印擷取到源檔案
  • 适用場景:

    資源不跟某一個單獨的使用者綁定,而是一份資源,多個使用者檢視,需要在每一個使用者檢視的時候添加使用者特有的水印,多用于某些機密文檔或者展示機密資訊的頁面,水印的目的在于文檔外流的時候可以追究到責任人

後端伺服器加水印:

  • 當遇到大檔案密集水印,或是複雜水印,占用伺服器記憶體、運算量,請求時間過長
  • 安全性高,無法擷取到加水印前的源檔案
  • 适用場景:資源為某個使用者獨有,一份原始資源隻需要做一次處理,将其存儲之後就無需再次處理,水印的目的在于标示資源的歸屬人 這裡我們讨論前端浏覽器環境添加

二、收益分析

簡單介紹一下目前主流的前端加水印的方法,以後其他同學在用到的時候可以作為參考。

三、實作方案

1. 重複的dom元素覆寫實作

從效果開始,要實作的效果是「在頁面上充滿透明度較低的重複的代表身份的資訊」,第一時間想到的方案是在頁面上覆寫一個position:fixed的div盒子,盒子透明度設定較低,設定pointer-events: none;樣式實作點選穿透,在這個盒子内通過js循環生成小的水印div,每個水印div内展示一個要顯示的水印内容,簡單實作了一下

<!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> 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- </h2> 
            <br /> 
            <h2> 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- </h2> 
            <br /> 
            <h2 onclick="alert(1)"> 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- 機密内容- </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内循環建立多個dom元素,既不優雅也影響性能,于是考慮可不可以不生成這麼多個元素。

2. canvas輸出背景圖

第一步還是在頁面上覆寫一個固定定位的盒子,然後建立一個canvas畫布,繪制出一個水印區域,将這個水印通過toDataURL方法輸出為一個圖檔,将這個圖檔設定為盒子的背景圖,通過backgroud-repeat:repeat;樣式實作填滿整個螢幕的效果,簡單實作的代碼。

<!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;
                }
              })();
                
            // 調用
            __canvasWM({
              content: '水印123'
            });
        </script>
    </body>
</html>
           

3. svg實作背景圖

與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;
              }
            })();
        
            // 調用
            __canvasWM({
              content: '水印123'
            });
        </script>
    </body>
</html>

           

但是,以上三種方法存在一個共同的問題,由于是前端生成dom元素覆寫到頁面上的,對于有些前端知識的人來說,可以在開發者工具中找到水印所在的元素,将元素整個删掉,以達到删除頁面上的水印的目的,針對這個問題,我想到了一個很笨的辦法:設定定時器,每隔幾秒檢驗一次我們的水印元素還在不在,有沒有被修改,如果發生了變化則再執行一次覆寫水印的方法。網上看到了另一種解決方法:使用MutationObserver

MutationObserver是變動觀察器,字面上就可以了解這是用來觀察節點變化的。Mutation Observer API 用來監視 DOM 變動,DOM 的任何變動,比如子節點的增減、屬性的變動、文本内容的變動,這個 API 都可以得到通知。

但是MutationObserver隻能監測到諸如屬性改變、子結點變化等,對于自己本身被删除,是沒有辦法監聽的,這裡可以通過監測父結點來達到要求。監測代碼的實作:

const MutationObserver = window.MutationObserver || window.WebKitMutationObserver;
if (MutationObserver) {
  let mo = new MutationObserver(function () {
    const __wm = document.querySelector('.__wm');
    // 隻在__wm元素變動才重新調用 __canvasWM
    if ((__wm && __wm.getAttribute('style') !== styleStr) || !__wm) {
      // 避免一直觸發
      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元素變動才重新調用 __canvasWM
                    if ((__wm && __wm.getAttribute('style') !== styleStr) || !__wm) {
                      // 避免一直觸發
                      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;
              }
            })();
        
            // 調用
            __canvasWM({
              content: '水印123'
            });
        </script>
    </body>
</html>



           

當然,設定了MutationObserver之後也隻是相對安全了一些,還是可以通過控制台禁用js來跳過我們的監聽,總體來說在單純的在前端頁面上加水印總是可以通過一些騷操作來跳過的,防君子不防小人,防外行不防内行

前端水印實作方案

4. 圖檔加水印

有時我們需要在圖檔上加水印用來标示歸屬或者其他資訊,在圖檔上加水印的實作思路是,圖檔加載成功後畫到canvas中,随後在canvas中繪制水印,完成後通過canvas.toDataUrl()方法獲得base64并替換原來的圖檔路徑

代碼實作:

<!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;
         }
             
       })();

       // 調用
       __picWM({
           url: './a.png',
           content: '水印水印',
           cb: (base64Url) => {
                 document.querySelector('img').src = base64Url
           },
       });

    </script>
    </body>
</html>

           

5. 拓展:圖檔的隐性水印

對于圖檔資源來說,顯性水印會破壞圖檔的完整性,有些情況下我們想要在保留圖檔原本樣式,這時可以添加隐藏水印。

簡單實作思路是:圖檔的像素資訊裡存儲着 RGB 的色值,對于RGB 分量值的小量變動,是肉眼無法分辨的,不會影響對圖檔的識别,我們可以對圖檔的RGB以一種特殊規則進行小量的改動。

通過canvas.getImageData()可以擷取到圖檔的像素資料,首先在canvas中繪制出水印圖,擷取到其像素資料,然後通過canvas擷取到原圖檔的像素資料,標明R、G、B其中一個如G,周遊原圖檔像素,将對應水印像素有資訊的像素的G都轉成奇數,對應水印像素沒有資訊的像素都轉成偶數,處理完後轉成base64并替換到頁面上,這時隐形水印就加好了,正常情況下看這個圖檔是沒有水印的,但是經過對應規則(上邊例子對應的解密規則是:周遊圖檔的像素資料中對應的G,奇數則将其rgba設定為0,255,0,偶數則設定為0,0,0)的解密處理後就可以看到水印了。

這種方式下,當使用者采用截圖、儲存圖檔後轉換格式等方法獲得圖檔後,圖檔的色值可能是會變化的,會影響水印效果 加水印代碼實作:

<!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);
                // 擷取指定區域的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)){
                            // 沒有水印資訊的像素,将其對應通道的值設定為偶數
                            if(oData[i] === 255){
                                oData[i]--;
                            } else {
                                oData[i]++;
                            }
                        } else if (newData[i + offset] !== 0 && (oData[i] % 2 === 0)){
                            // 有水印資訊的像素,将其對應通道的值設定為奇數
                            if(oData[i] === 255){
                                oData[i]--;
                            } else {
                                oData[i]++;
                            }
                        }
                    }
                }
                ctx.putImageData(originalData, 0, 0);
            }
            
        </script>
    </body>
</html>

           

顯示水印代碼實作:

<!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);
                // 擷取指定區域的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 {
                        // 關閉其他分量,不關閉也不影響答案,甚至更美觀 o(^▽^)o
                        data[i] = 0;
                    }
                }
                // 将結果繪制到畫布
                ctx.putImageData(originalData, 0, 0);
            }
                
        </script>
    </body>
</html>


           

這是一種比較簡單的實作方式

1.盲水印和圖檔隐寫術

繼續閱讀