什么使软件出类拔萃? [译]

作者:

Mike Bostock

作为一名开源软件开发者,我经常思考如何改善软件。

这是必然的:在 Stack Overflow、GitHub 的问题反馈和 Slack 提醒,以及电子邮件和直接信息中,有源源不断的求助。幸运的是,你也会看到有人成功并创造出令人惊叹的成果,这超出了你的想象。知道自己的帮助起到了作用,这成了我不断前行的强大动力。

所以我常思考:是哪些软件特质让人们取得成功或遭遇失败?我怎样才能改进我的软件,帮助更多人成功?我能否总结出一些指导原则,或者我只是依靠每个案例的直觉?(思考与表达思考是两种截然不同的活动。)或许可以参考 Dieter Ram 的设计原则,但专门针对软件?

优秀的设计富有创新。
优秀的设计增加产品实用性。
优秀的设计美观。
优秀的设计使产品易于理解。
优秀的设计不引人注目。
优秀的设计诚实。
优秀的设计持久耐用。
优秀的设计关注到最小细节。
优秀的设计环保。
优秀的设计力求简洁。

我曾尝试讨论 宏观视角 的问题,比如寻找最小但有趣的问题,识别并减少工具中的负面偏见,或是利用相关技术和标准。

宏观视角非常重要 — 或许比我今天所讨论的内容还要重要 — 但我不禁感觉,这些宏观建议有时候难以实践,甚至有些是陈词滥调。就像是说,“尽量简化,但不要过分简化。”这是显而易见的。我们都希望事情更简单。但我们可能不清楚为了达到这个目标需要放弃什么。

即使你对宏观有了正确的理解,也无法保证你的设计一定会成功。一个想法的执行同其本身一样重要。正如俗语所说,“魔鬼藏在细节中”。

如果我不能提供具有实际操作性的宏观建议,或许还有一些较小的、有用的建议值得分享。Green & Petre 提出的一个实用的灵感来源是他们的“认知维度”框架,这个框架提出了一套“讨论工具”,旨在提升关于代码等“信息工件”的可用性讨论的层次。

抽象程度
映射的密切程度
一致性
分布范围
容易出错
心理负担
隐藏的依赖
过早承诺
逐步评估
角色明确性
辅助标记
修改难度
可视性

虽然这个框架并不完美——毕竟没有任何框架是万能的。它最初是为研究视觉编程环境而设计的,在某些情况下可能显得过于专门化。(比如,考虑到可视性,即能否一眼看到所有代码。现在有哪款软件能在一个屏幕上完全展现出来呢?或许模块化是一个更好的概念?)我发现,把某些可用性问题划归到特定的维度并非易事。(例如,隐藏的依赖角色明确性都让我误以为代码会执行一些实际并未发生的操作。)但这仍是从软件设计的“认知影响”角度进行思考的一个良好起点。

我不打算定义一个通用的框架,但我有一些观察和想法想要分享。现在是个很好的时机,来对我过去一年左右在D3 4.0上的工作进行一番事后的理性分析。

我不会重新审视 D3 的整体设计。我对如数据连接比例尺布局这些与视觉表示分离的概念非常满意。尽管这些方面的研究十分有趣,但它们并非我最近的研究重点。

我正在将 D3 分解成模块,目的是让它在更多应用中可用,更易于其他人扩展,开发起来也更加有趣。同时,我也在识别并修复 API 中许多出乎意料的怪癖和缺陷。这些问题可能容易被忽略,但我相信它们确实带来了实际困扰,限制了人们的使用可能性。

我时常担心,当单独看这些变化时,它们似乎微不足道。但我希望你能相信,它们并非如此。我的担忧源于我们(编写软件的人)通常倾向于忽视编程界面的易用性,而更多地关注那些更容易衡量的客观指标:功能性性能正确性

这些指标固然重要,但不良的易用性确实会造成实际的损失。只需问问那些费尽心思去理解混乱代码,或者在与调试器斗争中烦恼不已的人。我们需要更早地评估易用性,并且在软件开发初期就致力于提高其易用性。

Mori Masahiro Design Studio, LLC., CC-BY-3.0

你无法通过触摸一段代码来感受其重量或质感。代码是一种“信息性质的产物”,而非实体或视觉物品。你是通过在编辑器或命令行中操作文本来与 API 进行交互的。

尽管如此,这种交互依旧符合标准定义,涉及到复杂的人类因素。因此,在评估代码时,我们不应仅仅考虑它是否能完成既定任务,还应考虑学习使用它的难易程度,以及使用它的效率和愉悦感。我们应该思考代码的实用性甚至美学价值。它易于理解吗?使用时会感到沮丧吗?它具有美感吗?

编程界面也是用户界面。换句话说:程序员也是普通人。 在设计中忽视人性方面的问题上,再听听 Rams 的见解:

“对人们及其所处现实的漠视,实际上是设计领域中唯一不可饶恕的大罪。”

这首先表明,即使有优秀的文档,也无法补救糟糕的设计。你可以鼓励人们去阅读 RTFM,但假设他们能够阅读并记住所有细节是不现实的。实际上,示例的清晰度和软件在现实环境中的可解读性与调试性可能比设计本身更为关键。设计的形式应当清晰表达其功能。

在这个引言之后,我将介绍一些我为提高 D3 的易用性而做的更改。但在此之前,让我们先来了解一下 D3 的数据连接基础知识。

案例 1. 揭秘 enter.append 的魔力。

“D3”代表 _Data-_D_riven _D_ocuments(数据驱动文档)。这里的 _data(数据)是指你想要展现的内容,而 document(文档)则是指这些内容的视觉展示形式。之所以称之为“文档”,是因为 D3 是基于网页标准模型——Document Object Model(文档对象模型)来构建的。

一个简单的网页可能长这样:

<!DOCTYPE html>
<svg width="960" height="540">
<g transform="translate(32,270)">
<text x="0">b</text>
<text x="32">c</text>
<text x="64">d</text>
<text x="96">k</text>
<text x="128">n</text>
<text x="160">r</text>
<text x="192">t</text>
</g>
</svg>

这其实是一个含有 SVG 元素HTML 文档。即使你不太了解每个元素和属性的具体含义,也能大概理解其概念。重要的是要知道,每个元素,比如用于表示文本的 <text>…</text>,都是一个独立的图形标记。这些元素按层次组织(比如 <svg> 包含 <g><g> 又包含 <text>),这样可以方便地对一组元素进行定位和样式设置。

与之相对应的简单数据集可能是这样的:

var data = ["b", "c", "d", "k", "n", "r", "t"];

这是一个字符串数组。(字符串是由字符组成的序列,虽然这里的字符串是单个字母。)但你可以自由选择数据的结构,只要能在 JavaScript 中表达出来。

对于数据数组中的每一个条目(即每一个字符串),我们都需要在文档中对应一个 <text> 元素。这就是数据连接(data-join)的作用:它是一种高效的方法,用来调整文档——增加、删除或修改元素——使之与数据相匹配。

数据连接的操作是:输入一个数据数组和一个文档元素数组,然后输出三种选择:

  • enter 选择代表“缺失”的元素(即将到来的数据),你可能需要创建并添加到文档中。
  • update 选择代表现有的元素(持续存在的数据),你可能需要对其进行修改(例如,重新定位)。
  • exit 选择代表“剩余”的元素(即将离开的数据),你可能需要从文档中移除。

数据合并(data-join)并不直接修改文档本身。它计算 enterupdateexit 这三种状态,然后你可以对每种状态应用相应的操作。这种方式提供了更多的表现力:例如,可以实现元素在进入和退出时的动画效果。

bl.ocks.org/a8a5baa4c4a470cda598

可以想象,数据合并是你在创建可视化时经常使用的一个功能,每当数据发生变化时也会再次使用。这个功能的易用性对于 D3 的整体实用性至关重要。具体操作如下:

var text = g.selectAll("text").data(data, key); // JOIN
text
.exit() // EXIT
.remove();
text // UPDATE
.attr("x", function (d, i) {
return i * 32;
});
text
.enter() // ENTER
.append("text")
.attr("x", function (d, i) {
return i * 32;
}) // 🌶
.text(function (d) {
return d;
});

我略去了一些细节(例如,用于将数据分配给元素的 key 函数),但希望能够传达主要概念。在数据合并后,上述代码会移除离开的元素,调整更新的元素位置,并添加新进入的元素。

上述代码中有一个使用上的小麻烦,我用辣椒标记 🌶 来指出。那就是代码重复:在 enterupdate 上设置 x 属性的操作重复了。

对于同时进入和更新的元素应用操作是很常见的。如果一个元素正在更新(也就是说,你不是从头开始创建它),你可能需要对其进行修改以反映新数据。这些修改通常也适用于刚进入的元素,因为它们同样需要反映新的数据。

D3 2.0 引入了一个改进,以解决代码重复问题:在 enter 选择集中添加元素时,会自动将这些新进入的元素复制到 update 选择集。这样一来,在向 enter 选择集添加元素之后对 update 选择集施加的任何操作,都同时适用于新进入和正在更新的元素,从而避免了代码的重复:

var text = g.selectAll("text").data(data, key); // JOIN
text
.exit() // EXIT
.remove();
text
.enter() // ENTER
.append("text") // 🌶
.text(function (d) {
return d;
});
text // ENTER + UPDATE
.attr("x", function (d, i) {
return i * 32;
});

然而,这一改进反而使得操作的易用性变差了。

首先,用户很难看出程序底层到底发生了什么(这涉及到较差的角色表达性,或可以视为隐藏的依赖)。通常,selection.append 用于创建、添加并选择新元素;虽然它在这里也进行了这些操作,但同时它还悄然改变了 update 选择集。这让人措手不及!

其次,这种方法让代码的运行结果依赖于操作的顺序:如果在 enter.append 之前对 update 选择集进行操作,那么只有更新中的节点会受到影响;如果在之后进行操作,则两者都会受到影响。数据绑定的目的是为了消除这种复杂的逻辑,以便更加直观地描述文档的变化,而不需要复杂的分支和循环。代码虽然看似简洁,实则隐藏了复杂性。

D3 4.0 去除了 enter.append 的这种“魔法效果”。(实际上,D3 4.0 完全消除了 enter 与普通选择集之间的区别,现在只有一种类型的选择集。)新版本中引入了一个名为 selection.merge 的方法,这个方法能够将 enterupdate 选择集合并:

var text = g.selectAll("text").data(data, key); // JOIN
text
.exit() // EXIT
.remove();
text
.enter() // ENTER
.append("text")
.text(function (d) {
return d;
})
.merge(text) // ENTER + UPDATE
.attr("x", function (d, i) {
return i * 32;
});

这样做既消除了代码重复,又避免了改变常用方法(selection.append)的行为,同时也没有引入对操作顺序的隐蔽依赖。此外,selection.merge 方法对于不熟悉的读者来说是一个明显的指引,他们可以通过查阅文档来了解这一方法。

准则 1. 避免赋予过多含义。

这次的失败给我们带来了什么启示?D3 3.x 违反了 Rams 的设计原则,即优秀的设计应让产品易于理解。在认知维度上,其表现不佳主要是因为 一致性(consistency)不足:由于 selection.append 在 enter 选择上的行为与普通选择不同,用户难以将对常规选择的理解应用到 enter 选择上。同时,它在 角色表达性(role-expressiveness)方面也表现不佳,因为后者的行为不够明显。此外,还存在一个 隐藏的依赖关系(hidden dependency):必须在向 enter 追加内容后执行 text 选择的操作,但代码中并没有明确指出这一要求。

D3 4.0 则避免了这种意义的过度赋予。它不会在 enter.append 中默默地增加功能——即便这在某些常见情况下可能很有用——而是让 selection.append 始终只用于添加元素。如果你需要合并选择,就必须使用一个新的方法,即 selection.merge。

案例 2. 揭秘 transition.each 的魔力。

transition 是一种用于使文档动态变化的界面,它类似于选择器(Selector)。与瞬间改变文档不同,过渡(Transition)能够平滑地使文档从当前状态过渡到目标状态,这个过程持续一定的时间。

过渡可以有不同的形式:有时,你可能想要在多个选择器上同步一个过渡。例如,要实现轴的过渡效果,你必须同时调整刻度线和标签的位置:

bl.ocks.org/1166403

这样一个过渡可以这样指定:

d3.selectAll("line").transition().duration(750).attr("x1", x).attr("x2", x);
d3.selectAll("text")
.transition() // 🌶
.duration(750) // 🌶
.attr("x", x);

(此处的 x 指的是一个函数,比如一个 线性比例尺,它根据每个刻度的数据值来计算其水平位置。)

这里的两个辣椒象征着需要特别注意。问题又来了:线条和文本元素的过渡是分别设置的,因此我们不得不重复设置时间参数,例如延迟和持续时间。

更微妙的问题是,这两个过渡可能并不同步!因为第二个过渡是在第一个之后才创建的,所以它开始的时间会稍晚。虽然这里一两毫秒的差异可能不明显,但在其他应用场景中可能就会有所体现。

D3 2.8 版本引入了一个新功能,它可以同步这样的异质过渡:为 transition.each —— 一个用于迭代每个选中元素的方法 —— 增加了一些特殊效果,这使得在回调函数中创建的新过渡会自动继承周围过渡的时间设置。因此,现在你可以这样做:

var t = d3.transition().duration(750);
t.each(function () {
d3.selectAll("line")
.transition() // 🌶
.attr("x1", x)
.attr("x2", x);
d3.selectAll("text")
.transition() // 🌶
.attr("x", x);
});

enter.append 这样的操作虽然实用,但它改变了已有方法(selection.each 和 selection.transition)的行为,而且没有明显的提示表明这种行为的变化。如果你在某个选择上创建了一个新的过渡,这个新的过渡并不会取代旧的过渡;实际上,你只是重新选择了旧的过渡。这可能会让人感到困惑!

这个例子虽然有些人为构造,但为了教学目的而设置。在 D3 3.x 中,有一种更清晰的方法可以同步跨选择的转换,那就是使用 transition.select 和 transition.selectAll:

var t = d3.transition().duration(750);
t.selectAll("line").attr("x1", x).attr("x2", x);
t.selectAll("text").attr("x", x);

在这里,文档根上的转换 t 被应用于线条和文本元素,是通过选择它们来实现的。这种方法优雅但有局限性:转换只能应用于一个 新的 选择(selection),而不能应用于一个 现有的 选择。虽然可以重新选择,但这意味着不必要的额外工作和代码(尤其是针对数据连接(data-join)返回的临时 enterupdate、和 exit 选择)。

D3 4.0 改进了这一点,移除了 transition.each 的特殊处理,使其和 selection.each 有相同的实现。现在,selection.transition 可以传递一个转换对象,让新的转换继承自指定转换的时序。这样,在创建新选择时,我们就能实现所需的同步效果:

var t = d3.transition().duration(750);
d3.selectAll("line").transition(t).attr("x1", x).attr("x2", x);
d3.selectAll("text").transition(t).attr("x", x);

或者在使用现有的选择时:

var t = d3.transition().duration(750);
line.transition(t).attr("x1", x).attr("x2", x);
text.transition(t).attr("x", x);

尽管这个新设计在某种程度上改变了 selection.transition 的原有行为,但采用相同名称却参数不同的新方法签名是一种常见的设计模式。至少这种行为上的差异仅限于单个调用,并且需要在调用时明确启用。

原则 2:避免使用模式化行为。

这一原则是对前一个原则“避免含义重叠”的扩展,着重指出了更为严重的违规情况。例如,在 D3 2.8 版本中,与 selection.transition 的行为不一致的问题出现了,但这种行为的触发并不是由于不同的类别;而仅仅是因为代码被置于 transition.each 的调用之内。这种设计的特殊后果是,你可以通过将代码包装在 transition.each 中来改变那些你并未编写的代码的行为!

如果你看到某段代码通过设置一个全局变量来引发全局性的行为变化,那很可能是个不佳的设计。

回顾过去,这个问题显得尤为突出。我当时怎么想的?我是个设计失败者吗? 理解坏主意为何具有吸引力其实是一种安慰:这使我们未来更容易识别并拒绝这些坏主意。回想当时,我试图通过避免引入新方法来减少 感知上的 复杂性。然而,这实际上是一个典型的例子,说明引入新的方法(或新的函数签名)比起让现有方法功能过载要简单得多。

案例 3. 揭秘 d3.transition(Selection) 的魔法。

在现代编程语言中,一个强大的概念是能定义函数作为可重复使用的代码单元。将代码封装进函数后,你可以在需要的任何地方调用,避免重复编写。虽然有些软件库为代码重用定义了特殊的抽象(例如扩展图表类型),但 D3 对封装方法持中立态度。我推荐简单地使用一个函数

由于 Selection 和 Transition 在很多方法上是共享的,比如 Selection.style 和 Transition.style 都用于设置样式属性,你可以编写一个既能操作 Selection 又能操作 Transition 的函数。比如:

function makeitred(context) {
context.style("color", "red");
}

你可以给 makeitred 传递一个 Selection,即刻将文本颜色变为红色:

d3.select("body").call(makeitred);

同样地,你也可以传递一个 Transition 给 makeitred,在这种情况下,文本颜色会在短时间内渐变为红色:

d3.select("body").transition().call(makeitred);

像坐标轴和画笔这样的 D3 内置组件,以及像缩放这样的行为,都采用了这种方法。

但是,这种方法的一个问题是,Selection 和 Transition 的 API 并不完全相同,因此并非所有代码都可以通用。比如,计算数据连接以更新轴刻度这样的操作,就需要使用 Selection。

在 D3 2.8 版本中,为了这种用例引入了一个不太恰当的特性:它对 d3.transition 进行了重载。通常情况下,d3.transition 返回的是文档根上的一个新 Transition。如果你把一个 Selection 传给 d3.transition,并且你处于 Transition.each 的回调函数中,那么 d3.transition 会返回一个在指定的 Selection 上的新 Transition;否则,它仅返回指定的 Selection。(这个特性是与上文讨论的 Transition.each 缺陷在同一次提交中添加的。不幸总是连绵不断!)

从我的复杂描述中可以看出,这个特性并不是一个好主意。但为了科学,我们还是来仔细分析一下。以下是另一种编写上述 makeitred 函数的方式,它允许部分代码(使用 s)仅限于 Selection API,而其他部分(使用 t)则应用于 Transition API,前提是 context 是一个 Transition:

function makeitred(context) {
context.each(function () {
// 🌶
var s = d3.select(this),
t = d3.transition(s); // 🌶
t.style("color", "red");
});
}

transition 中的每一种魔法都在这里:d3.transition 调用 selection.transition,并位于 transition.each 的回调函数中,因此新的过渡会继承周围过渡的时间设置。但这里存在一些困惑,d3.transition 并不像平常那样工作。同时,contextt 这两个类型不明确 —— 它们可能是选择(selection)或过渡(transition)—— 尽管可能是为了方便在这两者上应用 makeitred 而设计的。

D3 4.0 移除了 d3.transition(selection) 的用法;d3.transition 现在只能用于在文档的根元素上创建过渡,就像 d3.selection 那样。要区分选择和过渡,可以使用 JavaScript 中常见的类型检查方法,比如 instanceof,或者如果你更喜欢,可以使用 鸭子类型

function makeitred(context) {
var s = context.selection ? context.selection() : context,
t = context;
t.style("color", "red");
}

注意,除了去除了 transition.each 和 d3.transition 的特性,新的 makeitred 函数完全避开了 transition.each,同时允许你使用 D3 4.0 的新方法 transition.selection 编写特定于选择的代码。这里有一个假设的例子:选择 s 实际上没有被使用,而 tcontext 的值相同,因此可以简化回最初的定义:

function makeitred(context) {
context.style("color", "red");
}

但这正是我想表达的。为了特定于选择的代码而需要完全重写,以适应 transition.each,Green & Petre 称这种情况为“过早承诺”(premature commitment)。

Maxim 3. 倡导简约。

d3.transition 方法试图聪明地结合两种操作。第一种是检查你是否在 transition.each 的神奇回调中。如果是,第二种则是从一个选择中派生出一个新的过渡。但后者已经可以通过 selection.transition 实现,因此 d3.transition 试图做得太多,最终结果是隐藏了太多信息。

案例 4. 利用 d3.active 实现过渡动画的重复播放。

在 D3 中,过渡动画是由一系列有限的步骤组成的。大多数情况下,一个过渡动画仅包含一个阶段,即从文档的当前视觉状态转变到目标视觉状态。然而,在某些情况下,你可能需要设计更复杂的过渡动画,这些动画包含多个阶段并逐步展示变化:

bl.ocks.org/4341417

(在设计动画时需谨慎!推荐阅读 Heer & Robertson 撰写的 统计数据图形中的动画过渡。)

有时,你可能希望一个过渡动画能无限期地重复播放,例如在这个圆形波动的玩具示例中,圆形不断地来回移动:

bl.ocks.org/346f4d967650b27c0511

虽然 D3 并没有专门用于实现无限循环过渡的方法,但你可以通过监听过渡动画的 开始结束 事件来实现。当一个过渡动画结束时,你可以基于这些事件触发一个新的过渡动画。这种方法曾让我编写了一段极其复杂的示例代码:

svg
.selectAll("circle")
.transition()
.duration(2500)
.delay(function (d) {
return d * 40;
})
.each(slide); // 🌶
function slide() {
var circle = d3.select(this);
(function repeat() {
circle = circle
.transition() // 🌶
.attr("cx", width)
.transition()
.attr("cx", 0)
.each("end", repeat);
})(); // 🌶
}

三个辣椒!在之前的例子中我们已经经历了许多复杂的概念,我本不想再做更深入的解释。但既然你已经跟随到这里,我还是会尽我所能进行说明。

首先,每次调用 transition.each 都会触发 slide 回调函数,对每个圆形元素进行迭代处理。slide 回调定义了一个自我调用的 repeat 闭包函数,其中包含了 circle 变量。起初,circle 代表一个单独的圆形元素的选择集合;因此,通过使用 selection.transition 创建过渡的第一阶段,继承了周围过渡的时间设置!第二阶段通过 transition.transition 创建,以确保在第一阶段结束后开始。然后,这个第二阶段的过渡被赋予给了 circle。最后,每当这两阶段的过渡序列结束时,就会调用 repeat 函数,从而重复并重新定义 circle 的过渡过程。

另外,你有没有注意到,transition.each 函数在传入一个参数时与传入两个参数时的行为完全不同?这就像是我还需要更多的创新思考(第四个辣椒)。

真是让人大开眼界!

现在我们来看看 D3 4.0 的情况:

svg
.selectAll("circle")
.transition()
.duration(2500)
.delay(function (d) {
return d * 40;
})
.on("start", slide);
function slide() {
d3.active(this)
.attr("cx", width)
.transition()
.attr("cx", 0)
.transition()
.on("start", slide);
}

D3 4.0 版本引入了 d3.active 功能,它可以返回指定元素上当前活跃的过渡效果。这样一来,就不再需要为每个圆形元素分别捕获本地变量(即 circle 变量),也不需要自我调用的闭包函数(即 repeat 函数),甚至不再需要 transition.each 的特殊技巧了!

核心原则 4. 复杂晦涩的解决方案并非真正的解决方案。

有时候,虽然某个问题存在合理的解决方法,但如果这个方法过于复杂和脆弱,使人难以发现并记住,那它实际上就不是一个好的解决方案。即便是我这个库的作者,在解决问题时也不得不去查阅相关资料。

而且,这个方法看起来也不太美观。

案例 5. 在后台冻结时间。

在 D3 3.x 中,一个无限循环的动画效果在长时间保持在后台标签页时会表现出一些有趣的行为。这里的“有趣”是指它呈现出这样的效果:

这是因为当你将标签页切换回前台时,它会试图展示所有你错过的动画效果。如果一个标签页在前台每秒钟都在数百个元素上应用动画效果,而且这个标签页被放在后台几个小时,那就相当于错过了数百万次动画效果!

显然,展示这些过去的动画效果是没有意义的,因为它们已经是历史了,一旦开始就会立即结束。但是,由于这种无限循环的动画效果不会自行中断,所以这些动画还是会继续播放。

D3 4.0 解决了这个问题,它通过改变时间的定义。在大多数情况下,动画效果并不需要与实际时间严格同步;它们主要是作为一种视觉辅助工具,帮助用户跟踪对象在不同视图中的变化。因此,D3 4.0 是基于 感知时间 (perceived time) 运行的,这种时间只在页面处于前台时才会前进。当一个标签页从后台切换回前台时,动画就会从停下的地方继续播放,就好像什么都没发生过一样。

准则 5. 质疑你的假设。

有时候,一个设计上的缺陷并不能仅仅通过添加或修改某个方法来解决。相反,有时需要重新审视背后的某些基本假设 — 比如认为时间是绝对不变的这种观念。

案例 6. 使用 selection.interrupt 取消动画过渡。

动画过渡常常是由事件触发的,比如新数据的到来或用户的互动。由于过渡动画需要时间来完成,这就可能导致多个过渡动画同时争夺对元素的控制。为了避免混乱,每个过渡都应该是独立的,这样新的过渡就可以中断并取代旧的过渡。

但是,这种独立性不应该是全局性的。只要它们作用于不同的元素,允许多个过渡动画同时进行是可行的。如果你快速在下面的堆叠条形图和分组条形图之间切换,就会在图表上形成波纹效果:

bl.ocks.org/3943967

D3 默认对每个元素进行单独的过渡处理。如果你需要更严格的独立性控制,可以使用 selection.interrupt 来中断所选元素上当前正在进行的过渡。

在 D3 3.x 中,selection.interrupt 的问题是它无法取消那些已经在所选元素上计划好的、即将进行的过渡。这个问题可能是由于 D3 3.x 在设计计时器时的一个缺陷,导致这些计时器无法被外部停止,因此难以取消已计划的过渡。(相反,被中断的过渡会在开始时自行结束。)

D3 3.x 中的一种解决办法是,在中断一个过渡后,创建一个无实际操作、零延迟的新过渡:

selection
.interrupt() // interrupt the active transition
.transition(); // pre-empt any scheduled transitions

这种方法在大多数情况下都是有效的。但你可以通过安排另一个新的过渡来规避这个方法:

selection.transition().each("start", alert); // 🌶
selection.interrupt().transition();

因为第一个过渡尚未开始,所以它没有被中断。而第二个过渡是在第一个过渡之后安排的,这就使得第一个过渡能够开始,然后再被第二个过渡中断。

在 D3 4.0 中,selection.interrupt 既可以中断任何正在进行的过渡,也可以取消所有已计划的过渡。取消的效果比中断更彻底:所有计划中的过渡都会被立即终止,释放资源,并确保它们不会启动。

selection.interrupt();

最大化 6. 考虑所有可能的使用模式。

异步编程之所以出了名地困难,是因为操作的顺序高度不可预测。虽然实现既健壮又可靠的异步 API(Application Programming Interface, 应用程序编程接口)是一项艰巨任务,但使用那些脆弱不稳定的异步 API 则更加棘手。设计者需要做到关注每一个细节,确保周全。

案例 7. 命名参数。

在这里,我将用一个较为简单的例子作为结束。D3 4.0 引入了一些语法改进,旨在使代码更加易读和自描述。来看一下使用 D3 3.x 版本的这段代码:

selection.transition().duration(750).ease("elastic-out", 1, 0.3);

你可能会产生这样的疑问:

  • 数值 1 代表什么意义?
  • 数值 0.3 又有何含义?
  • 除了“弹性 - 出”(elastic-out),还支持哪些类型的缓动效果?
  • 我能否实现一个自定义的缓动函数?

现在来比较一下 D3 4.0 版本:

selection
.transition()
.duration(750)
.ease(d3.easeElasticOut.amplitude(1).period(0.3));

现在,1 和 0.3 的含义变得明晰,或者至少你可以在 API 参考手册 中查询关于弹性缓动的振幅和周期信息。该手册还包括了一张有助于理解的图片:

此外,在 D3 4.0 版本中,transition.ease 不再是一组固定的缓动效果名称;缓动效果始终由函数来定义。D3 4.0 依然提供了内置的缓动函数,但它更明确地指出,你也可以自行开发定制的缓动函数。

最大化 7. 提供提示。

设计需要众多参数的函数显然是不佳的设计。不能期望人们记住如此复杂的定义(例如,我不知道查阅 context.arc 参数多少次了,每当我在 2D 画布上作图时)。自创立以来,D3 一直倾向于使用命名属性,通过方法链和单参数的 getter-setter(获取器 - 设置器)方法。但仍有改善的空间。如果代码不能做到完全自解释,至少它应该能指引你找到文档中的相关部分。

好软件的真正目的是什么?

好软件并不仅仅是快速准确地得出结果,也不仅仅在于它的简洁或优雅。

人类拥有强大却有限的认知能力,需要应对众多竞争这份能力的因素,比如小孩子。最关键的是,人类具有学习的能力。我希望通过我的例子,你能看到编程界面的设计如何深刻影响人们理解代码的能力,以及他们学习精通编程的可能性。

然而,学习不仅仅是熟练掌握某个工具。如果你能将在一个领域学到的知识应用到其他领域,那这些知识就更加宝贵。这就是为什么 D3 选择使用标准的文档对象模型(Document Object Model)而不是特殊的表示方式。特殊的表示方式可能更高效,但当工具发生变化时,你花在专业知识上的时间可能就白费了。

我希望你学习 D3,不仅仅是为了掌握 D3 本身。我希望你学会如何探索数据并有效传达你的洞察。

好软件易于接近。 你可以通过独立、简单的部分来完整地理解它。你不需要先理解所有内容才能开始理解任何部分。

好软件保持一致性。 它让你能将对某一部分的理解推广到其他部分。它不会自我矛盾,避免了不必要的冗余。

好软件会自我解释。 它提供了学习和发现的便利,表达清晰,减少了不明显的隐喻。

好软件具有教育意义。 它不仅仅是自动化现有任务,而是能够提供深刻的见解或传授诸如最佳实践或对问题的新看法等知识。

好软件以人为本。 它考虑到人们的生活现实,不需要用户记住复杂且武断的规则。它预料到了学习和调试的需求。