流程圖渲染方式:Canvas vs SVG

袋鼠云数栈前端發表於2024-06-28

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:霽明

背景

我們產品中會有一些流程圖應用,例如審批中心的審批流程圖:

file

我們數棧產品內的流程圖,基本都是使用的 mxGraph 實現的,mxGraph 使用了SVG來渲染圖形。
流程圖元件庫除了 mxGraph,還有其他一些流行的庫,例如:ReactFlow、G6、X6等等,各個庫的特點、具體實現原理各有不同,但圖形渲染方式卻主要都是這兩種:Canvas 和 SVG。
本文會透過繪製流程圖(只是簡單繪製,不涉及圖表庫的實現),來介紹 Canvas 和 SVG 的使用方式、動畫實現以及兩者之間的一些差異。

Canvas

簡介

MDN 對 Canvas 的介紹:

Canvas API 提供了一個透過 JavaScript 和 HTML的 <canvas>元素來繪製圖形的方式。它可以用於動畫、遊戲畫面、資料視覺化、圖片編輯以及實時影片處理等方面。

目前所有主流的瀏覽器都支援 Canvas。

使用

基本用法

建立藍白紅3個色塊:

import { useEffect } from 'react';

function Page() {
  useEffect(() => {
    const canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas?.getContext) {
      const ctx = canvas.getContext('2d');
      ctx.fillStyle = '#002153';
      ctx.fillRect(10, 10, 50, 100);
      ctx.fillStyle = '#ffffff';
      ctx.fillRect(60, 10, 50, 100);
      ctx.fillStyle = '#d00922';
      ctx.fillRect(110, 10, 50, 100);
    }
  }, []);
  return <canvas id="canvas"></canvas>;
}

export default Page;

效果如下圖:

file

繪製流程圖

繪製一個開始節點、一箇中間節點和一個結束節點,節點之間用有向線條進行連線,如下圖:

file

前置知識:
devicePixelRatio:裝置畫素比,返回當前顯示裝置的物理畫素解析度與 _CSS _ 畫素解析度之比,它告訴瀏覽器應使用多少螢幕實際畫素來繪製單個 CSS 畫素。比如螢幕物理畫素是2000px,css 畫素是1000px,則裝置畫素比為2。

實現程式碼如下:

import { useEffect } from 'react';
import styles from '../../styles/canvas.module.css';

function Page() {
  useEffect(() => {
    const canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas?.getContext) {
      // 處理影像模糊問題
      const ratio = window.devicePixelRatio || 1;
      const { width, height } = canvas;
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;

      const ctx = canvas.getContext('2d');
      // 放大(處理影像模糊問題)
      ctx.scale(ratio, ratio);
      ctx.font = '12px sans-serif';

      // 開始節點
      ctx.beginPath();
      ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左邊框
      ctx.lineTo(350, 100);
      ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右邊框
      ctx.lineTo(300, 150);
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('開始', 312, 130);

      // 中間節點
      ctx.beginPath();
      ctx.arc(280, 230, 5, Math.PI, (Math.PI * 3) / 2, false); // 左上圓角
      ctx.lineTo(370, 225);
      ctx.arc(370, 230, 5, (Math.PI * 3) / 2, Math.PI * 2, false); // 右上圓角
      ctx.lineTo(375, 270);
      ctx.arc(370, 270, 5, 0, Math.PI / 2, false); // 右下圓角
      ctx.lineTo(280, 275);
      ctx.arc(280, 270, 5, Math.PI / 2, Math.PI, false); // 左下圓角
      ctx.lineTo(275, 230);
      ctx.lineWidth = 3;
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('中間節點', 300, 254);

      // 結束節點
      ctx.beginPath();
      ctx.arc(300, 400, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左邊框
      ctx.lineTo(350, 375);
      ctx.arc(350, 400, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右邊框
      ctx.lineTo(300, 425);
      ctx.stroke();
      ctx.fillStyle = '#FFF';
      ctx.fill();
      ctx.fillStyle = '#000';
      ctx.fillText('結束', 312, 405);

      // 線條1
      ctx.beginPath();
      ctx.moveTo(325, 150);
      ctx.lineTo(325, 225);
      ctx.lineWidth = 1;
      ctx.stroke();
      // 箭頭1
      ctx.beginPath();
      ctx.moveTo(320, 215);
      ctx.lineTo(330, 215);
      ctx.lineTo(325, 225);
      ctx.fill();

      // 線條2
      ctx.beginPath();
      ctx.moveTo(325, 275);
      ctx.lineTo(325, 375);
      ctx.stroke();
      // 箭頭2
      ctx.beginPath();
      ctx.moveTo(320, 365);
      ctx.lineTo(330, 365);
      ctx.lineTo(325, 375);
      ctx.fill();
    }
  }, []);
  return (
    <div className={styles.container}>
      <canvas id="canvas" width="800" height="600"></canvas>
    </div>
  );
}

export default Page;

繪製圖形可以透過繪製矩形、繪製路徑的方式來繪製圖形,還可以使用 Path2D 物件來繪製,具體使用方法可以檢視MDN

樣式和顏色

給節點加上樣式,效果如下:

file

對比上一步,可以發現給節點內容和邊框填充了顏色,以開始節點為例:

...

// 開始節點
ctx.beginPath();
ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左邊框
ctx.lineTo(350, 100);
ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右邊框
ctx.lineTo(300, 150);
ctx.lineWidth = 3;
ctx.strokeStyle = '#82b366';
ctx.stroke();
ctx.fillStyle = '#d5e8d4';
ctx.fill();
ctx.fillStyle = '#000';
ctx.fillText('開始', 312, 130);

...

canvas 支援繪製許多樣式,例如:顏色、透明度、線條樣式、陰影等,具體使用可檢視 MDN

動畫實現

實現線條流動動畫,實現效果如下圖所示:

file

實現原理:將線條設定為虛線,然後設定偏移量,每間隔一定時間渲染一次,每次的偏移量都遞增,便實現了線條流動的動畫效果。
原理了解了,但在開發之前有兩個點要考慮一下:

  • 動畫是有執行頻率的,要控制的話用哪種方式好一點?
  • 每次動畫執行時,一般是整個畫布都重新整理,考慮到效能問題,是否可以區域性重新整理?

帶著這兩個問題,我們看下程式碼實現:

import { useEffect } from 'react';
import styles from '../../styles/page.module.css';

const rAFSetInterval = (handler: (timer: number) => void, timeout?: number) => {
  let timer = null;
  let startTime = Date.now();
  const loop = () => {
    let currentTime = Date.now();
    if (currentTime - startTime >= timeout) {
      startTime = currentTime;
      handler(timer);
    }
    timer = requestAnimationFrame(loop);
  };
  loop();
  return timer;
};

function Page() {
  let canvas: HTMLCanvasElement;
  let ctx: CanvasRenderingContext2D;
  let offset = 0;

  useEffect(() => {
    canvas = document.getElementById('canvas') as HTMLCanvasElement;
    if (canvas) {
      const ratio = window.devicePixelRatio || 1;
      const { width, height } = canvas;
      canvas.width = Math.round(width * ratio);
      canvas.height = Math.round(height * ratio);
      canvas.style.width = `${width}px`;
      canvas.style.height = `${height}px`;
      ctx = canvas.getContext('2d');
      ctx.scale(ratio, ratio);
      ctx.font = '12px sans-serif';
      draw();
      rAFSetInterval(run, 50);
    }
  }, []);

  const run = () => {
    offset++;
    if (offset > 1000) {
      offset = 0;
    }
    drawAnimateLine();
  };

  const draw = () => {
    // 初始化
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.setLineDash([]);
    ctx.lineDashOffset = 0;

    // 開始節點
    ctx.beginPath();
    ctx.arc(300, 125, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左邊框
    ctx.lineTo(350, 100);
    ctx.arc(350, 125, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右邊框
    ctx.lineTo(300, 150);
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#82b366';
    ctx.stroke();
    ctx.fillStyle = '#d5e8d4';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('開始', 312, 130);

    // 中間節點
    ctx.beginPath();
    ctx.arc(280, 230, 5, Math.PI, (Math.PI * 3) / 2, false); // 左上圓角
    ctx.lineTo(370, 225);
    ctx.arc(370, 230, 5, (Math.PI * 3) / 2, Math.PI * 2, false); // 右上圓角
    ctx.lineTo(375, 270);
    ctx.arc(370, 270, 5, 0, Math.PI / 2, false); // 右下圓角
    ctx.lineTo(280, 275);
    ctx.arc(280, 270, 5, Math.PI / 2, Math.PI, false); // 左下圓角
    ctx.lineTo(275, 230);
    ctx.lineWidth = 3;
    ctx.strokeStyle = '#6c8ebf';
    ctx.stroke();
    ctx.fillStyle = '#dae8fc';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('中間節點', 300, 254);

    // 結束節點
    ctx.beginPath();
    ctx.arc(300, 375, 25, Math.PI / 2, (Math.PI * 3) / 2, false); // 左邊框
    ctx.lineTo(350, 350);
    ctx.arc(350, 375, 25, (Math.PI * 3) / 2, (Math.PI * 5) / 2, false); // 右邊框
    ctx.lineTo(300, 400);
    ctx.strokeStyle = '#82b366';
    ctx.stroke();
    ctx.fillStyle = '#d5e8d4';
    ctx.fill();
    ctx.fillStyle = '#000';
    ctx.fillText('結束', 312, 380);

    // 線條1
    ctx.beginPath();
    ctx.moveTo(325, 150);
    ctx.lineTo(325, 223);
    ctx.setLineDash([4, 4]);
    ctx.lineDashOffset = -offset;
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = '#000';
    ctx.stroke();
    // 箭頭1
    ctx.beginPath();
    ctx.moveTo(320, 215);
    ctx.lineTo(325, 218);
    ctx.lineTo(330, 215);
    ctx.lineTo(325, 225);
    ctx.fill();

    // 線條2
    ctx.beginPath();
    ctx.moveTo(325, 275);
    ctx.lineTo(325, 348);
    ctx.stroke();
    // 箭頭2
    ctx.beginPath();
    ctx.moveTo(320, 340);
    ctx.lineTo(325, 343);
    ctx.lineTo(330, 340);
    ctx.lineTo(325, 350);
    ctx.fill();
  };

  const drawAnimateLine = () => {
    // 清空線條
    ctx.clearRect(324, 150, 2, 67);
    ctx.clearRect(324, 275, 2, 67);

    // 繪製線條1
    ctx.beginPath();
    ctx.moveTo(325, 150);
    ctx.lineTo(325, 223);
    ctx.setLineDash([4, 4]);
    ctx.lineDashOffset = -offset;
    ctx.lineWidth = 1.5;
    ctx.strokeStyle = '#000';
    ctx.stroke();

    // 繪製線條2
    ctx.beginPath();
    ctx.moveTo(325, 275);
    ctx.lineTo(325, 348);
    ctx.stroke();
  };

  return (
    <div className={styles.container}>
      <canvas id="canvas" width="800" height="600"></canvas>
    </div>
  );
}

export default Page;

針對前面的兩個問題,這裡總結一下:

  • 使用 requestAnimationFrame 實現一個 setInterval 方法,做到定時控制和效能兼顧
  • 針對動畫區域,透過座標和區域寬高,進行 canvas 的區域性重新整理

SVG

簡介

引用 MDN 對 SVG 的介紹:

可縮放向量圖形(Scalable Vector Graphics,SVG)基於 XML 標記語言,用於描述二維的向量圖形。
和傳統的點陣影像模式(如 JPEG 和 PNG)不同的是,SVG 格式提供的是向量圖,這意味著它的影像能夠被無限放大而不失真或降低質量,並且可以方便地修改內容,無需圖形編輯器。透過使用合適的庫進行配合,SVG 檔案甚至可以隨時進行本地化。

目前所有主流的瀏覽器都支援SVG(IE部分支援)。

使用

常用標籤

流程圖中主要用到的幾種標籤:
<svg>
SVG 容器元素,SVG 的程式碼都包裹在該元素下,可以作為根元素(一般是 svg 圖片),也可以內嵌在HTML文件中。如果 svg 不是根元素,svg 元素可以用於在當前文件內巢狀一個獨立的 svg 片段。這個獨立片段擁有獨立的視口和座標系統。
<g>
元素 g 是用來組合物件的容器。新增到 g 元素上的變換會應用到其所有的子元素上。新增到 g 元素的屬性會被其所有的子元素繼承。
<rect>
rect元素是 SVG 的一個基本形狀,用來建立矩形,基於一個角位置以及它的寬和高。它還可以用來建立圓角矩形。
<path>
path 元素是用來定義形狀的通用元素。所有的基本形狀都可以用 path 元素來建立。
<foreignObject>
foreignObject 元素允許包含來自不同的 XML 名稱空間的元素。在瀏覽器的上下文中,很可能是 XHTML / HTML。在我們的流程圖中,透過 HTML 渲染的節點一般都渲染在這個標籤內。

基本用法

使用svg渲染圖片

function Page() {
  return (
    <svg width="150" height="100">
      <rect width="50" height="100" x="0" fill="#002153" />
      <rect width="50" height="100" x="50" fill="#ffffff" />
      <rect width="50" height="100" x="100" fill="#d00922" />
    </svg>
  );
}

export default Page;

上面程式碼渲染效果如下圖:

file

繪製流程圖

使用svg繪製流程圖:

file

程式碼實現如下:

import styles from '../../styles/page.module.css';

function Page() {
  return (
    <svg width="800" height="600" className={styles.container}>
      <g>
        <path
          d="M 320 110 C 286 110, 286 160, 320 160 L 370 160 C 404 160, 404 110, 370 110 Z"
          stroke="#82b366"
          strokeWidth="2"
          fill="#d5e8d4"
          />
        <text x="332" y="140" style={{ fontSize: 12 }}>
          開始
        </text>
      </g>
      <g>
        <rect
          x="295"
          y="235"
          width="100"
          height="50"
          rx="5"
          fill="#dae8fc"
          stroke="#6c8ebf"
          strokeWidth="2"
          ></rect>
        <text x="320" y="264" style={{ fontSize: 12 }}>
          中間節點
        </text>
      </g>
      <g>
        <path
          d="M 320 360 C 286 360, 286 410, 320 410 L 370 410 C 404 410, 404 360, 370 360 Z"
          stroke="#82b366"
          strokeWidth="2"
          fill="#d5e8d4"
          />
        <text x="332" y="390" style={{ fontSize: 12 }}>
          結束
        </text>
      </g>
      <g>
        <path d="M 345 160 L 345 235" stroke="#000"></path>
        <path d="M 340 225 L 345 228 L 350 225 L 345 235 Z" fill="#000"></path>
      </g>
      <g>
        <path d="M 345 285 L 345 360" stroke="#000"></path>
        <path d="M 340 350 L 345 353 L 350 350 L 345 360 Z" fill="#000"></path>
      </g>
    </svg>
  );
}

export default Page;

以開始節點為例,主要看下path元素:

<path
  d="M 320 110 C 286 110, 286 160, 320 160 L 370 160 C 404 160, 404 110, 370 110 Z"
  stroke="#82b366"
  strokeWidth="2"
  fill="#d5e8d4"
/>

d 屬性定義了要繪製的路徑,路徑定義是一個路徑命令組成的列表,其中的每一個命令由命令字母和用於表示命令引數的數字組成。每個命令之間透過空格或逗號分隔。
M 表示 move to,即移動到某個座標;L 表示 line to,即連線到某個座標。
C 表示使用三次方貝塞爾曲線,後面跟隨3個座標點,分別是起始控制點、終點控制點、終點。
Z 表示 ClosePath,將從當前位置繪製一條直線到路徑中的第一個點。上面只用到了4種命令,而命令總共有20種,具體可以檢視MDN
stroke、strokeWidth、fill 則分別指定了邊框顏色、寬度,以及填充顏色。

動畫實現

實現線條流動動畫,實現效果如下圖:

file

實現原理:先將線條設定為虛線,然後透過 css 動畫,修改虛線的偏移量並無限迴圈,從而實現線條流動效果。
程式碼實現如下:

.animate-path {
  stroke-dasharray: 5;
  animation: dashdraw 0.5s linear infinite;
}
@keyframes dashdraw {
  0% {
    stroke-dashoffset: 10;
  }
}

svg 可以透過 css、js 或者 animate 標籤來實現動畫,適用於需要高質量向量圖形、可縮放和互動性強的場景

對比

使用方式

Canvas 是比 SVG 更低階別的 API,繪製圖形需要透過 JS 來操作。Canvas 提供了更大的靈活性,但複雜度也更高,理論上任何使用 SVG 繪製的圖形,都可以透過 Canvas繪製出來。相反,由於 SVG 是比 Canvas 更高階別的 API,可以當作 HTML 元素去使用,也可以結合 JS、CSS 去操作,使用 SVG 建立一些複雜的圖形會比使用 Canvas 更加簡單。

互動性

SVG 位於 DOM 中,和普通 DOM 元素一樣支援響應事件。Canvas 也可以響應互動事件,但需要額外的程式碼去實現。

效能

Canvas 和 SVG 效能的影響因素主要有兩個:繪製圖形的數量、繪製圖形的大小。
下圖是微軟 MSDN 上給的一個對比圖。

file

Canvas 的效能受畫布尺寸影響更大,而 SVG 的效能受圖形元素個數影響更大。網路上的對於效能及使用相關的建議是:如果繪製影像面積大或者繪製元素數量小時,建議使用SVG,如果繪製影像面積小或者繪製元素數量較大時,則建議使用 Canvas。

總結

本文介紹了 Canvas 和 SVG 的一些基本概念和使用方式,在我們日常開發中,有時會碰到需要繪製圖形的場景,對於 Canvas 和 SVG,分別有其適合的場景:

  • 需要繪製的影像簡單、互動性強或者是向量圖(例如圖示),建議使用 SVG。
  • 需要支援畫素級別的操作,或者複雜的動畫和互動(例如資料視覺化、互動式遊戲),建議使用Canvas。

大多數流程圖元件庫都是使用 Canvas 或 SVG 來繪製圖形,流程圖一般圖形簡單,節點數量不多,會有一些簡單的互動,因而大多數流程圖元件庫都使用 SVG 來進行渲染,例如 ReactFlow、draw.io、mxGraph、X6、XFlow 等,都是使用 svg 來進行渲染。

連結

https://developer.mozilla.org/zh-CN/docs/Web/API/Canvas_API
https://developer.mozilla.org/zh-CN/docs/Web/SVG

最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

  • 大資料分散式任務排程系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大資料領域的 SQL Parser 專案——dt-sql-parser
  • 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
  • 一個針對 antd 的元件測試工具庫——ant-design-testing

相關文章