演示地址
演示地址
PC端的專案啦,需要在電腦上看哦,而且最好用Chrome開啟
引言
這是今年三月份幫學長做的一個專案,陪我度過了兩個月的春招生活,整個專案做下來也是學到了很多東西,下面就開始我的分享啦,包括一些知識點總結和遇到的坑,dalao莫笑哈。
專案概述
主要功能如上圖,左邊是圖形工具欄,右邊是canvas,上面是清除、刪除、旋轉、切換格子背景、儲存並下載圖片的操作。程式碼是基於vue-cli碼的,所以路由、vuex這些都不用講啦,我們把重點放在canvas上面吧。
知識點總結
拖拽
這裡的拖拽是指把左邊工具欄裡的圖形圖形拖拽到右邊畫布裡,三步完成:
- 被拖拽元素設定
draggable="true"
; - 被拖拽元素還有三個相應的事件
dragstart
drag
dragend
,分別對應拖拽開始、拖拽中和拖拽結束,如果你希望在這些過程加上特效,可以試試,但更多的還是用作響應資料,比如讓畫布知道具體是哪個元素被拖拽進來了; - 被放置元素設定
dragover
drop
兩個事件,分別表示被拖拽元素在該元素範圍內移動、被拖拽元素著陸,這裡注意dragover
事件函式內需設定event.preventDefault()
防止彈出新頁面,然後我們就可以愉快地在drop
事件函式裡畫圖形到畫布上啦。
HEX => RGBA
由於設計圖上顏色都沒有透明度,所以我們需要手動加一個0.3的alpha,不然畫布上圖形相互層疊,會覆蓋掉層級低的圖形和背景圖。
function hex2rgba(hex) {
// hex格式如#ffffff
let colorArr = [];
for(let i = 1; i<7; i += 2){
colorArr.push(parseInt("0x" + hex.slice(i,i+2))); // 16進位制值轉10進位制
}
return `rgba(${colorArr.join(",")},0.3)`;
}
複製程式碼
另外如果有興趣瞭解RGBA轉RGB的小夥伴,可以看看這篇部落格RGBA轉換成RGB
canvas基本用法
下面就是關於canvas的內容了,如果對它的基礎用法還不太瞭解的小夥伴,可以看看JavaScript之Canvas畫布
save與restore
save
可以儲存當前canvas的狀態,包括strokeStyle
、fillStyle
、變換矩陣、剪下區域等,restore
可以恢復到canvas狀態棧中的上一個狀態,所以我們在這兩個函式中間做的canvas狀態改變相當於被隔離起來了,不會汙染外部的canvas操作。
這樣看來,我們最好在每次畫圖前呼叫save
,畫完後呼叫restore
,從而保證每次繪製都有一個純粹的狀態。
這裡有一篇講得特別好的文章,如果嫌本直男沒講清楚的話,一定要看哦。Canvas學習:save()和restore()
drawImage
可能有些小夥伴會小看這個API,認為它只能繪製圖片,實際上它還能svg、canvas繪製到畫布上,我們先來看看如何繪製svg咯。
我們功能介面左側工具欄裡的圖示其實都是svg,我一開始是想把他們截圖下來切成一個個背景透明的png,然後畫到canvas上,後來發現放大看的話會比較模糊,畢竟是畫素圖嘛,所以新的需求來了。
我自己的程式碼不好貼出來,那就看看dalao的吧,將 DOM 物件繪製到 canvas 中,他這裡是將DOM塞到svg裡再往canvas上畫的,如果你只需要畫現成的svg,則可以不用foreignObject
包裹。
另外,如果你的svg有.svg格式圖片,可以直接呼叫drawImage
去繪製。
橢圓與貝賽爾曲線
canvas已經有畫橢圓的API了,但相容性還不夠好,在其他所有模擬繪製橢圓的方式裡,貝塞爾曲線可以說是最優雅的一種了,好吧,掃盲文 => 貝塞爾曲線原理(簡單闡述)
三維貝塞爾曲線需要一個起始點、兩個中間點、一個終止點確定,當然起始點一般預設當前點,所以bezierCurveTo
的引數就是按順序的後三個點座標了;當這四個點恰好圍成一個矩形時,就有點橢圓的模樣啦。
let a = this.width / 2;
let b = this.height / 2;
let ox = 0.5 * a,
oy = 0.6 * b;
this.ctx.beginPath();
// 從橢圓縱軸下端開始逆時針方向繪製
this.ctx.moveTo(0, b);
// 把橢圓劃成四份分開來畫
this.ctx.bezierCurveTo(ox, b, a, oy, a, 0);
this.ctx.bezierCurveTo(a, -oy, ox, -b, 0, -b);
this.ctx.bezierCurveTo(-ox, -b, -a, -oy, -a, 0);
this.ctx.bezierCurveTo(-a, oy, -ox, b, 0, b);
this.ctx.closePath();
this.ctx.fill();
複製程式碼
這裡有一篇整理得比較完整的橢圓繪製方法的文章 可以參考 HTML5 Canvas中繪製橢圓的5種方法
線條
帶箭頭的實線
實線好畫,但是箭頭怎麼來做呢?Emmm,其實就是計算線段與畫布x軸的夾角,然後線上段終點畫偏移對應角度的三角形嘛
drawArrow(x1, y1, x2, y2) {
// (x1, y1)是線段起點 (x2, y2)是線段終點
// 反正切函式計算夾角
let endRadians = Math.atan((y2 - y1) / (x2 - x1));
// 三角形的底邊與線段垂直,所以還要再轉 π / 2
endRadians += ((x2 >= x1) ? 90 : -90) * Math.PI / 180;
this.ctx.save();
this.ctx.beginPath();
// 座標原點 => (x2, y2)
this.ctx.translate(x2, y2);
this.ctx.rotate(endRadians);
this.ctx.moveTo(0, 0);
this.ctx.lineTo(5, 15);
this.ctx.lineTo(-5, 15);
this.ctx.closePath();
this.ctx.fill();
this.ctx.restore();
}
複製程式碼
虛線
- 比較傳統的一種做法是修改CanvasRenderingContext2D的原型,手動增加一個dashedLine的方法,原理大概是從起始點先畫一段實線,然後跳過一段,moveTo到下一個點繼續畫實線,這樣迴圈到終止點,就能得到虛線。具體實現見html5 實現畫虛線
- 其實canvas已經支援畫虛線了,畫線前用
setLineDash
即可指定虛線的樣式,詳見Canvas學習:繪製虛線和圓點線
但是這個方法用起來有些問題,角度不好或者間隔太小的時候,畫出來的虛線看起來就像是實線。
波浪線
一般常見的波浪線都是用正弦曲線來模擬的吧,y = A * sin(ω * x + φ),指定它的A和ω就可以確定波浪線的振幅和頻率(或者說每個波浪的高度和寬度)
let len = Math.sqrt(width * width + height * height);
this.ctx.save();
this.ctx.moveTo(this.start.x,this.start.y); // 起點
this.ctx.translate(this.start.x,this.start.y);
this.ctx.beginPath();
let x = 0;
let y = 0;
let amplitude = 5; // 振幅
let frequency = 5; // 頻率
while (x < len) {
y = amplitude * Math.sin(x / frequency);
this.ctx.lineTo(x, y);
x = x + 1;
}
this.ctx.stroke();
this.ctx.restore();
複製程式碼
參考文章:Draw a Sine Wave in JavaScript
圖形棧
儲存
簡單來說,我們畫布上的圖形都是一個類的例項,儲存在一個陣列中,每次有更新時都會清除畫布,再全部重新繪製一遍(後面會將優化)。這個圖形例項需要儲存的屬性一般有起始和終點座標、顏色、偏移角度等,根據自己的需求設定,還至少需要一個方法去動態計算該圖形的有效範圍,以便滑鼠事件找到它。
刪除
選中某圖形例項後,從圖形棧陣列中刪除即可。
旋轉
由於我們每次畫圖形的時候,都會把座標原點暫時移到圖形的中心,所以只需要rotate
一個角度再畫就可以實現旋轉啦
拖拽移動
Emmm,每個圖形不太一樣,有興趣的話看看專案原始碼唄
判斷一個點是否在某個四邊形內
- 向量法
詳見 判斷一個點是否在四邊形內部,但是這種方法有點侷限性,首先,圖形邊數必須事先確定,而且邊數多起來了程式碼會很長;其次,這種方法只是適用於凸多邊形,舉個凹多邊形的反例想想就能明白了。 - 射線法
詳見射線法理論,程式碼實現如下:
function inRange(x, y, points){
// points表示多邊形的頂點集合
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
let xi = points[i][0], yi = points[i][1];
let xj = points[j][0], yj = points[j][1];
let intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
if (intersect) inside = !inside;
}
return inside;
}
複製程式碼
- 一個公式
任意點(x,y),繞一個座標點(rx0,ry0)逆時針旋轉a角度後的新的座標設為(x0, y0),有公式:
x0= (x - rx0)*cos(a) - (y - ry0)*sin(a) + rx0 ;
y0= (x - rx0)*sin(a) + (y - ry0)*cos(a) + ry0 ;
極座標的知識啦,不想推就直接套公式唄。
撤銷與回退
類似PS的功能嘛,我這個專案沒做,但是思路不難,用past、present、future三個陣列來儲存圖形棧,Emm好像講起來還是有點長,可以參考實現撤銷歷史的思路。
優先順序
圖形棧裡的例項被依次取出繪製,後畫上去的圖形會覆蓋掉之前的圖形,所以這裡涉及到一個優先順序,重要的東西放在後面畫。
我們可以把儲存圖形的陣列再細分類,陣列的每個子元素都是一個Array,專門儲存某一種圖形,優先順序越高,對應的索引值越大,這樣我們就可以把重要的圖形全部放在後面畫了。
vuex中的狀態實現雙向繫結
一般我們用於雙向繫結的值都會放在vue例項的data
中,因為它預設提供了getter
和setter
;但vuex的狀態一般都需要computed
來讀取,但computed
預設是沒有setter方法的,需要手動設定,程式碼如下:
computed:{
text : {
get(){
return this.$store.state.text;
},
set(value){
this.$store.commit('setText',value);
}
}
}
複製程式碼
遇到的坑
html2canvas的一個小bug
在實現儲存圖片功能的時候,我希望能擷取一段DOM的內容,而不僅僅是canvas的內容,所以找到了這個外掛html2canvas,它可以把dom轉換成canvas,然後我們就能canvas.toDataURL()
把它轉換成圖片了。
轉換並儲存成圖片下載的程式碼如下:
downImg() {
html2canvas( this.$refs.ground, {
onrendered: function(canvas) {
let url = canvas.toDataURL();
let a = document.createElement('a');
a.href = url;
a.download = new Date() + ".png";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
});
}
複製程式碼
但是出現了一個bug,就是下載下來的圖片不清晰,左上角一大片空白。
於是我嘗試了網上的很多方法,都行不通,最後只能把專案從零開始慢慢加東西,最後發現是我畫虛線的時候改了CanvasRenderingContext2D的原型,我滴媽耶,做夢也沒想到會是這裡出問題,用外掛有風險啊。
上傳到gh-pages時的路徑錯誤
如果上傳到https://XXX.github.io/(GitHub的個人部落格)上,則跟上傳到伺服器上操作一致,但如果是傳到某個倉庫的gh-pages,那麼一堆問題都來了,解決步驟如下:
- 把
.gitignore
檔案裡的/dist
刪掉,忽略了的話,還怎麼上傳打包檔案到master分支呢; /config/index.js
裡build部分裡的assetsPublicPath
由'/'改成'./',相當於說把伺服器根目錄改成了相對路徑,倉庫gh-pages的根目錄不是'/'而是'/倉庫名';- 相對應的,如果使用了history模式,請改成hash模式,不然github可能會把前端路由識別成後端api;
- 還有一些
static
裡的圖片,使用了絕對路徑,可能上傳後顯示不出來; git subtree push --prefix dist origin gh-pages
敲完命令,應該就可以看到上傳成功了。
優化
多層次畫布
上面提到,我們的畫布每次更新時,總是要全部清除,然後重新再畫一遍,對於那些背景圖片等不變的內容來說,是不是可以優化呢?Emmm,好尬的設問句。
我們用多個同樣大小層疊的canvas來完成,層級低的下層canvas用來畫背景圖片等靜態圖形,層級高的上層canvas用來畫動態變化的圖形,這樣就可以每次渲染都優化一點啦。
離屏渲染
當我們在畫布上拖拽圖形時,一般做法是隨著滑鼠移動mousemove
,重新繪製所有圖形,但其實這個過程中,要繪製的可以分為兩部分,一個是被拖拽移動的圖形,另一個就是其他圖形;我們可以分別動態建立兩個canvas,把兩部分畫在兩個離屏畫布上,mousemove
時只要呼叫兩次drawImage(離屏canvas)即可,這樣是不是效能又花了很多呢
程式碼地址
程式碼地址
雖然程式碼質量差,我自己都不忍直視,但還是放出來吧,萬一哪裡看不懂了還可以翻翻原始碼嘛