迈向可更新的 D3.js 图表
已发表: 2022-03-11介绍
D3.js 是由 Mike Bostock 开发的用于数据可视化的开源库。 D3 代表数据驱动文档,顾名思义,该库允许开发人员轻松生成和操作基于数据的 DOM 元素。 尽管不受库功能的限制,D3.js 通常与 SVG 元素一起使用,并提供强大的工具来从头开始开发矢量数据可视化。
让我们从一个简单的例子开始。 假设您正在为一场 5k 比赛进行训练,并且您想制作一个水平条形图,显示您上周每天跑的英里数:
var milesRun = [2, 5, 4, 1, 2, 6, 5]; d3.select('body').append('svg') .attr('height', 300) .attr('width', 800) .selectAll('rect') .data(milesRun) .enter() .append('rect') .attr('y', function (d, i) { return i * 40 }) .attr('height', 35) .attr('x', 0) .attr('width', function (d) { return d*100}) .style('fill', 'steelblue');
要查看它的实际效果,请在 bl.ocks.org 上查看。
如果这段代码看起来很熟悉,那就太好了。 如果没有,我发现 Scott Murray 的教程是开始使用 D3.js 的绝佳资源。
作为一名使用 D3.js 开发数百小时的自由职业者,我的开发模式经历了一次演变,始终以创造最全面的客户端和用户体验为最终目标。 正如我稍后将更详细讨论的那样,Mike Bostock 的可重复使用图表模式提供了一种经过验证的真实方法,可以在任意数量的选择中实现相同的图表。 但是,一旦图表初始化,它的局限性就会显现。 如果我想通过这种方法使用 D3 的转换和更新模式,则必须在生成图表的同一范围内完全处理对数据的更改。 实际上,这意味着在同一功能范围内实现过滤器、下拉选择、滑块和调整大小选项。
在多次亲身体验这些限制之后,我想创建一种方法来充分利用 D3.js 的全部功能。 例如,在一个完全独立的组件的下拉列表中监听变化,并无缝触发从旧数据到新数据的图表更新。 我希望能够交出具有完整功能的图表控件,并以合乎逻辑和模块化的方式进行。 结果是一个可更新的图表模式,我将逐步完成创建此模式的完整过程。
D3.js 图表模式进展
第 1 步:配置变量
当我开始使用 D3.js 开发可视化时,使用配置变量来快速定义和更改图表的规格变得非常方便。 这使我的图表能够处理所有不同长度和值的数据。 显示里程数的同一段代码现在可以显示更长的温度列表而不会出现任何问题:
var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68]; var height = 300; var width = 800; var barPadding = 1; var barSpacing = height / highTemperatures.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(highTemperatures); var widthScale = width / maxValue; d3.select('body').append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(highTemperatures) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', 'steelblue');
要查看它的实际效果,请在 bl.ocks.org 上查看。
请注意条形的高度和宽度如何根据数据的大小和值进行缩放。 一个变量被改变,其余的被处理。
第 2 步:通过函数轻松重复
通过抽象出一些业务逻辑,我们能够创建更通用的代码,准备好处理通用的数据模板。 下一步是将此代码包装到生成函数中,从而将初始化减少到一行。 该函数接受三个参数:数据、DOM 目标和可用于覆盖默认配置变量的选项对象。 看看如何做到这一点:
var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; function drawChart(dom, data, options) { var width = options.width || 800; var height = options.height || 200; var barPadding = options.barPadding || 1; var fillColor = options.fillColor || 'steelblue'; var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(dom).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); } var weatherOptions = {fillColor: 'coral'}; drawChart('#weatherHistory', highTemperatures, weatherOptions); var runningOptions = {barPadding: 2}; drawChart('#runningHistory', milesRun, runningOptions);
要查看它的实际效果,请在 bl.ocks.org 上查看。
在此上下文中记录 D3.js 选择也很重要。 应始终避免像d3.selectAll('rect')
这样的一般选择。 如果 SVG 出现在页面的其他位置,页面上的所有rect
都将成为选择的一部分。 相反,使用传入的 DOM 引用,创建一个svg
对象,您可以在添加和更新元素时引用该对象。 这种技术还可以提高图表生成的运行时间,因为使用条形图之类的参考也可以避免再次进行 D3.js 选择。
第 3 步:方法链接和选择
虽然之前使用配置对象的框架在 JavaScript 库中非常普遍,但 D3.js 的创建者 Mike Bostock 推荐了另一种创建可重用图表的模式。 简而言之,Mike Bostock 建议使用 getter-setter 方法将图表实现为闭包。 虽然增加了图表实现的一些复杂性,但调用者只需使用方法链接,设置配置选项就变得非常简单:
// Using Mike Bostock's Towards Reusable Charts Pattern function barChart() { // All options that should be accessible to caller var width = 900; var height = 200; var barPadding = 1; var fillColor = 'steelblue'; function chart(selection){ selection.each(function (data) { var barSpacing = height / data.length; var barHeight = barSpacing - barPadding; var maxValue = d3.max(data); var widthScale = width / maxValue; d3.select(this).append('svg') .attr('height', height) .attr('width', width) .selectAll('rect') .data(data) .enter() .append('rect') .attr('y', function (d, i) { return i * barSpacing }) .attr('height', barHeight) .attr('x', 0) .attr('width', function (d) { return d*widthScale}) .style('fill', fillColor); }); } chart.width = function(value) { if (!arguments.length) return margin; width = value; return chart; }; chart.height = function(value) { if (!arguments.length) return height; height = value; return chart; }; chart.barPadding = function(value) { if (!arguments.length) return barPadding; barPadding = value; return chart; }; chart.fillColor = function(value) { if (!arguments.length) return fillColor; fillColor = value; return chart; }; return chart; } var milesRun = [2, 5, 4, 1, 2, 6, 5]; var highTemperatures = [77, 71, 82, 87, 84, 78, 80, 84, 86, 72, 71, 68, 75, 73, 80, 85, 86, 80]; var runningChart = barChart().barPadding(2); d3.select('#runningHistory') .datum(milesRun) .call(runningChart); var weatherChart = barChart().fillColor('coral'); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
要查看它的实际效果,请在 bl.ocks.org 上查看。

图表初始化使用 D3.js 选择,绑定相关数据并将 DOM 选择作为this
上下文传递给生成器函数。 生成器函数将默认变量包装在一个闭包中,并允许调用者通过方法链接与返回图表对象的配置函数来更改这些变量。 通过这样做,调用者可以一次将相同的图表呈现给多个选择,或者使用一个图表将相同的图表呈现给具有不同数据的不同选择,同时避免传递庞大的选项对象。
第 4 步:可更新图表的新模式
Mike Bostock 建议的先前模式为我们作为图表开发人员提供了生成器功能的强大功能。 给定一组数据和传入的任何链接配置,我们从那里控制一切。 如果需要从内部更改数据,我们可以使用适当的转换,而不是从头开始重绘。 甚至可以优雅地处理窗口大小调整之类的事情,创建响应式功能,例如使用缩写文本或更改轴标签。
但是如果数据是从生成器函数的范围之外修改的呢? 或者如果图表需要以编程方式调整大小怎么办? 我们可以再次调用图表函数,使用新数据和新大小配置。 一切都会重新绘制,瞧。 问题解决了。
不幸的是,这种解决方案存在许多问题。
首先,我们几乎不可避免地会执行不必要的初始化计算。 当我们所要做的就是缩放宽度时,为什么还要进行复杂的数据操作? 这些计算可能在第一次初始化图表时是必需的,但肯定不是在我们需要进行的每次更新时。 每个编程请求都需要进行一些修改,作为开发人员,我们确切地知道这些更改是什么。 不多也不少。 此外,在图表范围内,我们已经可以访问许多我们需要的东西(SVG 对象、当前数据状态等),从而使更改变得简单易行。
以上面的条形图示例为例。 如果我们想更新宽度,并通过重绘整个图表来实现,我们会触发很多不必要的计算:找到最大数据值、计算条形高度以及渲染所有这些 SVG 元素。 实际上,一旦将width
分配给它的新值,我们需要做的唯一更改是:
width = newWidth; widthScale = width / maxValue; bars.attr('width', function(d) { return d*widthScale}); svg.attr('width', width);
但它变得更好。 由于我们现在有一些图表的历史,我们可以使用 D3 的内置转换来更新我们的图表并轻松地为它们制作动画。 继续上面的例子,在width
上添加一个过渡就像改变一样简单
bars.attr('width', function(d) { return d*widthScale});
到
bars.transition().duration(1000).attr('width', function(d) { return d*widthScale});
更好的是,如果我们允许用户传入一个新数据集,我们可以使用 D3 的更新选择(进入、更新和退出)也将转换应用于新数据。 但是我们如何允许新数据呢? 如果你还记得,我们之前的实现创建了一个像这样的新图表:
d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
我们将数据绑定到 D3.js 选择,并调用我们的可重用图表。 对数据的任何更改都必须通过将新数据绑定到相同的选择来完成。 从理论上讲,我们可以使用旧模式并探索现有数据的选择,然后用新数据更新我们的发现。 这不仅实现起来混乱且复杂,而且还需要假设现有图表具有相同的类型和形式。
相反,通过对 JavaScript 生成器函数的结构进行一些更改,我们可以创建一个图表,允许调用者通过方法链轻松地在外部提示更改。 而在配置和数据被设置然后保持不变之前,调用者现在可以做这样的事情,即使在图表初始化之后:
weatherChart.width(420);
结果是从现有图表平滑过渡到新宽度。 没有不必要的计算和流畅的过渡,结果是一个快乐的客户。
这种额外的功能会稍微增加开发人员的工作量。 然而,我发现从历史上看,这是一项非常值得花时间的努力。 这是可更新图表的骨架:
function barChart() { // All options that should be accessible to caller var data = []; var width = 800; //... the rest var updateData; var updateWidth; //... the rest function chart(selection){ selection.each(function () { // //draw the chart here using data, width // updateWidth = function() { // use width to make any changes }; updateData = function() { // use D3 update pattern with data } }); } chart.data = function(value) { if (!arguments.length) return data; data = value; if (typeof updateData === 'function') updateData(); return chart; }; chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; }; //... the rest return chart; }
要查看完全实施,请在 bl.ocks.org 上查看。
让我们回顾一下新的结构。 与之前的闭包实现最大的变化是添加了更新函数。 如前所述,这些函数利用 D3.js 转换和更新模式,根据新数据或图表配置平滑地进行任何必要的更改。 为了使调用者可以访问这些函数,将函数作为属性添加到图表中。 为了使它更容易,初始配置和更新都通过相同的函数处理:
chart.width = function(value) { if (!arguments.length) return width; width = value; if (typeof updateWidth === 'function') updateWidth(); return chart; };
请注意,在图表初始化之前不会定义updateWidth
。 如果它是undefined
,那么配置变量将被全局设置并在图表闭包中使用。 如果图表函数已被调用,则所有转换都将移交给updateWidth
函数,该函数使用更改后的width
变量进行任何需要的更改。 像这样的东西:
updateWidth = function() { widthScale = width / maxValue; bars.transition().duration(1000).attr('width', function(d) { return d*widthScale}); svg.transition().duration(1000).attr('width', width); };
使用这种新结构,图表的数据就像任何其他配置变量一样通过方法链接传递,而不是将其绑定到 D3.js 选择。 区别:
var weatherChart = barChart(); d3.select('#weatherHistory') .datum(highTemperatures) .call(weatherChart);
变成:
var weatherChart = barChart().data(highTemperatures); d3.select('#weatherHistory') .call(weatherChart);
所以我们做了一些改变并增加了一些开发人员的努力,让我们看看好处。
假设您有一个新功能请求:“添加一个下拉列表,以便用户可以在高温和低温之间切换。 并且在你使用的时候也可以改变颜色。” 无需清除当前图表、绑定新数据、重新绘制,现在可以在选择低温时进行简单调用:
weatherChart.data(lowTemperatures).fillColor('blue');
并享受魔法。 我们不仅节省了计算,而且我们在可视化更新时增加了一个新的理解水平,这在以前是不可能的。
这里需要注意关于转换的重要提示。 在同一元素上安排多个转换时要小心。 开始一个新的过渡将隐式取消任何以前运行的过渡。 当然,可以在一个 D3.js 启动的过渡中更改元素的多个属性或样式,但我遇到过一些同时触发多个过渡的情况。 在这些情况下,请考虑在创建更新函数时对父元素和子元素使用并发转换。
哲学的改变
Mike Bostock 引入闭包作为封装图表生成的一种方式。 他的模式针对在许多地方创建具有不同数据的相同图表进行了优化。 然而,在我使用 D3.js 的这些年里,我发现优先级略有不同。 我引入的新模式不是使用图表的一个实例来创建具有不同数据的相同可视化,而是允许调用者轻松创建图表的多个实例,即使在初始化之后,每个实例都可以完全修改。 此外,这些更新中的每一个都可以完全访问图表的当前状态来处理,从而允许开发人员消除不必要的计算并利用 D3.js 的强大功能来创建更无缝的用户和客户端体验。