issue 地址: https://github.com/Jiang-Xuan/tuchuang.space/issues/36測試地址: https://beta.tuchuang.space/javascript
做者: 蔣璇, 一個 English Lover🥰, TDD&BDD Lover🎯css
在網頁中上傳圖片有多重選擇.html
本篇文章着重介紹最後一種, 也是最方便的上傳的方法, Control/Command
+ v
進行上傳, 以及如何使用 selenium 來跨瀏覽器的自動化測試這個功能.java
通常的截圖程序, 好比 QQ
, 微信
, PrintScreen
按鈕, 都會將截圖以 png 格式放入系統粘貼板, 因此這裏討論 png 格式的粘貼, 而不是其餘格式的, 更多的仍是給截圖程序使用.node
Control/Command
+ v
粘貼的圖片數據📋?Note: 支持 IE 11, 以及現代瀏覽器Chrome, Firefox, Safarigit
獲取粘貼板中的圖片數據能夠經過監聽 paste 事件來實現:github
document.addEventListener('paste', (event) => { const { items } = event.clipboardData if (items) { ;[...items].forEach((item) => { if (item.type.indexOf('image') !== -1) { // item 的 mime 類型是圖片, 說明想要粘貼的是圖片數據 // https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem } }) } })
上面的代碼中 item 提供 getAsFile 方法來獲取粘貼的圖片的數據的二進制數據:chrome
file = item.getAsFile()
這裏獲取到的 file 爲 File 的實例, 繼承自 [Blob](), js 中的二進制數據, 你能夠直接將 file 上傳給後端服務器就能夠完成圖片的上傳:macos
const formData = new FormData() formData.append('images', file) const xhr = new XMLHTTPRequest() xhr.open('POST', 'https://tuchuang.space/api/v1/images') xhr.send(formData)
上訴討論的是現代瀏覽器的處理, 麻煩的是 IE 11 的處理(IE11 如下的瀏覽器沒法獲取粘貼板中的圖片數據, 就不用嘗試了😉), IE 11 支持粘貼板中的圖片以 img 標籤, src 爲 圖片的 base64 編碼放入設置了 contenteditable 屬性的元素之中, 官方來源 Enhanced Rich Editing Experiences in IE11npm
hack 的實例能夠去 這裏 看下, 要使用 IE 11 瀏覽器哦, 目前能找到的在線編輯器支持 IE 的也就是 jsfiddle 了. 大部分代碼都是 copy 來自 這個 Stack Overflow 問題 中, 思想就是在用戶 paste 的時候 focus 一個 設置 contenteditable 屬性的 div, 而後從這個 div 中獲取數據. 接下來`庖丁解牛`, 這裏解釋的代碼的原理和 jsfiddle 中的例子一致, 可是作了必定的優化, 實際的使用能夠去 這裏 看下, 所有代碼以下:
class PasteImage { /** * 在獲取到用戶 paste 的圖片數據時的回調函數 * @param {(imageBlobData: Blob) => void} callback */ constructor (callback) { this._callBack = callback /** @private {boolean} 用戶是否正在按下 ctrl 鍵 */ this._ctrlPressed = false /** @private {boolean} 用戶是否正在按下 command 鍵, MacOS 系統下 */ this._commandPressed = false /** @private {HTMLDivElement} 捕獲用戶粘貼的圖片的容器 */ this._pasteCatcher = document.createElement('div') /** @private {boolean} 是否支持 Native paste 事件 */ this._pasteEventSupport = false /** @private {HTMLDivElement} 捕獲用戶粘貼的圖片的容器的 ID */ this._pasteCatcherId = `paste-image-${Math.random()}` this._pasteCatcher.setAttribute('id', this._pasteCatcherId) this._pasteCatcher.setAttribute('contenteditable', '') this._pasteCatcher.style.cssText = 'opacity:0;position:fixed;top:0px;left:0px;width:10px;margin-left:-20px;' /** * 處理頁面按鍵按下 * @private * @type {(event: KeyboardEvent) => void} */ this._handleOnKeyDown = this._handleOnKeyDown.bind(this) /** * 處理頁面按鍵釋放 * @private * @type {(event: KeyboardEvent) => void} */ this._handleOnKeyUp = this._handleOnKeyUp.bind(this) /** * 處理頁面的 paste 事件 * @private * @type {(event: ClipboardEvent) => void} */ this._handleOnPaste = this._handleOnPaste.bind(this) const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (this._pasteEventSupport || this._ctrlPressed === false || mutation.type !== 'childList') { return } if (mutation.addedNodes.length === 1) { if (mutation.addedNodes[0].src !== undefined) { this._pasteCreateImage(mutation.addedNodes[0].src) } } }) }) observer.observe(this._pasteCatcher, { childList: true, attributes: true, characterData: true }) } /** * 處理非標準的 paste 事件, 從 image 標籤中獲取數據 * 目前支持的瀏覽器中只有 IE 11 不支持標準的 paste 事件 * IE 11 中粘貼的圖片的格式爲 [data url](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) * * atob('MTIz') // 123 * * Example: data:image/png;base64,MTIz * * @private * @param {string} source image 標籤的 src 屬性 */ _pasteCreateImage (source) { const base64String = source.split(',')[1] const buffer = base64js.toByteArray(base64String) const uint8 = new Uint8Array(buffer) const pngBlob = new Blob([uint8], { type: 'image/png' }) this._callBack(pngBlob) } /** * 處理頁面按鍵按下 * @private * @param {KeyboardEvent} event */ _handleOnKeyDown (event) { const { keyCode } = event console.log(event) if (keyCode === 17 || event.metaKey || event.ctrlKey) { if (this._ctrlPressed === false) { this._ctrlPressed = true } } if (keyCode === 86) { if (document.activeElement !== null && document.activeElement.type === 'text') { // 容許用戶拷貝文字進入輸入框 return false } if (this._ctrlPressed === true) { this._pasteCatcher.focus() } } } /** * 處理頁面按下釋放 * @private * @param {KeyboardEvent} event */ _handleOnKeyUp (event) { // ctrl if (event.ctrlKey && this._ctrlPressed === true) { this._ctrlPressed = false } // command if (event.metaKey && this._commandPressed === true) { this._commandPressed = false this._ctrlPressed = false } } /** * 處理頁面的 paste 事件 * @private * @param {ClipboardEvent} event */ _handleOnPaste (event) { this._pasteCatcher.innerHTML = '' if (event.clipboardData) { const { items } = event.clipboardData if (items) { this._pasteEventSupport = true ;[...items].forEach((item) => { if (item.type.indexOf('image') !== -1) { console.log(item) const blob = item.getAsFile() this._callBack(blob) } }) } } } /** * 監聽事件, 將 pasteCatcher 放入 body 中 * @public */ install () { document.body.appendChild(this._pasteCatcher) document.addEventListener('keydown', this._handleOnKeyDown) document.addEventListener('keyup', this._handleOnKeyUp) document.addEventListener('paste', this._handleOnPaste) } uninstall () { document.body.removeChild(this._pasteCatcher) document.removeEventListener('keydown', this._handleOnKeyDown) document.removeEventListener('keyup', this._handleOnKeyUp) document.removeEventListener('paste', this._handleOnPaste) } }
使用方法:
const pasteImage = new PasteImage((blob) => { // blob 就是獲取到的圖片的二進制數據 }) pasteImage.install() // 若是想要中止監聽 paste, 調用 pasteImage.uninstall()
構造函數接受一個回調函數做爲在接收到數據的時候的回調.
this._callBack = callback
_ctrlPressed
判斷用戶是否按下 control
按鍵(Windows 下粘貼組合鍵爲 Control
+ v
), _commandPressed
判斷用戶是否按下 command
按鍵(Macos 下粘貼組合鍵爲 command
+ v
, Macos 沒有 IE 11, 其實 Firefox 22 如下也不支持標準的 paste
方法獲取圖片數據😂, 不過也能夠忽略了) .
/** @private {boolean} 用戶是否正在按下 ctrl 鍵 */ this._ctrlPressed = false /** @private {boolean} 用戶是否正在按下 command 鍵, MacOS 系統下 */ this._commandPressed = false
_pasteEventSupprt
判斷瀏覽器是否支持經過標準的 paste 事件獲取數據.
/** @private {boolean} 是否支持 Native paste 事件 */ this._pasteEventSupport = false
接下來建立一個 div, 用來在不支持標準的 paste 事件獲取數據的瀏覽器中捕獲用戶粘貼操做(其實就是 IE 11), 給這個 div 設置 id 屬性, 而後設置其的 contenteditable 屬性, 給這個 div 設置 css, 讓其不會顯示在用戶的屏幕上.
/** @private {HTMLDivElement} 捕獲用戶粘貼的圖片的容器 */ this._pasteCatcher = document.createElement('div') /** @private {HTMLDivElement} 捕獲用戶粘貼的圖片的容器的 ID */ this._pasteCatcherId = `paste-image-${Math.random()}` this._pasteCatcher.setAttribute('id', this._pasteCatcherId) this._pasteCatcher.setAttribute('contenteditable', '') this._pasteCatcher.style.cssText = 'opacity:0;position:fixed;top:0px;left:0px;width:10px;margin-left:-20px;'
接下來是綁定頁面上的幾個事件監聽器的 this 指向, 包括監聽用戶按下按鍵, 釋放按鍵, 和 paste
事件的監聽器.
/** * 處理頁面按鍵按下 * @private * @type {(event: KeyboardEvent) => void} */ this._handleOnKeyDown = this._handleOnKeyDown.bind(this) /** * 處理頁面按鍵釋放 * @private * @type {(event: KeyboardEvent) => void} */ this._handleOnKeyUp = this._handleOnKeyUp.bind(this) /** * 處理頁面的 paste 事件 * @private * @type {(event: ClipboardEvent) => void} */ this._handleOnPaste = this._handleOnPaste.bind(this)
爲了在 IE 11 上獲取到用戶粘貼到上面的 _pasteCatcher
容器之中的內容, 須要監聽這個 DOM 的子元素的變更, 經過 MutationObserver 來實現, 若是支持標準的 paste
事件獲取數據, 或者是 control
沒有被按下, 或者是否是子元素的變化, 則不處理. 不然找到被添加的元素, 若是是圖片的粘貼, 在 IE11 中將是經過 img 標籤以 data url 爲 src, data url 爲 image base64 編碼, 將這個 data url 取出來傳遞給 _pasteCreateImage
函數.
const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (this._pasteEventSupport || this._ctrlPressed === false || mutation.type !== 'childList') { return } if (mutation.addedNodes.length === 1) { if (mutation.addedNodes[0].src !== undefined) { this._pasteCreateImage(mutation.addedNodes[0].src) } } }) }) observer.observe(this._pasteCatcher, { childList: true, attributes: true, characterData: true })
接收一個參數, 就是 圖片的 data url, 好比 data:image/png;base64,MTIz
(MTIz 是 123 的 base64 編碼)
將圖片的 base64 編碼數據從 data url 找出並提取出來.
const base64String = source.split(',')[1]
將 base64 轉成二進制數據, 這裏用到的是 base64-js, 能夠將 base64 編碼轉換成二進制數據, 在 nodejs 中, 這種轉換是內置的.
const buffer = base64js.toByteArray(base64String).buffer
而後用這個 buffer 建立 mimetype 是 image/png
的 Blob 對象
const pngBlob = new Blob([buffer], { type: 'image/png' })
成功的拿到了須要的數據, 調用回調將數據傳遞出去
this._callBack(pngBlob)
這是一個按鍵按下監聽器, 在鍵盤被按下的時候觸發該函數.
const { keyCode } = event
若是 keycode 是 17 或者是 event.metaKey, event.ctrlKey 成立, 則是用戶按下了 control
修飾鍵.
if (keyCode === 17 || event.metaKey || event.ctrlKey) { if (this._ctrlPressed === false) { this._ctrlPressed = true } }
若是 keycode 是 86, 86 是 v
的 keycode. document.activeElement 獲取當前被聚焦的元素 , 若是被聚焦的是一個 type 是 text 的 input 輸入框, 用戶是想將文字拷貝進輸入框, 而不是粘貼圖片.
if (keyCode === 86) { if (document.activeElement !== null && document.activeElement.type === 'text') { // 容許用戶拷貝文字進入輸入框 return false } if (this._ctrlPressed === true) { this._pasteCatcher.focus() } }
在 _pasteCatcher
元素被 focus 以後, 用戶 ctrl+v 的數據就會粘貼進 _pasteCatcher
元素內部中:
這會觸發 _pasteCacher
的 MutationObserver 的回調
const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (this._pasteEventSupport || this._ctrlPressed === false || mutation.type !== 'childList') { return } if (mutation.addedNodes.length === 1) { if (mutation.addedNodes[0].src !== undefined) { this._pasteCreateImage(mutation.addedNodes[0].src) } } }) })
若是瀏覽器原生支持標準的 paste 事件, 或者是 control 按鍵沒有被按下, 或者這不是一個 childList 類型的 mutation, 不處理. 不然判斷 mutation 的對否有添加的節點, 而後判斷第一個被添加的節點的 src 屬性是否存在, 由於圖片的粘貼一定是 img 標籤, 而且有 src 屬性, 這個時候就能夠判斷出用戶粘貼的是一張圖片, 將得到到的圖片的 data url 傳遞給處理函數 pasteCreateImage.
監聽 _pasteCacher
的變化的調用, 其實能夠只監聽 childList.
observer.observe(this._pasteCatcher, { childList: true, attributes: true, characterData: true })
CI 服務爲 Github 提供的 Github Actions
測試的瀏覽器爲: IE 11, Chrome latest(Github Actions 提供的 Chrome), Firefox latest(Github Actions 提供的 Firefox)
e2e 測試的工具爲 selenium
爲何是 selenium?
測試的步驟以下:
準備一張測試的 png 圖片, 計算這張圖片的 bitmap, 這裏是用 jimp 來計算出測試圖片的 bitmap
image.bitmap.data; // a Buffer of the raw bitmap data來自: https://github.com/oliver-moran/jimp/tree/master/packages/jimp#low-level-manipulation
ctrl + v
快捷鍵粘貼圖片第一步和第二步被封裝到了一個單獨的 npm 包中 copy-logo-to-clipboard
測試圖片爲 tuchuang.space 的 壓縮版 logo, 爲了測試方便, 壓縮到了只有4個像素, 像素的 rgba 16進制的值爲:
第一個像素的 rgba 值: rgba(124, 158, 181, 253)
第二個像素的 rgba 值: rgba(139, 137, 165, 253)
第三個像素的 rgba 值: rgba(243, 188, 110, 253)
第四個像素的 rgba 值: rgba(219, 89, 89, 253)
處理起來最麻煩的一步
支持 Windows, Macos
nodejs 中沒有一個很好的辦法操做操做系統的剪切板, Windows 操做系統下可使用 C# 加上 .net 框架和操做系統的剪切板交互, 能夠看下個人嘗試 https://github.com/Jiang-Xuan... https://github.com/Jiang-Xuan/tuchuang.space/issues/36#issuecomment-552183501 編寫代碼使用的平臺是 Macos, 因此還要處理 Mac 平臺的剪切板的交互, Swift 太難了. 最後我放棄了, 轉而使用 electron 提供的 api 來處理 https://github.com/Jiang-Xuan...
electron 提供了 方法 來將圖片寫入操做系統的剪切板.
// Modules to control application life and create native browser window const { app, clipboard, nativeImage } = require('electron') const path = require('path') console.log('main.js') const fooImage = nativeImage.createFromPath(path.resolve(__dirname, './logo.png')) // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', () => { console.log('ready') clipboard.writeImage(fooImage) console.log(clipboard.readImage()) app.quit() })
打包一個 electron 應用來實現, electron 的應用打包出來都比較大, 可是在沒有更好的辦法的狀況下只能這樣, 分發一個 electron 應用來實現跨平臺的操做系統的剪切板操做. 暴露出去一個 copyLogoToClip
方法, 使用方法爲:
const { copyLogoToClip } = require('copy-logo-to-clipboard') // 寫入操做系統 await copyLogoToClip()
這裏說一個小故事在剛開始的時候我並無給這個模塊寫測試用例, 在我實際在 tuchuang.space 項目中寫測試用例的時候我發如今讀取出來的圖片和寫入的圖片的 bitmap 並不一致, 這個時候我不肯定是哪一部分出的問題了, 究竟是 copy-logo-to-clipboard 在向系統剪切板寫入圖片的時候修改了圖片的 bitmap, 仍是瀏覽器在讀取操做系統的剪切板的 bitmap 的時候改變了圖片的 bitmap? 太相信瀏覽器致使我一度懷疑是 electron 修改了圖片的 bitmap, 但是最後卻發現了是某些瀏覽器修改了圖片的 bitmap, 若是我在剛開始的時候對 copy-logo-to-clipboard 寫了測試用例, 我就有理由相信是瀏覽器出了問題, 因此後續我對 copy-logo-to-clipboard 寫了 測試用例 來保證這個模塊是正確的
ctrl+v
在按下 ctrl+v
這一步也有坑, 在 Macos chrome 上, 你會發現不管是 control+v
仍是 command+v
都沒法執行粘貼操做, 展轉多處, 在 Stack Overflow 上面發現了 解決辦法, 就是按下 Shift + Insert
另外一個須要注意的點是在 IE 11 下, 咱們作了特殊的粘貼圖片的處理, 若是咱們用程序按下 ctrl+v
你會發現沒法粘貼圖片, 是由於程序的操做太快了, 沒有給咱們聚焦 _pasteCatcher
的機會, 可是實際的用戶操做的時候並無這麼快, 因此特殊處理一下 IE 11 下的 ctrl+v
的按下的時機, 以更符合實際的用戶操做
const controlKeyDown = driver.actions().keyDown(Key.CONTROL) const vKeyDown = driver.actions().keyDown('v') const vKeyUp = driver.actions().keyUp('v') const controlKeyUp = driver.actions().keyUp(Key.CONTROL) await new Promise((resolve) => setTimeout(resolve, 2000)) await body.click() await controlKeyDown.perform() await new Promise((resolve) => setTimeout(resolve, 500)) await vKeyDown.perform() await new Promise((resolve) => setTimeout(resolve, 500)) await vKeyUp.perform() await new Promise((resolve) => setTimeout(resolve, 500)) await controlKeyUp.perform()
不想讓頁面真正的向後端發起請求, 可是卻沒有找到一種能夠攔截 selenium 操做的瀏覽器的請求, 在 puppeteer 中能夠經過監聽 page.on('request')
事件來攔截和 mock 請求
// 來自: https://pptr.dev/#?product=Puppeteer&version=v2.0.0&show=api-pagesetrequestinterceptionvalue const puppeteer = require('puppeteer'); puppeteer.launch().then(async browser => { const page = await browser.newPage(); await page.setRequestInterception(true); page.on('request', interceptedRequest => { if (interceptedRequest.url().endsWith('.png') || interceptedRequest.url().endsWith('.jpg')) interceptedRequest.abort(); else interceptedRequest.continue(); }); await page.goto('https://example.com'); await browser.close(); });
爲了達到相似 puppeteer 的這種功能, 能夠 mock 一個服務器, 而後在 selenium 環境中請求 mock 的服務器, 我手動實現了一個 mock-server, 提供的功能僅僅知足該測試的需求, 詳情能夠去項目倉庫看細節(建議看測試用例來了解, 沒有文檔的狀況下測試用例就是最好的文檔).
配置想要返回的請求, 啓動 mock 服務器
mockServer.config.configResponse({ body: { images: { 'image_from_clipboard.png': { mimetype: 'image/png', md5: '637e2ee416a2de90cf6e76b6f4cc8c89', filename: 'test-test.png', ossPath: 'http://example.com/test-test.png', cdnPath: 'https://i.tuchuang.space/test.png', deleteKey: '2436b48115486de952296f2b5295aeb90d284761278661102e7dda990c3f67022133080fb1bcd99d7f94678a991c57f1' } } } }) await mockServer.start()
在請求執行完畢以後, 在 mock 服務器接收到的請求中進行搜索, 找到須要的請求, 而後進行判斷請求服務接收到的參數是否正常
// assert https://github.com/Jiang-Xuan/tuchuang.space/issues/36#issuecomment-566868929 await new Promise((resolve) => setTimeout(resolve, 2000)) const requests = mockServer.search({ path: '/api/v1/images' }) await new Promise((resolve) => setTimeout(resolve, 2000)) const outputLogoJimp = await jimp.read(requests[0].files[0].buffer) const logoBitmap = await getLogoBitmap() if (forBrowser === 'chrome') { expect(md5(outputLogoJimp.bitmap.data)).toEqual(md5(logoBitmap)) } else { expect(outputLogoJimp.getMIME()).toEqual('image/png') }
你可能注意到了, 對於 bitmap 的一致斷定, 我只判斷了 chrome 瀏覽器, 這是一個我目前也都沒有找到具體緣由的地方, 接下來用一個段落詳解緣由
本段只針對 Windows 平臺, 在 macos 平臺下, Firefox 是能夠正常的讀取出在粘貼板中的圖片的 bitmap
在剛開始寫測試的時候, 我篤定瀏覽器能夠正常的讀取出在粘貼板中的圖片的 bitmap, 可是通過後續的測試發現只有 chrome 能正確的讀取圖片的 bitmap, IE 11(hack 方式處理), Firefox(標準的方法) 均沒法保證讀取出來的圖片的 bitmap 和最初的圖片的 bitmap 徹底一致, 雖然有時肉眼並沒有法分辨出圖片的細節. 最明顯的一個問題是透明通道丟失了, 初覺得是 IE 11 在讀取的時候作了處理, 後來發現 Firefox 也是如此, 而且同一張圖片, 在 IE11和 Firefox 中的結果一致, 因此作出瞭如下猜想:
因此最後只針對 chrome 作了 bitmap 的對比, 而在 IE11 和 Firefox 上則只判斷接收到了一張 png 圖片 expect(outputLogoJimp.getMIME()).toEqual('image/png')
你可使用官方的測試用例來測試一下不一樣的瀏覽器 https://w3c-test.org/clipboard-apis/async-write-image-read-image-manual.https.html
IE 11 獲取粘貼板中的圖片須要 hack 的方式
用 TDD 的方式來測試這個功能其實很是複雜, 由於涉及到了操做系統, 而操做系統又是一個很難以 mock 的對象, 因此只能操做真正的操做系統, Macos 平臺和 Windows 平臺提供的接口不一致, 使用 electron 能夠幫助抹平平臺差別.
只有 chrome 保證了讀取出來的圖片的 bitmap 是和原始的圖片的 bitmap 徹底一致, 其餘瀏覽器均不能保證(在 Windows 下, Macos 下 chrome, Firefox 都可以保證, Safari 沒有測試). 因此儘可能不要測試圖片的 bitmap 是否一致, 測試是一張圖片就夠了.
測試步驟:
ctrl+v
. Macos 的 chrome 下按下的是 shift+insert
究竟是什麼緣由致使的 Firefox 和 IE11 在 Windows 下沒法讀取出一致的圖片的 bitmap ?