当前位置: 首页 > news >正文

【D3.js in Action 3 精译_037】4.1 DIY 实战:D3 源码分析之——d3.timeFormat() 函数

当前内容所在位置(可进入专栏查看其他译好的章节内容)

  • 第一部分 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》全新第三版封面

《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 根据需求自行实现的月份格式化逻辑

【图 1 根据需求自行实现的月份格式化逻辑】

难道说 D3 另有深意?带着这个疑问,我又一次愉快地打开了潘多拉女神的魔盒:

图 2 将 d3.timeFormat("%b") 打印到控制台得到的结果(貌似玩笑开大了点)

【图 2 将 d3.timeFormat(“%b”) 打印到控制台得到的结果(貌似玩笑开大了点)】

点进去一看,发现还不如不点:

图 3 点开 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-07Z
  • %%:一个字面上的百分号 ( % )。

其中,末尾带星号标记(*)的指令可能会受到当地区域设置的影响。

另外,% 符号用来标识一个指令,后面还可以紧跟一个填充修饰符:

  • 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 默认地区的相关接口测试情况(切换为中文)

【图 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.jsBDD 风格,断言方法也是直接来自 node 的内置断言模块。这里有两点需要明确:

  1. 第 3 ~ 14 行中的 local(...) 函数为什么不使用 new Date(...)
  2. 第 2 行的 timeFormat 是否是我要考察的目标函数?

由于网页不支持方法的快速定位,只能转到本地操作了:

git clone https://github.com/d3/d3-time-format.git d3-time-format
cd d3-time-format
yarn
yarn test

不出意外的话,马上就出意外了:

图 5 本地运行单元测试报错(不支持 Windows 环境)

【图 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 系统的问题后,重新运行测试,全部通过。

【图 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 的日期做了单独处理,不让原生 JavaScriptDate 构造函数中的默认转换生效(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) 究竟在干什么。还是分两步走:

  1. 搞懂 definition 是什么;
  2. 搞懂 formatLocale 函数的定义。

第一个问题很简单,definition 就是前面提过的 D3 默认地区设置,来看 defaultLocale.js 的完整截图就明白了:

图 7 搞懂 definition 是什么:D3 默认的地区语言设置

【图 7 搞懂 definition 是什么:D3 默认的地区语言设置】

接着跳转到 formatLocale() 函数的定义,就来到了 src/locale.js 模块:

图 8 找到 src/locale.js 模块下的 formatLocale() 函数定义

【图 8 找到 src/locale.js 模块下的 formatLocale() 函数定义】

这里我们只关心函数返回值中的 format 属性,因此直接定位到该函数的 return 语句:

图 9 定位到 formatLocale 函数的 return 语句,并锁定返回值中的 format 属性

【图 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 => 2c,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_shortMonthsmonths 是不是同一个数组?别急,来看 locale_shortMonths 的定义:

图 10 变量 locale_shortMonths 的声明情况

【图 10 变量 locale_shortMonths 的声明情况】

显然,locale_shortMonths 是从参数中直接赋的值。那这个参数 locale 是什么值呢?这就得再回到此前调用 formatLocale() 函数的地方了,也就是前面提过的图 7:

图 7 搞懂 definition 是什么:D3 默认的地区语言设置

注意第 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 对象里找到对应的格式化方法,然后返回最终结果。
  • 遇到复杂的问题,要时刻明确自己的目标,并围绕目标将问题一步步简化,做到心中有数,稳扎稳打。

http://www.mrgr.cn/news/55641.html

相关文章:

  • 查找与排序-选择排序
  • 【嵌入式设备】蓝牙鼠标遥控器
  • 数据库、数据仓库、数据湖和数据中台有什么区别
  • 【linux开发-Qt】-网络编程
  • 干货|react router- loader 和组件 useEffect 加载数据的选择
  • 【热门主题】000006 案例 探索云原生后端:创新与挑战
  • 【AI学习】扩散模型学习总结PPT
  • python 爬虫 入门 四、线程,进程,协程
  • Mysql常见面试题总结
  • 深入理解Oracle闪回技术
  • JMeter快速入门示例
  • pycharm中使用ctrl+鼠标滚轮改变字体大小
  • 深入探秘ReentrantLock的实现与应用:从底层原理到业务场景的实践
  • 【LLM】大模型工具调用之AllTools模型
  • 【状态机DP】力扣1262. 可被三整除的最大和
  • 01-编程入门
  • 传感器信号的存储和传输
  • 首个统一生成和判别任务的条件生成模型框架BiGR:专注于增强生成和表示能力,可执行视觉生成、辨别、编辑等任务
  • Qt学习笔记第21到30讲
  • DataWhale10月动手实践——Bot应用开发task04学习笔记
  • MySQL 服务器配置与管理<二>
  • CAS 详解
  • Reverse.Kr—— 前四题
  • 08-流程控制语句
  • 简单汇编教程9 字符串与字符串指令
  • tkintrt.Button位置试炼——计算器“键盘”