React& D3.js: 整合 D3.js 可视化组件到 React 应用中

·1293 字·7 分钟
Javascript d3 react
n3xtchen
作者
n3xtchen
Sharing Funny Tech With You

使用一个小小的例子来演示如何把 D3.js 数据可视化到 React 应用中。

我最近在使用 D3.jsReact。因此,我想要分享使用这两个库创建组件和交互接口的几种方式。我希望它们能帮助你更优雅地实现两个库的整合。

这里,假设你有 ReactD3.js 的基础知识。

三大准则 #

首先,我认为 ReactD3.js 的整合是可行的,因为它们都有共同的哲学(给我一些数据,告诉我如何显示,然后我将自行计算哪一些 DOM 需要被更新)。确实,ReactVirtual DOM diffs,而 D3.jsupdate selections;它们都是在同步 UI 和数据同步的逻辑上相当有效。

现在我们看看下面三条准则,就会发现 D3 作为组件可以很好地融合到 React 中:

  1. One Source Of Truth(真正的单一源)D3 可视化要求获取所有它需要渲染的数据。React 组件的 state,它既能被 D3 组件使用,也可以被其他的 React 组件使用。
  2. Stateless All The Things(一切事务皆无状态)D3React 组件都需要尽可能的无状态,换句话说,相同的输入只会产生相同的渲染。
  3. 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 的最常见使用方式(enterupdateexit 模式):

// 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 图表的 createupdatedestroy 函数映射到 React 组件的生命周期函数 componentDidMountcomponentDidUpdatecomponentWillUnmount

最后,创建一个 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 图表渲染组件:你传递一个带 datadomain 属性的对象,进行渲染。

首先,我们增加分页(** pagination**)控制;这样,它就能帮助我们探索更大的数据集。我们创建一个 React 组件 <Pagination>,它根据用户点击 NextPrevious 按钮来控制数据的显示:

// 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);
      });
  // ...
};

注意到了吗? d3Chartupstream 接受 domaindata,例如 它的父节点 <Chart><App>。我把这个过程当做一个数据下发的过程。我们可以使用 dispatcher 发送 mouseovermouseout 事件和相关的数据回传给 upstream。而这个过程就是数据回流。

解决这么多问题,就仅仅为了正确的展示 tooltip 吗?当然不是,我是为了演示第三准则(Don’t Make Too Many Assumptions),因此我们不想假设这段代码紧紧用于监听圆点的鼠标悬停事件,来展示它的 tooltip 。我们只是提供信息 “Hey, this circle was hovered”。

另外,如果我们想直接显示 tooltip,我们将在 d3Chart 引入状态,这个违背了第二准则(Stateless All The Things)的指南。确实,相同的 domaindata,图表可能渲染会不同(无论圆点被悬停与否)。外部代码没办法知道图表渲染的状态。

因此,我们需要鼠标事件回传数据,让我们做一些事。我们把 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 数组,连同 domaindata 一起传递给 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
    };
  },

  // ...
});

搞定,只需要加几行代码就实现了 显示/隐藏所有提示控件 的功能:

显示/隐藏所有提示控件

结语 #

实际上有很多种方式来组合 ReactD3 的方式,也有很多现成工具和库供你使用。我展示的只是其中一种方式,是我个人认为更容易上手的一种方式。我相信一定还有其他种的方式,因此希望你也能分享下你自己的方法吧!

完整代码: github 演示地址: demo

译自 Integrating D3.js visualizations in a React app