假设有这么一个需求: 给定一组数据,然后要绘制一个可交互的,可动态变化,并拥有良好性能的图形。这个图形可能是条形图,散点图,树或是其他任何形式。限你两个星期做出来 --咳咳..
也许为了能快速应对,会对当前的某一个图具体的去画出来(自己也这么搞过)。但毕竟只是权宜之计,有没有一种方式可以满足上述需求呢? d3 便是一个答案
可交互: 可以为图形的任意位置,添加任意类型的事件交互,也许是键盘按键,或是鼠标按下/滚动
动态变化: 数据的更替,图形也即需要对应刷新;
这种场景我们可以把它再细分:
新增的数据就在图形中加一个
不要的数据就对应删掉
已经有的数据,但值有变化,更新它的值
良好性能: 分片 + 异步 + 缓存, 都用上
图形: 不画就行了,用的人去画。大不了提供一些图行算法
这里要说的 Selection - data,就是数据和图形元素的关联关系。从一个例子出发
var selection = d3.selectAll('.bar').data([2,3,4,5,6]); selection .enter() .append('div') .merge(selection) .attr('class', 'bar') .style('background', 'green') .style('width', '200px') .style('height', '40px') .style('margin-bottom', '2px')
d3.selectAll('.bar').data([2,3,4,5,6]) 这一串代码便完成了图形和数据的关联
selection.enter() 选中新增的数据节点
selection.exit() 选中删除的数据节点
selection.merge(selection2) 将选择集 selection2合并到 selection, 这个过程不会对 selection 插入新节点, 只会执行替换
selection.attr() 遍历 selection的节点, 对每一个节点设置属性
selection.style() 遍历 selection的节点, 对每一个节点设置样式
简单介绍了它的作用,下面来细说下它的实现。
d3.selectAll('.bar') 实际调用的
// d3.selectAll --> selectAll
// 该方法返回一个 Selection 实例对象, 创建一个拥有 _groups, _parents属性的对象
// _groups: 保存着选择的子节点,
// 如果传递的 selector 是字符串,返回该字符串选中的 dom节点,
// 如果没有传递 selector, 返回空数组,
// 如果传递的是其他, 返回传递的对象本身
// _parents 保存着选择的父节点,
// 如果传递的 selector 是字符串, 返回 document,
// 如果传递的 selector 不是字符串, 返回 null
// 所以例子返回的是一个 _parents 为 document, _groups 为所有类名为 .bar的 NodeList 所组成的一个对象
function selectAll(selector) { return typeof selector === "string" ? new Selection([document.querySelectorAll(selector)], [document.documentElement]) : new Selection([selector == null ? [] : selector], root); }
// 关于 Selection, 实例本身只有 _groups, _parents 两个属性, 原型对象中有操作数据,操作 dom节点等方法 function Selection(groups, parents) { this._groups = groups; this._parents = parents; } Selection.prototype = selection.prototype = { constructor: Selection, select: selection_select, selectAll: selection_selectAll, filter: selection_filter, data: selection_data, enter: selection_enter, exit: selection_exit, join: selection_join, merge: selection_merge,
append: selection_append, ... }
最终返回的一个 { _groups: [NodeList], _parents: [document] } 对象
d3.selectAll('.bar').data([2,3,4,5,6]) 也即调用的 Selection原型上的 selection_data 方法;
function constant$1(x) { return function() { return x; }; }
function selection_data(value, key) {
if (!value) { data = new Array(this.size()), j = -1; this.each(function(d) { data[++j] = d; }); return data; } var bind = key ? bindKey : bindIndex, parents = this._parents, groups = this._groups; if (typeof value !== "function") value = constant$1(value); for (var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) { var parent = parents[j], group = groups[j], groupLength = group.length,
// 如果传入的 data为一个函数, 这时候就会拿去调用父元素, 将调用函数的返回结果作为 data值 data = value.call(parent, parent && parent.__data__, j, parents), dataLength = data.length, enterGroup = enter[j] = new Array(dataLength), updateGroup = update[j] = new Array(dataLength), exitGroup = exit[j] = new Array(groupLength); bind(parent, group, enterGroup, updateGroup, exitGroup, data, key); // Now connect the enter nodes to their following update node, such that // appendChild can insert the materialized enter node before this node, // rather than at the end of the parent node. for (var i0 = 0, i1 = 0, previous, next; i0 < dataLength; ++i0) { if (previous = enterGroup[i0]) { if (i0 >= i1) i1 = i0 + 1; while (!(next = updateGroup[i1]) && ++i1 < dataLength); previous._next = next || null; } } } update = new Selection(update, parents); update._enter = enter; update._exit = exit; return update; }
函数最终返回一个新的 Selection实例,新实例的 _parent属性保持不变, _groups属性被替换成了待更新的所有节点 update;
并且为新实例额外创建了 _enter, _exit 属性,分别保存着新增加的节点和多出来的节点
上述代码中首先定义了
update 为一个长度和子节点同样长度的空数组, 然后再重新赋值为一个和给定数据(假定为 dataList)同样长度的空数组,
enter 为一个长度和子节点同样长度的空数组, 然后再重新赋值为一个和给定数据同样长度的空数组,
exit 为一个长度和子节点 _groups同样长度的空数组,然后再重新赋值为一个和 _groups同样长度的空数组,
注意的是, 当我们使用常规的 d3.selectAll('.bar') 方式创建出来的 Selection实例, _groups属性是 [QuerySelectAll('.bar')] 的一个二维数组, 因此 上面的循环也只会走一次;
var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m) dataLength = data.length, enterGroup = enter[j] = new Array(dataLength), updateGroup = update[j] = new Array(dataLength), exitGroup = exit[j] = new Array(groupLength);
然后来给她们分配对象啦
bind(parent, group, enterGroup, updateGroup, exitGroup, data, key);
因为这里我们并没有传入第二个参数,所以 bind即为 bindIndex函数,也即
function bindIndex(parent, group, enter, update, exit, data) { var i = 0, node, groupLength = group.length, dataLength = data.length; // Put any non-null nodes that fit into update. // Put any null nodes into enter. // Put any remaining data into enter. for (; i < dataLength; ++i) { if (node = group[i]) { node.__data__ = data[i]; update[i] = node; } else { enter[i] = new EnterNode(parent, data[i]); } } // Put any non-null nodes that don’t fit into exit. for (; i < groupLength; ++i) { if (node = group[i]) { exit[i] = node; } } }
_groups dataList
遍历 dataList,
如果 _groups里面有节点,update插入当前节点, 并更新节点属性 __data__
如果 _groups里面没有节点, enter插入一个新的 EnterNode实例
以 dataList结束点为起点, 开始遍历 _groups
如果有节点, exit插入当前节点
由此步骤, 就拿到了 要新增的节点, 待更新的节点, 和多出来的节点
selection_data 方法最后, 给新实例对象添加 _enter, _exit两个属性, 分别保存着 新增的节点, 多出来的节点;
自此, 便完成了数据和节点的关联过程。
而 selection[enter | exit], 也即是返回一个以 [_enter | _exit] 为 _groups(子节点), 以本身父节点为 _parent(父节点)的一个新的 Selection实例。
额外说一下 merge, join;
merge对应的源码:
function selection_merge(selection) { // _groups => [nodeList] 一般情况是一个二维数组, 因此这里只会迭代一次
// 取本身和待合并的节点的交集, 创建一个交集组成的空数组 for (var groups0 = this._groups, groups1 = selection._groups, m0 = groups0.length, m1 = groups1.length, m = Math.min(m0, m1), merges = new Array(m0), j = 0; j < m; ++j) {
// 对 nodeList的迭代, 创建一个跟本身同样长度的空数组, merges[i] = new Array(n), 并且创建一个 merge变量引用该数组
for (var group0 = groups0[j], group1 = groups1[j], n = group0.length, merge = merges[j] = new Array(n), node, i = 0; i < n; ++i) {
// 在本身 nodeList和待合并对象 nodeList上取值, 优先取本身, 再取待合并对象; 如果都没有则跳过 if (node = group0[i] || group1[i]) { merge[i] = node; } } } // 以上次循环的结束点为起点, 以本身子节点的长度为终点, 将未合并操作的节点加进来 for (; j < m0; ++j) { merges[j] = groups0[j]; } return new Selection(merges, this._parents); }
方法最终返回一个以 对象本身节点与待合并对象节点 "合并操作"后的结果 -> 作为节点, 以对象本身父节点为父节点, 组成的一个新的 Selection对象;
由于这一个判断
if (node = group0[i] || group1[i]) {
这会导致一个现象: 对已存在的节点进行合并 merge(newData)之后再重绘时, 节点不生效(可以直接绘制不 merge)。 在使用时这一点是需要注意的,比如:
var arr = [20,23,199,23,12,350,22,10] var oldSelect = d3.selectAll('.v-bar').data(arr); oldSelect.enter() .append('div') .style('width', '100px') .attr("class", 'v-bar') .text(d=>'old' + d) var newArr = ['23', 123, 111, 112, 22]; var newSelect = d3.selectAll('.v-bar').data(newArr);
newSelect.text(v => 'new' + v) newSelect.exit().remove()
// var newSelect = d3.selectAll('.v-bar').data(newArr).merge(oldSelect)
// 如果使用的 merge, 根据上面的代码分析, 返回的是一个包含 newArr.length 个节点且数据绑定为 newArr的选择集
// 并不会返回个带有 _exit属性, 或 _enter属性 Selection对象, 因此如果接着用 exit(), enter() 是无法生效的
join对应的源码
function selection_join(onenter, onupdate, onexit) { var enter = this.enter(), update = this, exit = this.exit(); enter = typeof onenter === "function" ? onenter(enter) : enter.append(onenter + ""); if (onupdate != null) update = onupdate(update); if (onexit == null) exit.remove(); else onexit(exit); return enter && update ? enter.merge(update).order() : update; }
根据源码推测它的用法: 同时接受三个参数
onenter: 可以传入一个字符串, 便捷的插入dom节点; 也可以是一个函数, 对新增的节点执行操作
onupdate: 如果传了值则只能是一个函数, 并将自身作为参数,函数调用的返回结果会作为一个 Selection , 和新增的节点组成的 Selection进行合并。
onexit: 如果传了值会作为函数调用, 并将多余的节点作为参数; 如果没传值多余的节点将被删除
方法最终返回一个新的经过排序后的 Selection或者本身。
根据上述 数据和图形的关联介绍, 可以发现 d3所建立的 数据-图形 的关联概念 是很通用的。
我们可以操作 svg来绘图, d3操作 dom非常友好;
同样也可以用 d3.selectAll(null) 的方式来作为一个载体, 同样通过 [enter | exit] 的方式来选择 '绘图对象', 通过对 '绘图对象'的更新去重绘画布,在 canvas上同样可以适用; --自己没有这样实验过