当前内容所在位置(可进入专栏查看其他译好的章节内容)
- 第一部分 D3.js 基础知识
- 第一章 D3.js 简介(已完结)
- 1.1 何为 D3.js?
- 1.2 D3 生态系统——入门须知
- 1.3 数据可视化最佳实践(上)
- 1.3 数据可视化最佳实践(下)
- 1.4 本章小结
- 第二章 DOM 的操作方法(已完结)
- 2.1 第一个 D3 可视化图表
- 2.2 环境准备
- 2.3 用 D3 选中页面元素
- 2.4 向选择集添加元素
- 2.5 用 D3 设置与修改元素属性
- 2.6 用 D3 设置与修改元素样式
- 2.7 本章小结
- 第三章 数据的处理(已完结)
- 3.1 理解数据
- 3.2 准备数据
- 3.3 将数据绑定到 DOM 元素
- 3.3.1 利用数据给 DOM 属性动态赋值
- 3.4 让数据适应屏幕
- 3.4.1 比例尺简介(上篇)
- 3.4.2 线性比例尺(中篇)
- 3.4.2.1 基于 Mocha 测试 D3 线性比例尺(DIY 实战)
- 3.4.3 分段比例尺(下篇)
- 3.4.3.1 使用 Observable 在线绘制 D3 条形图(DIY 实战)
- 3.5 加注图表标签(上篇)
- 3.5.1 人物专访:Krisztina Szűcs(下篇)
- 3.6 本章小结
- 第四章 直线、曲线与弧线的绘制 ✔️
- 4.1 坐标轴的创建(上篇)
- 4.1.1 D3 中的边距约定(中篇)
- 4.1.2 坐标轴的生成(中篇)
- 4.1.2.1 比例尺的声明(中篇)
- 4.1.2.2 坐标轴的添加(下篇)
- 4.1.2.3 轴标签的添加(下篇)
- 4.1.2.4 DIY 实战:在 Observable 平台实现折线图坐标轴的绘制
- 4.1.2.5 DIY 实战:D3 源码分析之 d3.timeFormat() 函数 ✔️
- 4.2 D3 折线图的绘制(精译中 ⏳)
文章目录
- DIY 实战:D3 源码分析之:d3.timeFormat() 函数
- 1 起因
- 2 官方文档探秘
- 3 源码分析
- 3.1 验证一:local() 函数和 new Date() 是否一样
- 3.2 验证二:timeFormat() 函数是否为 d3.timeFormat() 函数
- 3.3 newFormat() 函数详解
- 4 小结
《D3.js in Action》全新第三版封面
DIY 实战:D3 源码分析之:d3.timeFormat() 函数
1 起因
前几天完成了 4.1 节剩余内容的翻译,主要介绍了 D3 折线图坐标轴的绘制方法(详见本专栏 第 035 篇译文)。讲解过程中,作者通过 d3.timeFormat('%b')
函数拿到了月份的英文简写字符串(即 "Jan"
、"Feb"
等),但对于该函数的用法及参数的含义却一笔带过,让大家感兴趣的话自行参考 D3 官方文档(更奇怪的是,当时也没有提供具体的文档链接)。这一做法似乎和本书一贯的“手把手”教学风格相悖。怀着这份好奇,我自行补上了这个链接(https://d3js.org/d3-time-format),想看看作者不展开讲解的原因;结果在 D3 官网越看越上头,就有了分享出来的冲动。
2 官方文档探秘
原来,这个 d3-time-format
模块是仿照 C 语言的标准库函数 strptime 和 strftime 实现的。要在 D3 语境下格式化某个日期,需要用指定的标识符(specifier,格式为 %格式指令
,如 %b
、%d
等等)声明一个格式化工具函数 formatter
,然后再将日期传入,就能得到最终的结果。换句话说,d3.timeFormat()
其实是一个高阶函数,示例代码中传入 tickFormat()
的其实就是一个 formatter
函数:
const bottomAxis = d3.axisBottom(xScale)
.tickFormat(d3.timeFormat("%b"));
我就纳闷了:实现这么简单的一个格式化逻辑,竟然也需要用高阶函数这把牛刀?不就是两行代码的事么:
const months = 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(',');
const formatter = date => months[date.getMonth()];
原谅我的强迫症——
【图 1 根据需求自行实现的月份格式化逻辑】
难道说 D3 另有深意?带着这个疑问,我又一次愉快地打开了潘多拉女神的魔盒:
【图 2 将 d3.timeFormat(“%b”) 打印到控制台得到的结果(貌似玩笑开大了点)】
点进去一看,发现还不如不点:
【图 3 点开 d3.timeFormat(“%b”) 看到的格式化处理后的函数源码】
这是要逼我看源码的节奏啊……别慌,先把那页官方文档看完。所谓的标识符 specifier
,可用的格式指令(directives)如下:
%a
:缩写的星期名称。*%A
:完整的工作日名称。*%b
:缩写的月份名称。*%B
:完整月份名称。*%c
:本地的日期和时间,例如%x, %X
.*%d
:用十进制数字表示的零填充的月份中的天数 [01,31]。%e
:用空格填充的月份日期,作为十进制数字 [1,31];等同于%_d
。%f
:微秒作为十进制数字 [000000, 999999]。%g
:ISO 8601 基于周的年份(不含世纪),以十进制数字表示 [00,99]。%G
:ISO 8601 基于周的年份,世纪作为十进制数字。%H
:小时(24 小时制)作为十进制数字 [00,23]。%I
:小时(12 小时制)作为十进制数字 [01,12]。%j
:一年中的天数,作为十进制数字 [001,366]。%m
:作为十进制数字的月份 [01,12]。%M
:以十进制数字表示的分钟 [00,59]。%L
:毫秒,作为一个十进制数字 [000, 999]。%p
:早上或下午。*%q
:年的四分之一,作为小数表示 [1,4].%Q
:自 UNIX 纪元以来的毫秒数。%s
:自 UNIX 纪元以来的秒数。%S
:作为小数的秒数 [00,61].%u
:以星期一为基础的(ISO 8601)工作日,作为十进制数字 [1,7]。%U
:以星期日为基础的年份周数,作为十进制数字 [00,53]。%V
:ISO 8601 年中的周数,作为十进制数字 [01, 53]。%w
:以星期日为基础的工作日,作为十进制数字 [0,6]。%W
:以星期一为基础的年份周数,作为十进制数字 [00,53]。%x
:本地的日期,例如%-m/%-d/%Y
.*%X
:本地时间,例如%-I:%M:%S %p
.*%y
:不带世纪的年份,作为十进制数字 [00,99]。%Y
:以十进制数字表示的世纪年份,例如1999
。%Z
:时区偏移,例如-0700
、-07:00
、-07
或Z
。%%
:一个字面上的百分号 (%
)。
其中,末尾带星号标记(*
)的指令可能会受到当地区域设置的影响。
另外,%
符号用来标识一个指令,后面还可以紧跟一个填充修饰符:
0
:用0
来填充;_
:用空格来填充;-
:禁用填充。
介绍完 specifier
,文档还提到了 D3 的默认区域设置(美国-英文):
const enUs = d3.timeFormatDefaultLocale({
dateTime: "%x, %X",
date: "%-m/%-d/%Y",
time: "%-I:%M:%S %p",
periods: ["AM", "PM"],
days: ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
shortDays: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
months: ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],
shortMonths: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
});
言下之意……D3 还支持其他地区和语言的设置吗?于是果断进入 d3-time-format
模块的 GitHub 仓库。果然,在 d3-time-format/locale/
文件夹看到了 8 年前最后提交的中文配置(zh-CN.json
):
{
"dateTime": "%x %A %X",
"date": "%Y年%-m月%-d日",
"time": "%H:%M:%S",
"periods": ["上午", "下午"],
"days": ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"],
"shortDays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
"months": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
"shortMonths": ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]
}
要配置成中文对应的地区,D3 只提供了一个 d3.timeFormatDefaultLocale(definition)
接口,参数 definition
就是上面的 JSON
配置。只可惜,D3 没能提供查询地区配置文件的接口,如果要让 d3.timeFormat('%b')
显示 十月
,只能像这样手动操作:
【图 4 手动切换 D3 默认地区的相关接口测试情况(切换为中文)】
有了上述的准备工作,就可以正式开始 d3.timeFormat()
的源码解读了。
3 源码分析
可能很多朋友看源码都是直接从 src
目录开始的,但我更习惯从项目的测试用例入手。找到 test
文件夹下的 format-test.js
,很快就定位到了 %b
标识符对应的单元测试模块:
it("timeFormat(\"%b\")(date) formats abbreviated months", () => {
const f = timeFormat("%b");
assert.strictEqual(f(local(1990, 0, 1)), "Jan");
assert.strictEqual(f(local(1990, 1, 1)), "Feb");
assert.strictEqual(f(local(1990, 2, 1)), "Mar");
assert.strictEqual(f(local(1990, 3, 1)), "Apr");
assert.strictEqual(f(local(1990, 4, 1)), "May");
assert.strictEqual(f(local(1990, 5, 1)), "Jun");
assert.strictEqual(f(local(1990, 6, 1)), "Jul");
assert.strictEqual(f(local(1990, 7, 1)), "Aug");
assert.strictEqual(f(local(1990, 8, 1)), "Sep");
assert.strictEqual(f(local(1990, 9, 1)), "Oct");
assert.strictEqual(f(local(1990, 10, 1)), "Nov");
assert.strictEqual(f(local(1990, 11, 1)), "Dec");
});
可能为了大幅降低单元测试的编写难度,这里只用了 Mocha.js
的 BDD
风格,断言方法也是直接来自 node 的内置断言模块。这里有两点需要明确:
- 第 3 ~ 14 行中的
local(...)
函数为什么不使用new Date(...)
? - 第 2 行的
timeFormat
是否是我要考察的目标函数?
由于网页不支持方法的快速定位,只能转到本地操作了:
git clone https://github.com/d3/d3-time-format.git d3-time-format
cd d3-time-format
yarn
yarn test
不出意外的话,马上就出意外了:
【图 5 本地运行单元测试报错(不支持 Windows 环境)】
好在这个坑已经踩过了,加个 cross-env
依赖就行了:
# 修复 Windows 不兼容 TZ 设置问题
$ yarn add -D cross-env
# 修改 test 命令脚本
$ (gc package.json) -replace '"test": "(.*?)"', '"test": "cross-env $1"' | Set-Content package.json
# 验证 test 命令脚本是否修改成功
$ cat package.json | sls TZ
"test": "cross-env TZ=America/Los_Angeles mocha 'test/**/*-test.js' && eslint src test",
# 再次运行测试
$ yarn test
运行结果:
【图 6 修复单元测试不兼容 Windows 系统的问题后,重新运行测试,全部通过。】
然后就可以用 VSCode
打开该模块了:
$ code .
3.1 验证一:local() 函数和 new Date() 是否一样
先从简单的问题入手:单元测试为什么要用自定义的 local()
函数,而不是使用原生的 new Date()
?直接跳转到 local()
的定义:
export function local(year, month, day, hours, minutes, seconds, milliseconds) {
if (year == null) year = 0;
if (month == null) month = 0;
if (day == null) day = 1;
if (hours == null) hours = 0;
if (minutes == null) minutes = 0;
if (seconds == null) seconds = 0;
if (milliseconds == null) milliseconds = 0;
if (0 <= year && year < 100) {
const date = new Date(-1, month, day, hours, minutes, seconds, milliseconds);
date.setFullYear(year);
return date;
}
return new Date(year, month, day, hours, minutes, seconds, milliseconds);
}
原来如此!第 9 行对年份介于 0 ~ 99 的日期做了单独处理,不让原生 JavaScript
的 Date
构造函数中的默认转换生效(new Date(99, 0, 1)
的结果为 1999 年 1 月 1 日)。第 10 行的 -1
也很巧妙,刚好绕开了 Date
的默认转换,写起来也方便。
结论:local()
函数得到的就是一个 Date
实例,只不过考虑得更全面。
3.2 验证二:timeFormat() 函数是否为 d3.timeFormat() 函数
再来看此次源码解读的核心 —— timeFormat()
函数。虽然种种迹象表明,答案必定是肯定的,但还是有必要跟着源码过一遍。这样就跟踪到了 src/index.js
,进而定位到 defaultLocale.js
模块:
// d3-time-format/test/format-test.js
import {timeFormat} from "../src/index.js";
// index.js
export {default as timeFormatDefaultLocale, timeFormat, timeParse, utcFormat, utcParse} from "./defaultLocale.js";
// defaultLocale.js
export var timeFormat;
// ...
export default function defaultLocale(definition) {
locale = formatLocale(definition);
timeFormat = locale.format;
// ...
return locale;
}
从第 2 行可以断定,单元测试中的 timeFormat()
函数就是 d3.timeFormat()
函数。继续追踪可以看到,它的赋值是在 defaultLocale.js
中完成的(第 10 行)。那么赋给它的值 locale.format
究竟是什么呢?这得看上一行中的 formatLocale(definition)
究竟在干什么。还是分两步走:
- 搞懂
definition
是什么; - 搞懂
formatLocale
函数的定义。
第一个问题很简单,definition
就是前面提过的 D3 默认地区设置,来看 defaultLocale.js
的完整截图就明白了:
【图 7 搞懂 definition 是什么:D3 默认的地区语言设置】
接着跳转到 formatLocale()
函数的定义,就来到了 src/locale.js
模块:
【图 8 找到 src/locale.js 模块下的 formatLocale() 函数定义】
这里我们只关心函数返回值中的 format
属性,因此直接定位到该函数的 return
语句:
【图 9 定位到 formatLocale 函数的 return 语句,并锁定返回值中的 format 属性】
从图 9 不难看出,最终赋值给 d3.timeFormat
函数的,正是第 366 行中的 newFormat(specifier += "", formats)
,也就是文章最开始的图 2 所看到的那一堆压缩版的函数定义。注意第 366 行还传入了第二个参数 formats
,这是一个典型的闭包结构,formats
是一个内置的 JS 对象。对于我们要考察的 %b
而言,只需要用到其中的两个键值对,可简化为:
var specifier = "%b";
var formats = {
"b": formatShortMonth,
"%": formatLiteralPercent
};
var f = newFormat("%b", {
"b": formatShortMonth,
"%": formatLiteralPercent
})
这样一来,问题的关键就变为对函数 newFormat()
的解读了。
3.3 newFormat() 函数详解
定位到 newFormat
函数,将看到这一段终极源码:
function newFormat(specifier, formats) {
return function(date) {
var string = [],
i = -1,
j = 0,
n = specifier.length,
c,
pad,
format;
if (!(date instanceof Date)) date = new Date(+date);
while (++i < n) {
if (specifier.charCodeAt(i) === 37) {
string.push(specifier.slice(j, i));
if ((pad = pads[c = specifier.charAt(++i)]) != null) c = specifier.charAt(++i);
else pad = c === "e" ? " " : "0";
if (format = formats[c]) c = format(date, pad);
string.push(c);
j = i + 1;
}
}
string.push(specifier.slice(j, i));
return string.join("");
};
}
虽然也比较复杂,但对比图 3 那样的简化版已经很不错了。注意第 16 行新引入的闭包结构 pads
,这是格式化结果中负责拼接填充符号的键值对,比较简单:
var pads = {
"-": "",
"_": " ",
"0": "0"
};
再次明确我们的分析目标:考察以下代码的底层逻辑:
const formatter = d3.timeFormat('%b');
console.log(formatter(new Date())); // 'Oct'
因此,将 '%b'
、new Date()
即刚才分析的简化 formats
代入,就可以得到简化版的 formatter
定义:
var formats = {
"b": formatShortMonth,
"%": () => '%'
};
var pads = {"-": "", "_": " ", "0": "0"};
const formatter = function(date) {
var string = [],
i = -1,
j = 0,
n = 2, // '%b'.length => 2
c,
pad,
format;
while (++i < 2) {
if ('%b'.charCodeAt(i) === 37) {
string.push('%b'.slice(j, i));
if ((pad = pads[c = '%b'.charAt(++i)]) != null) c = '%b'.charAt(++i);
else pad = c === "e" ? " " : "0";
if (format = formats[c]) c = format(date, pad);
string.push(c);
j = i + 1;
}
}
string.push('%b'.slice(j, i));
return string.join("");
}
注意,第 11 行就是判定字符串的首字符是否为 %
,这显然是满足的,因此重点关注第 16 ~ 22 行。
第一轮:i = 0, j = 0
——
- 执行第 17 行,结果为
string = ['']
; - 执行第 18 行,
c = '%b'.charAt(1) = 'b'
、pad = pads[c] = undefined
,显然if
条件undefined != null
为假,pad
转到第 19 行被重新赋值:pad = c === "e" ? " " : "0"
,因此pad = " "
; - 执行第 20 行,此时
c = 'b'
,故format = formats['b'] = formatShortMonth
,满足if
条件,c
被重新赋值为formatShortMonth(date, " ")
; - 执行第 21 行,得到新的
string
数组:['', c]
; - 执行第 22 行,此时
i = 1, j = 1
。
第二轮:i = 2, j = 1
——
-
由于
i
值已不满足while
循环条件,因此跳出循环,直接前往第 26 行;此时i = 2, j = 1
; -
执行第 26 行,
string
数组更新为['', c, '']
; -
执行第 27 行,可得到
formatter
的进一步简化版定义:const formatter = date => "" + formatShortMonth(date, " ") + "";
这里的 formatShortMonth()
又是什么呢,跳转过去看到的源码是这样的:
function formatShortMonth(d) {
return locale_shortMonths[d.getMonth()];
}
可见,formatShortMonth(date, " ")
的第二个参数根本没用到!因此 formatter
还可以精简为:
const formatter = date => "" + locale_shortMonths[date.getMonth()] + "";
这样,就和我自定义的逻辑很像了,我之前是这样写的:
const months = 'Jan,Feb,Mar,Apr,May,Jun,Jul,Aug,Sep,Oct,Nov,Dec'.split(',');
const formatter = date => months[date.getMonth()];
现在问题就变成了:locale_shortMonths
与 months
是不是同一个数组?别急,来看 locale_shortMonths
的定义:
【图 10 变量 locale_shortMonths 的声明情况】
显然,locale_shortMonths
是从参数中直接赋的值。那这个参数 locale
是什么值呢?这就得再回到此前调用 formatLocale()
函数的地方了,也就是前面提过的图 7:
注意第 17 行,shortMonths
就是我要找的那个数组。终于衔接上了!!!formatter
的终极定义如下:
var locale_shortMonths = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const formatter = date => "" + locale_shortMonths[date.getMonth()] + "";
大功告成。
4 小结
通过对 d3.timeFormat()
源码的全面解读,可以归纳出以下几点:
- 从单元测试用例入手,既可以快速锁定目标函数,又可以了解目标函数的具体用法,一举多得;
- 遇到需要分步走的情况时,先做好记录,从简单的分支入手,再逐步逼近复杂分支;
- 作为工具库函数,需要考虑各种格式化指令的解析和其他辅助配置,因此不得不经过一系列筛选、赋值、高阶函数处理,以满足工具函数的一致性;对于一些简单的格式化逻辑,手写应该比调用库函数更方便。
- 源码最复杂的部分,其实就是那个
while
循环,用于解析不同的specifier
标识符,并在内置的formats
对象里找到对应的格式化方法,然后返回最终结果。 - 遇到复杂的问题,要时刻明确自己的目标,并围绕目标将问题一步步简化,做到心中有数,稳扎稳打。