谁喜欢图表?每个人都喜欢,对吧?创建图表有很多方法,包括许多库。 有 D3.js、Chart.js、amCharts、Highcharts 和 Chartist,仅举几例众多选项中的几个。
但我们并不一定需要图表库来创建图表。 以 Mobx-state-tree (MST) 为例,这是一个直观的 Redux 替代方案,用于管理 React 中的状态。 我们可以使用简单的 SVG 元素构建交互式自定义图表,并使用 MST 管理和操作图表的数据。 如果您过去尝试过使用 D3.js 之类的东西构建图表,我认为您会发现这种方法更直观。 即使您是经验丰富的 D3.js 开发人员,我仍然认为您会对 MST 作为可视化数据架构的强大功能感兴趣。
这是一个 MST 用于驱动图表的示例
此示例使用 D3 的缩放函数,但图表本身只是使用 JSX 中的 SVG 元素渲染的。 我不知道任何图表库具有闪烁仓鼠点的选项,因此这是一个很好的例子,说明为什么构建自己的图表很棒——而且不像您想象的那么难!
我使用 D3 构建图表已有 10 多年了,虽然我喜欢它强大的功能,但我总是发现我的代码最终可能变得笨拙且难以维护,尤其是在处理复杂的可视化时。MST 通过提供一种优雅的方式将数据处理与渲染分离,彻底改变了这一切。 我希望这篇文章能够鼓励您尝试一下。
熟悉 MST 模型
首先,让我们快速概述一下 MST 模型的外观。 这不是关于 MST 的所有内容的深入教程。 我只想展示基础知识,因为实际上,在 90% 的时间里,您只需要这些知识。
下面是一个沙盒,其中包含使用 MST 构建的简单待办事项列表的代码。 快速浏览一下,然后我将解释每个部分的作用。
首先,对象的形式使用模型属性的类型定义来定义。 用通俗易懂的语言来说,这意味着待办事项模型的一个实例必须有一个标题,该标题必须是字符串,并且默认情况下“已完成”属性为false
。
.model("Todo", {
title: types.string,
done: false //this is equivalent to types.boolean that defaults to false
})
接下来,我们有视图和动作函数。 视图函数是根据模型中的数据访问计算值的方法,而无需对模型保存的数据进行任何更改。 可以将它们视为只读函数。
.views(self => ({
outstandingTodoCount() {
return self.todos.length - self.todos.filter(t => t.done).length;
}
}))
另一方面,动作函数允许我们安全地更新数据。 这始终在后台以不可变的方式完成。
.actions(self => ({
addTodo(title) {
self.todos.push({
id: Math.random(),
title
});
}
}));
最后,我们创建存储的新实例
const todoStore = TodoStore.create({
todos: [
{
title: "foo",
done: false
}
]
});
为了展示存储的实际应用,我添加了几个控制台日志,以显示在触发第一个Todo
实例的切换函数之前和之后outStandingTodoCount()
的输出。
console.log(todoStore.outstandingTodoCount()); // outputs: 1
todoStore.todos[0].toggle();
console.log(todoStore.outstandingTodoCount()); // outputs: 0
如您所见,MST 为我们提供了一个数据结构,使我们能够轻松访问和操作数据。 更重要的是,它的结构非常直观,代码一目了然——没有 reducer!
让我们创建一个 React 图表组件
好的,既然我们对 MST 的外观有了一些了解,那么让我们使用它来创建一个存储,用于管理图表的的数据。 不过,我们将从图表 JSX 开始,因为一旦知道需要哪些数据,构建存储就容易得多。
让我们看一下渲染图表的 JSX。
首先要注意的是,我们正在使用 styled-components 来组织我们的 CSS。 如果您不熟悉它,Cliff Hall 有一篇很棒的文章 展示了它在 React 应用程序中的使用。
首先,我们正在渲染将更改图表轴的下拉列表。 这是一个相当简单的 HTML 下拉列表,包装在一个样式化组件中。 需要注意的是,这是一个 受控输入,其状态使用我们模型中的selectedAxes
值设置(我们稍后将查看此值)。
<select
onChange={e =>
model.setSelectedAxes(parseInt(e.target.value, 10))
}
defaultValue={model.selectedAxes}
>
接下来,我们有图表本身。 我将轴和点拆分成了它们自己的组件,这些组件位于一个单独的文件中。 这通过使每个文件保持简洁来真正帮助保持代码的可维护性。 此外,这意味着如果我们想使用线形图而不是点,我们可以重用轴。 在处理具有多种图表类型的大型项目时,这确实会带来回报。 它还可以轻松地在隔离中测试组件,无论是在编程上还是在活动样式指南中手动测试。
{model.ready ? (
<div>
<Axes
yTicks={model.getYAxis()}
xTicks={model.getXAxis()}
xLabel={xAxisLabels[model.selectedAxes]}
yLabel={yAxisLabels[model.selectedAxes]}
></Axes>
<Points points={model.getPoints()}></Points>
</div>
) : (
<Loading></Loading>
)}
尝试注释掉上面沙盒中的轴和点组件,以查看它们如何独立于彼此工作。
最后,我们将用观察者函数包装组件。 这意味着模型中的任何更改都将触发重新渲染。
export default observer(HeartrateChart);
让我们看一下Axes
组件
如您所见,我们有一个XAxis
和一个YAxis
。 每个都有一个标签和一组刻度标记。 我们稍后将介绍如何创建标记,但在此处您应该注意,每个轴都由一组刻度组成,这些刻度是通过遍历一组对象生成的,这些对象具有标签以及x
或y
值(具体取决于我们正在渲染哪个轴)。
尝试更改元素的一些属性值,看看会发生什么……或者会发生什么错误! 例如,将YAxis
中的线元素更改为以下内容
<line x1={30} x2="95%" y1={0} y2={y} />
学习如何使用 SVG 构建视觉效果的最佳方法就是进行实验并破坏它们。 🙂
好的,那是图表的一半。 现在,我们将看一下Points
组件。
图表上的每个点都由两部分组成:一个 SVG 图像和一个圆形元素。 图像是动物图标,圆形提供了将鼠标悬停在图标上时可见的脉冲动画。
尝试注释掉image
元素,然后注释掉circle
元素,看看会发生什么。
这次模型必须提供一个点对象数组,该数组为我们提供四个属性:用于在图形上定位点的x
和y
值、点的label
(动物的名称)和pulse
,它是每个动物图标的脉冲动画的持续时间。 希望这一切看起来都直观且合乎逻辑。
同样,尝试修改属性值以查看哪些内容发生了变化和错误。 您可以尝试将image
的y
属性设置为 0。相信我,这是一种比阅读 SVG 图像元素的 W3C 规范 更不吓人的学习方式!
希望这能让您了解我们在 React 中如何渲染图表。 现在,只需创建一个具有适当操作的模型即可生成我们需要在 JSX 中循环遍历的点和刻度数据。
创建我们的存储
这是存储的完整代码
我将代码分解成前面提到的三个部分
定义模型的属性
我们在此处定义的所有内容都可以在外部作为模型实例的属性访问,并且——如果使用observable
包装的组件——这些属性的任何更改都将触发重新渲染。
.model('ChartModel', {
animals: types.array(AnimalModel),
paddingAndMargins: types.frozen({
paddingX: 30,
paddingRight: 0,
marginX: 30,
marginY: 30,
marginTop: 30,
chartHeight: 500
}),
ready: false, // means a types.boolean that defaults to false
selectedAxes: 0 // means a types.number that defaults to 0
})
每种动物有四个数据点:名称(Creature
)、寿命(Longevity__Years_
)、重量(Mass__grams_
)和静息心率(Resting_Heart_Rate__BPM_
)。
const AnimalModel = types.model('AnimalModel', {
Creature: types.string,
Longevity__Years_: types.number,
Mass__grams_: types.number,
Resting_Heart_Rate__BPM_: types.number
});
定义动作
我们只有两个动作。 第一个(setSelectedAxes
)是在更改下拉菜单时调用的,它更新selectedAxes
属性,该属性反过来决定使用哪些数据来渲染轴。
setSelectedAxes(val) {
self.selectedAxes = val;
},
setUpScales
动作需要更多解释。 此函数在图表组件挂载后立即在useEffect
钩子函数中调用,或在窗口大小调整后调用。 它接受一个包含元素的 DOM 宽度对象。 这使我们能够为每个轴设置缩放函数以填充完整的可用宽度。 我将很快解释缩放函数。
为了设置缩放函数,我们需要计算每种数据类型的最大值,因此我们首先遍历动物来计算这些最大值和最小值。 对于我们想要从零开始的任何比例,我们可以使用零作为最小值。
// ...
self.animals.forEach(
({
Creature,
Longevity__Years_,
Mass__grams_,
Resting_Heart_Rate__BPM_,
...rest
}) => {
maxHeartrate = Math.max(
maxHeartrate,
parseInt(Resting_Heart_Rate__BPM_, 10)
);
maxLongevity = Math.max(
maxLongevity,
parseInt(Longevity__Years_, 10)
);
maxWeight = Math.max(maxWeight, parseInt(Mass__grams_, 10));
minWeight =
minWeight === 0
? parseInt(Mass__grams_, 10)
: Math.min(minWeight, parseInt(Mass__grams_, 10));
}
);
// ...
现在开始设置比例尺函数!在这里,我们将使用 D3.js 中的 scaleLinear
和 scaleLog
函数。在设置这些函数时,我们需要指定域,即函数可以接收的最小和最大输入值,以及范围,即最大和最小输出值。
例如,当我用 maxHeartrate
值调用 self.heartScaleY
时,输出将等于 marginTop
。这是有道理的,因为这将位于图表的最顶部。对于寿命属性,我们需要两个比例尺函数,因为这些数据将出现在 x 轴或 y 轴上,具体取决于选择的下拉选项。
self.heartScaleY = scaleLinear()
.domain([maxHeartrate, minHeartrate])
.range([marginTop, chartHeight - marginY - marginTop]);
self.longevityScaleX = scaleLinear()
.domain([minLongevity, maxLongevity])
.range([paddingX + marginY, width - marginX - paddingX - paddingRight]);
self.longevityScaleY = scaleLinear()
.domain([maxLongevity, minLongevity])
.range([marginTop, chartHeight - marginY - marginTop]);
self.weightScaleX = scaleLog()
.base(2)
.domain([minWeight, maxWeight])
.range([paddingX + marginY, width - marginX - paddingX - paddingRight]);
最后,我们将 self.ready
设置为 true
,因为图表已准备好渲染。
定义视图
我们有两组视图函数。第一组输出渲染轴刻度线所需的数据(我说过我们会说到这里!),第二组输出渲染点所需的数据。我们先来看一下刻度函数。
React 应用中只调用了两个刻度函数:getXAxis
和 getYAxis
。它们根据 self.selectedAxes
的值简单地返回其他视图函数的输出。
getXAxis() {
switch (self.selectedAxes) {
case 0:
return self.longevityXAxis;
break;
case 1:
case 2:
return self.weightXAxis;
break;
}
},
getYAxis() {
switch (self.selectedAxes) {
case 0:
case 1:
return self.heartYAxis;
break;
case 2:
return self.longevityYAxis;
break;
}
},
如果我们看一下 Axis
函数本身,我们可以看到它们使用了比例尺函数的 ticks
方法。这将返回一个适合轴的数字数组。然后我们遍历这些值,返回轴组件所需的数据。
heartYAxis() {
return self.heartScaleY.ticks(10).map(val => ({
label: val,
y: self.heartScaleY(val)
}));
}
// ...
尝试将刻度函数的参数值更改为 5,并查看它对图表的影响:self.heartScaleY.ticks(5)
。
现在我们有了用于返回 Points
组件所需数据的视图函数。
如果我们看一下 longevityHeartratePoints
(它返回“寿命与心率”图的点数据),我们可以看到我们正在遍历 animals
数组,并使用适当的比例尺函数获取点的 x
和 y
位置。对于 pulse
属性,我们使用一些数学运算将心率的每分钟跳动次数转换为表示单个心跳持续时间的毫秒值。
longevityHeartratePoints() {
return self.animals.map(
({ Creature, Longevity__Years_, Resting_Heart_Rate__BPM_ }) => ({
y: self.heartScaleY(Resting_Heart_Rate__BPM_),
x: self.longevityScaleX(Longevity__Years_),
pulse: Math.round(1000 / (Resting_Heart_Rate__BPM_ / 60)),
label: Creature
})
);
},
在 store.js
文件的末尾,我们需要创建一个 Store
模型,然后使用动物对象的原始数据实例化它。一个常见的模式是将所有模型附加到一个父 Store
模型,然后可以在需要时通过顶层的提供者访问它。
const Store = types.model('Store', {
chartModel: ChartModel
});
const store = Store.create({
chartModel: { animals: data }
});
export default store;
就是这样!再次看看我们的演示
这绝不是在 JSX 中组织数据构建图表唯一的方法,但我发现它非常有效。我在实际项目中使用过这种结构和堆栈,为大型企业客户构建了一个自定义图表库,并且对 MST 在此用途上的出色表现感到震惊。希望你也能获得同样的体验!
请原谅我的困惑,但这不算是变异吗?
嗨,Michel Weststrate 在他的文章 Mobx State Tree 的奇特案例中很好地解释了这一点。转到名为可变的不可变树的部分
https://codeburst.io/the-curious-case-of-mobx-state-tree-7b4e22d461f
我明白了,感谢你提供的有趣链接。
顺便说一句,这是一篇很棒的文章!非常详细和全面。