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 的方式,也有很多现成工具和库供你使用。我展示的只是其中一种方式,是我个人认为更容易上手的一种方式。我相信一定还有其他种的方式,因此希望你也能分享下你自己的方法吧!
