React 最佳实践:集成第三方库(D3.js)

4,327 阅读4分钟

React 提供了声明式方式,让我们可以更方便清晰的描述 UI ,但是,对于需要依赖真实 DOM 节点的第三方 js 库,例如 D3.js,我们又该怎么在 React 组件中使用呢?我们至少要思考以下三点:

  • Q:如何获取原生 DOM 节点?

    A:使用 ref 获取原生 DOM 节点引用。

  • Q:如何更新原生 DOM 节点上的组件状态?

    A:手动更新,React 只会维护虚拟 DOM 节点上的组件状态。

  • 需要注意,在组件销毁时移除原生节点上的 DOM 事件。

我们以 D3.js 作为例子,具体聊聊~

D3.js 是什么?

D3Data-Driven DocumentsD3.js)是一个非常著名的画图的 JavaScript 库,用于使用 Web 标准将数据可视化。它是必须要对底层的 DOM 节点进行操作的,这也是选择 D3.js 的其中原因;其次,它数据驱动的属性和 React 也十分类似。

目前也有基于 D3.js 封装的 React 库,一般来说功能会收到限制,肯定不如直接使用 D3.js 得到的功能更完备。掌握 D3.js 在 React 中的使用,对于开发可视化会有很大的帮助。

创建一个仿真力模型

d3-1.png

每个节点(Node)都是可以拖动的,并且有一个力反馈的效果。用户可以为中间的圈手动添加新的节点(Node)。

安装

  • yarn
yarn add d3
  • npm
npm install d3

类组件实现

创建(componentDidMount)

首页,我们定义了一组初始数据,包括节点(nodes)和节点之间的关系(links):

{
  "nodes": [
    {
      "id": "id1",
      "group": 1
    },
    {
      "id": "id2",
      "group": 2
    },
    {
      "id": "id3",
      "group": 3
    },
    {
      "id": "id4",
      "group": 4
    }
  ],
  "links": [
    {
      "source": "id1",
      "target": "id2",
      "value": 1
    },
    {
      "source": "id1",
      "target": "id3",
      "value": 1
    },
    {
      "source": "id1",
      "target": "id4",
      "value": 1
    }
  ]
}

对于 d3 而言,它需要一个 DOM Node 作为画图区域。 在 React 中,可以通过 ref 得到对组件真正实例的引用,ref 属性可以设置为一个回调函数,这也是官方强烈推荐的用法:

  • 组件被挂载后,回调函数被立即执行,回调函数的参数为该组件的具体实例。

  • 组件被卸载或者原有的 ref 属性本身发生变化时,回调也会被立即执行,此时回调函数参数为 null,以确保内存泄露。

// d3Node 作为我们的画图区域
<div className="d3-node" ref={(node) => (this.d3Node = node)} />

在组件挂载完成后,也就是 componentDidMount,d3Node 会初始化一个 svg(包括长宽背景色等样式和一个力学仿真空间)。linksGroup 是线条的存放容器,nodesGroup 是节点的存放容器。

this.svg = d3
  .select(this.d3Node)
  .append('svg')
  .attr('width', width)
  .attr('height', height);
this.color = d3.scaleOrdinal(d3.schemeCategory10);
this.simulation = d3
  .forceSimulation()
  .force(
    'link',
    d3.forceLink().id((d) => d.id),
  )
  .force('charge', d3.forceManyBody())
  .force('center', d3.forceCenter(width / 2, height / 2));

this.linksGroup = this.svg.append('g');
this.nodesGroup = this.svg.append('g');

关于数据渲染的逻辑,在首次绘制和后续更新中是一样的,所以我们可以共用这部分的代码,画图的主要逻辑就是 有多少个 node 就画多少个圈,他们之间的关系有 line 连接,关于 node 的位置由 d3.force API 自动生成:

  updateDiagrarm() {
    const { data } = this.state;
    let link = this.linksGroup
      .attr('class', 'links')
      .selectAll('line')
      .data(data.links);
    link.exit().remove();
    link = link
      .enter()
      .append('line')
      .attr('stroke-width', function (d) {
        return Math.sqrt(d.value);
      })
      .merge(link);

    let node = this.nodesGroup
      .attr('class', 'nodes')
      .selectAll('circle')
      .data(data.nodes);
    node.exit().remove();
    node = node
      .enter()
      .append('circle')
      .attr('r', (d) => (d.id === 'id1' ? 24 : 16))
      .attr('fill', (d) => {
        return this.color(d.group);
      })
      .call(
        d3
          .drag()
          .on('start', this.dragstarted)
          .on('drag', this.dragged)
          .on('end', this.dragended),
      )
      .merge(node);

    this.simulation.nodes(data.nodes).on('tick', ticked);

    this.simulation.force('link').links(data.links).distance(100);

    this.simulation.alpha(1).restart();

    function ticked() {
      link
        .attr('stroke', '#c7c7c7')
        .attr('x1', (d) => d.source.x)
        .attr('y1', (d) => d.source.y)
        .attr('x2', (d) => d.target.x)
        .attr('y2', (d) => d.target.y);

      node.attr('cx', (d) => d.x).attr('cy', (d) => d.y);
    }
  }

  dragstarted = (event, d) => {
    if (!event.active) this.simulation.alphaTarget(0.3).restart();
    d.fx = d.x;
    d.fy = d.y;
  };
  dragged = (event, d) => {
    d.fx = event.x;
    d.fy = event.y;
  };
  dragended = (event, d) => {
    if (!event.active) this.simulation.alphaTarget(0);
    d.fx = null;
    d.fy = null;
  };

更新(componentDidUpdate)

基于 D3 数据驱动这个性质,新增 Node 的行为就是为 data 新增 node 和新 node 的对应关系:

handleAddNode = () => {
  const id = `id${new Date().getTime()}`;
  const node = { id, group: _.random(1, 9) };
  this.setState({
    data: {
      nodes: [...this.state.data.nodes, node],
      links: [
        ...this.state.data.links,
        { source: 'id1', target: id, value: 1 },
      ],
    },
  });
};

需要注意的是,React 只会管理到 d3Node 层,d3Node 以下的内容都是我们手动管理的,所以在 componentDidUpdate 中,我们添加更新逻辑:

componentDidUpdate(prevProps, prevState) {
  if (this.state.data !== prevState.data) this.updateDiagrarm();
}

销毁

在这个例子中,因为没有为真实 Dom 绑定额外的事件,在组件销毁之后,React 会移除 d3Node 这个节点的同时,也会移除 d3Node 下的所有内容。

函数组件实现

React Hooks 中的画图逻辑和类组件中是一样的。

  • 初始化 Svg

    useEffect(() => {
      // initSvg
    }, []);
    
  • 在 data 变化时,更新视图

    useEffect(() => {
      // updateDiagrarm
    }, [data]);
    

在实践的时候,发现有两个坑需要防一下:

  • 避免重复绘图,为 useEffect 添加第二个参数 [],并不能完全避免这个问题,我们可以再加一层判断:

    const checkElementExist = (element) => {
      if (element) {
        element.remove();
      }
    };
    checkElementExist(getSvg().selectAll('svg'));
    
  • useState 必须要执行完 react 整个生命周期才会获取最新值,这就导致在生命周期结束前无法操作新建的 Dom 节点。其实我们也不需要通过 useState 来自动更新视图,这里可以用 useRef 代替。

    const color = useRef(null);
    const simulation = useRef(null);
    const linksGroup = useRef(null);
    const nodesGroup = useRef(null);
    

完整代码

想自己上手跑一跑的朋友,可以戳这里

React 最佳实践