Observable Plot 源码深度解析——4 核心抽象逐项学习

第 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/...)从 defaultsstyles()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 === truescale === "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)。这种设计带来两个好处:

  1. Mark 声明 scale: "x" 自动知道属于 position → 自动有对应轴、margin 自动调整。
  2. 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 方案

特点

  • 注册表 + 族标记registryMap 把 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 的钥匙------所有用户传入的 xyfill 都会先经过它"翻译"成数组。

特点

  • 这一层没有"功能",只有"协议" :它把"用户可能怎样描述一列数据"这个开放问题,收敛成 valueof 一个入口。
  • 5 种输入形式对应 5 种认知习惯:函数(自定义逻辑)、字符串(字段名)、数组(字面值)、对象(完整 channel 选项)、常量(广播)------从"最懒的写法"到"最精确的写法"全都覆盖。
  • dataify 让数据本身也可以是函数 :支持惰性 / 响应式数据源,例如 () => fetch(...) 求值后才拿到数组。
  • range(data) 提供"无数据也能有下标":为那些只关心索引的 mark(如 frame、批注 rule)保留统一寻址方式。

启发

  • 把多态收敛到一个入口函数 ,是设计 DSL 性价比最高的做法:上游 Mark 完全不必关心用户传了什么,只管调 valueof。如果你要为自己库做 DSL,先定义好这个"翻译器",再往上加各种语法糖。
  • "数据可以是函数"(dataify)这一点常被忽略,但它正是 Plot 能嵌入 Observable 响应式运行时、随数据自动重绘的基础之一。

思考

  • 一个函数承担 5 种语义,内部必然堆叠 typeof 分支:对调用者友好,对维护者则依赖足够的测试覆盖。便利与复杂度是被转移了,而不是消失了。
  • maybeValuemaybeSymbol 这类"顺手做一点归一化"的命名偏隐晦,新读者不容易意识到它们在链路里的存在感。
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.envcontext.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),意味着 dimensionsscales 存在隐式耦合;一旦 scale 类型或 band 语义变化,高度的计算假设也要跟着改。
  • margin 取"所有 mark 的最大值"在叠加多个 mark 时是保守策略,可能让某一侧留白偏大、需要手动调------这是"安全默认"换来的轻微浪费。

4.7 小结:六个抽象的设计手法对照
抽象 一句话定位 核心设计手法 留给你的启发
Mark 图形的声明与生命周期 模板方法 + 组合(transform 钩子) 先定协议,再填实现
Channel 数据列 → 美学的桥梁 集中式类型推断 "约定优于配置"要收口到一个函数
Scale 值域 → 视觉域的映射 注册表 + 能力查询(族标记) 问能力,别问类型
Options 用户输入的"翻译器" 多态收敛到单一入口 DSL 的性价比来自入口收敛
Context 渲染的运行时环境 依赖注入 环境与核心解耦才能跨端
Dimensions 画布尺寸的推导 安全默认 + 单向覆盖 常见情况零配置

六个抽象合起来,回答的是同一个问题:如何让"用户只描述意图"与"程序精确执行"这两端对接 。Plot 的答案可以归纳为三句话------用推断消除配置、用注册表替代分支、用注入隔离环境 。第 5 章将看它们如何被 plot() 串成一条流水线。

相关推荐
浔溺2 小时前
al+大数据每日学习笔记37
大数据·笔记·学习
TheBestRucy2 小时前
PyTorch 基本使用学习笔记
pytorch·笔记·学习
今日无bug2 小时前
为什么大模型回答总是一个字一个字蹦出来?聊聊 SSE 流式输出与 BFF 层的那些事
前端·llm
ouynagda2 小时前
51 单片机 LED 流水灯 + 数码管动态扫描学习笔记(STC89C52)
笔记·单片机·学习
CIO_Alliance3 小时前
AI微调系列(1)| 全量微调、LoRA、QLoRA 三种方案怎么选
前端·人工智能·神经网络·机器学习·embedding·企业ai转型
hanbo17C23 小时前
企业官网能不能看出公司实力?选建站公司前,先看这6条硬标准
运维·服务器·前端
zzzll11113 小时前
LLM 学习第 24 课:Agent Harness
前端·人工智能·学习
Bmob后端云3 小时前
Bmob后端云实战|Python实现SSE流式输出,给备忘录AI加上打字机效果
前端·github
nunumaymax3 小时前
【第九章-React Router 6】
前端·react.js