iT邦幫忙

2025 iThome 鐵人賽

DAY 7
3
Modern Web

從 Canvas 到各式各樣的 Web API 之旅系列 第 7

Day 7 - Canvas 生態圈:Fabric.js 如何幫你簡化複雜繪圖

  • 分享至 

  • xImage
  •  

Day 2–6 我們一路用原生 Canvas 打底。老實說,要畫一個「會動、能拖、還要可重新編輯」的小工具,用原生真的很硬:🥵

  • 你要自己存一份「場景資料」:有哪些圖形、座標、角度、縮放、zIndex……
  • 每次滑鼠動一下,就得清空 → 重畫全部;還要自己算命中測試(hit‑test)到底點到誰。
  • 想要框選、對齊、群組、Undo/Redo?每一項都是一個小專案。

可以想像成原生 Canvas 是「拿畫筆在像素上作畫」。要做互動式編輯器,缺的不是畫筆,而是「物件」與「狀態管理」

這正是 Fabric.js 的價值:🌟

Fabric 幫 Canvas 做好「狀態管理」與「物件導向」,把 Immediate Mode 的畫布,升級成有 Scene Graph(物件樹)事件系統 的 Retained Mode。拖曳、縮放、旋轉、群組、序列化、SVG、甚至自訂控制點,通通變成 API,你不用再重新造輪子。

下面我會先快速說明 Fabric 解決了哪些痛,再用幾個「Fabric 幾行 vs 原生一長串」的程式碼對照,讓差距一眼看懂。


為什麼需要 Fabric.js?(Immediate Mode 的痛)

原生 Canvas 是 Immediate Mode:每次重繪都要「重新把所有像素畫一遍」,瀏覽器不幫你記物件。當功能走向互動/編輯,痛點爆炸:

  • 物件管理:自己維護一個「場景資料結構」(陣列、樹、zIndex),再自己重算 transform 與重繪順序。
  • 命中測試(hit-testing):滑鼠點到哪個圖形?旋轉、縮放後怎麼算?要不要 per-pixel?完全得你寫。
  • 互動邏輯:拖曳、縮放、旋轉、對齊、吸附、複選、框選、鍵盤刪除……每一條都是一段邏輯。
  • 群組與階層:選多個物件成 group、再拆、再 group;zIndex 與巢狀 transform 會讓人靈魂出竅。
  • 序列化:要「存檔/復原」就得自己定義 JSON、自己寫 load()save()
  • 事件模型mousedown/mousemove 只有一層,要分發到物件、再冒泡/捕獲,全靠自己。

Fabric.js 把上述通通變成「內建能力」,讓 Canvas 從像素畫布,升級成有場景圖(Scene Graph)事件系統的「可編輯畫板」。


Fabric.js 是什麼?

  • 一個 Retained Mode 的 Canvas Library:

    • 幫你管理 物件模型(Rect、Circle、Path、Text、Image、Group…)。
    • 內建 選取、拖曳、縮放、旋轉、框選 等互動。
    • 提供 事件系統object:movingobject:modifiedselection:created…)。
    • 支援 序列化toJSON() / toObject() / loadFromJSON())、輸出toDataURL()/toSVG())。
    • 匯入 SVG濾鏡自由筆畫縮放/平移視口
  • 兩大核心類別:

    • fabric.Canvas:可互動的畫布(預設含 hit-test、選取框、控制點)。
    • fabric.StaticCanvas:只負責繪製,不提供互動(適合背景圖層、效能優化)。

一言以蔽之:Fabric.js 把「物件導向」塞進原本只有像素的 Canvas 世界


什麼時候建議用 Fabric.js?

很適合

  • 多物件互動的產品:白板、流程圖、示意圖。
  • 設計與排版工具:貼紙、海報、簡報、模型標註、室內設計平面圖。
  • 替使用者提供 拖拉/編輯 的場景:調整大小、旋轉、對齊、群組、鎖定。
  • 需要 存檔/載入 的場景:把畫面變 JSON、之後還原再編輯。

不一定要

  • 高度動態、60fps 以上的遊戲/粒子特效(考慮更高效能的 Pixi.js、Three.js/WebGL)。
  • 純顯示、不需互動(原生 Canvas 或 SVG、Chart library 就好)。
  • 嚴苛記憶體限制(Retained Mode 會保留物件狀態)。

思考法:是否要讓使用者「操作物件」?是否要「存檔再編輯」? 如果兩題都 Yes,Fabric.js 往往省你數週到數月工時。

還記得 Day 1 提到的個人專案嗎? Canvas 2D 實作的室內設計工具,其實就是用 Fabric.js 打造的,一路寫下來,真的感受到 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 解法

痛點 原生怎麼做 Fabric 怎麼做
選取/拖曳/縮放/旋轉 自寫滑鼠事件、矩陣、邊界 物件 selectable: true 就有控制點與互動
命中測試 自算幾何/像素碰撞 內建 hit-test(含 per-pixel)
群組/多選 自管樹狀結構 ActiveSelectionGrouptoGroup()/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

帶程式碼的對照範例(Fabric vs 原生)

下面示意都刻意壓到最小版本,讓你感受心智負擔的差距。

1. 拖曳一個矩形

Fabric(內建互動)

<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>

原生 Canvas(自己處理資料結構 + 事件)

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();

2. 多選 + 群組 + Delete 刪除

Fabric

// 監聽整個文件的鍵盤事件(不是只在畫布上),用來處理「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);
}

原生 Canvas(自己維護選取集與群組結構)

// 用來存放目前被框選的 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、相對位移…這裡省略。

3. 存檔/載入(序列化)

Fabric

// 1. 序列化:將目前畫布上的所有物件(含自訂屬性 'id')轉成 JSON 格式
const json = canvas.toObject(['id']);


// 2. 還原場景:直接用 JSON 資料重建畫布內容
// loadFromJSON 會自動解析 JSON,重建所有物件與狀態
// callback 裡的 canvas.renderAll() 確保畫布立即刷新
canvas.loadFromJSON(json, () => canvas.renderAll());

原生 Canvas

// 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、框選、序列化等底層功能反覆造輪子。


Fabric.js 核心概念一覽

物件類別:

RectCircleLinePolygonPathTextTextboxImageGroup

關鍵屬性:

left/topwidth/heightanglescaleX/scaleYoriginX/originYopacityselectableeventedhasControls

事件:

Canvas 級:
mouse:downmouse:moveselection:createdselection:updated

物件級:
mousedblclickmovingscalingrotatingmodifiedselecteddeselected

效能:

objectCachingskipTargetFindperPixelTargetFindrequestRenderAll()StaticCanvas 背景疊層。

輸出/存檔:

toJSON(props)toObject(props)loadFromJSON()toSVG()toDataURL()


Fabric.js 實戰範例

1. 對齊吸附(Snap to Grid)

// 定義網格間距為 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,
  });
});

2. 群組、鎖定與階層

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();

3. 序列化存檔/載入(含自訂屬性)

** 以 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());
}

4. 匯入 SVG(把多個 path 合為一個物件)

// 從指定路徑載入 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);
});

5. 自訂控制點(加一顆刪除鈕)

// 為 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();
  }
});

6. 超小型 Undo/Redo(以 JSON 為快照)

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();

Fabric.js 注意事項

  • 分層:把不互動的背景丟 StaticCanvas 或設 selectable:falseevented:false,減輕 hit-test。
  • 物件快取objectCaching: true 可加速複雜物件重繪;若有奇怪殘影可暫時關閉或 dirty = truerequestRenderAll()
  • 大場景:千物件以上,關閉 perPixelTargetFind、提高 targetFindTolerance、切換虛擬化(只建立可見物件)、或改做分頁/分層載入。
  • 縮放/平移:用 setZoom()relativePan();若要固定線條/文字視覺粗細,需在縮放時調整 strokeWidth/scale
  • 高 DPI:設定 canvas.setDimensions({ width, height }, { backstoreOnly: true }) 並處理 devicePixelRatio;或使用 Fabric 內建 enableRetinaScaling
  • 輸出圖像:跨域圖片需 ImagecrossOrigin: 'anonymous' 並搭配來源伺服器 CORS,否則 toDataURL() 會被 tainted canvas 阻擋(Day 8 會細講)。

小結

  • 原生 Canvas 強在像素級控制與效能,但 缺少物件層級能力
  • Fabric.js 把 Canvas 變成「可編輯的場景」:物件模型、互動、事件、群組、序列化、SVG 支援,讓你用更短時間做出能「被使用者操作」的工具。
  • 選用時請衡量:互動與再編輯需求(Yes → Fabric.js)、或極致效能與特效(考慮 WebGL/Pixi/Three)。

👉 歡迎追蹤這個系列,我會從 Canvas 開始,一步步帶你認識更多 Web API 🎯


上一篇
Day 6 - Canvas API 效能最佳化
下一篇
Day 8 - Canvas 跨域安全:理解並避免 Tainted Canvas 問題
系列文
從 Canvas 到各式各樣的 Web API 之旅8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言