You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

03 | 声明式图形系统如何用SVG图形元素绘制可视化图表

你好我是月影。今天我们来讲SVG。

SVG的全称是Scalable Vector Graphics可缩放矢量图它是浏览器支持的一种基于XML语法的图像格式。

对于前端工程师来说使用SVG的门槛很低。因为描述SVG的XML语言本身和HTML非常接近都是由标签+属性构成的而且浏览器的CSS、JavaScript都能够正常作用于SVG元素。这让我们在操作SVG时没什么特别大的难度。甚至我们可以认为SVG就是HTML的增强版

对于可视化来说SVG是非常重要的图形系统。它既可以用JavaScript操作绘制各种几何图形还可以作为浏览器支持的一种图片格式来 独立使用img标签加载或者通过Canvas绘制。即使我们选择使用HTML和CSS、Canvas2D或者WebGL的方式来实现可视化但我们依然可以且很有可能会使用到SVG图像。所以关于SVG我们得好好学。

那这一节课我们就来聊聊SVG是怎么绘制可视化图表的以及它的局限性是什么。希望通过今天的讲解你能掌握SVG的基本用法和使用场景。

利用SVG绘制几何图形

在第1节课我们讲过SVG属于声明式绘图系统它的绘制方式和Canvas不同它不需要用JavaScript操作绘图指令只需要和HTML一样声明一些标签就可以实现绘图了。

那SVG究竟是如何绘图的呢我们先来看一个SVG声明的例子。

<svg xmlns="http://www.w3.org/2000/svg" version="1.1">
  <circle cx="100" cy="50" r="40" stroke="black"
  stroke-width="2" fill="orange" />
</svg>

在上面的代码中svg元素是SVG的根元素属性xmlns是xml的名字空间。那第一行代码就表示svg元素的xmlns属性值是"http://www.w3.org/2000/svg"浏览器根据这个属性值就能够识别出这是一段SVG的内容了。

svg元素下的circle元素表示这是一个绘制在SVG图像中的圆形属性cx和cy是坐标表示圆心的位置在图像的x=100、y=50处。属性r表示半径r=40表示圆的半径为40。

以上就是这段代码中的主要属性。如果仔细观察你会发现我们并没有给100、50、40指定单位。这是为什么呢

因为SVG坐标系和Canvas坐标系完全一样都是以图像左上角为原点x轴向右y轴向下的左手坐标系。而且在默认情况下SVG坐标与浏览器像素对应所以100、50、40的单位就是px也就是像素不需要特别设置。

说到这你还记得吗在Canvas中为了让绘制出来的图形适配不同的显示设备我们要设置Canvas画布坐标。同理我们也可以通过给svg元素设置viewBox属性来改变SVG的坐标系。如果设置了viewBox属性那SVG内部的绘制就都是相对于SVG坐标系的了。

现在我们已经知道上面这段代码的含义了。那接下来我们把它写入HTML文档中就可以在浏览器中绘制出一个带黑框的橙色圆形了。

现在我们已经知道了SVG的基本用法了。总的来说它和HTML的用法基本一样你可以参考HTML的用法。那接下来我还是以上一节课实现的层次关系图为例来看看使用SVG该怎么实现。

利用SVG绘制层次关系图

我们先来回忆一下,上一节课我们要实现的层次关系图,是在一组给出的层次结构数据中,体现出同属于一个省的城市。数据源和前一节课相同,所以数据的获取部分并没有什么差别。这里我就不列出来了,我们直接来讲绘制的过程。

首先我们要将获取Canvas对象改成获取SVG对象方法是一样的还是通过选择器来实现。

const svgroot = document.querySelector('svg');

然后我们同样实现draw方法从root开始遍历数据对象。 不过在draw方法里我们不是像上一讲那样通过Canvas的2D上下文调用绘图指令来绘图而是通过创建SVG元素将元素添加到DOM文档里让图形显示出来。具体代码如下

function draw(parent, node, {fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white'} = {}) {
    const {x, y, r} = node;
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    circle.setAttribute('cx', x);
    circle.setAttribute('cy', y);
    circle.setAttribute('r', r);
    circle.setAttribute('fill', fillStyle);
    parent.appendChild(circle);
    ...
}

draw(svgroot, root);



从上面的代码中你可以看到,我们是使用document.createElementNS方法来创建SVG元素的。这里你要注意与使用document.createElement方法创建普通的HTML元素不同SVG元素要使用document.createElementNS方法来创建。

其中第一个参数是名字空间对应SVG名字空间http://www.w3.org/2000/svg。第二个参数是要创建的元素标签名因为要绘制圆型所以我们还是创建circle元素。然后我们将x、y、r分别赋给circle元素的cx、cy、r属性将fillStyle赋给circle元素的fill属性。最后我们将circle元素添加到它的parent元素上去

接着我们遍历下一级数据。这次我们创建一个SVG的g元素递归地调用draw方法。具体代码如下

if(children) {
    const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
    for(let i = 0; i < children.length; i++) {
      draw(group, children[i], {fillStyle, textColor});
    }
    parent.appendChild(group);
  }

SVG的g元素表示一个分组我们可以用它来对SVG元素建立起层级结构。而且如果我们给g元素设置属性那么它的子元素会继承这些属性。

最后如果下一级没有数据了那我们还是需要给它添加文字。在SVG中添加文字只需要创建text元素然后给这个元素设置属性就可以了。操作非常简单你看我给出的代码就可以理解了。

else {
    const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
    text.setAttribute('fill', textColor);
    text.setAttribute('font-family', 'Arial');
    text.setAttribute('font-size', '1.5rem');
    text.setAttribute('text-anchor', 'middle');
    text.setAttribute('x', x);
    text.setAttribute('y', y);
    const name = node.data.name;
    text.textContent = name;
    parent.appendChild(text);
  }

这样我们就实现了SVG版的层次关系图。你看它是不是看起来和前一节利用Canvas绘制的层次关系图没什么差别纸上得来终觉浅你可以自己动手实现一下这样理解得会更深刻。

SVG和Canvas的不同点

那么问题就来了既然SVG和Canvas最终的实现效果没什么差别那在实际使用的时候我们该如何选择呢这就需要我们了解SVG和Canvas在使用上的不同点。知道了这些不同点我们就能在合适的场景下选择合适的图形系统了。

SVG和Canvas在使用上的不同主要可以分为两点分别是写法上的不同用户交互实现上的不同。下面,我们一一来看。

1. 写法上的不同

第1讲我们说过SVG是以创建图形元素绘图的“声明式”绘图系统Canvas是执行绘图指令绘图的“指令式”绘图系统。那它们在写法上具体有哪些不同呢我们以层次关系图的绘制过程为例来对比一下。

在绘制层次关系图的过程中SVG首先通过创建标签来表示图形元素circle表示圆g表示分组text表示文字。接着SVG通过元素的setAttribute给图形元素赋属性值这个和操作HTML元素是一样的。

而Canvas先是通过上下文执行绘图指令来绘制图形画圆是调用context.arc指令然后再调用context.fill绘制画文字是调用context.fillText指令。另外Canvas还通过上下文设置状态属性context.fillStyle设置填充颜色conext.font设置元素的字体。我们设置的这些状态在绘图指令执行时才会生效。

从写法上来看因为SVG的声明式类似于HTML书写方式本身对前端工程师会更加友好。但是SVG图形需要由浏览器负责渲染和管理将元素节点维护在DOM树中。这样做的缺点是在一些动态的场景中也就是需要频繁地增加、删除图形元素的场景中SVG与一般的HTML元素一样会带来DOM操作的开销所以SVG的渲染性能相对比较低。

那除了写法不同以外SVG和Canvas还有其他区别吗当然是有的不过我要先卖一个关子我们讲完一个例子再来说。

2. 用户交互实现上的不同

我们尝试给这个SVG版本的层次关系图添加一个功能也就是当鼠标移动到某个区域时这个区域会高亮并且显示出对应的省-市信息。

因为SVG的一个图形对应一个元素所以我们可以像处理DOM元素一样很容易地给SVG图形元素添加对应的鼠标事件。具体怎么做呢我们一起来看。

首先我们要给SVG的根元素添加mousemove事件添加代码的操作很简单你可以直接看代码。

 let activeTarget = null;
  svgroot.addEventListener('mousemove', (evt) => {
    let target = evt.target;
    if(target.nodeName === 'text') target = target.parentNode;
    if(activeTarget !== target) {
      if(activeTarget) activeTarget.setAttribute('fill', 'rgba(0, 0, 0, 0.2)');
    }
    target.setAttribute('fill', 'rgba(0, 128, 0, 0.1)');
    activeTarget = target;
  });


就像是我们熟悉的HTML用法一样我们通过事件冒泡可以处理每个圆上的鼠标事件。然后我们把当前鼠标所在的圆的颜色填充成rgba(0, 128, 0, 0.1)’,这个颜色是带透明的浅绿色。最终的效果就是当我们的鼠标移动到圆圈范围内的时候,当前鼠标所在的圆圈变为浅绿色。你也可以尝试设置其他的值,看看不同的实现效果。

接着,我们要实现显示对应的省-市信息。在这里我们需要修改一下draw方法。具体的修改过程可以分为两步。

第一步是把省、市信息通过扩展属性data-name设置到svg的circle元素上这样我们就可以在移动鼠标的时候通过读取鼠标所在元素的属性拿到我们想要展示的省、市信息了。具体代码如下

  function draw(parent, node, {fillStyle = 'rgba(0, 0, 0, 0.2)', textColor = 'white'} = {}) {
    ...
    const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
    ...
    circle.setAttribute('data-name', node.data.name);
    ...
    
    if(children) {
     const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
      ...
      group.setAttribute('data-name', node.data.name);
      ...
    } else {
      ...
    }
  }


第二步我们要实现一个getTitle方法从当前鼠标事件的target往上找parent元素拿到“省-市”信息把它赋给titleEl元素。这个titleEl元素是我们添加到网页上的一个h1元素用来显示省、市信息。

 const titleEl = document.getElementById('title');

  function getTitle(target) {
    const name = target.getAttribute('data-name');
    if(target.parentNode && target.parentNode.nodeName === 'g') {
      const parentName = target.parentNode.getAttribute('data-name');
      return `${parentName}-${name}`;
    }
    return name;
  }


最后我们就可以在mousemove事件中更新titleEl的文本内容了。


  svgroot.addEventListener('mousemove', (evt) => {
    ...
    titleEl.textContent = getTitle(target);
    ...
  });

这样,我们就实现了给层次关系图增加鼠标控制的功能,最终的效果如下图所示,完整的代码我放在GitHub仓库了,你可以自己去查看。

其实我们上面讲的鼠标控制功能就是一个简单的用户交互功能。总结来说利用SVG的一个图形对应一个svg元素的机制我们就可以像操作普通的HTML元素那样给svg元素添加事件实现用户交互了。所以SVG有一个非常大的优点那就是可以让图形的用户交互非常简单

和SVG相比利用Canvas对图形元素进行用户交互就没有那么容易了。不过对于圆形的层次关系图来说在Canvas图形上定位鼠标处于哪个圆中并不难我们只需要计算一下鼠标到每个圆的圆心距离如果这个距离小于圆的半径我们就可以确定鼠标在某个圆内部了。这实际上就是上一节课我们留下的思考题相信现在你应该可以做出来了。

但是试想一下如果我们要绘制的图形不是圆、矩形这样的规则图形而是一个复杂得多的多边形我们又该怎样确定鼠标在哪个图形元素的内部呢这对于Canvas来说就是一个比较复杂的问题了。不过这也不是不能解决的在后续的课程中我们就会讨论如何用数学计算的办法来解决这个问题。

绘制大量几何图形时SVG的性能问题

虽然使用SVG绘图能够很方便地实现用户交互但是有得必有失SVG这个设计给用户交互带来便利性的同时也带来了局限性。为什么这么说呢因为它和DOM元素一样以节点的形式呈现在HTML文本内容中依靠浏览器的DOM树渲染。如果我们要绘制的图形非常复杂这些元素节点的数量就会非常多。而节点数量多就会大大增加DOM树渲染和重绘所需要的时间。

就比如说在绘制如上的层次关系图时我们只需要绘制数十个节点。但是如果是更复杂的应用比如我们要绘制数百上千甚至上万个节点这个时候DOM树渲染就会成为性能瓶颈。事实上在一般情况下当SVG节点超过一千个的时候你就能很明显感觉到性能问题了。

幸运的是对于SVG的性能问题我们也是有解决方案的。比如说我们可以使用虚拟DOM方案来尽可能地减少重绘这样就可以优化SVG的渲染。但是这些方案只能解决一部分问题当节点数太多时这些方案也无能为力。这个时候我们还是得依靠Canvas和WebGL来绘图才能彻底解决问题。

那在上万个节点的可视化应用场景中SVG就真的一无是处了吗当然不是。SVG除了嵌入HTML文档的用法还可以直接作为一种图像格式使用。所以即使是在用Canvas和WebGL渲染的应用场景中我们也依然可能会用到SVG将它作为一些局部的图形使用这也会给我们的应用实现带来方便。在后续的课程中我们会遇到这样的案例。

要点总结

这一节课我们学习了SVG的基本用法、优点和局限性。

我们知道SVG作为一种浏览器支持的图像格式既可以作为HTML内嵌元素使用也可以作为图像通过img元素加载或者绘制到Canvas内。

而用SVG绘制可视化图形与用Canvas绘制有明显区别SVG通过创建标签来表示图形元素然后将图形元素添加到DOM树中交给DOM完成渲染。

使用DOM树渲染可以让图形元素的用户交互实现起来非常简单因为我们可以直接对图形元素注册事件。但是这也带来问题如果图形复杂那么SVG的图形元素会非常多这会导致DOM树渲染成为性能瓶颈。

小试牛刀

  1. DOM操作SVG元素和操作普通的HTML元素几乎没有差别所以CSS也同样可以作用于SVG元素。那你可以尝试使用CSS来设置这节课我们实现的层级关系图里circle的背景色和文字属性。接着你也可以进一步想一想这样做有什么好处

  2. 因为SVG可以作为一种图像格式使用所以我们可以将生成的SVG作为图像然后绘制到Canvas上。那如果我们先用SVG生成层级关系图再用Canvas来完成绘制的话和我们单独使用它们来绘图有什么不同为什么

欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!


源码

用SVG绘制层次关系图和给层次关系图增加鼠标控制的完整代码.