news 2026/9/25 18:06:13

数据可视化库 Observable Plot 源码深度解析——10 Plot 如何站在 D3 肩膀上

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据可视化库 Observable Plot 源码深度解析——10 Plot 如何站在 D3 肩膀上

第 10 章 Plot 如何站在 D3 肩膀上

数据可视化库 Observable Plot 的官方定位是 “a D3-based library”。这句话很容易被误读成两种极端:要么以为它"只是 D3 的语法糖",要么以为它"和 D3 没什么关系"。

本章用实测数据把这两种印象都修正掉:Plot 确实深度依赖D3(52 个文件、243 个符号),但它把自己造的七套机制都放在了 D3 没有覆盖的地方——比例尺编排、变换管线、分面状态、样式体系、依赖注入、警告系统、惰性模板。

读完本章你应该能回答:

  1. Plot 一共从 D3 借用了多少东西?集中在哪几个 D3 子模块?
  2. 哪些 D3 能力是 Plot刻意不用的?这个"不用"透露了什么设计立场?
  3. 如果你要基于 D3 写一个类似的库,可以照搬哪几条经验?
10.1 先量化:52 个文件、60 条 import、243 个符号

不要凭印象讨论"依赖深浅",直接测。下面这条 PowerShell 脚本统计src/下所有.js文件从"d3"导入的符号:

$files=Get-ChildItem'src'-Recurse-Filter*.js$sym=New-ObjectSystem.Collections.Generic.HashSet[string]$stmts= 0$fileSet=New-ObjectSystem.Collections.Generic.HashSet[string]foreach($fin$files){$t=Get-Content-Raw-Encoding UTF8$f.FullName$ms=[regex]::Matches($t,'(?s)import\s*\{([^}]*?)\}\s*from\s*"d3"')if($ms.Count-gt0){[void]$fileSet.Add($f.Name)}foreach($min$ms){$stmts++foreach($xin($m.Groups[1].Value-split',')){$k=($x-replace'\s+as\s+.*','').Trim()if($k){[void]$sym.Add($k)}}}}"files importing d3:$($fileSet.Count)""import statements:$stmts""distinct d3 symbols:$($sym.Count)"

实测结果(v0.6.17):

files importing d3: 52 import statements: 60 distinct d3 symbols: 243

三个数字各有含义:

数字含义
52 / 81约64% 的源文件直接依赖 D3——D3 是真正的基础设施,不是可选项
60 条 import平均每个文件 1.15 条,说明导入很集中(多数文件只从 D3 取 1~3 个函数)
243 个符号覆盖面极广:从scaleLinear到contourDensity到randomLcg

一个有意思的写法细节:Plot 全部从伞包"d3"导入,而不是从"d3-scale"/"d3-array"这样的子包。这样做的代价是"必须靠 tree-shaking 才能去掉没用到的模块",收益是依赖管理极简——只有一个版本号要对齐。对于一个要被大量下游项目引用的库来说,这个取舍是合理的。

10.2 Plot 从 D3 借用了哪九个领域

把 243 个符号按 D3 子模块归类,可以清楚看到 Plot 的"能力来源":

D3 子模块Plot 用它做什么典型符号代表文件
d3-scale比例尺的底层实现scaleLinearscaleBandscaleOrdinalscaleUtcscaleDivergingscaleQuantilescaleThresholdscaleImplicittickstickIncrementscales/*.js
d3-scale-chromatic配色方案的色表来源interpolateTurbointerpolateViridisschemeObservable10schemeTableau10schemeRdBu…scales/schemes.js
d3-array数据聚合的原语minmaxsummeanmedianmodedeviationvariancequantilegrouprollupcrossrangebisecttransforms/group.js、facet.js
d3-selectionDOM 操作与命名空间selectcreatornamespacespointerplot.js、context.js、style.js
d3-shape几何路径与符号lineareasymbolCirclesymbolSquarecurveLinearcurveMonotoneXmarks/line.js、marks/area.js、symbol.js
d3-format数字与时间格式化formatformat.js、marks/axis.js
d3-time / d3-time-format时间刻度与解析timeDaytimeMonthutcYearutcFormattimeFormatmarks/axis.js、scales/temporal.js
d3-geo / d3-contour / d3-delaunay / d3-hierarchy领域专用算法geoPathgeoAlberscontourscontourDensityDelaunayclusterstratifyprojection.js、marks/contour.js、transforms/hexbin.js、transforms/tree.js
d3-interpolate / d3-random数值插值与可控随机interpolateRgbinterpolateNumberpiecewiserandomLcgscales/quantitative.js、marks/...

这张表给出的第一条经验是:把"数学与算法"交给 D3,把"约定与编排"留给自己。Plot 从 D3 拿走的几乎全是"原语"(scale 构造函数、数组聚合、路径生成器),而不是"流程"(D3 也没有流程可给)。

10.3 同一个任务:D3 怎么做 vs Plot 做了什么

抽象的比较不如三个具体例子。

10.3.1 例一:比例尺——从"手动组装"到"自动推断"

在纯 D3 里,一张点图的横轴至少要写六行,而且每一行都要你手算:

// 纯 D3:所有决定都由人来做constx=d3.scaleLinear().domain([d3.min(data,d=>d.weight),d3.max(data,d=>d.weight)])// ① 值域.nice()// ② 圆整.range([40,width-20]);// ③ 像素范围(自己定的边距)consty=d3.scaleLinear().domain(d3.extent(data,d=>d.height)).range([height-30,20]);// ④ 还得记得反向

在 Plot 里,同样的语义只需要:

Plot.dot(data,{x:"weight",y:"height"})

少掉的四行并没有消失,而是变成了第 6 章那套规则:inferScaleType判断类型、inferDomain算值域、autoScaleRange从dimensions反推像素范围、autoScaleRangeY负责反向。Plot 的价值不在于"替你做决定",而在于把决定变成了有次序、可覆盖的默认值:

// 任何一层都可以被单独接管,其它层仍然自动Plot.dot(data,{x:{value:"weight",domain:[0,100]},y:"height"})
10.3.2 例二:坐标轴——D3 的axisBottomvs Plot 的 axis mark

D3 画轴的标准姿势是:

svg.append("g").attr("transform",`translate(0,${height-marginBottom})`).call(d3.axisBottom(x).ticks(5));// 立刻输出一堆 DOM

它有三个特点:立即求值(调用即产生 DOM)、依赖外部 scale(必须先有 x)、位置要手写(translate)。

Plot 的坐标轴则是一个mark(marks/axis.js),走的是与其他图形完全相同的生命周期:

// 隐式插入(plot.js L578-L587)后,轴会经历与 Dot 一样的流程:// initialize → (initializer 生成 tick 通道) → scale → render

这样做换来三件事,每件都是 D3 的axisBottom结构上做不到的:

  1. 轴可以参与分面锚点体系(第 9 章):facetAnchor: "left-empty"之类的行为需要在"mark 层"做判断,而不是在"D3 axis 层"。
  2. 轴可以和其他 mark 共享比例尺与样式体系(第 8 章的三层样式)。
  3. 轴的刻度(ticks)可以延迟到 initializer 阶段生成——因为轴需要知道最终的 dimensions 才能决定"显示几个刻度",而这正好是第 5 章步骤 14 的用途。

但 Plot 并没有教条式地拒绝 D3:src/legends/ramp.js里就老老实实用了axisBottom来画色带下面的刻度。判断标准是"这一层需不需要参与 Plot 的管线"——图例的色带是独立的、一次性的 SVG,直接借用 D3 更划算。

10.3.3 例三:数据绑定——D3 的 join vs Plot 的 index

D3 v3 时代需要写enter()/update()/exit()三段式,v5 之后有selection.join()。Plot 走了一条更"原始"的路:把 index 数组当数据绑定给 D3 selection(第 8 章)。

// Plot(marks/dot.js L88-L91,精简)g.selectAll().data(index)// 绑定的是 [0, 3, 4, 7] 这样的下标.enter().append(circle?"circle":"path")

这样做的原因是 Plot 不需要"更新"语义:它每次都重新渲染整张图(不可变、无状态)。既然没有 update/exit,index这种最轻的绑定方式就足够,还顺带得到了两个好处:

  • 节点池(pool选项)可以安全复用:因为绑定的是数字而不是对象引用,复用节点不会引发数据错配;
  • 属性写入可以走"常量优先"的快速路径:R ? (i) => R[i] : r这种双形态让 d3 能跳过函数调用(第 8 章 8.2 节)。

结论:D3 的 selection 是一套"通用 DOM 编程抽象",Plot 只取其中"创建元素 + 设置属性"这一小块,然后把"什么时候创建、创建多少个"完全掌握在自己手里。

10.4 Plot 自己造的七套机制

那么 D3 没有提供什么?下面这七件事在 D3 里找不到对应物,正是 Plot 的核心资产:

#Plot 自研机制位置为什么 D3 没有
1比例尺系统编排scales.js+scales/*D3 只提供单个 scale 构造函数,"从 channels 推断整套 scale"是应用层问题
2声明式管线plot.jsD3 是命令式的,没有"一次声明、完整求值"的概念
3变换管线transforms/*(17 个文件)D3 只有group/rollup这样的数组原语,没有"以 mark 为单位的数据变换"
4分面状态机facet.jsD3 没有"小倍数"概念,分面在 D3 里是"写个循环手动摆"
5样式体系style.js(478 行)D3 的.attr()是逐个设值,没有"通道 → 属性"的映射约定
6依赖注入环境context.js+plot.js的 6 处挂载D3-selection 默认走全局document,无法在 Node 里直接跑
7弱约束与警告warnings.jsD3 的哲学是"不做判断",自然也不会有"数据看起来像日期字符串"这类提醒

补充两个"小而关键"的自研模块:

  • template.js:把模板字符串编译成惰性函数(第 8 章 8.6 节),避免"字符串拼接后才发现用不上"的开销;
  • interactions/pointer.js:只封装d3.pointer,把"鼠标坐标 → 数据坐标"这一步做成可复用组件。
10.5 刻意不用的部分,比用到的部分更能说明立场

实测 243 个符号里,完全找不到下面这些 D3 模块的痕迹:

未使用的 D3 模块通常用来做Plot 的替代选择
d3-zoom/d3-brush/d3-drag平移、缩放、框选、拖拽交互完全不提供:图是静态的(+ 可选tip),交互交给上层框架
d3-transition动画与过渡不提供:plot()每次输出全新 DOM
d3-force力导向布局不提供(Plot 不是图网络库)
d3-quadtree最近邻查询用interval-tree-1d(区间查询,适配dodge的重叠检测)
d3-chord/d3-sankey(在 d3 之外)弦图、桑基图由用户用 mark 组合实现
d3-axis直接输出坐标轴 DOM自己在marks/axis.js里画(唯一例外:legends/ramp.js用了axisBottom)

"不提供交互与动画"是一个极其重要的定位决策,它带来三个连锁结果:

  1. plot()可以是纯函数:输入 options,输出一个全新的 DOM 节点,没有内部可变状态、没有事件监听泄漏。这让它能安全地用在 React/Vue 的渲染函数里,也可以在 Node/jsdom 里跑测试与静态导出。
  2. 整张图是可哈希、可缓存的:因为输出只依赖输入,把它当成"数据 → 图片"的映射非常自然(这也是 Plot 常被用于服务端出图的原因)。
  3. 交互成为"组合层"的事:需要联动刷选、缩放时,用Plot.plot()重画一张图(而不是让库去 mutate DOM),配合figure.scale()读取/改写 domain 即可。
10.6 依赖清单也是架构:一个可改进的细节

最后看package.json(v0.6.17):

"dependencies":{"d3":"^7.9.0","interval-tree-1d":"^1.0.0","isoformat":"^0.2.0","rimraf":"^6.1.3"}

四点观察:

  1. 只有 4 个运行时依赖,而且都"很小、很专一":interval-tree-1d只被transforms/dodge.js用(区间重叠检测),isoformat只被format.js与options.js用(ISO 8601 解析/格式化)。"依赖的粒度对齐到功能点"是好设计。
  2. rimraf显然放错了位置:它是一个删除文件的 CLI 工具,在源码里没有任何 import(实测 grep 0 命中),scripts.prepublishOnly用的是rm -rf。它应该待在devDependencies。依赖清单的准确性也是代码质量的一部分——多一个依赖,下游就多一份安装体积与安全审计负担。
  3. "main": "src/index.js"(源码即产物):Plot不强制下游使用打包后的dist,而是直接把 ESM 源码作为入口(dist/plot.umd.min.js只服务 CDN 场景)。这要求源码本身"可被任意打包器处理"——这也是它坚持只用 ESM import、不引入运行时语法糖的原因之一。
  4. "sideEffects": ["./src/index.js"]:向打包器声明"其余模块无副作用",从而允许整模块级别的 tree-shaking。一个 243 个 D3 符号的重依赖库能做到"用什么打什么",靠的就是这一行。
10.7 三条可迁移的经验

如果你要基于某个底层库(D3、ECharts、Canvas API…)写上层封装,Plot 的取舍值得借鉴:

  1. 底层库给你"原语",你要自己造"约定"。D3 里没有"默认比例尺"这种东西,因为它不知道你的领域;但 Plot 知道"统计图表的 x 轴通常不需要从 0 开始",于是它把这类判断固化成规则。封装的真正价值是领域默认值,而不是语法更短。
  2. 默认值要"可分层接管"。Plot 的x: "weight"→x: {value: "weight", domain: [...]}→x: {value: "weight", domain: [...], type: "log", scale: ...}是同一套语法的三种深度。使用者永远不需要"全盘接管",这才让默认值不会变成黑箱。
  3. 对底层库保持"按需取用"而非"阵营忠诚"。Plot 没画坐标轴却用了axisBottom画图例色带;不提供交互却在interactions/里封装pointer。判断依据永远是"这一层是否属于我的管线",而不是"这个模块是不是 D3 的"。
10.8 常见误区
  1. 「Plot 是 D3 的语法糖」——如果只是语法糖,它不会自研 17 个变换、一个分面状态机、一套样式体系。它复用的是算法,自研的是流程与约定。
  2. 「用了 Plot 就不需要懂 D3」——日常画图确实不需要,但一旦要自定义 mark(第 11 章)或自定义 scale(第 12 章),你会立刻遇到d3.scaleLinear、d3.symbol、context.path这些概念。Plot 是 D3 的上层,不是替代品。
  3. 「全部从"d3"伞包导入会导致包体积巨大」——源码层面确实导入了 243 个符号,但配合sideEffects声明与 ESM,打包器只保留真正用到的部分。"依赖多"与"体积大"不能划等号。
  4. 「Plot 不提供交互是因为做不到」——是主动选择。不提供交互换来的是纯函数式渲染,这直接决定了"React 里安全使用"与"服务端出图"两个能力。
10.9 本章小结
  • 实测:52/81 个源文件、60 条 import、243 个 D3 符号;从伞包"d3"统一导入,靠 tree-shaking 控制体积。
  • Plot 从 D3 借的主要是"算法原语":scale 构造函数、数组聚合、路径/符号生成、格式化、地理与层次布局。
  • 三个任务上 D3 与 Plot 的分工:比例尺(手动组装 → 自动推断)、坐标轴(命令式 DOM → 参与管线的 mark)、数据绑定(join → index + 常量优先)。
  • Plot 自研的七套机制里,最能体现定位的是分面状态机、样式体系、依赖注入;最能体现立场的是不提供 zoom/brush/drag/transition。
  • 设计原则可总结为三条:造领域默认值、默认值可分层接管、对底层库按需取用。

理论部分到此结束。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 18:03:27

12G显存跑27B模型:量化、KV Cache优化与投机解码实战

1. 为什么要在12G显存上折腾27B模型先把结论摆在前面:12G显存跑27B模型,128K上下文,decode速度50 tokens/s,这件事在一年前基本属于天方夜谭,但现在通过量化压缩、KV Cache优化、投机解码这几条路组合起来,…

作者头像 李华
网站建设 2026/9/25 18:02:19

免费CRM总折腾?自建私有化CRM全流程实战——以DeskcommCRM为例

搞了这么多年软件,我见过太多团队在CRM选型上反复折腾:一开始图省事用免费CRM,业务跑起来后数据越来越多,权限一复杂就发现平台带不动;想自己写一套专门给销售和客服用的后台,又舍不得那个开发成本。后来我…

作者头像 李华
网站建设 2026/9/25 18:01:46

基于LLM的企微量化推送系统:从盯盘焦虑到自动推送

1. 从盯盘焦虑到自动推送:这套系统到底在解决什么做A股的人大概都有过这种体验:早上九点半开盘,手里几只票,一边上班一边偷偷刷行情,涨了怕回撤,跌了怕深套,一天下来正事没干几件,心…

作者头像 李华
网站建设 2026/9/25 17:54:05

手写ReAct循环:不靠框架,用while循环搭建Agent推理核心

还在纠结要不要给项目引入 Agent 的时候,我第一个跳出来的念头就是:别给项目加一堆东西,先把你头脑里的推理过程翻译成一个循环。ReAct 这个名字一旦出现,网上搜出来的全是框架、库、Agent 中间件,搞得人以为这是什么重…

作者头像 李华