Day 2–6 我們一路用原生 Canvas 打底。老實說,要畫一個「會動、能拖、還要可重新編輯」的小工具,用原生真的很硬:🥵
可以想像成原生 Canvas 是「拿畫筆在像素上作畫」。要做互動式編輯器,缺的不是畫筆,而是「物件」與「狀態管理」。
這正是 Fabric.js 的價值:🌟
Fabric 幫 Canvas 做好「狀態管理」與「物件導向」,把 Immediate Mode 的畫布,升級成有 Scene Graph(物件樹) 與 事件系統 的 Retained Mode。拖曳、縮放、旋轉、群組、序列化、SVG、甚至自訂控制點,通通變成 API,你不用再重新造輪子。
下面我會先快速說明 Fabric 解決了哪些痛,再用幾個「Fabric 幾行 vs 原生一長串」的程式碼對照,讓差距一眼看懂。
原生 Canvas 是 Immediate Mode:每次重繪都要「重新把所有像素畫一遍」,瀏覽器不幫你記物件。當功能走向互動/編輯,痛點爆炸:
load()
與 save()
。mousedown
/mousemove
只有一層,要分發到物件、再冒泡/捕獲,全靠自己。Fabric.js 把上述通通變成「內建能力」,讓 Canvas 從像素畫布,升級成有場景圖(Scene Graph)與事件系統的「可編輯畫板」。
一個 Retained Mode 的 Canvas Library:
object:moving
、object:modified
、selection:created
…)。toJSON()
/ toObject()
/ loadFromJSON()
)、輸出(toDataURL()
/toSVG()
)。兩大核心類別:
fabric.Canvas
:可互動的畫布(預設含 hit-test、選取框、控制點)。fabric.StaticCanvas
:只負責繪製,不提供互動(適合背景圖層、效能優化)。一言以蔽之:Fabric.js 把「物件導向」塞進原本只有像素的 Canvas 世界。
很適合:
不一定要:
思考法:是否要讓使用者「操作物件」?是否要「存檔再編輯」? 如果兩題都 Yes,Fabric.js 往往省你數週到數月工時。
還記得 Day 1 提到的個人專案嗎? Canvas 2D 實作的室內設計工具,其實就是用 Fabric.js 打造的,一路寫下來,真的感受到 Fabric.js 幫我節省了大量重複勞力,讓複雜的互動和物件管理變得簡單許多!🥹
npm i fabric
<canvas id="c" width="800" height="500"></canvas>
<script type="module">
import { fabric } from 'fabric';
// 建立一個可互動的 Fabric 畫布,設定背景顏色與可選取物件
const canvas = new fabric.Canvas('c', { backgroundColor: '#fafafa', selection: true });
// 建立一個矩形物件,設定位置、大小、顏色、圓角
const rect = new fabric.Rect({ left: 100, top: 100, width: 140, height: 90, fill: '#4f46e5', rx: 8, ry: 8 });
// 建立一個圓形物件,設定位置、半徑、顏色
const circle = new fabric.Circle({ left: 280, top: 140, radius: 45, fill: '#22c55e' });
// 將矩形和圓形加入到畫布上
canvas.add(rect, circle);
// 重新渲染畫布,顯示所有物件
canvas.renderAll();
</script>
痛點 | 原生怎麼做 | Fabric 怎麼做 |
---|---|---|
選取/拖曳/縮放/旋轉 | 自寫滑鼠事件、矩陣、邊界 | 物件 selectable: true 就有控制點與互動 |
命中測試 | 自算幾何/像素碰撞 | 內建 hit-test(含 per-pixel) |
群組/多選 | 自管樹狀結構 | ActiveSelection 、Group 、toGroup() /toActiveSelection() |
zIndex | 自排陣列順序 | bringToFront() / sendToBack() |
對齊/吸附 | 自寫網格/容差 | 事件 object:moving 裡取得 target 直接改座標 |
序列化/反序列化 | 自訂 JSON 結構 | toJSON() / toObject() (可附加自訂屬性) / loadFromJSON() |
匯入 SVG | 解析 path、style | loadSVGFromURL() / util.groupSVGElements() |
匯出 | toDataURL() 自己管 CORS |
內建 toDataURL() / toSVG() + CORS 欄位 |
視口縮放/平移 | 自寫 transform 與重繪 | setZoom() / relativePan() / viewportTransform |
下面示意都刻意壓到最小版本,讓你感受心智負擔的差距。
<canvas id="c" width="500" height="300"></canvas>
<script type="module">
import { fabric } from 'fabric';
const canvas = new fabric.Canvas('c');
const rect = new fabric.Rect({ left: 80, top: 80, width: 140, height: 90, fill: '#60a5fa' });
// 就這樣,直接可拖拉、縮放、旋轉(有控制點)!!!
canvas.add(rect);
</script>
const cvs = document.querySelector('canvas');
const ctx = cvs.getContext('2d');
const shapes = [{ x: 80, y: 80, w: 140, h: 90, fill: '#60a5fa' }];
let dragging = null, offsetX = 0, offsetY = 0;
// 畫出所有圖形
function draw(){
// 清空畫布
ctx.clearRect(0,0,cvs.width,cvs.height);
shapes.forEach(s=>{
ctx.fillStyle=s.fill;
ctx.fillRect(s.x,s.y,s.w,s.h);
});
}
// 命中測試:判斷 (x, y) 是否在某個圖形內
function hit(x,y){
for (let i=shapes.length-1;i>=0;i--) {
const s = shapes[i];
// 命中則回傳該圖形與索引
if (x>=s.x && x<=s.x+s.w && y>=s.y && y<=s.y+s.h) return { s, i };
}
// 沒有命中
return null;
}
// 滑鼠按下時,檢查是否點到圖形,若有則進入拖曳狀態
cvs.addEventListener('mousedown', e=>{
const r = cvs.getBoundingClientRect();
const x = e.clientX - r.left, y = e.clientY - r.top;
const h = hit(x,y);
if (h){
// 記錄被拖曳的圖形
dragging = h.s;
// 記錄滑鼠與圖形左上角的距離
offsetX = x - h.s.x;
offsetY = y - h.s.y;
}
});
// 滑鼠移動時,若正在拖曳,更新圖形位置並重畫
cvs.addEventListener('mousemove', e=>{
if(!dragging) return;
const r = cvs.getBoundingClientRect();
dragging.x = e.clientX - r.left - offsetX;
dragging.y = e.clientY - r.top - offsetY;
draw();
});
// 滑鼠放開時,結束拖曳
cvs.addEventListener('mouseup', ()=>
dragging=null
);
// 初始繪製
draw();
// 監聽整個文件的鍵盤事件(不是只在畫布上),用來處理「Delete」快捷鍵,刪除這些被選中的物件
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete') {
// 取得目前「選取中的物件集合」
canvas.getActiveObjects().forEach(o =>
// 逐一從畫布移除被選中的物件
canvas.remove(o)
);
// 清除目前的「選取狀態」。若不清除,UI 上仍會顯示框線/控制點,但實際物件已被移除,會造成錯亂
canvas.discardActiveObject();
// 要求 Fabric 重新渲染畫布,立刻反映刪除的變化
// Fabric 是 Retained Mode,變更後通常需 requestRenderAll() 才會重畫
canvas.requestRenderAll();
}
});
// 也可以把「多選」轉成「群組」物件,以便之後一起移動、縮放、旋轉
const sel = canvas.getActiveObjects();
if (sel.length > 1) {
// ActiveSelection 是「多選態」的臨時容器。呼叫 toGroup() 會把它轉成真正的 Group 物件
const group = new fabric.ActiveSelection(sel, { canvas }).toGroup();
// 把生成的群組加入到畫布上(同時 ActiveSelection 會被替換為 Group)
canvas.add(group);
}
// 用來存放目前被框選的 shape 物件
let selection = new Set();
// 拖曳選取框的狀態與起點座標
let draggingBox = null, startX, startY;
// 滑鼠按下時,開始畫選取框
cvs.addEventListener('mousedown', e=>{
const {left, top} = cvs.getBoundingClientRect();
startX = e.clientX-left;
startY = e.clientY-top;
// 初始化一個選取框物件,w/h 先設 0
draggingBox = { x:startX, y:startY, w:0, h:0 };
});
// 滑鼠移動時,若正在框選,更新選取框的寬高,並計算哪些 shape 被選到
cvs.addEventListener('mousemove', e=>{
if(!draggingBox) return;
const {left, top} = cvs.getBoundingClientRect();
// 計算目前滑鼠位置與起點的距離,作為選取框的寬高
draggingBox.w = e.clientX-left - startX;
draggingBox.h = e.clientY-top - startY;
// 遍歷所有圖形,判斷是否與選取框有重疊,有的就加入 selection
selection = new Set(shapes.filter(s=>
s.x < startX+draggingBox.w && s.x+s.w > startX &&
s.y < startY+draggingBox.h && s.y+s.h > startY
));
// 重新繪製畫布(還需要在 draw() 裡把選取框畫出來)
draw();
});
// 滑鼠放開時,結束框選
cvs.addEventListener('mouseup', ()=> draggingBox=null);
// 鍵盤按下 Delete 時,刪除所有被選取的 shape
document.addEventListener('keydown', (e)=>{
if(e.key==='Delete'){
for (const s of selection) {
const idx = shapes.indexOf(s);
if (idx>-1) shapes.splice(idx,1);
}
// 清空選取集
selection.clear();
// 重新繪製畫布
draw();
}
});
// 真正的群組還要做巢狀 transform、相對位移…這裡省略。
// 1. 序列化:將目前畫布上的所有物件(含自訂屬性 'id')轉成 JSON 格式
const json = canvas.toObject(['id']);
// 2. 還原場景:直接用 JSON 資料重建畫布內容
// loadFromJSON 會自動解析 JSON,重建所有物件與狀態
// callback 裡的 canvas.renderAll() 確保畫布立即刷新
canvas.loadFromJSON(json, () => canvas.renderAll());
// shapes 是目前畫布上的所有圖形資料
const shapes = [
{ x: 80, y: 80, w: 140, h: 90, fill: '#60a5fa' },
{ x: 200, y: 120, w: 100, h: 60, fill: '#f59e0b' }
];
// 1. 序列化:將圖形資料結構轉成 JSON 字串
const json = JSON.stringify(shapes);
// 2. 還原場景:將 JSON 字串解析回物件陣列
const restoredShapes = JSON.parse(json);
// 4. 用還原後的資料重畫畫布
function draw() {
// 4-1. 先清空畫布
ctx.clearRect(0, 0, cvs.width, cvs.height);
// 4-2. 依序繪製每個圖形
restoredShapes.forEach(s => {
ctx.fillStyle = s.fill;
ctx.fillRect(s.x, s.y, s.w, s.h);
});
}
draw();
從這幾個例子的對照可以看出,Fabric 省下了大量「基礎設施」的重複勞動。這樣一來,你可以把時間放在產品規則與 UX,而不用再為 hit-test、框選、序列化等底層功能反覆造輪子。
Rect
、Circle
、Line
、Polygon
、Path
、Text
、Textbox
、Image
、Group
。
left/top
、width/height
、angle
、scaleX/scaleY
、originX/originY
、opacity
、selectable
、evented
、hasControls
。
Canvas 級:mouse:down
、mouse:move
、selection:created
、selection:updated
。
物件級:mousedblclick
、moving
、scaling
、rotating
、modified
、selected
、deselected
。
objectCaching
、skipTargetFind
、perPixelTargetFind
、requestRenderAll()
、StaticCanvas
背景疊層。
toJSON(props)
、toObject(props)
、loadFromJSON()
、toSVG()
、toDataURL()
。
// 定義網格間距為 20px
const grid = 20;
// 畫出垂直線條,形成網格
for (let i = 0; i < 800 / grid; i++) {
canvas.add(new fabric.Line([ i * grid, 0, i * grid, 500 ], { stroke: '#eee', selectable: false, evented: false }));
}
// 畫出水平線條,形成網格
for (let i = 0; i < 500 / grid; i++) {
canvas.add(new fabric.Line([ 0, i * grid, 800, i * grid ], { stroke: '#eee', selectable: false, evented: false }));
}
// 新增一個可移動的藍色矩形方塊
const box = new fabric.Rect({ left: 73, top: 87, width: 120, height: 80, fill: '#60a5fa' });
canvas.add(box);
// 監聽物件移動事件,讓方塊移動時自動吸附到最近的網格
canvas.on('object:moving', (e) => {
const t = e.target;
t.set({
left: Math.round(t.left / grid) * grid,
top: Math.round(t.top / grid) * grid,
});
});
const r = new fabric.Rect({ left: 120, top: 260, width: 120, height: 80, fill: '#4ade80' });
const c = new fabric.Circle({ left: 260, top: 260, radius: 40, fill: '#fb7185' });
canvas.add(r, c);
// 建群組(把原本的兩個從畫布移除,再加入 group)
const group = new fabric.Group([r, c], { left: 420, top: 240 });
canvas.add(group);
// 鎖住群組不讓移動
group.lockMovementX = group.lockMovementY = true;
canvas.requestRenderAll();
** 以 Fabric v6 為例,.toObject()
可以接受欄位名稱(如 ['customId']
),用來序列化自定義屬性**,但從原始碼可知,.toJSON()
並不支援傳入參數。關於這部分的細節,我在另一篇文章中有更完整的說明:Canvas 跨域圖片的瀏覽器安全機制 - 先介紹 Fabric 的序列化操作
// 自訂屬性(例如物件 id)
box.set('id', 'hero-box-1');
// 存
// .toObject() 支援自定義屬性,.toJSON() 並不接受任何參數
const json = canvas.toObject(['id']);
localStorage.setItem('scene.v1', JSON.stringify(json));
// 載
const saved = localStorage.getItem('scene.v1');
if (saved) {
canvas.loadFromJSON(JSON.parse(saved), () => canvas.renderAll());
}
// 從指定路徑載入 SVG 檔案,並將其匯入 Fabric 畫布
fabric.loadSVGFromURL('./assets/couch.svg', (objects, options) => {
// 將載入的多個 SVG 元素合併成一個 Fabric 物件(群組)
const obj = fabric.util.groupSVGElements(objects, options);
// 設定物件的位置、縮放比例,以及可選取
obj.set({ left: 100, top: 340, scaleX: 0.6, scaleY: 0.6, selectable: true });
// 將合併後的 SVG 物件加入到 Fabric 畫布
canvas.add(obj);
});
// 為 box 物件新增一個自訂控制點
box.controls.deleteControl = new fabric.Control({
// 控制點相對於物件中心的位置(0.5: 右側, -0.5: 上方)
x: 0.5, y: -0.5,
// 控制點的像素偏移(微調位置到物件外側)
offsetX: 16, offsetY: -16,
// 滑鼠移到控制點時顯示為指標
cursorStyle: 'pointer',
// 當使用者在控制點上放開滑鼠時,執行刪除動作
mouseUpHandler: (_, transform) => {
canvas.remove(transform.target);
canvas.requestRenderAll();
},
// 控制點的繪製方式(這裡畫一個「✕」符號)
render: (ctx, left, top) => {
ctx.save();
ctx.font = '16px system-ui';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('✕', left, top);
ctx.restore();
}
});
let undoStack = [], redoStack = [];
// 快照函式:每次呼叫時,將目前畫布狀態序列化並存入 undoStack
const snapshot = () => {
redoStack = [];
undoStack.push(canvas.toObject(['id']));
};
// 監聽畫布物件的新增、修改、移除事件,每次都自動記錄快照
canvas.on('object:added', snapshot);
canvas.on('object:modified', snapshot);
canvas.on('object:removed', snapshot);
function undo() {
// 至少要保留一份當前狀態,避免全部清空
if (undoStack.length < 2) return;
redoStack.push(undoStack.pop());
const state = undoStack[undoStack.length - 1];
canvas.loadFromJSON(state, () =>canvas.renderAll());
}
function redo() {
const state = redoStack.pop();
if (!state) return;
undoStack.push(state);
canvas.loadFromJSON(state, () => canvas.renderAll());
}
// 初始化一份狀態
snapshot();
StaticCanvas
或設 selectable:false
、evented:false
,減輕 hit-test。objectCaching: true
可加速複雜物件重繪;若有奇怪殘影可暫時關閉或 dirty = true
後 requestRenderAll()
。perPixelTargetFind
、提高 targetFindTolerance
、切換虛擬化(只建立可見物件)、或改做分頁/分層載入。setZoom()
與 relativePan()
;若要固定線條/文字視覺粗細,需在縮放時調整 strokeWidth/scale
。canvas.setDimensions({ width, height }, { backstoreOnly: true })
並處理 devicePixelRatio
;或使用 Fabric 內建 enableRetinaScaling
。Image
設 crossOrigin: 'anonymous'
並搭配來源伺服器 CORS,否則 toDataURL()
會被 tainted canvas 阻擋(Day 8 會細講)。👉 歡迎追蹤這個系列,我會從 Canvas 開始,一步步帶你認識更多 Web API 🎯