流程图渲染方式:Canvas vs SVG

canvas,vs,svg · 浏览次数 : 14

小编点评

本文主要介绍了 Canvas 和 SVG 两种图形绘制技术在数据可视化中的应用及其特点。首先,文章通过一个审批流程图的例子,展示了如何使用 Canvas 和 SVG 分别实现流程图的绘制。接着,文章详细介绍了 Canvas 和 SVG 的基本概念、使用方式、动画实现以及它们之间的区别。 1. **Canvas 简介**:Canvas 是一种基于 JavaScript 和 HTML5 Canvas 元素的图形绘制 API,可用于动画、游戏画面、数据可视化等方面。文章通过一个简单的流程图示例,展示了 Canvas 的基本用法和样式设置。 2. **SVG 简介**:SVG 是一种基于 XML 的矢量图形描述格式,与 Canvas 相比,SVG 提供了更高的交互性和可扩展性。文章通过一个流程图示例,展示了 SVG 的基本用法和动画实现。 3. **Canvas 和 SVG 的选择**:文章根据实际需求,对比了 Canvas 和 SVG 在图形绘制方面的优劣,并给出了在不同场景下的使用建议。同时,文章还提到了袋鼠云数栈 UED 团队的一些开源项目,如 Taier、Molecule、dt-sql-parser、code-review-practices 和 ko 等,这些项目都使用了 Canvas 或 SVG 进行图形绘制。 总结:本文通过一个实际的审批流程图案例,详细介绍了 Canvas 和 SVG 的使用方法和特点,帮助开发者根据自己的需求选择合适的图形绘制技术。

正文

我们是袋鼠云数栈 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

与流程图渲染方式:Canvas vs SVG相似的内容:

流程图渲染方式:Canvas vs SVG

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。 本文作者:霁明 背景 我们产品中会有一些流程图应用,例如审批中心的审批流程图: 我们数栈产品内的流程图,基本都是使用的 mxGraph 实现的,mxGraph 使用了S

Flutter框架渲染流程与使用

Flutter简述 Flutter是一个UI框架, 可以进行移动端(iOS, Android),Web端, 桌面端开发,它是一个跨平台解决方案。 Flutter的特点:美观,快速,高效,开放。 美观:Flutter内置了美丽的Material Design和 Cupertino widget, 方便

APP中RN页面渲染流程-ReactNative源码分析

在APP启动后,RN框架开始启动。等RN框架启动后,就开始进行RN页面渲染了。 RN页面原生侧页面渲染的主要逻辑实现是在RCTUIManager和RCTShadowView完成的。 通过看UIMananger的源码可以看到,UIMananger导出给JS端的API接口在对UI的操作上,基本都会同时对

iOS视图控件的内容显示和离屏渲染流程

iOS中UI控件内容显示流程 UIKit界面组成 iOS中组成页面的各个元素基本来自UIKit,我们可以修改布局或自定义绘制来修改UIKit元素的默认展示。 UIView的页面显示内容有CALayer负责,事件的接收与响应由UIView自己负责。 为什么需要有这样的分工呢,原因是因为Mac上和iPh

Nuxt3页面开发实战探索

摘要:这篇文章是关于Nuxt3页面开发实战探索的。它介绍了Nuxt3的基础入门,安装与配置,项目结构,内置组件与功能,以及页面与路由的相关内容。Nuxt3是基于Vue 3的服务器端渲染框架,旨在简化Vue应用程序的开发流程,提供最佳的性能和开发

低功耗引擎 Cliptrix 有什么价值

在一些硬件配置较低的设备(如 POS 机,穿戴设备等)中运行使用小程序时可能会出现无法运行,运行后卡顿的问题。 Cliptrix 的开发目标则是作为完全独立的小程序渲染引擎,与当前小程序逻辑分开,最终完全替换现有的 WebView 引用,保证即使在硬件配置较低的设备中也可以提供流畅的使用与运行体验。

如何在.NET电子表格应用程序中创建流程图

前言 流程图是一种常用的图形化工具,用于展示过程中事件、决策和操作的顺序和关系。它通过使用不同形状的图标和箭头线条,将任务和步骤按照特定的顺序连接起来,以便清晰地表示一个过程的执行流程。 在企业环境中,高管和经理利用流程图来规划业务流程,使他们能够识别瓶颈、优化生产力并增强决策能力……用例列表不胜枚

快速绘制流程图「GitHub 热点速览 v.22.47」

画流程图一直是研发的一个难题,如何画得通俗易懂已经够让人头疼了,还要美观大方。用 d2 的语法描述下流程,d2 会自动帮你生成一张配色极佳的流程图。说到研发的选择,本周特推的 choiceof.dev 罗列了众多开发过程中会遇到的选项,你可以自测下你同主流研发的契合度。 本周周榜呢,有监控网络流量的

基于drawio构建流程图编辑器

# 基于drawio构建流程图编辑器 `drawio`是一款非常强大的开源在线的流程图编辑器,支持绘制各种形式的图表,提供了`Web`端与客户端支持,同时也支持多种资源类型的导出。 ## 描述 在我们平时写论文、文档时,为了更好地阐述具体的步骤和流程,我们经常会有绘制流程图的需求,这时我们可能会想到

论文的技术路线流程图如何绘制?

本文介绍基于Visio软件绘制技术路线图、流程图、工作步骤图等的方法~