React& D3.js: 整合 D3.js 可视化组件到 React 应用中
使用一个小小的例子来演示如何把 D3.js 数据可视化到 React 应用中。
我最近在使用 D3.js 和 React。因此,我想要分享使用这两个库创建组件和交互接口的几种方式。我希望它们能帮助你更优雅地实现两个库的整合。
这里,假设你有 React 和 D3.js 的基础知识。
三大准则 #
首先,我认为 React 和 D3.js 的整合是可行的,因为它们都有共同的哲学(给我一些数据,告诉我如何显示,然后我将自行计算哪一些 DOM 需要被更新)。确实,React 有 Virtual DOM diffs,而 D3.js 有 update selections;它们都是在同步 UI 和数据同步的逻辑上相当有效。
现在我们看看下面三条准则,就会发现 D3 作为组件可以很好地融合到 React 中:
- One Source Of Truth(真正的单一源):D3 可视化要求获取所有它需要渲染的数据。React 组件的
state
,它既能被 D3 组件使用,也可以被其他的 React 组件使用。 - Stateless All The Things(一切事务皆无状态):D3 和 React 组件都需要尽可能的无状态,换句话说,相同的输入只会产生相同的渲染。
- Don’t Make Too Many Assumptions(不要做太多假设):组件在使用方法上不应该做太多的假设。这一点,当提示需要显示,它不需要规定,它只要要求它是否接受
tooltips
数据。这个让我们显示工具提示的显示,同样也很容易创建显示隐藏的切换。
太理论了,让我们写一些例子。
第一个基础例子 #
我们将调用 D3.js 组件。让我们定义他的公用接口,同时也能展示它的生命周期:
var d3Chart = {};
d3Chart.create = function(el, props, state) {
var svg = d3.select(el).append('svg')
.attr('class', 'd3')
.attr('width', props.width)
.attr('height', props.height);
svg.append('g')
.attr('class', 'd3-points');
this.update(el, state);
};
d3Chart.update = function(el, state) {
// Re-compute the scales, and render the data points
var scales = this._scales(el, state.domain);
this._drawPoints(el, scales, state.data);
};
d3Chart.destroy = function(el) {
// Any clean-up would go here
// in this example there is nothing to do
};
注意到了吗?D3.js 组件是完全无状态的(二条准则);例如,它不能关联其他东西,数据都来源传值。我发现这么做使它更容易融入其他语境中(在我们的例子中,我把它放在 React 组件里)。
_drawPionts()
函数是 D3 的最常见使用方式(enter
,update
和 exit
模式):
// d3Chart.js
d3Chart._drawPoints = function(el, scales, data) {
var g = d3.select(el).selectAll('.d3-points');
var point = g.selectAll('.d3-point')
.data(data, function(d) { return d.id; });
// ENTER
point.enter().append('circle')
.attr('class', 'd3-point');
// ENTER & UPDATE
point.attr('cx', function(d) { return scales.x(d.x); })
.attr('cy', function(d) { return scales.y(d.y); })
.attr('r', function(d) { return scales.z(d.z); });
// EXIT
point.exit()
.remove();
};
现在,我在 React 组件 <chart>
中实现:
// Chart.js
var d3Chart = require('./d3Chart');
var Chart = React.createClass({
propTypes: {
data: React.PropTypes.array,
domain: React.PropTypes.object
},
componentDidMount: function() {
var el = this.getDOMNode();
d3Chart.create(el, {
width: '100%',
height: '300px'
}, this.getChartState());
},
componentDidUpdate: function() {
var el = this.getDOMNode();
d3Chart.update(el, this.getChartState());
},
getChartState: function() {
return {
data: this.props.data,
domain: this.props.domain
};
},
componentWillUnmount: function() {
var el = this.getDOMNode();
d3Chart.destroy(el);
},
render: function() {
return (
<div className="Chart"></div>
);
}
});
我们把 D3 图表的 create
,update
,destroy
函数映射到 React 组件的生命周期函数 componentDidMount
,componentDidUpdate
,componentWillUnmount
。
最后,创建一个 React 组件应用 <App>
,然后使用 <Chart>
来为一些数据描点:
// App.js
var Chart = require('./Chart');
var sampleData = [
{id: '5fbmzmtc', x: 7, y: 41, z: 6},
{id: 's4f8phwm', x: 11, y: 45, z: 9},
// ...
];
var App = React.createClass({
getInitialState: function() {
return {
data: sampleData,
domain: {x: [0, 30], y: [0, 100]}
};
},
render: function() {
return (
<div className="App">
<Chart
data={this.state.data}
domain={this.state.domain} />
</div>
);
}
});
React.renderComponent(App(), document.body);
看见了吧!我们现在拥有一张漂亮的圆点图了
添加分页和统计挂件(Widget) #
在前面,我们写了一个 React 组件 <App>
;它遵循 One Source Of Truth(准则 #1)。并且,我们还拥有一个 D3 图表渲染组件:你传递一个带 data
和 domain
属性的对象,进行渲染。
首先,我们增加分页(** pagination**)控制;这样,它就能帮助我们探索更大的数据集。我们创建一个 React 组件 <Pagination>
,它根据用户点击 Next 或 Previous 按钮来控制数据的显示:
// Pagination.js
var Pagination = React.createClass({
propTypes: {
domain: React.PropTypes.object,
getData: React.PropTypes.func,
setAppState: React.PropTypes.func
},
render: function() {
return (
<p>
{'Pages: '}
<a href="#" onClick={this.handlePrevious}>Previous</a>
<span> - </span>
<a href="#" onClick={this.handleNext}>Next</a>
</p>
);
},
handlePrevious: function(e) {
e.preventDefault();
this.shiftData(-20);
},
handleNext: function(e) {
e.preventDefault();
this.shiftData(+20);
},
shiftData: function(step) {
var newDomain = _.cloneDeep(this.props.domain);
newDomain.x = _.map(newDomain.x, function(x) {
return x + step;
});
var newData = this.props.getData(newDomain);
this.props.setAppState({
data: newData,
domain: newDomain
});
}
});
我们那这个功能添加到 <App>
中:
// App.js
var Pagination = require('./Pagination');
var App = React.createClass({
getInitialState: function() {
var domain = [0, 30];
return {
data: this.getData(domain),
domain: {x: domain, y: [0, 100]},
};
},
_allData: [/* some big dataset, too much to display at once */],
getData: function(domain) {
return _.filter(this._allData, this.isInDomain.bind(null, domain));
},
isInDomain: function(domain, d) {
return d.x >= domain[0] && d.x <= domain[1];
},
render: function() {
return (
<div className="App">
<Pagination
domain={this.domain}
getData={this.getData}
setAppState={this.setAppState} />
<Chart
data={this.state.data}
domain={this.state.domain} />
</div>
);
},
setAppState: function(partialState, callback) {
return this.setState(partialState, callback);
}
});
现在,我们已经实现它了!<Pagination>
组件通过使用 setAppState()
函数来改变我们的状态;只要有新的数据传入,就会重新渲染。如果我们想要去掉这个功能,或者其他的 widget,我们只需要删除 App.render()
中的 <Pagination ... />
部分。
我们还可以为 App.state.data
添加新的东西。例如,我们添加 <Stats>
挂件,让它展示一些必要的统计数据:
// Stats.js
var Stats = React.createClass({
propTypes: {
data: React.PropTypes.array
},
render: function() {
var data = this.props.data;
return (
<div className="Stats">
{this.renderCount(data)}
{this.renderAverage(data)}
</div>
);
},
renderCount: function(data) {
return (
<div className="Stats-item">
{'Count: '}<strong>{data.length}</strong>
</div>
);
},
renderAverage: function(data) {
var avg;
var n = data.length;
if (!n) {
avg = '-';
}
else {
var sum = _.reduce(data, function(sum, d) {
return sum + d.z;
}, 0);
avg = Math.round(sum/n * 10)/10;
}
return (
<div className="Stats-item">
{'Average size: '}<strong>{avg}</strong>
</div>
);
}
});
然后,在 <App>
组件中,我们把 <Stats>
组件放进去:
// App.js
var Stats = require('./Stats');
var App = React.createClass({
// ...
render: function() {
return (
<div className="App">
<Pagination
domain={this.domain}
getData={this.getData}
setAppState={this.setAppState} />
<Chart
data={this.state.data}
domain={this.state.domain} />
<Stats data={this.state.data} />
</div>
);
}
});
感谢 One Source Of Truth,所展示的统计数据永远和 D3 图表相关联。
添加工具提示(tooltip) #
最后,我为我们的界面添加 tooltip,来展示每一个圆点的数值。
我们希望我们悬停在某个圆点上,该圆点的 tooltip 展示相应数值。由于 D3 图表创建的元素与圆点关联,所以我们需要一些方法来告诉 d3Chart
的父节点把鼠标悬停事件挂载在圆点上。我有几种方法可以用。这里,我们只是使用简单的 Node.js EventEmitter
来调用 dispatcher
:
// d3Chart.js
var EventEmitter = require('events').EventEmitter;
d3Chart.create = function(el, props, state) {
// ...
var dispatcher = new EventEmitter();
this.update(el, state, dispatcher);
return dispatcher;
};
d3Chart.update = function(el, state, dispatcher) {
// ...
this._drawPoints(el, scales, state.data, dispatcher);
};
d3Chart._drawPoints = function(el, scales, data, dispatcher) {
// ...
// ENTER & UPDATE
point.attr('cx', function(d) { return scales.x(d.x); })
.attr('cy', function(d) { return scales.y(d.y); })
.attr('r', function(d) { return scales.z(d.z); });
.on('mouseover', function(d) {
dispatcher.emit('point:mouseover', d);
})
.on('mouseout', function(d) {
dispatcher.emit('point:mouseout', d);
});
// ...
};
注意到了吗? d3Chart
从 upstream 接受 domain
和 data
,例如 它的父节点 <Chart>
和 <App>
。我把这个过程当做一个数据下发的过程。我们可以使用 dispatcher
发送 mouseover
和 mouseout
事件和相关的数据回传给 upstream。而这个过程就是数据回流。
解决这么多问题,就仅仅为了正确的展示 tooltip 吗?当然不是,我是为了演示第三准则(Don’t Make Too Many Assumptions),因此我们不想假设这段代码紧紧用于监听圆点的鼠标悬停事件,来展示它的 tooltip 。我们只是提供信息 “Hey, this circle was hovered”。
另外,如果我们想直接显示 tooltip,我们将在 d3Chart
引入状态,这个违背了第二准则(Stateless All The Things)的指南。确实,相同的 domain
和 data
,图表可能渲染会不同(无论圆点被悬停与否)。外部代码没办法知道图表渲染的状态。
因此,我们需要鼠标事件回传数据,让我们做一些事。我们把 tooltip
对象添加到我们的 One Source Of Truth:
// App.js
var App = React.createClass({
getInitialState: function() {
var domain = [0, 30];
return {
data: this.getData(domain),
domain: {x: domain, y: [0, 100]},
tooltip: null
};
},
// ...
});
然后在 <Chart>
中,我们通过 dispatcher
监听鼠标事件来更新 tooltip
对象:
// Chart.js
var Chart = React.createClass({
propTypes: {
data: React.PropTypes.array,
domain: React.PropTypes.object,
setAppState: React.PropTypes.func
},
dispatcher: null,
componentDidMount: function() {
var el = this.getDOMNode();
var dispatcher = d3Chart.create(el, {
width: '100%',
height: '300px'
}, this.getChartState());
dispatcher.on('point:mouseover', this.showTooltip);
dispatcher.on('point:mouseout', this.hideTooltip);
this.dispatcher = dispatcher;
},
componentDidUpdate: function(prevProps, prevState) {
var el = this.getDOMNode();
d3Chart.update(el, this.getChartState(), this.dispatcher);
},
// ...
showTooltip: function(d) {
this.props.setAppState({tooltip: d});
},
hideTooltip: function() {
this.props.setAppState({tooltip: null});
}
});
很好,但是我们还没显示人和提示控件。让我们把一个 tooltips
数组传递给 d3Chart.update()
(同时也是用 D3 绘制 tooltip 的函数):
// d3Chart.js
d3Chart.update = function(el, state, dispatcher) {
// ...
this._drawTooltips(el, scales, state.tooltips);
};
d3Chart._drawTooltips = function(el, scales, tooltips) {
var g = d3.select(el).selectAll('.d3-tooltips');
var tooltipRect = g.selectAll('.d3-tooltip-rect')
.data(tooltips, function(d) { return d.id; });
// ENTER
tooltipRect.enter().append('rect')
.attr('class', 'd3-tooltip-rect')
.attr('width', TOOLTIP_WIDTH)
.attr('height', TOOLTIP_HEIGHT);
// ENTER & UPDATE
tooltipRect.attr('y', function(d) { return scales.y(d.y) - scales.z(d.z)/2 - TOOLTIP_HEIGHT; })
.attr('x', function(d) { return scales.x(d.x) - TOOLTIP_WIDTH/2; });
// EXIT
tooltipRect.exit()
.remove();
var tooltipText = g.selectAll('.d3-tooltip-text')
.data(tooltips, function(d) { return d.id; });
// ENTER
tooltipText.enter().append('text')
.attr('class', 'd3-tooltip-text')
.attr('dy', '0.35em')
.attr('text-anchor', 'middle')
.text(function(d) { return d.z; });
// ENTER & UPDATE
tooltipText.attr('y', function(d) { return scales.y(d.y) - scales.z(d.z)/2 - TOOLTIP_HEIGHT/2; })
.attr('x', function(d) { return scales.x(d.x); });
// EXIT
tooltipText.exit()
.remove();
};
注意下 Don’t Make Too Many Assumption 的另一个实例。D3 图表请求 tooltips
数组(对比单个 tooltip
对象),因为谁告诉你不能够一次展示多个提示控件?(当然这个过程中,我们将只看到一个点)。
让我们在 <Chart>
中创建这个 tooltips
数组,连同 domain
和 data
一起传递给 D3 图表:
// Chart.js
var Chart = React.createClass({
propTypes: {
data: React.PropTypes.array,
domain: React.PropTypes.object,
tooltip: React.PropTypes.object,
setAppState: React.PropTypes.func
},
// ...
componentDidUpdate: function(prevProps, prevState) {
var el = this.getDOMNode();
d3Chart.update(el, this.getChartState(), this.dispatcher);
},
getChartState: function() {
return {
data: this.props.data,
domain: this.props.domain,
tooltips: [this.props.tooltip]
};
},
// ...
});
噢啦!我们现在拥有悬停的 tooltip 了:
接下来,你就可以看到让事物无状态,不做过多假设的好处!让我们添加一个带按钮的 widget 来展示/隐藏所有 tooltip。我们所需要东西都已经准备就绪,剩下就很简单了!
我们添加布尔型属性 showingAllTooltips
到我们的 One Source Of Truth:
// App.js
var App = React.createClass({
getInitialState: function() {
var domain = [0, 30];
return {
data: this.getData(domain),
domain: {x: domain, y: [0, 100]},
tooltip: null,
showingAllTooltips: false
};
},
// ...
});
我们创建一个 React 组件 <ShowHideTooltips>
来切换 showingAllTooltips
状态(实现这个挂件的代码不是很有趣,所以我就不在这里贴出来了)。
最后,我们调整下我们构建 tooltips
数组的传递方式:
// Chart.js
var Chart = React.createClass({
propTypes: {
data: React.PropTypes.array,
domain: React.PropTypes.object,
tooltip: React.PropTypes.object,
showingAllTooltips: React.PropTypes.bool,
setAppState: React.PropTypes.func
},
// ...
getChartState: function() {
var tooltips = [];
if (this.props.showingAllTooltips) {
tooltips = this.props.data;
}
else {
tooltips = [this.props.tooltip];
}
return {
data: this.props.data,
domain: this.props.domain,
tooltips: tooltips
};
},
// ...
});
搞定,只需要加几行代码就实现了 显示/隐藏所有提示控件 的功能:
结语 #
实际上有很多种方式来组合 React 和 D3 的方式,也有很多现成工具和库供你使用。我展示的只是其中一种方式,是我个人认为更容易上手的一种方式。我相信一定还有其他种的方式,因此希望你也能分享下你自己的方法吧!