我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。 本文作者:霽明 背景 我們產品中會有一些流程圖應用,例如審批中心的審批流程圖: 我們數棧產品內的流程圖,基本都是使用的 mxGraph 實現的,mxGraph 使用了S ...
我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式數據中台產品。我們始終保持工匠精神,探索前端道路,為社區積累並傳播經驗價值。
本文作者:霽明
背景
我們產品中會有一些流程圖應用,例如審批中心的審批流程圖:
我們數棧產品內的流程圖,基本都是使用的 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;
效果如下圖:
繪製流程圖
繪製一個開始節點、一個中間節點和一個結束節點,節點之間用有向線條進行連接,如下圖:
前置知識:
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。
樣式和顏色
給節點加上樣式,效果如下:
對比上一步,可以發現給節點內容和邊框填充了顏色,以開始節點為例:
...
// 開始節點
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
動畫實現
實現線條流動動畫,實現效果如下圖所示:
實現原理:將線條設置為虛線,然後設置偏移量,每間隔一定時間渲染一次,每次的偏移量都遞增,便實現了線條流動的動畫效果。
原理瞭解了,但在開發之前有兩個點要考慮一下:
- 動畫是有執行頻率的,要控制的話用哪種方式好一點?
- 每次動畫執行時,一般是整個畫布都刷新,考慮到性能問題,是否可以局部刷新?
帶著這兩個問題,我們看下代碼實現:
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;
上面代碼渲染效果如下圖:
繪製流程圖
使用svg繪製流程圖:
代碼實現如下:
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 則分別指定了邊框顏色、寬度,以及填充顏色。
動畫實現
實現線條流動動畫,實現效果如下圖:
實現原理:先將線條設置為虛線,然後通過 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 上給的一個對比圖。
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