最近在做一个服务稳定性大盘,想把服务调用关系画成一张有向图,节点按 QPS 染色,调用线按流量染色。找了一圈现成的图表方案,条形图、饼图都有现成的,这种带方向的动态拓扑图基本没有能直接拿来用的,打算用 D3.js 自己画。之前没系统用过 D3,边看文档边记笔记,方便后面直接查。这是第一篇,先搞清楚最基础的部分:选择集(selection)和数据绑定(data join)。
用的是 D3 v3(页面上直接 <script src="d3.min.js"></script> 引入的那种),下面代码都是按 v3 的 API 写的,命名空间比如 d3.svg.line()、d3.scale.linear() 这种写法要认准版本,不然网上查到的例子会对不上。
和 jQuery 的思路差异
一开始看 D3 的例子,感觉和 jQuery 差不多,都是 d3.select('#chart') 这种链式调用。写了几个例子之后发现思路不一样。
jQuery 的操作方式是先选中已经存在的 DOM 元素,再改它的属性。比如页面上已经有 5 个 .dot,用 $('.dot').css('fill', 'red') 把它们都改成红色。如果数据变成了 8 条,该新增几个节点、该删除几个节点,这部分逻辑得自己写判断。
D3 的思路是把一个数组和一组 DOM 节点绑定在一起,绑定之后如果数据条数和已有节点数不一致,会分成三种情况处理:
- 数据比节点多,多出来的数据没有对应节点,这部分叫 enter(进入)
- 数据和节点一一对应,这部分叫 update(更新),也就是普通的选择集
- 节点比数据多,多出来的节点没有数据对应,这部分叫 exit(退出)
官方管这套东西叫 Data-Driven Documents,数据驱动文档,D3 这个名字也是这么来的。是不是真的”驱动”了文档不重要,重要的是它把”数据条数变化时该怎么处理 DOM”这件事变成了一套固定模式,不用每次自己写增删判断。
一个具体的例子:画一组圆点
先看最简单的场景,不考虑增删,只是把一个数组画成一组圆点,圆点半径按数值大小变化。
var dataset = [5, 10, 15, 20, 25];
var svg = d3.select("#chart")
.append("svg")
.attr("width", 300)
.attr("height", 60);
svg.selectAll("circle")
.data(dataset)
.enter()
.append("circle")
.attr("cx", function(d, i) {
return i * 60 + 30;
})
.attr("cy", 30)
.attr("r", function(d) {
return d;
})
.style("fill", "steelblue");从上到下看:d3.select("#chart") 选中 id 为 chart 的容器,.append("svg") 往里面加一个 svg 标签,返回的是这个新加元素的选择集,后面的 .attr() 都是在设置这个 svg 的宽高。
svg.selectAll("circle") 这一句最容易看不懂。这时候页面上根本没有 circle 元素,选出来的是一个空的选择集。第一次看到这行代码以为写错了,应该是先 append 再 selectAll,后来查文档才明白,这里的 selectAll 只是告诉 D3 接下来 .data() 要绑定的对象类型是 circle,为后面的 enter 操作做准备,它本身不查找已经存在的元素。
.data(dataset) 把数组 dataset 和刚才选出来的空选择集绑定。绑定之后 D3 会对比数据条数(5 条)和节点数(0 个),数据比节点多 5 个,这 5 条数据全部进入 enter 选择集。
.enter() 拿到 enter 选择集,也就是那 5 条还没有对应节点的数据。
.append("circle") 对 enter 选择集里的每一条数据新建一个 circle 元素并插入 DOM,返回的选择集是这 5 个新建的 circle。
后面 .attr("cx", ...)、.attr("r", ...)、.style("fill", ...) 都是在这 5 个新建元素上设置属性。.attr() 和 .style() 的第二个参数可以是一个函数,D3 会对选择集里的每个元素分别调用这个函数,参数 d 是绑定的那条数据,i 是这条数据在数组里的下标(从 0 开始)。cx 用下标算横坐标间距,r 直接用数值当半径。
enter / update / exit 到底是什么
上面的例子只处理了 enter,因为一开始页面是空的。实际场景里数据会变,比如大盘里的服务节点会增加或者下线,圆点个数要跟着变,这时候三种选择集都要处理。
把画圆点的逻辑拆成一个函数,调用两次,模拟数据变化:
function render(dataset) {
var circles = svg.selectAll("circle")
.data(dataset, function(d) {
return d.id;
});
// enter:新增的数据,创建新节点
circles.enter()
.append("circle")
.attr("cy", 30)
.attr("r", 0)
.style("fill", "steelblue");
// update:已有数据,更新已有节点
// enter 部分也会走一遍这里,因为 enter().append() 返回的元素会合并回原选择集
circles
.attr("cx", function(d, i) {
return i * 60 + 30;
})
.attr("r", function(d) {
return d.value;
});
// exit:多余的节点,直接移除
circles.exit().remove();
}
render([
{ id: "a", value: 10 },
{ id: "b", value: 20 },
{ id: "c", value: 15 }
]);
// 过一段时间数据变化,b 下线了,新增了 d,c 的值变了
render([
{ id: "a", value: 10 },
{ id: "c", value: 25 },
{ id: "d", value: 8 }
]);这里有个地方之前理解错了,走了点弯路。最开始直接用数组下标当 key(不传 .data() 的第二个参数),结果发现服务下线之后,圆点没有按 id 消失,而是后面的服务顶替了前面的位置,颜色和大小全乱了。查文档才知道,.data() 默认按数组下标匹配,第一次数组第 0 项对应第一个节点,第二次数组第 0 项还是对应第一个节点,不管这两次第 0 项是不是同一个东西。数据变化之后,节点和数据的对应关系就对不上了。
解决办法是给 .data() 传第二个参数,一个返回 key 的函数(上面代码里的 function(d) { return d.id; }),D3 按这个 key 匹配新旧数据,而不是按下标匹配。服务节点用固定的 id(服务名)做 key,不管数组顺序怎么变、中间插入删除了几个,同一个服务始终对应同一个 DOM 节点,不会串位置。做实时更新的图表时这一点是必须处理的,不然频繁的数据刷新会让节点看起来在乱跳。
三个选择集的含义:
circles.enter():.data()之后,数据里有、但还没有对应 DOM 节点的部分。第一次渲染如果页面是空的,等于全部数据;后续渲染只是新增的那部分(按 key 匹配不到已有节点的)。如果这次没有新增数据,这个选择集是空的,后面接的.append()不会有任何效果。circles(也就是.data()直接返回的选择集,不接.enter()):数据和已有节点通过 key 能对应上的部分,也就是要更新的部分。enter().append()新建出来的节点执行完 append 之后会自动合并回这个 update 选择集,所以把公共的属性设置写在circles.attr(...)而不是分别在 enter 和 update 里各写一遍,新旧节点都能设置到。circles.exit():已有节点里、这次数据按 key 对应不上的部分,说明这些节点对应的数据已经不存在了。常见处理是.remove()直接删除,稳定性大盘里服务下线大概就是这么处理,节点直接从图上消失。
链式调用
上面代码里能看到 .attr().attr().style() 一路点下去,这是因为 D3 里大多数方法(select、selectAll、data、enter、append、attr、style 等)执行完返回的都是选择集本身或者新的选择集,所以能一直链下去,不用每次存中间变量再取出来调用。要注意的是 .enter() 和 .exit() 返回的是不同的选择集(分别是待创建和待删除的部分),链式调用到这一步之后,后面操作对象已经变了,这也是前面例子里为什么要把 circles.enter()... 和 circles.exit()... 分开写,不能全部接在一条链上。
选择集和数据绑定这块理清楚了,接下来要解决的问题是怎么把服务的 QPS 数值映射成合适的颜色深浅、把调用量映射成线条粗细,这是比例尺(scale)的事,d3.scale.linear()、d3.scale.ordinal() 这些还没看,下一篇再记。

