↓

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

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

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

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

这里,假设你有 React 和 D3.js 的基础知识。

三大准则 #

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

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

  1. One Source Of Truth(真正的单一源):D3 可视化要求获取所有它需要渲染的数据。React 组件的 state,它既能被 D3 组件使用,也可以被其他的 React 组件使用。
  2. Stateless All The Things(一切事务皆无状态):D3 和 React 组件都需要尽可能的无状态,换句话说,相同的输入只会产生相同的渲染。
  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 的最常见使用方式(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 的方式,也有很多现成工具和库供你使用。我展示的只是其中一种方式,是我个人认为更容易上手的一种方式。我相信一定还有其他种的方式,因此希望你也能分享下你自己的方法吧!

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

译自 Integrating D3.js visualizations in a React app