第 4 章 核心抽象逐项学习
本章导读 :第 3 章回答"有哪些模块",本章回答"这些模块内部长什么样"。
Mark/Channel/Scale/Options/Context/Dimensions是 Plot 语义内核的六个面:前四个决定"数据怎么被理解",后两个决定"理解之后画到哪、画多大"。每节采用同一节奏:先给精简源码 (保留源码原意,注释不删),再用 特点 → 启发 → 思考 三层解读。特点 说"它是这样设计的",启发 说"从中学到什么",思考说"它为此付出了什么代价"。
4.1 Mark 基类(src/mark.js)
Mark 是所有 30+ 个标记的父类。理解 Mark 就理解了 Plot 一半。
js
// src/mark.js L10-L135(核心精简)
export class Mark {
constructor(data, channels = {}, options = {}, defaults) {
// 1) 保存基础选项
this.data = data;
this.facet = keyword(facet === true ? "include" : facet, ...);
this.fx = ...; this.fy = ...;
this.facetAnchor = maybeFacetAnchor(facetAnchor);
// 2) 合并通道:用户传入 + extraChannels + defaults(styles)
channels = {...styles(this, options, defaults), ...channels};
// 3) 过滤掉 value 为 null 且非 optional 的 channel
this.channels = Object.fromEntries(...filter null...);
// 4) 保存其他常用字段
this.dx = +dx; this.dy = +dy;
this.marginTop = +marginTop; ...;
this.tip = maybeTip(tip);
}
// 生命周期方法,子类通常 override render
initialize(facets, facetChannels, plotOptions) {
let data = dataify(this.data);
// 1) 跑 transform(来自 Plot.options 或 transforms/basic)
if (this.transform != null) ({facets, data} = this.transform(data, facets, plotOptions));
// 2) 把每个 channel 的 value 转成真实数组
const channels = createChannels(this.channels, data);
// 3) 如果有 sort 选项,计算排序后的 domain
if (this.sort != null) channelDomain(data, facets, channels, facetChannels, this.sort);
return {data, facets, channels};
}
filter(index, channels, values) {
// 默认过滤掉 undefined / NaN(来自 defined.js)
for (const name in channels) {
const {filter = defined} = channels[name];
if (filter !== null) index = index.filter((i) => filter(value[i]));
}
return index;
}
scale(channels, scales, context) {
// 把每个 channel.value 用 scale 转成像素/颜色值
const values = valueObject(channels, scales);
// 如果是地图投影,应用 d3-geo
if (context.projection) this.project(channels, values, context);
return values;
}
}
子类典型(如 src/marks/dot.js):
js
// src/marks/dot.js L29-L46
export class Dot extends Mark {
constructor(data, options = {}) {
const {x, y, r, rotate, symbol = symbolCircle, frameAnchor} = options;
super(
data,
{
x: {value: x, scale: "x", optional: true},
y: {value: y, scale: "y", optional: true},
r: {value: vr, scale: "r", filter: positive, optional: true},
rotate: {value: vrotate, optional: true},
symbol: {value: vsymbol, scale: "auto", optional: true}
},
withDefaultSort(options),
defaults
);
}
render(index, scales, channels, dimensions, context) {...}
}
Dot 自身只声明了 5 个 channel (x/y/r/rotate/symbol)并指定了 scale 名。其余样式(fill/stroke/strokeWidth/...)从
defaults经styles()(src/style.js)合并进来。
特点
- 模板方法模式 :基类把渲染拆成
initialize → filter → scale → render四步骨架,子类通常只 override 最后一步render;像Dot这样甚至"只声明通道"就够了。骨架在上、变化在下,是整章反复出现的组织方式。 - 构造期只做"归一化",不做"计算" :
new Mark(...)阶段只合并通道、过滤空通道、规整字段,完全不碰数据内容。所以"构造一个 mark"是纯声明、无副作用、可重复的,能在plot()之前自由组合(如Plot.stackY(Plot.bar(...)))。 - 语义与外观分离 :
Dot只声明几何通道(x/y/r/rotate/symbol),fill/stroke 等外观走defaults+styles()合并。"位置"是每个 mark 独有的知识,"外观"是同类 mark 共享的默认值,两者不该挤在同一张声明表里。 - 默认值靠参数注入 :
defaults是第 4 个构造参数而不是硬编码常量,于是不同 mark 能复用同一套基类实现,又能各自定制默认外观。
启发
- 想写自己的图表库,不必一上来就写 30 种图。先定"生命周期协议 + 通道协议",再把具体图形压缩成"声明 + 最后一步渲染",就能用小得多的代码量覆盖大量图形。
this.transform是挂在实例 上的可插拔钩子------同一个Bar可以被不同 transform 包裹。组合优于继承,把可变部分做成"包装函数"而不是"子类"。filter的默认实现是defined(过滤 undefined / NaN),体现"默认正确"原则:缺数据时悄悄少画一个点,而不是画出一个畸形图形。
思考
Mark同时承担了"数据契约"(initialize / filter / scale)与"渲染契约"(render),职责偏重。更"纯粹"的做法是把数据准备抽成独立的 pipeline 对象;Plot 选择务实------少一层抽象,读起来更直接。this.dx = +dx这类隐式转换(字符串 → 数字)在 JS 库源码里很常见,但对读者不友好,也丢掉了非法输入的错误提示。这是"库代码追求宽容"的代价。channels = {...styles(...), ...channels}的顺序意味着用户传入是整体覆盖默认样式,而非"叠加"。正确但不够灵活:用户无法表达"在默认 strokeWidth 基础上再加 1。
4.2 Channel 通道(src/channel.js)
Channel 是 Mark 与 Scale 的桥梁。每个 channel 描述"这个数据列要怎么映射"。语义上 Channel 是 Mark options 的一个子集 (详见第 2 章 2.2),本节按源码模块维度讲 src/channel.js 的实现。
js
// src/channel.js L8-L18
export function createChannel(data, {scale, type, value, filter, hint, label = labelof(value)}, name) {
if (hint === undefined && typeof value?.transform === "function") hint = value.hint;
return inferChannelScale(name, {
scale, type,
value: valueof(data, value), // 把 "weight" 字段名解析成 data 中对应的值数组
label, filter, hint
});
}
valueof(data, value) 是 Plot 的核心"DSL 解析器"------它接受 5 种形式:
js
Plot.dot(data, {
x: d => d.weight, // ① 函数
y: "height", // ② 字段名(字符串)
fill: ["red", "blue"], // ③ 数组
stroke: {value: ...}, // ④ 完整 channel 选项对象
r: 5 // ⑤ 常量
});
自动 scale 推断在 inferChannelScale(L43):
js
// src/channel.js L43-L78
export function inferChannelScale(name, channel) {
const {scale, value} = channel;
if (scale === true || scale === "auto") {
switch (name) {
case "fill":
case "stroke":
case "color":
channel.scale = isEvery(value, isColor) ? null : "color";
break;
case "fillOpacity":
case "strokeOpacity":
case "opacity":
channel.scale = isEvery(value, isOpacity) ? null : "opacity";
break;
case "symbol":
channel.scale = isEvery(value, isSymbol) ? null : "symbol";
channel.value = map(value, maybeSymbol); // "plus" → symbolPlus
break;
default:
channel.scale = registry.has(name) ? name : null; // x/y/r 走这里
}
} else if (scale === false) {
channel.scale = null;
} else if (scale != null && !registry.has(scale)) {
throw new Error(`unknown scale: ${scale}`);
}
}
特点
- 职责单一 :
createChannel只做两件事------把用户输入"翻译"成值数组(交给valueof)、把 scale 名"推断"成具体类型(交给inferChannelScale)。取数、推断、调用被拆成三个函数。 - 靠"值的内容"反推 scale 类型 :
isEvery(value, isColor)检查的是"每个值本身是不是已经是颜色",而不是"用户有没有声明颜色通道"。这正是 Plot "零配置"卖点的实现位置。 label = labelof(value)写在默认参数里:从字段名自动生成轴标题 / 图例名,用户显式传 label 时才覆盖------默认值设计得很"聪明"。hint是一条轻量类型通道 :当 value 是带.hint的变换对象时,把类型提示透传给 scale(例如让 time scale 知道该按时间处理),是一个几乎零成本的类型系统。
启发
- "约定优于配置"落地成代码的具体形态,就是把推断逻辑集中到一个函数里、所有 Mark 共享 。与其在每个 mark 里写
if (typeof x === ...),不如收敛为inferChannelScale。 isEvery(value, isColor)这招很值得抄:如果数据自身已经自洽(全是合法颜色),就不要再套一层 scale。很多图表库正因为多套了一层而生成多余图例。scale === true与scale === "auto"等价,说明作者刻意让 API 宽容------同一语义允许多种写法,降低记忆负担。
思考
- 基于值内容的运行期推断是启发式的,覆盖不了所有情况。例如字符串
"1"既像数值又像类别,此时只能靠用户显式声明兜底(scale: "identity")。 - 推断过程对用户不可见,初次使用最容易踩的坑就是"我的 fill 为什么不生成图例"------因为推断判定它是字面颜色,直接跳过了 color scale。这类"魔法"最好配一份可打开的调试输出。
4.3 Scale 比例尺系统(src/scales.js + src/scales/)
比例尺注册表是 Plot 把数据映射到视觉值的核心:
js
// src/scales/index.js L32-L43
export const registry = new Map([
["x", position],
["y", position],
["fx", position],
["fy", position],
["r", radius],
["color", color],
["opacity", opacity],
["symbol", symbol],
["length", length],
["projection", projection]
]);
export function isPosition(kind) {
return kind === position || kind === projection;
}
注册表用 7 个 Symbol 标记 scale 的"族"(position/color/radius/opacity/symbol/length/projection)。这种设计带来两个好处:
- Mark 声明
scale: "x"自动知道属于 position → 自动有对应轴、margin 自动调整。 - Plot 内部统一处理:见 6 章比例尺推断。
比例尺创建入口:
js
// src/scales.js L38-L57
export function createScales(channelsByScale, options) {
const scales = {};
for (const [key, channels] of channelsByScale) {
const scaleOptions = options[key];
const scale = createScale(key, channels, {...scaleOptions});
if (scale) { /* populate generic options: percent, transform, insets, ... */ }
}
return scales;
}
具体实现:
- 连续型
src/scales/quantitative.js:linear/sqrt/pow/log/symlog/quantile/quantize/threshold/identity - 时间型
src/scales/temporal.js:time/utc - 离散型
src/scales/ordinal.js:ordinal/point/band - 双向发散
src/scales/diverging.js:diverging + Sqrt/Pow/Log/Symlog 变体 - 配色
src/scales/schemes.js:categorical + diverging 方案
特点
- 注册表 + 族标记 :
registry用Map把 scale 名("x")映射到"族"(position),把"名字"与"类别"两件事解耦。 - 能力查询代替类型判断 :
isPosition(kind)让 Plot 在不知道具体是哪种 scale 的情况下,也能回答"它是不是位置类"------而轴、margin、facet 的推断恰恰只关心这个。 - 编排与实现分离 :
scales.js只负责"按 key 批量创建并回填通用选项",具体映射算法在scales/*.js里,两者可各自演化、互不牵制。
启发
- 用"一张注册表 + 一组族标记"替代厚厚的
switch/if-else,是插件化设计的最小形态。第 12 章会看到,自定义 scale 只需往注册表里加一项,其余代码一行都不用改。 - 面向"能力查询"而非"具体类型"编码:不要问"这是不是 linear",而要问"它是不是 position"。这样新增 scale 时消费方完全无感------这条原则可以直接迁移到任何"可扩展对象"设计里。
思考
registry是模块级单例,天然全局可变:写测试时容易互相污染,也无法做多实例隔离(虽然实际业务很少需要)。- 族标记用
Symbol而不是字符串,好处是不怕命名冲突,代价是调试时不直观------控制台里只能看到Symbol(position),得翻源码才知道含义。
4.4 Options 选项解析(src/options.js)
options.js 不实现功能,而是提供贯穿全库的基础工具:
| 工具 | 作用 |
|---|---|
valueof(data, value) |
字符串字段名 / 函数 / 数组 / 通道对象 / 常量 → 值数组 |
column(data, name) |
字段名 → 列访问器函数 |
identity(d) |
返回自身(默认字段访问器) |
dataify(data) |
函数 → 求值后的数据 |
range(data) |
[0, 1, ..., length-1] 索引数组 |
maybeValue(o) |
通道 shorthand 解析(string → {value: string}) |
isColor/isOpacity/isSymbol |
推断通道类型(推断后决定走哪种比例尺) |
理解 valueof 是理解 Plot DSL 的钥匙------所有用户传入的 x、y、fill 都会先经过它"翻译"成数组。
特点
- 这一层没有"功能",只有"协议" :它把"用户可能怎样描述一列数据"这个开放问题,收敛成
valueof一个入口。 - 5 种输入形式对应 5 种认知习惯:函数(自定义逻辑)、字符串(字段名)、数组(字面值)、对象(完整 channel 选项)、常量(广播)------从"最懒的写法"到"最精确的写法"全都覆盖。
dataify让数据本身也可以是函数 :支持惰性 / 响应式数据源,例如() => fetch(...)求值后才拿到数组。range(data)提供"无数据也能有下标":为那些只关心索引的 mark(如 frame、批注 rule)保留统一寻址方式。
启发
- 把多态收敛到一个入口函数 ,是设计 DSL 性价比最高的做法:上游 Mark 完全不必关心用户传了什么,只管调
valueof。如果你要为自己库做 DSL,先定义好这个"翻译器",再往上加各种语法糖。 - "数据可以是函数"(
dataify)这一点常被忽略,但它正是 Plot 能嵌入 Observable 响应式运行时、随数据自动重绘的基础之一。
思考
- 一个函数承担 5 种语义,内部必然堆叠
typeof分支:对调用者友好,对维护者则依赖足够的测试覆盖。便利与复杂度是被转移了,而不是消失了。 maybeValue、maybeSymbol这类"顺手做一点归一化"的命名偏隐晦,新读者不容易意识到它们在链路里的存在感。
4.5 Context 渲染上下文(src/context.js)
Context 是 Mark 渲染时拿到的"环境":
js
// src/plot.js L153-L184(plot() 中创建 context)
const context = createContext(options);
const document = context.document;
const svg = creator("svg").call(document.documentElement);
let figure = svg;
context.ownerSVGElement = svg;
context.className = className;
context.projection = createProjection(options, subdimensions);
context.path = function () {
return geoPath(this.projection ?? xyProjection(scales));
};
context.filterFacets = (data, channels) => { ... };
context.getMarkState = (mark) => { ... };
context.dispatchValue = (value) => { ... };
关键能力:
context.document:标准化的 DOM(jsdom / 浏览器 / Observable runtime 都支持)context.path:GeoJSON 路径生成器(d3.geoPath)context.projection:地图投影(如果有)context.getMarkState(mark):让一个 Mark 引用另一个 Mark 的 channels/values(用于 axis、tip 等)
特点
- 依赖注入容器 :Mark 不
import document、不import d3-geo,而是从context取。因此同一份 Mark 代码能在浏览器、Node + jsdom、Observable runtime 三种环境里跑。 - 可选能力用可选字段表达 :
projection/path只在 geo mark 场景下才有值,普通图表拿到undefined即可,无需到处特判"当前是不是地图模式"。 getMarkState建立 mark 之间的引用 :轴(axis)、提示(tip)这类"派生图元"本身没有数据,全部信息都来自被画的 mark------getMarkState就是官方认可的"访问另一个 mark 状态"的入口。- 把"对外通信"也纳入注入 :
dispatchValue让库能把值 / 警告交给宿主环境,而不是console.log了事。
启发
- 想让渲染代码脱离浏览器复用,关键是把环境(DOM、路径生成、投影、错误上报)全部收敛进一个 context 对象,而不是散落的全局变量或直接 import。这是"渲染核心与运行时解耦"的标准做法。
- "把事件回调也注入"这点尤其值得学:库不假设宿主是谁,只假设宿主愿意接收事件。这让同一个库既能给浏览器用,也能给 Notebook / 报表引擎用。
思考
context既承担"环境容器"(document、projection),又顺手夹带了plot()的局部状态(scales闭包、figure引用),边界有些模糊------它并不是一个纯粹的"只读输入环境"。path/filterFacets/getMarkState/dispatchValue都挂在同一个对象上,功能增长时有滑向"上帝对象"的风险。若继续扩张,拆成context.env与context.services会更清晰。
4.6 Dimensions 尺寸计算(src/dimensions.js)
js
// src/dimensions.js L6-L48(精简)
export function createDimensions(scales, marks, options = {}) {
// 1) 默认 margin = 所有 marks 中最大的 margin
let marginTopDefault = 0.5 - offset;
for (const {marginTop, marginRight, marginBottom, marginLeft} of marks) {
marginTopDefault = max(marginTopDefault, marginTop);
// ...
}
// 2) 用户选项覆盖
let {marginTop = marginTopDefault, marginRight, marginBottom, marginLeft} = options;
// 3) 默认 width=640,height 自动根据 y 轴的 band count 算
let {width = 640, height = autoHeight(scales, options, ...)} = options;
// 4) 输出
return {width, height, marginTop, marginRight, marginBottom, marginLeft};
}
autoHeight 是个亮点:Plot 根据 y 轴的 ordinal band 数自动算 height,让条形图"多高 = 多条",不需要用户手算。
特点
- 尺寸是"推导"出来的,不是"要求"出来的 :默认 margin 取所有 mark 声明里的最大值,默认 height 由 y 轴 band 数量决定,用户只在需要时才覆盖。
- 覆盖顺序单向、清晰 :先算 default,再让
options覆盖,绝无"用户与推导互相打架"的情况。 - "宽度给定、高度自适应" 符合多数表格化图表的直觉:横向宽度受屏幕限制,纵向高度应由数据条数决定。
启发
- 好的默认值设计应当做到"常见情况零配置,特殊情况可覆盖,且覆盖方向单一"。这条准则比任何具体默认数值都重要。
autoHeight把"条形图高度应随条目数增长"这种业务常识编码进了库------用户不必记住经验规则。这正是库相对手写 D3 的价值所在:替用户记住经验。
思考
autoHeight需要读取 scales 的具体形态(ordinal band),意味着dimensions与scales存在隐式耦合;一旦 scale 类型或 band 语义变化,高度的计算假设也要跟着改。- margin 取"所有 mark 的最大值"在叠加多个 mark 时是保守策略,可能让某一侧留白偏大、需要手动调------这是"安全默认"换来的轻微浪费。
4.7 小结:六个抽象的设计手法对照
| 抽象 | 一句话定位 | 核心设计手法 | 留给你的启发 |
|---|---|---|---|
Mark |
图形的声明与生命周期 | 模板方法 + 组合(transform 钩子) | 先定协议,再填实现 |
Channel |
数据列 → 美学的桥梁 | 集中式类型推断 | "约定优于配置"要收口到一个函数 |
Scale |
值域 → 视觉域的映射 | 注册表 + 能力查询(族标记) | 问能力,别问类型 |
Options |
用户输入的"翻译器" | 多态收敛到单一入口 | DSL 的性价比来自入口收敛 |
Context |
渲染的运行时环境 | 依赖注入 | 环境与核心解耦才能跨端 |
Dimensions |
画布尺寸的推导 | 安全默认 + 单向覆盖 | 常见情况零配置 |
六个抽象合起来,回答的是同一个问题:如何让"用户只描述意图"与"程序精确执行"这两端对接 。Plot 的答案可以归纳为三句话------用推断消除配置、用注册表替代分支、用注入隔离环境 。第 5 章将看它们如何被
plot()串成一条流水线。