示例
背景
驗證碼主要是防止機器暴力破解。之前的驗證碼都是以靜態為主,現在一些產品開始使用動態方式,增加破解的難度。動態方式以 gif 最為簡單可靠。gif 相容性好,尺寸小。這裡分享的就是一種:用 JS 實現 gif 動態驗證碼的思路。感謝關注。
任務分解
- 繪製旋轉的文字
- 計算每個字元出現位置和角度
- 生成 gif 圖片
逐步求精
如何繪製旋轉的文字?
瞭解能用的 API
context.rotate(angle)
使當前座標系旋轉 angle,單位弧度context.translate(x, y)
使當前座標系偏移 x, y,單位畫素context.font
設定字型context.strokeText(text, x, y [, maxWidth ])
給文字描邊context.fillText(text, x, y [, maxWidth ])
給文字填充
怎麼以文字的中心位置旋轉?
1 2 3 4 5 6 7 8 9 10 |
void function() { // ... var x = 100; var y = 100; var angle = 1 / 8 * Math.PI; context.translate(x, y); context.rotate(angle); context.strokeText('A', 0, 0); // ... }() |
以文字的左下角為圓心旋轉,不符合預期,見下圖效果
本打算做一下偏移的計算,一想到要計算文字中心位置貌似還挺複雜。 還是看看其他人怎麼做的,通過關鍵詞 canvas rotate text center
找到一點線索。
1 2 3 4 5 6 |
context.save(); context.translate(newx, newy); context.rotate(-Math.PI / 2); context.textAlign = "center"; context.fillText("Your Label Here", labelXposition, 0); context.restore(); |
textAlign
是橫向對齊,再根據標準找到了一個縱向對齊 textBaseline
1 2 3 4 5 6 7 8 9 10 11 12 |
void function() { // ... context.textAlign = 'center'; // <<<<<<< insert context.textBaseline = 'middle'; // <<<<<<< insert var x = 100; var y = 100; var angle = 1 / 8 * Math.PI; context.translate(x, y); context.rotate(angle); context.strokeText('A', 0, 0); // ... }() |
修改以後,效果符合預期,見下圖:
按我的習慣就這種 “常用” 功能就封裝成獨立函式,方便以後使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
/** * 繪製旋轉的文字 * @param {CanvasRenderingContext2D} context 上下文 * @param {String} text 文字 * @param {Number} x 中心座標 x * @param {Number} y 中心座標 y * @param {Number} angle 角度,單位弧度 */ function rotateText(context, text, x, y, angle) { if (!context) { return; } context.save(); // 儲存上次的風格設定 context.textAlign = 'center'; // 橫向居中 context.textBaseline = 'middle'; // 縱向居中 context.translate(x, y); // 修改座標系原點 context.rotate(angle); // 旋轉 context.strokeText(text, 0, 0); // 繪製文字 context.restore(); // 恢復上次的風格設定 } |
如何計算每個字元出現位置和角度?
背景文字左右平移 + 旋轉,生成隨機的字串計算中心座標就好了
前景文字基本相似,只要上下來回移動和稍微搖擺,這裡用的 cos 曲線控制搖擺。
如何生成 gif 圖片
生成 gif 有第三方庫可以使用 gifjs。 這裡要注意的是,gifjs 用到 worker 技術,所以得在 http://
環境裡除錯,不能用 file://
環境
注意:由於新增的是同一個 canvas 物件,所以的是使用 copy
模式,將影象資料保留給每一幀。
1 |
gif.addFrame(canvasTemp, { delay: 100, copy: true }); |
完整程式碼
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 |
<!doctype html> <html> <head> <meta charset="utf-8" /> <style> canvas { border: black 1px solid; } </style> <script src="../library/gif.js"></script> </head> <body> <div> Key: <input type="text" maxlength="8" /> <input type="button" value="build" /> </div> <canvas width="300" height="70"></canvas> <img width="300" height="70" /><a download="captcha.gif">download...</a> <script> /** * 繪製旋轉的文字 * @param {CanvasRenderingContext2D} context 上下文 * @param {String} text 文字 * @param {Number} x 中心座標 x * @param {Number} y 中心座標 y * @param {Number} angle 角度,單位弧度 */ function rotateText(context, text, x, y, angle) { if (!context) { return; } context.save(); // 儲存上次的風格設定 context.textAlign = 'center'; // 橫向居中 context.textBaseline = 'middle'; // 縱向居中 context.translate(x, y); // 修改座標系原點 context.rotate(angle); // 旋轉 context.strokeText(text, 0, 0); // 繪製文字 context.restore(); // 恢復上次的風格設定 } /** * 隨機字串 * @param{String} chars 字串 * @param{Number} len 長度 */ function randomText(chars, len) { var result = ''; for (var i = 0; i < len; i++) { result += chars.charAt(parseInt(chars.length * Math.random())); } return result; } void function() { // @see http://www.w3.org/TR/2dcontext/ var canvas = document.querySelector('canvas'); var context = canvas.getContext('2d'); context.font = '30px Verdana'; // 字型大小和字型名 var lineHeight = 15; // 行高 var backLength = 3; var backTexts = {}; var backXOffsets = {}; var keyYOffsets = {}; var keyAOffsets = {}; var backSpeed = 10000 + parseInt(100 * Math.random()); var keySpeed = 12000 + parseInt(100 * Math.random()); var key = ''; function init(value) { key = String(value).toUpperCase(); // 隨機備件 for (var i = 0; i < canvas.height / lineHeight; i++) { backTexts[i] = randomText('ABCDEFGHIJKLMNOPQRST0123456789', backLength); backXOffsets[i] = Math.random() * canvas.width; } for (var i = 0; i < key.length; i++) { keyYOffsets[i] = Math.random() * lineHeight / 2; keyAOffsets[i] = 0.05 - Math.random() * 0.1; } } function renderBack(now, context, text, y, xOffset) { var tick = now % backSpeed; for (var i = 0; i < backLength; i++) { var t = (xOffset + (tick / backSpeed) * canvas.width + (canvas.width / backLength) * i) % canvas.width; rotateText(context, text[i], t, y, i / backLength * Math.PI * 2 + (tick / backSpeed) * Math.PI * 2); } } function render(now, context) { context.fillStyle = '#FFFFFF'; context.fillRect(0, 0, canvas.width, canvas.height); context.fillStyle = '#000000'; // 繪製背景文字 for (var i = 0; i < canvas.height / lineHeight; i++) { renderBack(now, context, backTexts[i], lineHeight * i, backXOffsets[i]); } // 繪製 key var tick = now % keySpeed; var keyCharWidth = canvas.width / key.length; for (var i = 0; i < key.length; i++) { var tx = keyCharWidth + (((canvas.width - keyCharWidth) / key.length) * i) % canvas.width; var ty = Math.cos(now / 1000) * Math.PI * keyYOffsets[i]; rotateText(context, key[i], tx, canvas.height / 2 - ty, Math.cos(now / 1000) * Math.PI * 0.1 + keyAOffsets[i]); } } init('zswang'); setInterval(function() { render(Number(new Date), context); }, 100); document.querySelector('input[type=text]').addEventListener('input', function() { init(this.value); }); document.querySelector('input[type=button]').addEventListener('click', function() { var self = this; self.disabled = true; var gif = new GIF({ repeat: 0, workers: 2, quality: 10, workerScript: '../library/gif.worker.js' }); // 生成 gif 圖片 var canvasTemp = document.createElement('canvas'); canvasTemp.width = canvas.width; canvasTemp.height = canvas.height; var context = canvasTemp.getContext('2d'); context.font = '30px Verdana'; // 字型大小和字型名 context.textAlign = 'center'; for (var i = 0; i < 5000; i += 100) { render(i, context); gif.addFrame(canvasTemp, { delay: 100, copy: true }); } gif.on('finished', function(blob) { var url = URL.createObjectURL(blob); document.querySelector('img').src = url; document.querySelector('a').href = url; self.disabled = false; }); gif.render(); }); }(); </script> </body> </html> |
後記
功能比較簡單,也寫得比較簡單,僅供參考。如果要應用到實戰,還有很多細節要考慮
- gif 建立的過程必然得放到後端完成,否則 相容性、效能、安全性 都是問題(這塊和傳統的驗證過程並無區別)。
- 快取(背景效果可以重複利用一段時間)。
- 圖片大小需要優化,目前是 200K(通過調整幀率和壓縮比)。
- 提供方便的呼叫介面(模組化)。