最近在做網頁版圖片處理相關的項目,也算是初入了canvas 的坑。項目需求中有一個為圖片添加浮水印的功能。我們知道,在瀏覽器端實作圖片新增浮水印功能,通常的做法就是使用canvas
的drawImage
方法。對於普通的合成(例如一張底圖和一張PNG 水印圖片合成)來說,其大致實現原理如下:
var canvas = document.getElementById(canvas);var ctx = canvas.getContext('2d');// img: 底圖// watermarkImg: 水印圖片// x, y 是畫布上放置img 的座標ctx.drawImage( img, x, y);ctx.drawImage(watermarkImg, x, y);
直接連續使用drawImage()
把對應的圖片繪製到canvas
畫布上就好。
以上就是背景介紹。但是略麻煩的是添加浮水印的需求中還有一個需要實現的功能是用戶能夠切換浮水印的位置。我們自然會想到能否實現canvas
的undo
功能,當使用者切換浮水印位置時,先撤銷上一步drawImage
操作,然後再重新繪製浮水印圖片位置。
restore
/ save
?
效率最高也是最方便的肯定是查閱canvas 2D
原生API 是否有此功能。經過一番搜索, restore
/ save
這一對API 進入視線。我們先來看看這兩個API 的描述:
CanvasRenderingContext2D.restore() 是Canvas 2D API 透過在繪圖狀態堆疊中彈出頂端的狀態,將canvas 還原為最近的儲存狀態的方法。 如果沒有保存狀態,此方法不做任何改變。
CanvasRenderingContext2D.save() 是Canvas 2D API 透過將目前狀態放入堆疊中,儲存canvas 全部狀態的方法。
乍看之下可以滿足需求。我們來看看官方範例程式碼:
var canvas = document.getElementById(canvas);var ctx = canvas.getContext(2d);ctx.save(); // 儲存預設的狀態ctx.fillStyle = green;ctx.fillRect(10, 10, 100, 100) ;ctx.restore(); //還原到上次儲存的預設狀態ctx.fillRect(150, 75, 100, 100);
結果如下圖所示:
奇怪,好像和我們預期的結果不太一致。我們想要的結果是save
方法呼叫後能夠保存目前畫布的快照, resolve
方法呼叫後能夠完全回到上一個儲存的快照處的狀態。
再仔細研究一下API。原來我們遺漏一個重要概念: drawing state
,也就是繪製狀態。儲存到堆疊中的繪製狀態包含以下幾個部分:
下列屬性目前的值:strokeStyle, fillStyle, globalAlpha, lineWidth, lineCap, lineJoin, miterLimit, lineDashOffset, shadowOffsetX, shadowOffsetY, shadowBlur, shaColdowor, gloComline,Operation, gloComline,Alignion imageSmoothingEnabled.
好吧, drawImage
操作後對畫布的改變根本不存在於繪製狀態。所以,使用resolve
/ save
無法實現我們需要的undo 功能。
既然原生的API 保存繪製狀態的堆疊無法滿足需求,那麼自然我們會想到自己模擬一個保存操作的堆疊。隨之而來的問題就是:每次繪製操作之後,應該要保存什麼資料進棧?前面說過,我們想要的是每步繪製操作之後能夠保存當前畫布的快照,如果能拿到快照數據,同時能利用快照數據恢復畫布的話,問題也就迎刃而解了。
幸運的是canvas 2D
原生提供了一個取得快照和透過快照還原畫布的API - getImageData
/ putImageData
。以下是API 說明:
/* * @param { Number } sx 將要被提取的圖像資料矩形區域的左上角x 座標* @param { Number } sy 將要被提取的圖像資料矩形區域的左上角y 座標* @param { Number } sw 將要被提取的圖像資料矩形區域的寬度* @param { Number } sh 將要被提取的圖像資料矩形區域的高度* @return { Object } ImageData 包含canvas給定的矩形影像資料*/ ImageData ctx.getImageData(sx, sy, sw, sh); /* * @param { Object } imagedata 包含像素值的物件* @param { Number } dx 來源影像資料在目標畫布中的位置偏移量(x 軸方向的偏移量) * @param { Number } dy 來源影像資料在目標畫布中的位置偏移(y軸方向的偏移) */ void ctx.putImageData(imagedata, dx, dy);
讓我們來看一個簡單的應用方式:
class WrappedCanvas { constructor (canvas) { this.ctx = canvas.getContext('2d'); this.width = this.ctx.canvas.width; this.height = this.ctx.canvas.height; this.imgStack = [ ]; } drawImage (...params) { const imgData = this.ctx.getImageData(0, 0, this.width, this.height); this.imgStack.push(imgData);this.ctx.drawImage(...params); } undo () { if (this.imgStack.length > 0) { const imgData = this.imgStack.pop(); this.ctx.putImageData(imgData, 0, 0); } }}
我們封裝了一下canvas
的drawImage
方法,每次呼叫方法之前都會保存上一個狀態的快照到模擬的堆疊中。執行undo
操作時,從堆疊中取出最新儲存的快照,然後重新繪製畫布,即可實現撤銷操作。實際測試也符合預期。
上一節我們很粗獷地實作了canvas
的撤銷功能。為什麼說粗獷呢?一個很顯而易見的原因就是此方案性能不好。我們的方案相當於每次都是重新繪製整個畫布。假設操作步驟很多,我們在模擬棧也就是記憶體中就會保存很多預先儲存的圖片資料。此外,在繪製圖片過於複雜時, getImageData
和putImageData
這兩個方法會產生較嚴重的效能問題。 stackoverflow 上有詳細的討論: Why is putImageData so slow? 。我們也可以從jsperf 上這個測試案例的資料來驗證這一點。淘寶FED 在Canvas 最佳實踐中也提到了盡量不在動畫中使用putImageData
方法。另外,文章裡也提到一點,盡可能呼叫那些渲染開銷較低的API。我們可以從這裡入手思考如何進行最佳化。
之前說過,我們透過對整個畫布保存快照的方式來記錄每個操作,換個角度思考,如果我們把每次繪製的動作保存到一個數組中,在每次執行撤銷操作時,首先清空畫布,然後重繪這個繪圖動作數組,也可以實現撤銷操作的功能。可行性方面,首先這樣可以減少儲存到記憶體的資料量,其次也避免了使用渲染開銷較高的putImageData
。以drawImage
為比較對象,看jsperf 上這個測試案例,二者的效能有數量級的差距。
因此,我們認為此最佳化方案是可行的。
改進後的應用方式大致如下:
class WrappedCanvas { constructor (canvas) { this.ctx = canvas.getContext('2d'); this.width = this.ctx.canvas.width; this.height = this.ctx.canvas.height; this.executionArray = [this.height = this.ctx.canvas.height; this.executionArray = [ ]; } drawImage (...params) { this.executionArray.push({ method: 'drawImage', params: params });this.ctx.drawImage(...params); } clearCanvas () { this.ctx.clearRect(0, 0, this.width, this.height); } undo () { if (this.executionArray.length > 0) { // 清空畫布this.clearCanvas(); //刪除目前操作this.executionArray.pop(); // 逐一執行繪圖動作進行重繪for (let exe of this.executionArray) { this[exe.method](...exe.params) } } }}
新人入坑canvas,如有錯誤與不足,歡迎指出。以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持VeVb武林網。