D3.js —— 绘制柱状图(一)

1,669 阅读6分钟

一、前言

1.1、D3.js 是什么?

D3.js 是一个基于 JavaScript 开发的库,主要是用于在浏览器中操作 SVGHTMLCSS ,通常我们可以利用它来进行一些图表绘制的工作。

1.2、为什么要学 D3.js

在我们学某种技术之前,最好是能够带有一些比较明确的目的,那么在学习的过程中就不容易丧失目标,最后只学到皮毛。

在我看来,学 D3 主要是有这几个方面:

  1. 感兴趣,希望在业余时间能够学习自己感兴趣的一些技术;同时 D3 也具有一定的复杂度,也可以拓宽自己的技术广度
  2. D3 可以很方便的绘制一些交互式的图表,同时相比业界现成的组件库更加的定制化,可以灵活的根据自己的需求来实现一些功能
  3. 工作中的一部分内容涉及到了图表交互,苦于一些组件库的定制化程度不高,或者是文档太过于繁杂,难以满足一些需求

因此,在这些动机的促使下我决定学习 D3 这个库是怎么使用的。

二、基础知识

2.1、常用 API

2.1.1、选择节点

API功能描述
d3.select用于选中某个 dom 元素,类似于 document.querySelector
d3.selectAll用于选中多个 dom 元素,类似于 document.querySelectorAll

2.1.2、修改节点

API功能描述
selection.text("content")获取或者返回当前选中节点的文本内容
selection.append("element name")添加节点到当前已选中节点的末尾处
selection.insert("element name")插入节点到当前已选中节点
selection.remove移除指定节点
selection.html("content")获取或返回当前已选中节点的 html
selection.attr("name", value)获取或设置当前已选中节点的属性
selection.property("name", value)同上
selection.style("name", value)获取或设置当前已选中节点的样式
selection.classed("css class", bool)获取、删除或者添加类名到当前已选中的节点

2.1.3、增删节点

API功能描述
selection.data将 data 绑定到节点上
selection.join基于绑定的 data 可实现 enter、update、exit 三种函数,更加精细的控制实际的效果
selection.enter获得进入的 selection
selection.exit获得退出的 selection
selection.datum获取/设置节点数据

更多 API 请参考 官方文档

2.2、链式调用

为了方便使用,D3 支持了链式调用。如上面的 API 所示,D3 里面分成这两类方法:

  1. d3.select / d3.selectionAll
  2. d3.selection.function
  3. 其他(以后再讲)

因此,我们必须要有一个 selection 对象才能够使用其他操作方法。如:

d3.select('div')
  .append('svg')
  .attr('width', 100)
  .attr('height', 100)

2.3、坐标系

需要注意的是,在 D3.js 绘制的 svg 中,坐标原点是在左上角的。

坐标系.drawio.svg

三、实战

3.1、第一步 —— 创建 svg

创建一个宽高为 400 x 33 的 svg

const width = 400
const height = 33
const svg = d3.create("svg")
  .attr("width", width)
  .attr("height", height)
  .attr("viewBox", `0 -20 ${width} 33`);

3.2、第二步 —— 生成随机字符

定义一个生成随机字符的函数。如下所示,我们会生成随机的字符,最多为 26 个大写的字母。代码如下:

function randomLetters() {
    return d3
      .shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''))
      .slice(0, Math.ceil(Math.random() * 26))
      .sort()
  }

目前到这里还算比较简单。

3.3、第三步 —— 更新字符串

根据生成的随机字符,把数据绑定到对应的节点上。其中,我们把新增、更新、删除的节点颜色分别设置为:

绿色、红色、金色

const t = svg.transition().duration(750);

svg.selectAll("text")
  // 绑定数据
  .data(randomLetters(), d => d)
  .join(
    // 设置新增的字符
    enter => enter.append("text")
      // 设置颜色
      .attr("fill", "green")
      // 设置位置
      .attr("x", (d, i) => i * 16)
      .attr("y", -30)
      // 设置字符到节点上
      .text(d => d)
      .call(enter => enter.transition(t)
        .attr("y", 0)),
    // 设置更新的字符
    update => update
      .attr("fill", "red")
      .attr("y", 0)
      .call(update => update.transition(t)
        .attr("x", (d, i) => i * 16)),
      // 设置删除的字符
    exit => exit
      .attr("fill", "gold")
      .call(exit => exit.transition(t)
        .attr("y", 30)
        .remove())
);

当然,这里的代码比较长并且理解成本也比较高,我们可以形成几个问题并尝试通过解决问题来理解他。

3.3.1、为什么 text 节点还没有就能够调用 selectAll

关于这点一开始我在学习 D3.js 也曾经想过,后来我去翻了下对应的源码感觉就能够 get 到了。

如下所示,我们可以看到调用 selectAll 的时候,实际上会返回一个 Selection 对象。而传入的 selector 则会被作为第一个参数接受,第二个参数是默认的 document.documentElement

import array from "./array.js";
import {Selection, root} from "./selection/index.js";

export default function(selector) {
  return typeof selector === "string"
      ? new Selection([document.querySelectorAll(selector)], [document.documentElement])
      : new Selection([array(selector)], root);
}

然后对象本身会保存着两个参数到 _groups_parents 这两个变量中,如下所示:

export function Selection(groups, parents) {
  this._groups = groups;
  this._parents = parents;
}

所以这一步本身只是生成了 Selection 的实例并且把 selector 及其父节点绑定到这个实例上。

那么问题就来了:

  1. 这一步可以用 select 函数来替代吗? 答:不行,因为 selectselectAll 函数的区别在于:前者调用的是 document.querySelector,结果返回的是对象;后者则调用的是 document.querySelectorAll,结果返回的是“数组”。那么最后的数据结构一个是对象,另外一个是二维数组。最直接的表现是,替换之后程序马上就会报错~
  2. 可以用其他函数来代替 selectAll 么? 答:不行,虽然本质上只需要拿到一个 Selecton 对象并且绑定数据就可以了,但是目前 D3.js 并没有把这个功能直接暴露出来。

综上,我们只能使用 selectAll 来获取 Selection 对象,即使没有预先生成节点而调用而导致看起来很别扭😢。

3.3.2、data 函数是怎么实现数据绑定的?

提到这个问题那么就必须看一下 data 函数的内部实现了:

  1. data 会根据传入的 key 来进行绑定,调用 bindKey 函数;否则则调用 bindIndex 函数

    1. bindIndex 按照下标分别将节点存入 updateenterexit 数组中
    2. bindKey 传入的 key 是一个函数,利用生成的 keyValue 来将节点存入 updateenterexit 数组中

值得注意的是,如果需要针对 update 阶段做处理(比如说动画),那么传 key 是非常重要的。因为默认传 index 的话,即使是节点更新了,那么也有可能触发不了 update 函数。

  1. 返回一个新的 Selection 对象
export default function(value, key) {
  if (!arguments.length) return Array.from(this, datum);

  var bind = key ? bindKey : bindIndex,
      parents = this._parents,
      groups = this._groups;

  if (typeof value !== "function") value = constant(value);

  for (var m = groups.length, update = new Array(m), enter = new Array(m), exit = new Array(m), j = 0; j < m; ++j) {
    var parent = parents[j],
        group = groups[j],
        groupLength = group.length,
        data = arraylike(value.call(parent, parent && parent.__data__, j, parents)),
        dataLength = data.length,
        enterGroup = enter[j] = new Array(dataLength),
        updateGroup = update[j] = new Array(dataLength),
        exitGroup = exit[j] = new Array(groupLength);

    bind(parent, group, enterGroup, updateGroup, exitGroup, data, key);

    // Now connect the enter nodes to their following update node, such that
    // appendChild can insert the materialized enter node before this node,
    // rather than at the end of the parent node.
    for (var i0 = 0, i1 = 0, previous, next; i0 < dataLength; ++i0) {
      if (previous = enterGroup[i0]) {
        if (i0 >= i1) i1 = i0 + 1;
        while (!(next = updateGroup[i1]) && ++i1 < dataLength);
        previous._next = next || null;
      }
    }
  }

  update = new Selection(update, parents);
  update._enter = enter;
  update._exit = exit;
  return update;
}

戳我查看完整源码

3.4、第四步 —— 收尾

Okay, 如果上面这些代码都看懂了,最后一步就很简单了 —— 添加循环。

;(async () => {
    // ...
    while (true) {
      // ...
      await new Promise(resolve => setTimeout(resolve, 3000));
      document.body.appendChild(svg.node());
    }

那么经过以上这些步骤,我们就实现了一个简单的 demo。通过这个 demo,可以将前面提到的一些知识点融会贯通。

戳我看完整源码👈

四、总结

这篇文章我们花了一些篇幅来了解 D3.js 中常见的 API 及其用法。目前涉及到的部分比较少,主要是为了为下一篇带大家绘制柱状图做一下铺垫。