d3.js 是一个用于数据图形化的 javascript 库, 有点类似于 jQuery. 与 jQuery 不同的是, 它除了能操作 dom 之外, 还具有数据绑定的功能, 这对于动态数据的展示非常友好.
刚开始看 d3 的文档可能会比较难以理解, 因为官方文档是按照模块一个一个排列展示的, 这种方式对于已经了解 d3 的人比较友好, 可以直接查看想要的模块, 但是对于初学者来说,因为模块之间并不是渐进的关系, 所以并不利于形成初步认识.
这篇文章让我们先来认识一下 d3 selection 模块, 这是一个非常重要的模块, 我们用例子的方式一步一步展开, 这对初学者来说无疑是最友好的.
在开始 d3 之前我们先用 js 来创建一个 svg 图形. 因为 d3 本质上只是 js 的封装.
用 js 画一个柱子
在学习 d3 之前我们需要简单了解一下 svg 的知识, svg 的属性与 html 稍有不同, 以下面的例子为例, 我们要画一个矩形就要用到 rect 元素.
<rect width=20 height=100 x=20 y=0 fill='green' />
svg 的知识不在本篇的重点讨论范围内
与 html 一样, js 可以创建和修改 svg.
const svg = document.querySelector('svg');
const ns = 'http://www.w3.org/2000/svg' // namespace
const rect = document.createElementNS(ns, 'rect')
rect.setAttribute('width', 20)
rect.setAttribute('height', 100)
rect.setAttribute('x', 50)
rect.setAttribute('fill', 'green')
svg.appendChild(rect)
用 d3 画一个柱子
同样的例子也可以用 d3 来操作, 直接看语法就能明白它在做什么.
const svg = d3.select("svg");
svg
.append("rect")
.attr("x", 20)
.attr("y", 0)
.attr("width", 20)
.attr("height", 100)
.attr("fill", "green");
d3.select
与 document.querySelector
类似返回第一个匹配项. 对应的也有返回所有匹配项的方法 d3.selectAll
.
d3.select/selectAll
方法返回的是一个 selection 对象. 这个对象继承了很多 方法, 比如 append
, attr
等.
调完 append
或 attr
等这些方法后又会返回一个新的 selection 对象, 因此它们是可以链式调用的.
假设 html 上有两个 svg 元素, 每个 svg 元素里又有两个 g 元素.
<html>
<svg>
<g />
<g />
</svg>
<svg>
<g />
<g />
</svg>
</html>
// 代码块1号
d3.selectAll("svg") // 返回 selection 对象, 其对应内容是多个 svg
.selectAll('g') // 返回 selection 对象, 其对应内容是多个 g
.append('rect') // 返回 selection 对象, 其对应内容是多个 rect
.attr("x", (d, i) => i * 50) // 修改 rect 元素属性, 返回 selection 对象, 其对应内容是多个 rect
.attr('width', 20) // 修改 rect 元素属性, 返回 selection 对象, 其对应内容是多个 rect
...
执行上面的 代码块1号
, 页面上会出现 4 根柱子.
光看这个代码理解起来好像很简单, 先选择多个 svg 元素, 再选择 svg 下面的多个 g 元素, 然后给每个 g 元素插入 rect 元素.
这看起来很酷, selectAll
似乎帮我们做了循环的事情, 不需要 forEach 就创建了多个元素, 这让我们的代码在使用层面看起来非常简洁.
很好奇这个 selection 对象的数据结构是怎么样的? 每次 selectAll
后它是如何变化的? 让我们来深入看一下这个 selection 对象.
selection 对象
以这个例子为例, 我们把每一次选择拆开来, 然后打印出来看看这个对象是什么:
<!-- html -->
<svg class="first" width=200 height=200 >
<g />
<g />
</svg>
<svg class='second' width=200 height=200 >
<g />
<g />
</svg>
// js
const svgs = d3.selectAll('svg') // 第一步
const groups = svgs.selectAll('g') // 第二步
const rects = groups.append('rect') // 第三步
rects.attr("x", (d, i) => i * 50)
.attr("y", 0)
.attr("width", 20)
.attr("height", 100)
.attr("fill", "green")
console.log({svgs, groups, rects})
-
第一步,
d3.selectAll('svg')
:
如截图所示,d3.selectAll('svg')
后返回的就是一个纯粹的 js 对象, 它看起来很吓人, 其实就是两个键值, 分别是 _groups 和 _parents.
_parents 和 _groups 有对应关系, _parents[i] 是 _groups[i] 的父元素.
对于第一步来说, _parents[0] 是 html.
_groups[0] 是一个数组, 里面是选到的两个 svg 元素 (它们是纯粹的 js dom 节点) -
第二步
svgs.selectAll('g')
到了第二步, 选择范围是基于第一步中的 _groups 来选择的. 所以第一步中的 _groups 变成了第二步中的_parents.
同样的 _parents[i] 是 _groups[i] 的父元素, 它们是对应关系. -
第三步
rects.append('rect')
第三步是在第二步的基础上append
rect 元素.
append
方法也是返回一个 selection 对象. 它会给每个选择的元素里添加一个新的元素.append 方法会有点不一样,它会遍历 _group 中的元素,给它们添加进元素然后再返回成 selection 对象。文章最后我放上了
append
方法的源码解读, 会有点绕, 但是没关系, 会一步一步的往下看, 这对理解 selection 是有帮助的. 有兴趣的可以看一下.但是不管怎么说我们已经明白了 selection 对象的大体结构了. 它把选择的元素放在了 _parents 和 _groups 数组里, 并且维护了他们之间的父子关系.
data 数据绑定
在上一个例子中我们通过 selectAll
选中了多个 g 元素, 然后给他们添加了 rect 元素. 这个看起来已经比用 js 简化了, 但是前提是需要在 html 中先添加若干个 g 元素. 也就是说 selectAll
需要选中已经存在的元素.
假如我们有一个数组 data = [10, 20, 30]
, 我们想画三个矩形, 高度分别是 10, 20, 30.
最简单的我们可以用 js 循环遍历的方法来画它们.
const svg = d3.select('svg')
data.forEach((d, i) => {
svg.append('rect').attr('height', d).attr('x', 25 * i).attr('width', 20)
})
除了这个还有没有其它的方法来实现呢? d3 为我们提供了数据绑定 data 方法.
d3.select('svg').selectAll('rect').data(data).enter().append('rect').attr('height', d => d).attr('x', (d, i) => i * 25).attr('width', 20)
等一下! 页面上还没有 rect 元素怎么能 selectAll('rect')
呢,还有 data
方法是做什么的, enter
方法又是做什么的?
看一下官方文档, 文档上写了一大堆, 一下子不知道什么意思. 数据绑定其实就是把数据绑定到相应的元素上. 同时又维护了 _enter 和 _exit 两个数组, 当数据数量大于元素数量时, 往 _enter 数组里添加元素. 当数据数量小于元素数量时, 往 _exit 数组里添加要被移除的元素.
让我们先来看几个例子, 看一看 data 方法的返回值:
-
当数据数量与元素数量相等 ( 3 对 3, 数据数量 3 对元素数量 3 )
<!-- html 文件 --> <svg width=200 height=200> <rect id='one' /> <rect id='two' /> <rect id='three' /> </svg>
// js 文件 const data = [10, 20, 30] const one = document.querySelector('#one') const two = document.querySelector('#two') const three = document.querySelector('#three') d3.select('svg').selectAll('rect').data(data) /** data 方法会返回一个 selection 对象, 这个对象有额外两个键值 _enter 和 _exit: 在这个例子里这两个数组都是空, 因为没有元素的添加和移除. { _parents: [svg], _groups: [[one, two, three]], _enter: [,,], _exit: [,,], } 同时每一个 rect dom 节点会挂载上 __data__ 数据: one.__data__ = 10 two.__data__ = 20 three.__data__ = 30 **/
-
当数据数量大于元素数量 ( 3 对 2, 数据数量 3 对元素数量 2 )
<!-- html 文件 --> <svg width=200 height=200> <rect id='one' /> <rect id='two' /> </svg>
// js 文件 const data = [10, 20, 30] const one = document.querySelector('#one') const two = document.querySelector('#two') d3.select('svg').selectAll('rect').data(data) /** 返回: { _parents: [svg], _groups: [[one, two, ,]], _enter: [,, EnterNode], _exit: [,,], } _enter 数组中有新元素加入, 因为数据的数量比元素数量多一个 one.__data__ = 10 two.__data__ = 20 **/
-
数据数量小于元素数量 ( 2 对 3, 数据数量 2 对元素数量 3 )
<!-- html 文件 --> <svg width=200 height=200> <rect id='one' /> <rect id='two' /> <rect id='three' /> </svg>
// js 文件 const data = [10, 20] const one = document.querySelector('#one') const two = document.querySelector('#two') const three = document.querySelector('#three') d3.select('svg').selectAll('rect').data(data) /** 返回: { _parents: [svg], _groups: [[one, two]], _enter: [,,], _exit: [,,Three], } _exit 数组中有要移除的元素加入. one.__data__ = 10 two.__data__ = 20 **/
附上 data 方法的测试用例, 看用例其实比官方文档还要清晰.
回到上面的例子, 我们可以把语句拆开来执行:
<!-- html 文件 -->
<svg width=200 height=200>
</svg>
const data = [10, 20, 30]
// 3 对 0, 数据数量 3 对元素数量 0, 对应上面的第 2 点, _exit 数组里会有三个元素。
const rects = d3.select('svg').selectAll('rect').data(data)
// enter() 方法能够拿到 _exit 数组的内容并转化成 selection 对象
rects.enter().append('rect').attr('height', (d) => d))
所有 selection 方法的返回值都是一个 selection 对象, 所以能链式调用.
元素移除
在上面的基础上如果 data 又变成 [10, 20], 那么移除的元素会添加进 _exit 数组, 用 exit 方法可以拿到.
rects.exit().remove()
看一下这个例子, 我写了一个循环来模拟数据的不断变化, 利用 enter 和 exit 方法就可以很好的展示这些变化.
动画
在此基础上我们还能给进入的元素添加 x 方向的动画, 给移除的元素添加 y 方向的动画来更好的区分.
这看起来很酷 !
现在让我们更进一步.
上面的例子我们只关注了 enter 和 exit 也就是添加和移除的元素. 更新的元素我们并没有考虑, 假设数据的变化是这样的:
如果用上面例子的代码, 我们会发现第一个柱子的高度根本没有变化.
这是自然的, 因为我们只用了 enter 和 exit, 那更新的元素怎么来控制呢? 其实更新的元素就是 data 方法的返回值本身. 可以观察上面 1, 2, 3 例子中的 _groups.
所以再加上一行代码就可以了
rects.attr('height', (d) => d)
让我们加入高度的动画
rects.transition()
.duration(800)
.attr('height', d => d)
Join 方法
d3 还提供了一个 join 方法, 相当于是 enter, update, exit 的合并简写形式.
svg.selectAll('rect').data(data).join(
enter => ...,
update => ...,
exit => ...
)
data 数据绑定的高级形式 key 函数
从上面的例子可以看出, data 方法默认是按照数组的顺序来绑定元素的. 数组的第一位绑定第一个元素, 第二位绑定第二个元素.
但是有些情况按照数组顺序的绑定方式是实现不了的.
比如有一个数组 [10, 20, 30] 对应了三个元素 one, two, three
<svg>
<rect id='one' />
<rect id='two' />
<rect id='three' />
</svg>
我想要移除掉 two 元素, 该怎么办呢?
我可以让数组变成 [10, 30]. 但是由于绑定是按照顺序绑定的, 所以最后只会移除掉最后一位的 three 元素. 而 two 元素会被保留, 并且它的的 __data__
会变成 30.
那怎么办呢? 就需要用到 data 方法的第二个参数, 叫做 key 函数.
这个 key 函数光看文档很难理解它在干什么. 大概意思就是返回一个唯一 key 值, 函数的第一个参数是绑定的数据 d.
看一下这个例子, 我把数据结构改成对象数组: [ { name: ‘one’, value: 10 } ], key 函数用 (d) => d.name
字面意思是用数据中的 name 来绑定元素。
初看起来没有什么问题.
但其实 key 函数还是有点复杂的, 这个例子没有问题是因为它的 rect 节点是从无到有的, 因此每个节点上都有绑定值.
但是有些情况 d 可能是 null.
让我们再来看一下 key 函数的源码 😅. 请再保持点耐心, 快结束了. 这对理解 key 函数非常有帮助.
<!-- html 文件 -->
<svg>
<rect id='one' />
<rect id='two' />
</svg>
const data = [{name: 'one', value: 10}, {name: 'two', value: 20}]
d3.selectAll('rect').data(data, (d) => d.name)
假设我们调用了这个 data 方法, 让我们来看一下在源码中是如何运行的.
// 仅为示意
// 代码解释写在注释中
/**
selection 对象如下:
{
_groups: [[one, two]],
_parents: [html]
}
**/
// 遍历 _groups, 然后调用 bindKey 方法
for (var m = _groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) {
bindKey(...)
}
function bindKey(parent, group, enter, update, exit, data, key) {
/**
parent: html
group: [one, two]
enter, update, exit 为数组
data: 调用 data 方法的第一个参数, 即 [{name: 'one', value: 10}, {name: 'two', value: 20}]
key: 调用 data 方法的第二个参数(key 函数), 即 (d) => d.name
**/
// 定义一些变量
var i,
node,
nodeByKeyValue = new Map(),
groupLength = group.length,
dataLength = data.length,
keyValues = new Array(groupLength),
keyValue;
// 遍历 group 中的节点.
// 维护 key 和节点的对应关系
for (i = 0; i < groupLength; ++i) {
if ((node = group[i])) {
// 执行 key 函数 👇 意味着当 group 中有节点时, 初次执行 key 函数时 node.__data__ 为 null, 因此 key 函数要注意判空.
keyValues[i] = keyValue = key.call(node, node.__data__, i, group) + "";
if (nodeByKeyValue.has(keyValue)) {
exit[i] = node;
} else {
nodeByKeyValue.set(keyValue, node);
}
}
}
// 遍历 data
// 如果 nodeByKeyValue 有对应的节点, 放入 update 数组.
// 如果没有, 放入 enter 数组.
for (i = 0; i < dataLength; ++i) {
// 第二次执行 key 函数 👇
keyValue = key.call(parent, data[i], i, data) + "";
if ((node = nodeByKeyValue.get(keyValue))) {
update[i] = node;
node.__data__ = data[i];
nodeByKeyValue.delete(keyValue);
} else {
enter[i] = new EnterNode(parent, data[i]);
}
}
// 剩余没有对应的节点放入 exit 数组.
for (i = 0; i < groupLength; ++i) {
if ((node = group[i]) && nodeByKeyValue.get(keyValues[i]) === node) {
exit[i] = node;
}
}
}
因此当页面有初始元素时, key 函数要注意判空.
d3.selectAll('rect').data(data, (d) => d ? d.name : this.id)
// this 是什么? 看上面的源码能理解了.
append 方法
下面是 append 方法的源码,其实理解了 selection 对象再理解 append 就能明白了。
import creator from "../creator.js";
export default function(name) {
var create = typeof name === "function" ? name : creator(name); // 01
return this.select(function() { // 03
return this.appendChild(create.apply(this, arguments)); // 02
});
}
这块代码看起来不多, 但是我们要拆开来一步一步看.
-
create 是一个函数, 由 creator 创建而来.
// create 函数示意 function() { return this.ownerDocument.createElementNS(fullname.space, fullname.local); };
它能创建一个 dom 元素, 注意这个里面的
this
, 在第2点里能体现出来. -
第二步这里要拆成两块:
a. create.apply(this, arguments) 把 this 绑定到 create 函数.
b. this.appendChild() 把 create 函数生成的元素添加进 this.
那这个 this 就很重要, this 是什么呢? 我们往下看. -
第三步 this.select(fn)
首先这个 this 是指调用该方法的对象. 假如我们调用的是d3.select('svg').append('rect')
, 那么这个 this 就是指d3.select('svg')
返回的 selection 对象.
再来看一下 select 方法import {Selection} from "./index.js"; import selector from "../selector.js"; export default function(select) { if (typeof select !== "function") select = selector(select); for (var groups = this._groups, m = groups.length, subgroups = new Array(m), j = 0; j < m; ++j) { for (var group = groups[j], n = group.length, subgroup = subgroups[j] = new Array(n), node, subnode, i = 0; i < n; ++i) { // 核心代码 👇 if ((node = group[i]) && (subnode = select.call(node, node.__data__, i, group))) { if ("__data__" in node) subnode.__data__ = node.__data__; subgroup[i] = subnode; } } } return new Selection(subgroups, this._parents); }
它对 _groups 进行了两层遍历, 因为 _groups 是数组嵌数组. 我们只需要看到那一句核心代码
select.call(node, node.__data__, i, group)
, 就能明白了第2点中的 this 就是这个 node, 而这个 node 就是 _groups 中的 dom 元素.
总结一下 append
方法就是遍历了 selection 对象中的 _groups, 然后对里的元素 appendChild
进新添的元素. 最后再返回一个 selection 对象以便能链式调用.
写在最后
d3 selection 模块写完了, 相信理解了 selection 模块之后就能够更好的开启 d3 之旅了. 回头再来看官方文档就能理解的更加深刻.
最后推荐一个我自已写的 react 组件库 react-admin-kit, 对于中后台系统中常见的表单和表格能够提升很大的开发效率. (文档布署在 github 可能需要 vpn)
PS: d3 文档最好也要开 vpn 阅读, 否则他的案例库 observablehq 不能完整呈现.