想學canvas?那一定要看看這篇文章

大盜發表於2020-04-01

canvas簡介

在學習一項新技術之前,先了解這項技術的歷史發展及成因會幫助我們更深刻的理解這項技術。

歷史上,canvas最早是由Apple Inc. 提出的,在Mac OS X webkit中建立控制板元件使用,而在canvas稱為HTML草案及標準之前,我們是通過一些替代方式去繪圖的,比如為人所詬病的Flash,以及非常強大的SVG(Scalable Vector Graphics,可伸縮的向量標記圖),還有隻能在IE(IE 5.0以上的版本)中使用的VML(Vector Markup Language,向量可標記圖)。甚至於有些前端可以使用div+css來完成繪圖。

總的來說,沒有canvas的時候,在瀏覽器繪製圖形是比較複雜的,而在canvas出現之後,繪製2D圖形相對變得容易了。

NOTE: 用div繪製一些簡單的圖形,如矩形,圓形,三角形,梯形,倒也算是沒那麼複雜。

但canvas也有缺點。因為canvas本質上是一個與 解析度相關點陣圖畫布 ,也就註定了在不同解析度下,canvas繪製的內容顯示的時候會有所不同。此外,canvas繪製的內容 不屬於任何DOM元素 ,在瀏覽器的元素檢視器中也找不到,那自然無法檢測滑鼠點選了canvas中的哪個內容,很顯然,這兩方面,canvas都是不如SVG的。

舉個例子:如果使用CSS設定canvas元素的尺寸,那可能會導致繪製出來的圖形變得扭曲,如長方形變正方形,圓形變橢圓等,這是因為畫布尺寸和元素尺寸是不一樣的,畫布會自動適應元素的尺寸,如果二者是成比例的,那麼畫布就會等比例縮放,不會出現扭曲。

這麼說來,canvas有這麼明顯的缺點,那直接使用SVG豈不是更好?

No,聽過一句話嗎?沒有完美的方案,只有適不適合。

SVG是基於XML的,那麼就說明,SVG裡面的元素都可以認為是 DOM元素 ,可以啟用DOM操作,同時,SVG中每個繪製的影像均被視為物件,若SVG物件屬性變化,瀏覽器會自動重現圖形。

以上是SVG的優勢,但通過這個優勢,我們也能發現一些問題:

  1. 通常,過度使用DOM的應用都會變得很慢,所以,複雜的SVG會導致渲染速度變慢。但是像地圖這類的應用,首選是SVG。
  2. 瀏覽器的重排發生在瀏覽器視窗發生變化,元素尺寸位置變化,字型變化等等。
  3. 即使可以啟用DOM操作,但DOM操作的代價還是比較昂貴的(DOM和JS的實現是分開的)。

回到主題。

canvas是通過JavaScript進行2D圖形的繪製,而 <canvas> 標籤本身是沒有任何繪製能力的,它僅僅是一個容器。在繪製時,canvas是逐畫素的進行渲染的,一旦圖形繪製完成,該元素就不再被瀏覽器所關注(指令碼執行結束,繪製的圖形也不屬於DOM)。

值得注意的是,在HTML標準(whatwg標準)中明確的指出: Authors should not use the canvas element in a document when a more suitable element is available. 所以,不要濫用元素。

canvas目前幾乎被所有的瀏覽器支援,但是IE 9.0 之前的版本不支援 canvas元素

canvas基本使用

canvas是一個HTML元素,所以要使用canvas,首先需要:

<canvas id="canvas" width="600" height="300">
	當前瀏覽器不支援canvas
</canvas>
複製程式碼

在第一行HTML程式碼中可以看到兩個屬性:widthheight ,它指明瞭畫布的寬高,在上文中提到過,不要使用CSS規定尺寸,因為當CSS規定的尺寸和畫布尺寸比例不一致時,無法成比例縮放,導致繪製出來的圖形變得扭曲。在沒有設定畫布大小時,canvas預設會初始化成300px * 150px的畫布。

“當前瀏覽器不支援canvas”是元素的內容,但他只是作為一個後備內容(即fallback content),只有當瀏覽器不支援canvas時,這個內容才會被顯示出來。

canvas元素本身沒有繪製能力,只是作為一個容器,所以需要通過JavaScript這類指令碼進行繪製:

const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
複製程式碼

上面的HTML+JS程式碼是使用canvas所必須的,無論要繪製什麼內容,這幾行程式碼不可缺少。

getContext() 是canvas元素提供的方法,用於獲取繪製上下文(或者說渲染上下文,The rendering context),他只有一個引數:上下文格式。這裡傳入2d 表示獲取2D影像繪製環境。由於getContext是canvas元素提供的方法,故我們可以通過檢測getContext方法的存在性來檢查瀏覽器的支援性。

context變數的型別是 CanvasRenderingContext2D

渲染上下文不好理解,可以理解為畫圖用的筆刷。

在畫布中如何確定繪製的位置?是座標。

在canvas中,畫布的左上角為原點,橫軸為x軸表示寬,縱軸為y軸表示高[1]。原點的位置是可以移動的,我們暫時不考慮原點的移動問題。

w3c school 中,將canvas提供的繪製API大致分為以下幾種[2]

  1. 顏色、樣式、陰影
  2. 線條樣式
  3. 矩形
  4. 路徑
  5. 轉換
  6. 文字
  7. 影像繪製
  8. 畫素操作
  9. 合成
  10. 其他

canvas組合示例

在上面這個例子中,包含了矩形,圓形,線,文字及“文字”幾大塊內容,細講下去,會涉及到不少API,會使得本文變得很長,而且沒有必要,值得一提的是貝塞爾曲線,這是二維圖形應用程式的數學曲線,一般的向量圖形軟體就是通過它來精確畫出曲線的,貝塞爾曲線是計算機圖形學中相當重要的引數曲線[3]

一次貝塞爾曲線

二次貝塞爾曲線

三次貝塞爾曲線

以上圖片按順序分別是一次貝塞爾曲線,二次貝塞爾曲線,三次貝塞爾曲線。從圖中,可以很清楚的看到,一次貝塞爾曲線實際上是一條直線。當然,還有更高階次的曲線,不過canvas只提供了二次和三次貝塞爾曲線。

以二次貝塞爾曲線的API為例:

quadraticCurveTo(cp1x, cp1y, x, y);
複製程式碼

(cp1x, cp1y)表示控制點座標,(x, y)表示結束點座標。這裡還缺少一個起始點座標,假設是(x0, y0),那這個(x0, y0)是誰?

就是在呼叫 quadraticCurveTo 函式時,context(繪製上下文)所處的座標。舉個例子:

var cxt = canvas.getContext('2d'); // 認為canvas已經獲取到
cxt.beginPath();
cxt.moveTo(120, 90);
cxt.quadraticCurveTo(130, 80, 130, 70);
cxt.quadraticCurveTo(115, 70, 115, 50);
cxt.quadraticCurveTo(115, 30, 155, 30);
cxt.quadraticCurveTo(195, 30, 195, 50);
cxt.quadraticCurveTo(195, 70, 155, 70);
cxt.quadraticCurveTo(135, 90, 120, 90);
cxt.stroke();
複製程式碼

這段程式碼執行結果就是一個對話方塊(在第一張圖片中體現),可以看到,在呼叫二次貝塞爾曲線之前,我們設定了起點,即,將筆刷移動到座標(120, 90),在之後呼叫中,都是以前一次貝塞爾曲線的終點作為本次曲線的起點。

這時候可能會有人問:我去掉這個moveTo的呼叫是不是就畫不出來了?如果後續是呼叫lineTo函式,那還真就畫不出來了。但是別忘了,還有一次貝塞爾曲線,這就是條直線,他是以(cp1x, cp1y)為起點,(x,y)為終點的一條直線。所以說,去掉moveTo後,只會影響到第一條曲線的繪製。但是如果刪除最後一行程式碼stroke(),那麼程式執行結束時,在瀏覽器上啥都看不到。

由此,我們應該思考另一個問題:為什麼stroke()函式是必須的呢?

其實,canvas是一種基於狀態的繪製,依照此,可以將canvas提供的API分為兩種:狀態設定,具體繪製。

stroke()fill()等函式就是將內容繪製到canvas畫布容器中的函式。

arc()lineTo()rect()等函式就是設定筆刷狀態的函式。

在那種玄幻型別的電影、電視劇裡面就經常能看到某個道士虛空畫符,畫完之後往前一推,就印在了對應的符或者人身上了。

道士虛空畫符,這個過程就像是canvas設定筆刷狀態的過程。

往前一推,這個就是具體的繪製了,怎麼繪製我們不知道,反正這符是畫上去了。(前文提到過,canvas是 逐畫素渲染 的)

“文字”的繪製,注意,這個文字是打了引號的,普通文字,我們繪製只需要呼叫fillText()即可,而這裡所指的文字是點陣字型,在微控制器或者LCD這類程式中,通過點亮一系列的點,顯示出文字或圖案,點亮的過程較為複雜,可以簡單的理解為LCD上的畫素點置為1時點亮該點,為0時不點亮(實際可能相反)。那麼canvas這裡的“文字”繪製也是一樣的道理,通過建立文字對應的字型庫,當需要繪製某個文字的時候,在字型庫中找到對應的文字點陣,然後將點陣中標誌為1的位置點亮(填充)即可。

實際操作時,可能並不是點亮這麼簡單,你可能會想要製作出更酷的內容,用圓形去填充,用矩形去填充,甚至說想要製作出動態爆炸的效果,這時候就牽扯到一些其他的計算了。

矩形填充

上圖是一個用矩形填充的示例,數字對應8x8的點陣。

canvas的高階動畫

先思考一個問題,假設現在我們已經學會了繪製一個圓形的方法,現在要求做出一個和物理學相關的動畫:平拋運動。

現在該如何去實現呢?

可能看到這個問題的時候,有些人瞬間懵圈了:我就學了個繪製圓的函式,你就讓我模擬這麼高難度的動畫,你這分明是想謀害鄭!

可能也有人會想到,平拋運動,在高中物理學中學到過,基本都只是研究一個小球的問題,在2維平面中,這小球完全可以視作一個圓,可不就只需要學會畫圓就行了?

經此,我們繼續往下思考,在平拋運動中的小球,假設水平方向設有初始速度v0,除了重力外,不受到其他外力影響,也即存在一個重力加速度g(為了計算簡單,我們可以簡單的設為g = 10m/s^2),同時豎直方向沒有初速度vh(或稱vh = 0;),如下圖:

平拋運動

從圖中,我們可以看到一些很有意思的現象,如:小球的水平方向剛好和canvas畫布的橫軸一致,豎直方向也和縱軸方向保持一致。

然後由平拋運動對應的物理公式:

// 豎直方向無初速度,水平方向沒有外力
x = v0 * t; // 水平方向位移
h = 1/2 * g * t * t; // 豎直方向位移

// 豎直方向有初速度
h = vh * t - 1/2 * g * t * t; // 豎直方向位移
複製程式碼

發現(x, h)和canvas上的座標(x, y)是一致的,而且我們也不是在做物理題,也就是說,v0, t, g, vh這些引數都是已知的,我們唯一需要做的就是,計算出任意時刻的(x, h),也即小球在canvas上的座標(x, y)。

分析結束,我們現在可以得到小球在任意時刻的位置座標,那麼我們也就可以在畫布上畫出來任意時刻的小球。

針對上面的分析,可能會有人說:你這不對,你這個應該是具有特殊性的吧,小球未必是從左邊丟擲去的,從右邊也可以啊,向上拋也可以。

的確,上面的分析只是取出了其中一個比較特殊的狀態來研究,限於篇幅(以及本文主題是canvas而非物理),沒有推廣到更一般的結論,但其實,這些分析已經足夠了,無論是位移還是速度,他都是向量,帶有方向,那麼我們不妨規定:以canvas的座標軸,數值增加的方向為正向,那麼從右邊丟擲,可以認為是反向,可以表示為-v0 ,最終通過計算位移的公式,可以得到正確的座標(但這時候算座標x是比較麻煩的,不能直接使用上述公式)。

分析這麼多,說點兒我們最關心的實現。

在之前的分析中,我們知道想求小球任意時刻所在位置座標,需要的引數有:v0, t, g, vh。這些引數應該存放在哪裡呢?怎麼設計這個資料結構?

我們當然可以直接將這些引數設為全域性變數,但這顯然是不合適的,這些引數裡,唯一適合設為全域性變數的是重力加速度g。而v0, t, vh這些都應該是小球自身的“屬性”,所以我們應該將其抽象成一個類。

function Ball(r, v0, vh, t) {
    this.r = r;
    this.v0 = v0;
    this.vh = vh;
    this.t = t;
    this.x = 0;
    this.h = 0;
    
    this.calcX = function() { /* 計算水平位移 */ }
    this.calcH = function() { /* 計算豎直位移 */ }
}

var ball = { x: 0, h: 0, r: 10, v0: 0, vh: 0, g: 10};
// 重力加速度無論是作為全域性變數還是小球屬性,均可

// es6之後
class Ball {
    constructor();
}
複製程式碼

以上三種方式,各有各的好處,選擇一個合適的方式即可。

“你這說物理我就頭大,有沒有更簡單的?”

更簡單也有啊,反正並沒有要求100%還原物理學場景:

var ball = { x: 0, y: 0, r: 10, vx: 5, vy: 0, g: 5 };
setInterval(() => {
    ball.vy += ball.g; // 豎直方向速度增加
    ball.y += ball.vy; // 豎直方向位移
    ball.x += ball.vx; // 水平方向位移
    cxt.clearRect(0, 0, 800, 300);
    cxt.beginPath();
    cxt.fillStyle = 'black';
    cxt.arc(ball.x, ball.y, ball.r, 0, 2*Math.PI);
    cxt.fill();
}, 50);
複製程式碼

OK,結束了。

這就是高階一點的動畫。可能在學幾個函式,這個動畫會更炫一點。比如學完矩形填充再掌握一點rgba的知識,你可以做個“尾巴”出來,即長尾效應。具體只需要將上述程式碼中的cxt.clearRect()替換成:

cxt.fillStyle = 'rgba(255, 255, 255, 0.2)';
cxt.fillRect(0, 0, 800, 300);
複製程式碼

這就能顯得我們們編碼能力很厲害的樣子。

做到這一步還是不滿足:小球一個勁兒的向下掉,這動畫沒一會兒就沒了。

沒關係,我們們可以做“碰撞檢測”啊。好像又是一個高大上的詞彙,但實際上也沒什麼高大上的,如果基於本節第一部分的分析,那我們還得考慮一下碰撞造成的動量損失的問題,挺複雜的。

但是簡化版就好說了啊。小球碰到上/下邊界,豎直方向速度反向,同時速率減半。左右邊界可以有類似的處理。

if (ball.r + ball.x > canvas_width) {
    ball.vx *= -0.5
}
if (ball.r + ball.y > canvas_height) {
    ball.vy *= -0.5;
}
複製程式碼

NOTE:碰撞檢測在這裡指的是“邊界檢測”,小球落到邊界的時候再繼續下落顯然是沒有意義的,因為後面的動畫我們們是看不到的。所以要麼碰到邊界就停止,要麼重新開始,或者進行其他處理,總之,不能出現無意義的動畫。

像以前玩的貪吃蛇,會有各種牆的存在,控制的小蛇在碰到牆的時候,遊戲就失敗了,或者說沒有牆的時候,小蛇會從另一個方向出來。

小結

說了這麼多,你會發現,本文不僅沒有直接的羅列不同的DEMO來介紹函式,更是在儘量避免過多的介紹canvas中的API。

個人看來,canvas其實就是一個函式庫,他和我們平時使用的那些什麼forEach,splice,split,map,reduce沒什麼區別,都是封裝好了直接用的,查一查函式手冊就可以瞭解用法了,多用幾次就會比較熟悉了。

剛進大學的時候,專業課老師就告訴我們,程式=演算法+資料結構,即使到現在,也有很多人在強調這一點。如果你有心,再回想一下上一節內容,在分析平拋運動的時候,我本質上是在考慮演算法問題;在設計小球的類時,考慮了物件導向,但更多的是在考慮資料結構的問題,在考慮了這些內容的基礎上,我才開始了具體的實現。

參考資料:


  1. MDN文件 ↩︎

  2. HTML 5 Canvas參考手冊 ↩︎

  3. 貝塞爾曲線 ↩︎

相關文章