简介
有向图
有向图是一个能表达很多场景的数据结构,想象一下你在城市里导航,或者你在考虑社交网络中人与人之间的关系。有向图是一种用来表示事物之间有方向的关系或连接的工具,我们可以将其视作一种简化的地图或者网络。 在这个简化的地图中,每个地点或者事物都可以被视作一个点,我们称之为"节点"。如果你可以从一个节点沿着一条特定的路径到达另一个节点,这之间的路径就被称为"边"。想象你正在开车从一个城市到达另一个城市,所行驶的道路就可以视为这样的一条边。 在有向图中,边是有方向的,就像一条单行道。这意味着你可以从节点A到节点B,但并不意味着你能从节点B回到节点A。在社交网络中的一个例子可能是:“Alice关注了Bob”,这是一条从Alice指向Bob的边,但 Bob 不一定关注 Alice。 使用有向图,我们可以表示网络中的各种关系,例如:
-
社交媒体上的人与人之间的关注关系。
-
互联网页面之间的链接(一个网页链接到另一个网页)。
-
任务安排中任务之间的先后顺序。
-
城市交通网络(哪些街道是单行,如何从一个地方到另一个地方)。 总而言之,有向图是表示方向性关系的直观工具,帮助我们理解和分析那些含有方向性的复杂网络和系统。 思考前端开发者的职责,不可或缺的一块就是将数据和信息可视化。如何更好的将有向图可视化,是一个值得分析的问题。
Reactful
Reactful 也许是本文新造的一个形容词,意图是指符合 React 框架思想和风格的编码开发方式。 对于 Reactful 本文总结为 4 点:
-
组件化:React 的核心思想是将用户界面拆分为多个可重用的组件。组件是独立的、有状态的实体,可以通过组件的组合和嵌套来构建复杂的界面。每个组件可以有自己的状态(state)和属性(props),并且可以根据这些状态和属性来渲染相应的界面。
-
虚拟 DOM:React 使用虚拟 DOM(Virtual DOM)进行高效的UI更新。虚拟 DOM 是 React 自己实现的一种轻量级的 DOM 表示形式,它类似于真实的DOM树,但是存在于内存中。当组件的状态发生变化时,React 会通过比较新旧虚拟 DOM 树的差异,然后只更新真实DOM中发生变化的部分,从而提高性能和渲染效率。
-
单向数据流:React 推崇使用单向数据流来管理组件间的数据流动。父组件通过 props 将数据传递给子组件,子组件通过回调函数将事件传递给父组件来修改数据。这种数据的单向流动使得应用的数据流更加可控,并且方便进行状态管理和调试。
-
JSX语法:React 使用 JSX(JavaScript XML)语法来描述用户界面的结构。JSX 允许在 JavaScript 代码中直接编写类似HTML的标记结构,使得界面的结构和交互逻辑更加直观和易于理解。通过 Babel、esbuild 等工具,JSX 会被转译成纯 JavaScript 代码。
SVG
SVG(Scalable Vector Graphics)是一种基于可扩展标记语言(XML)的矢量图像格式。
如果你是一名前端开发者,你大概已经知道 SVG 经常用于图标和图像的展示。SVG 使用可以使用数学公式来描述图形,能够做到图像无限缩放而不会失真。适用于需要在不同分辨率和屏幕尺寸下展示的图形,如图标、矢量艺术和数据可视化。
恰巧你还是一名 React 开发者的话,大概也已经接触过 import svg 文件,或者 svg icon 组件并通过 JSX 进行渲染。并且尝试 SVG 标签绑定 onclick 等事件处理函数。
这正是 SVG 和 和 HTML 良好的结合性的说明,在现代浏览器实现中,SVG 和 HTML 可以相互嵌套,在 HTML 中可以非常方便地使用 SVG。同时也意味着 SVG 和 React 有良好的结合性,我们可以像用 React 生成 HTML 标签一样生成 SVG 标签并处理点击、触摸、悬停等事件,来实现丰富的交互体验。
本文讨论的 SVG 特指 SVG 1.1,是当前现代浏览器主流支持版本。
SVG 1.0规范的起源可以追溯到1999年,当时由万维网联盟(W3C)发布。SVG 的创建旨在填补当时 Web 上缺少的一个关键部分—一种原生的、开放标准的矢量图形格式。
随后在 2003 发布 SVG 1.1,对原始 SVG 1.0 规范进行了细微的增强和更清晰的模块化。
同时在 2011年发布了 SVG 1.1 (Second Edition) ,对许多先前定义的特性进行了澄清和修正。
在 2011 之后,SVG 2 开始作为草案出现。SVG 2 旨在更新并扩展 SVG,引入新的特性,提高与 CSS 和 HTML 的集成度,并改进对图形和文本的处理。但 SVG2 至今(2024 年 1 月)仍未成为 W3C 的官方推荐标准。
为什么尝试 Reactful SVG
为什么 Reactful
选择 React 的原因,是 React 已经成为绝对主流的前端开发框架。既然想要实现有向图图的可视化,为何不在当前前端开发者最熟悉的技术领域实现?
当然 Vue、Svelte 也是很主流,笔者也欣赏的框架, 他们都能实现状态到 DOM 的映射, 组件的封装。读者只要发挥主观能动性,把下文教程的 React 简单替换成其他前端框架,本文大部分内容也依然成立。
为什么选择 SVG
-
HTML 不适合绘制边
- 作为前端开发者日夜接触的 HTML,在绘制有向图时,有一个较大的短板,就是 HTML 没有绘制“边”的简易标签。
- 在有向图中,节点和边是最基本的组成,这条构成边的看似简单的直线、折线以及曲线,在 HTML 中的绘制没有什么简易方式。而在 SVG 中,存在 line、polyline、path 等多种能够绘制线的方式。
- 事实就是 SVG 在绘制图形上面,比 HTML 强。
-
Canvas 和 React 结合性不好
- Canvas 也是当前浏览器内绘制图形的技术方案之一,而且相比 SVG 和 HTML 理论上能够实现对大规模图形渲染的更好的性能。但是 Canvas 是指令式的操作 API,如果要将其和 React 结合,则要依赖或者实现一个复杂的将状态映射为 React 标签,然后将 React 虚拟 DOM 在映射为 Canvas 绘图指令的方案。
为什么不使用已有可视化框架
-
主流可视化框架和 React 的结合
- 在做有向图绘制的研究时,已经调研过 AntV,D3 等可视化框架。它们对于前端可视化方面有着各自独特的思考和抽象。笔者在使用上述框架时框架时,也陆续发现了一些负面的点,比如 D3 内置 DOM 操作,和 React 在职责上有冲突,AntV x6 在使用 React 绘制自定义节点时,在更新时可能有性能问题。
-
祛魅
-
上述问题并非核心因素,甚至真实开发过程,如果和已有框架能力契合,也推荐利用框架快速完成业务。
-
更核心的因素在于笔者认为在研发中应该祛魅,抛除框架,我们回到更基础的视角去思考 Web 开发者如何绘制有向图。这样子也助于判断一个框架的优缺点。
-
SVG 101
既然需要 SVG 绘制有向图,那么完成 SVG 的入门引导也是本文的义务。
SVG 的功能强大,相关标签属性极多,本章节会介绍和有向图绘制强相关的内容,从而会选择性的忽略很多可能属于SVG 基础,但是不会应用到有向图绘制里的内容。
如果需要对 SVG 有全面的学习,笔者推荐 MDN 的 SVG 教程。
画布
在使用 SVG 绘图时,必须使用 svg
标签定义好画布。其余属于 SVG 命名空间的标签(如圆形 cicle、矩形 rect、路径 path 等)必须放入 svg
标签中。
SVG 图案要素
大小和形状
位置
在 SVG 中,几乎所有的元素都可以通过 x 和 y 属性来指定它们在画布上的位置。这些属性通常定义了元素的左上角位置。
如果你熟悉 HTML,熟悉 CSS 布局和定位,那么你可以直接将 SVG 的布局类比 HTML 中的绝对定位(position: absolute;
)。但是与绝对定位的差异是不是通过 left,right,top,bottom 来定义位置。而是通过在坐标轴中的位置 x,y 定义。
-
x,y 属性一般定义元素左上角的位置
-
cx,cy 一般定义为元素中心点的位置,其中的 c 可以理解为 center
-
x1, y1, x2, y2 一般在用户定义多个点时出行,比如直线 line
边
在SVG中使用path
元素可以创建复杂的图像形状,包括直线、折线以及带有曲线过渡的折线。
通过起点和终点画直线
直线是最简单的路径。你只需要定义path
的d
属性,并使用M
命令(moveto)来定义起点和L
命令(lineto)来定义终点。
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 在两点之间画直线:从(10,10)到(90,90) -->
<path d="M 10 10 L 90 90" stroke="black" fill="none"/>
</svg>
这段代码中的M 10 10
定义了线段的起点坐标,L 90 90
定义了线段的结束点坐标。
绘制折线
对于折线,您可以在d
属性中使用多个L
命令来定义每一个折点。
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 两点加中间点画折线:从(10,90)到(50,10)再到(90,90) -->
<path d="M 10 90 L 50 10 L 90 90" stroke="black" fill="none"/>
</svg>
为了让折线的拐点更加圆滑,您可以使用贝塞尔曲线。最简单的一种形式是二次贝塞尔曲线,使用Q
命令。
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 使用二次贝塞尔曲线画带圆滑拐点的折线 -->
<path d="M 10 90 Q 50 10 90 90" stroke="black" fill="none"/>
</svg>
在这段代码中,Q
命令后面跟着两组坐标。第一组是控制点(50,10)
,决定了曲线弯曲的程度和方向,第二组是终点(90,90)
。
如果你想要更加控制曲线的形状,可以使用立方贝塞尔曲线,使用C
命令。
<svg width="200" height="100" xmlns="http://www.w3.org/2000/svg">
<!-- 使用立方贝塞尔曲线画带圆滑拐点的折线 -->
<path d="M 10 90 C 50 10 150 10 190 90" stroke="black" fill="none"/>
</svg>
在这里,C
命令需要三组坐标,前两组为控制点(50,10)
和(150,10)
,最后一组为终点(190,90)
。
注意,在实际使用时,控制点的位置需要根据你希望创建的曲线形状进行调整。
为边增加箭头
defs
元素可以定义可重用的图形对象,<marker>
元素则允许定义一个图形标记,这个标记可以在 <path>
, <line>
, <polyline>
和 <polygon>
元素的起点、中点和终点自动渲染。
一个 <marker>
元素通常包含如下属性:
id
:一个唯一的标识符,用于在其他 SVG 元素中引用该 marker。markerWidth
和markerHeight
:定义 marker 的视图框(viewBox)大小。refX
和refY
:定义坐标系中 marker 的参考点(通常是 marker 要附着的确切点)。orient
:定义 marker 的方向。它可以是固定角度(如 "45deg"),或者 "auto" 表示自动调整以跟随路径方向。markerUnits
:用于设置如何计算 marker 大小,可以是 "strokeWidth"(按照线条的宽度来缩放 marker)或 "userSpaceOnUse" (按照 SVG 用户坐标系的单位来定义 marker 的大小)。
举个例子,创建一个简单的箭头 marker 并将其应用到一条直线上:
<svg width="240" height="40" xmlns="http://www.w3.org/2000/svg">
<!-- 定义 marker -->
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7"
refX="0" refY="3.5" orient="auto" markerUnits="strokeWidth">
<polygon points="0 0, 10 3.5, 0 7" />
</marker>
</defs>
<!-- 画一条线并使用刚刚定义的 marker -->
<line x1="10" y1="50" x2="190" y2="50" stroke="#000"
stroke-width="2" marker-end="url(#arrowhead)" />
</svg>
在这个例子中,<polygon>
元素定义了箭头的形状,并通过点 "0,0 10,3.5 0,7" 定义了一个简单的三角形箭头。
<line>
元素绘制了一条线,属性 marker-end
的值 "url(#arrowhead)" 表示该线的终点将附着我们定义的箭头 marker。如果你想在起点或中点添加 marker,可以分别使用 marker-start
和 marker-mid
属性。
基础样式
和 HTML 不同,想到 HTML 中画形状的轮廓,就是 border 属性,画标签的颜色,会用到 background。
在 SVG 中,使用 border 和 background 都不会生效,SVG 两个类似的概念——描边(stroke)和填充(fill),需要用这两个属性去完 成类似的能力。
描边
填充
基础变换
平移
- 属性变换:你可以在元素上直接使用
x
和y
属性来移动它,如果它们是可用的。如前所述,这些属性直接定义元素的位置。但这种方法适用范围有限,因为并非所有元素都有x
和y
属性。 transform
属性:更通用的移动方式是使用transform
属性中的translate()
函数。这个函数能够移动所有种类的SVG元素,包括那些没有x
和y
属性的元素。translate()
函数接收两个参数,首个参数是水平方向上的移动量(x轴),第二个参数是可选的垂直方向上的移动量(y轴)。
<rect
x="10"
y="10"
width="100"
height="100"
fill="red"
transform="translate(50,50)"
/>
- 上面的代码会把矩形沿x轴移动50单位,沿y轴移动50单位。
变换的原点默认情况下是SVG画布的原点(左上角)。但是可以通过transform-origin
属性指定变换的原点,对于旋转和缩放特别有用。
缩放时要注意,如果缩放因子小于1,则表示缩小;如果缩放因子大于1,则表示放大;如果缩放因子为负值,除了改变尺寸外,还会有镜像翻转的效果。
缩放
与平移类似,缩放也可通过transform
属性来实现,使用的是scale()
函数。这个函数可以接收一个或两个参数:一个参数时,它会同时沿x轴和y轴方向缩放;两个参数时,第一个参数是沿x轴的缩放因子,第二个参数是沿y轴的缩放因子。
<circle
cx="50"
cy="50"
r="40"
fill="blue"
transform="scale(1.5,2)"
/>
上面的代码会使圆形的x轴方向放大50%,y轴放大100%。
SVG 和 HTML 的结合性
由于SVG是XML的一种实现形式,它遵循XML的严格规范,因此,它与HTML5的结合非常良好。HTML5作为一个开放的标准,被设计为能够与多种格式(包括SVG以及其他图像及多媒体类型)一起工作。因此,在现代web开发中,SVG是一个非常有用的工具,并且与HTML有着良好的集成性。
HTML 中使用 SVG
HTML 中存在多种使用 SVG 的方式。
- 嵌入SVG到HTML
其中最直接的是直接将 SVG 嵌入到HTML文档中,成为DOM的一部分。这样SVG元素就可以通过CSS来样式化,也可以通过JavaScript进行交互和动态控制。
<!DOCTYPE html>
<html>
<head>
<title>SVG in HTML Example</title>
</head>
<body>
<svg width="100" height="100">
<circle cx="50" cy="50" r="40" stroke="green" stroke-width="4" fill="yellow" />
</svg>
</body>
</html>
2. 使用<img>
元素嵌入:SVG可以作为一个图像文件链接到HTML页面中,通过<img>
标签呈现。
<img src="image.svg" alt="SVG Image" />
3. 作为CSS背景:SVG也可以通过CSS作为背景图片被使用,既可以是外链的.svg文件,也可以是直接编码在CSS文件或style标签里的SVG。
#example {
background-image: url('image.svg');
}
4. 使用<object>
, <embed>
, 或者 <iframe>
标签:这些标签允许你将外部的SVG文件嵌入到HTML文档中,同时仍然保留对SVG文档的脚本和样式的访问。
<object data="image.svg" type="image/svg+xml"></object>
SVG 中使用 HTML
SVG 的 foreignObject
标签允许在 SVG 图像中嵌入外部的 XML 命名空间元素,例如 HTML 和 XHTML 内容。这使得我们可以在 SVG 图内包含富文本内容,表单元素,或者甚至是视频。
<svg width="300px" height="200px" xmlns="http://www.w3.org/2000/svg" version="1.1">
<foreignObject width="200px" height="200px">
<body xmlns="http://www.w3.org/1999/xhtml">
<p>
这是嵌入的HTML文本。
</p>
</body>
</foreignObject>
</svg>
在这个例子中,foreignObject
元素创建了一个SVG内的容器,我们设定了它的宽度和高度。在 <foreignObject>
内部,我们定义了 XHTML 命名空间的 <body>
元素,里面包含了一个普通的 HTML 段落 (<p>
)。
foreignObject
元素下面不强制要求一定要用 body
元素,但是如果你想在其中嵌入 HTML,通常会包含 XHTML 命名空间,并且为了确保正确的渲染,很多示例和实际使用情况都会包括一个 body
标签。
XHTML(可扩展超文本标记语言)元素需要与一个命名空间相关联,通常是 "www.w3.org/1999/xhtml"… <foreignObject>
中包含 XHTML 内容时,最好显式声明这个 XHTML 特定的命名空间。
SVG CSS 样式化
既然SVG可以作为HTML页面DOM的一部分,它的元素可以用CSS来进行样式设置。你可以给SVG元素添加类或ID,然后像样式化HTML元素一样来样式化它们。
svg circle {
fill: blue; /* 设置内部填充色 */
stroke: black; /* 设置边框颜色 */
stroke-width: 2px; /* 设置边框宽度 */
}
JavaScript 交互
SVG 内能实现 HTML 常见的交互,在现代浏览器的实现中,SVG 拥有和 HTML 标签一致的事件机制。依赖 MouseEvent,可以实现 HTML 内能实现的一切交互。
const MyComponent = () => {
const handleClick = (event) => {
// 处理点击事件的逻辑
console.log(event.target); // 可以通过event获取触发事件的具体元素
};
return (
<svg>
<circle cx="50" cy="50" r="30" onClick={handleClick} />
</svg>
);
};
同理,我们也可以简单的套用已有的交互库,比如使用 react-dnd 实现拖拽交互,依赖 react-selectable-box 实现框选。
SVG 和 React 的结合
正如上文中反复强调的一样,你可以直接用 React 书写 SVG 标签。
笔者已经预设大家是具备 React 开发能力,能够将自己日常需要实现的内容拆分为一个个 React 组件。
正如普通的 React HTML 开发,有阿里出品 AntD 和字节架构前端开源 ArcoDesign,在 React SVG 开发中,这类资源更加稀缺,当前值得推荐的库有 Airbnb 前端出品的 visx 。
visx 提供了大量和 SVG 绘制的包,单考虑有向图场景出发,visx 的不少包可能能够降低你绘制 SVG 的门槛。比如:
这里引用一个 visx 官方的 demo,演示 Line、curve 、和 marker 等功能。其中 curve 基于 D3 的 curve 实现,做了易用性上的封装。在给定边需要经过的点后,可以基于不同的算法,构建各种风格的曲线。
同时 visx 的源码也是相当的整洁和易读, 当使用 visx 时,去直接进入源码,也是学习 SVG 的极好的方式。
visx 更多的能力就期待读者的自行挖掘了。
有向图基础绘制
在已经具备 SVG 入门基础的情况下,绘制有向图不仅可行,而且还有点简单。
在已经得知节点的大小位置,和边的路径信息的情况下。只需要寥寥数行代码就可以绘制一个简单的有向图。
const Node = styled.div`
width: 100%;
height: 100%;
border-radius: 4px;
border: 1px solid #ddd;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
`;
function DAG() {
const { graph, nodes, edges } = useDag();
return (
<svg height={graph.height} width={graph.width}>
{nodes.map((node) => (
<foreignObject
key={node.key}
width={node.width}
height={node.height}
x={node.x}
y={node.y}
>
<Node>{node.label}</Node>
</foreignObject>
))}
{edges.map((edge, index) => {
// 将边经过的点集转化为 points 属性所需要的字符串描述
const pointsString = edge.points.map((item) => `${item.x},${item.y}`).join(' ');
return (
<polyline
key={index}
stroke="black"
fill="none"
points={pointsString}
/>
);
})}
</svg>
);
}
有向图的布局
有向图本质上是描述的节点直接的关系。在有向图的可视化中,真正需要消费的不是“关系”,而是节点的位置,以及边的路径。
当我们使用纸和笔手绘有向图时,是由人去决定每一个节点的位置,每一条边的走向。在计算机上,自然需要摒弃人的参与,使用程序自动生成。
本小节内容总结来自理论图形库 Cytoscape.js 关于布局的文章 《Using layouts》,对布局算法进行了简单梳理和汉化。
几何布局
将图形组织成常见的几何形状
层次布局
将图形划分为多个层级,每个层级表示图中的一个逻辑层次。在有向图中,通常根据节点的依赖关系将节点放置在不同的层级上。边一般从一个层级指向下一个层级,从而展示节点之间的逻辑顺序。
力导向布局
基于物理模拟原理,模拟节点之间的斥力和吸引力,以达到最佳的平衡状态。节点之间的边会受到拉力,将节点彼此拉近,而节点之间的斥力会阻止节点之间的重叠。
性能优化
假设你在使用 SVG 绘制有向图中遇到了性能问题。那么可以为这些性能问题做一些预判。
-
不合理的 JS 计算阻塞了主线程。
-
过量的 DOM 节点、过于复杂的 SVG 渲染导致了浏览器绘制时性能低下
JS 性能出现问题,可能是写了过于复杂的同步函数,也有可能是忘了正常退出循环,这是故障基本的问题。在合理的范围内讨论 SVG 性能问题的话,需要讨论 SVG 本身的渲染优化。
SVG 本身性能优化
-
避免过度细节:
-
- SVG图形如果过于复杂,会导致大量的DOM元素,这可能影响性能。在上述方案里,我们对 SVG 元素使用得并不复杂,即使是 path 中,也只有寥寥数点。一般不需要关注此问题。
-
-
使用
<symbol>
与<use>
结构渲染相同形状-
- 如果需要在页面上多次使用相同的SVG图形,避免通过 JavaScript 大量克隆内联 SVG。这样会导致大量的 DOM 元素被创建,损害性能。相反,可以使用
use
元素来重复使用 SVG。
- 如果需要在页面上多次使用相同的SVG图形,避免通过 JavaScript 大量克隆内联 SVG。这样会导致大量的 DOM 元素被创建,损害性能。相反,可以使用
-
-
动画性能优化
-
被动画化的 SVG 元素如果过于复杂(例如,有大量的点或复杂的路径),可能会导致性能问题。在使用动画路径时应该尝试简化动画路径,或者使用更加高效的动画属性(如变换)。
-
如果使用 js 修改属性实现动画,或者 React 修改状态然后状态映射到标签属性上时,注意这些操作的时间间隔。一般来说可以使用 requestAnimationFrame 来限制频率,从而减少不必要的 js 计算。
-
基于 React 的性能优化
最常触发的性能问题,是在渲染较大数据量的有向图时,SVG 内部的节点过多而引发性能问题。
因此可以针对这个主要问题进行优化。
- 在使用 minimap 这类交互时,可以只 viewBox 内的元素,而不渲染 viewBox 外的标签。这里需要实现元素和视口是否有交叉的检测算法。
- 合理的使用 React 标签中 key 属性,重复利用 React 虚拟 DOM 的 Diff 变更特性。当节点或边变化后,React 的 rerender 时,React 能够正确识别变化的 DOM 节点,实现最小的 DOM 操作。