第 4 章 数据可视化库 Observable Plot 核心抽象逐项学习
本章导读:第 3 章回答"有哪些模块",本章回答"这些模块内部长什么样"。
Mark/Channel/Scale/Options/Context/Dimensions是 Plot 语义内核的六个面:前四个决定"数据怎么被理解",后两个决定"理解之后画到哪、画多大"。每节采用同一节奏:先给精简源码(保留源码原意,注释不删),再用特点 → 启发 → 思考三层解读。特点说"它是这样设计的",启发说"从中学到什么",思考说"它为此付出了什么代价"。
4.1 Mark 基类(src/mark.js)
Mark是所有 30+ 个标记的父类。理解 Mark 就理解了 Plot 一半。
// src/mark.js L10-L135(核心精简)exportclassMark{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 的 channelthis.channels=Object.fromEntries(...filternull...);// 4) 保存其他常用字段this.dx=+dx;this.dy=+dy;this.marginTop=+marginTop;...;this.tip=maybeTip(tip);}// 生命周期方法,子类通常 override renderinitialize(facets,facetChannels,plotOptions){letdata=dataify(this.data);// 1) 跑 transform(来自 Plot.options 或 transforms/basic)if(this.transform!=null)({facets,data}=this.transform(data,facets,plotOptions));// 2) 把每个 channel 的 value 转成真实数组constchannels=createChannels(this.channels,data);// 3) 如果有 sort 选项,计算排序后的 domainif(this.sort!=null)channelDomain(data,facets,channels,facetChannels,this.sort);return{data,facets,channels};}filter(index,channels,values){// 默认过滤掉 undefined / NaN(来自 defined.js)for(constnameinchannels){const{filter=defined}=channels[name];if(filter!==null)index=index.filter((i)=>filter(value[i]));}returnindex;}scale(channels,scales,context){// 把每个 channel.value 用 scale 转成像素/颜色值constvalues=valueObject(channels,scales);// 如果是地图投影,应用 d3-geoif(context.projection)this.project(channels,values,context);returnvalues;}}子类典型(如src/marks/dot.js):
// src/marks/dot.js L29-L46exportclassDotextendsMark{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的实现。
// src/channel.js L8-L18exportfunctioncreateChannel(data,{scale,type,value,filter,hint,label=labelof(value)},name){if(hint===undefined&&typeofvalue?.transform==="function")hint=value.hint;returninferChannelScale(name,{scale,type,value:valueof(data,value),// 把 "weight" 字段名解析成 data 中对应的值数组label,filter,hint});}valueof(data, value)是 Plot 的核心"DSL 解析器"——它接受 5 种形式:
Plot.dot(data,{x:d=>d.weight,// ① 函数y:"height",// ② 字段名(字符串)fill:["red","blue"],// ③ 数组stroke:{value:...},// ④ 完整 channel 选项对象r:5// ⑤ 常量});自动 scale 推断在inferChannelScale(L43):
// src/channel.js L43-L78exportfunctioninferChannelScale(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" → symbolPlusbreak;default:channel.scale=registry.has(name)?name:null;// x/y/r 走这里}}elseif(scale===false){channel.scale=null;}elseif(scale!=null&&!registry.has(scale)){thrownewError(`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 把数据映射到视觉值的核心:
// src/scales/index.js L32-L43exportconstregistry=newMap([["x",position],["y",position],["fx",position],["fy",position],["r",radius],["color",color],["opacity",opacity],["symbol",symbol],["length",length],["projection",projection]]);exportfunctionisPosition(kind){returnkind===position||kind===projection;}注册表用 7 个Symbol标记 scale 的"族"(position/color/radius/opacity/symbol/length/projection)。这种设计带来两个好处:
- Mark 声明
scale: "x"自动知道属于 position → 自动有对应轴、margin 自动调整。 - Plot 内部统一处理:见 6 章比例尺推断。
比例尺创建入口:
// src/scales.js L38-L57exportfunctioncreateScales(channelsByScale,options){constscales={};for(const[key,channels]ofchannelsByScale){constscaleOptions=options[key];constscale=createScale(key,channels,{...scaleOptions});if(scale){/* populate generic options: percent, transform, insets, ... */}}returnscales;}具体实现:
- 连续型
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 渲染时拿到的"环境":
// src/plot.js L153-L184(plot() 中创建 context)constcontext=createContext(options);constdocument=context.document;constsvg=creator("svg").call(document.documentElement);letfigure=svg;context.ownerSVGElement=svg;context.className=className;context.projection=createProjection(options,subdimensions);context.path=function(){returngeoPath(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)
// src/dimensions.js L6-L48(精简)exportfunctioncreateDimensions(scales,marks,options={}){// 1) 默认 margin = 所有 marks 中最大的 marginletmarginTopDefault=0.5-offset;for(const{marginTop,marginRight,marginBottom,marginLeft}ofmarks){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()串成一条流水线。