☰
数据可视化库 Observable Plot 源码深度解析——8 Mark 如何变成 SVG
2026/9/25 18:05:37 网站建设 项目流程

第 8 章 Mark 如何变成 SVG

本章导读:第 5 章数据可视化库 Observable Plot 的步骤 19 只有三行有效代码,却完成了"数据 → 图形"的最后一跳:

constnode=mark.render(index,scales,values,superdimensions,context);if(node==null)continue;svg.appendChild(node);

本章要打开mark.render这个黑盒。选择的样本是 Plot 里最常用、也最有代表性的实现——src/marks/dot.js的Dot.render,只有 60 多行,却完整展示了"创建节点、绑定数据、注入样式、设置属性"的全套套路。

读完本章你应该能回答:

  1. render的五个参数分别是什么?其中哪一个参数代表"数据"(答案会出乎意料)?
  2. create("svg:g", context)里的context到底提供了什么?为什么 Plot 不直接用全局document?
  3. Plot 的样式系统为什么要把属性分成"挂在<g>上"和"挂在子元素上"两层?这两层分别由style.js的哪个函数负责?
8.1 渲染契约:一个函数,五个参数,一个返回值

所有 Mark 的渲染都必须符合同一个签名。以Dot.render为例:

// src/marks/dot.js L73-L81(节选)render(index,scales,channels,dimensions,context){const{x,y}=scales;const{x:X,y:Y,r:R,rotate:A,symbol:S}=channels;const{r,rotate,symbol}=this;const[cx,cy]=applyFrameAnchor(this,dimensions);constcircle=symbol===symbolCircle;constsize=R?undefined:r*r*Math.PI;if(negative(r))index=[];returncreate("svg:g",context)// ...}

五个参数的分工如下——注意第二个参数很容易被误认为是数据:

参数类型含义典型误用
indexnumber[] | null当前 facet 的索引数组,才是"哪些数据要画"当成数据对象数组,去读index[i].value(错)
scales{x, y, fx, fy, …}比例尺函数(已实例化,可调用),也带domain()/range()/bandwidth()又拿scales.x去推断 domain(多余,已算好)
channels{x?: number[], y?: number[], r?: number[], …}已经缩放过的值,按通道名索引误以为需要自己scale(channel.value)(会双重缩放)
dimensions{width, height, marginTop, …}画布尺寸与边距忘记 y 轴已经翻转,又反向一次
context{document, clip, projection, path, …}渲染环境(依赖注入)直接使用全局document,破坏可测试性

关于scales与channels的区别,一句话记忆:scales是"尺子"(函数),channels是"量好的长度"(数组)。Dot.render里真正用到的scales只有x/y两个——而且只用来判断"这个通道是不是按比例尺算出来的"(X ? (i) => X[i] : cx),因为channels里的值已经是像素了。

8.2 为什么"数据"是一个索引数组

index是 Plot 渲染设计里最容易被低估的一环。它是一个纯数字数组,例如[0, 3, 4, 7],含义是"当前 facet 内需要绘制的记录在整份 data 中的下标"。

这样设计带来三个好处:

  1. facet 过滤零拷贝:第 5 章步骤 19 提到mark.filter(index, channels, values)会按"值的有效性"筛掉下标。筛掉一个点只是从数组里去掉一个数字,不需要复制任何数据对象。
  2. 数据可以不是数组:data可以是Float64Array、可以是{get(i){...}}这类自定义结构,甚至可以是"函数生成的惰性数据"。只要能用下标取到值,渲染层就不用关心它的具体形态。
  3. 节点池(pool)成为可能:因为渲染只依赖下标,d3的data(index)绑定可以安全地复用节点(pool选项),而不必担心"数据对象引用变化导致节点重建"。

代价是渲染代码里到处都是(i) => X[i]这种间接访问。Dot.render里那段属性设置就非常典型:

// src/marks/dot.js L93-L98(selection)=>{selection.attr("cx",X?(i)=>X[i]:cx).attr("cy",Y?(i)=>Y[i]:cy).attr("r",R?(i)=>R[i]:r);}

注意这里的双形态写法:X存在时传函数(每个点不同),否则直接传常量(所有点相同)。这既是最简写法,也是一条性能约定——d3 对常量属性能省掉一次函数调用。当你有 10 万个点时,这个差别是可测量的。

8.3 逐行拆解Dot.render

把Dot.render完整读一遍(去掉分支细节后):

// src/marks/dot.js L73-L138(精简)render(index,scales,channels,dimensions,context){const{x,y}=scales;const{x:X,y:Y,r:R,rotate:A,symbol:S}=channels;const{r,rotate,symbol}=this;const[cx,cy]=applyFrameAnchor(this,dimensions);constcircle=symbol===symbolCircle;constsize=R?undefined:r*r*Math.PI;if(negative(r))index=[];returncreate("svg:g",context).call(applyIndirectStyles,this,dimensions,context).call(applyTransform,this,{x:X&&x,y:Y&&y}).call((g)=>g.selectAll().data(index).enter().append(circle?"circle":"path").call(applyDirectStyles,this).call(/* …按 circle / path 分别设置属性… */).call(applyChannelStyles,this,channels)).node();}

可以清晰地切成5 个阶段:

阶段代码做了什么
① 准备const {x, y} = scales; …applyFrameAnchor取出需要的通道与常量,算出 frameAnchor(无 x/y 时的默认位置)
② 建容器create("svg:g", context)创建一个<g>作为"一层的根",所有点都放进去
③ 组级样式applyIndirectStyles把 mark 级的常量样式(class / fill / stroke / stroke-width / clip…)挂到<g>上
④ 位移applyTransform处理dx/dy,并在离散比例尺下自动加半格使图元居中
⑤ 数据绑定与属性selectAll().data(index).enter().append(...)每个下标创建一个节点,再依次注入 direct / channel 样式与几何属性

其中第 ④ 步的"自动加半格"值得单独看:

// src/style.js L426-L432exportfunctionapplyTransform(selection,mark,{x,y},tx=offset,ty=offset){tx+=mark.dx;ty+=mark.dy;if(x?.bandwidth)tx+=x.bandwidth()/2;if(y?.bandwidth)ty+=y.bandwidth()/2;if(tx||ty)selection.attr("transform",`translate(${tx},${ty})`);}

x.bandwidth()是 band/point 比例尺特有的方法,返回"一个类别占的像素宽度"。当 x 是离散比例尺时,scales.x(value)返回的是该 band 的左边界,因此必须再平移半个带宽才是中心。这一行解释了很多人遇到的困惑:“为什么Plot.dot的点在类别轴上会自动居中,而我自己算scales.x时却偏左半格?”——因为这半格是applyTransform帮你加的,属于渲染层的职责,不是比例尺的职责。

第 ⑤ 步里有一处"防负值"的细节:

// src/marks/dot.js L80if(negative(r))index=[];

negative(r)检查的是r这个常量(this.r),如果用户写了r: -5,直接把index清空——整个 mark 什么都不画,而不是画出 640 个半径为负的圆。这类"入口处的显式拒绝"是 Plot 避免产生畸形 SVG 的常见做法。

8.4 样式系统的三层分工

Dot.render里出现了三个apply*Styles,加上几何属性,构成了 Plot 的样式体系。理解这套分工,是写自定义 Mark 时最关键的一步:

函数挂在哪处理什么典型属性是否 per-element
applyIndirectStyles<g>(组)mark 的常量样式class、fill、stroke、stroke-width、fill-opacity、stroke-dasharray、clip否(作用于整组,靠继承生效)
applyChannelStyles子元素通道驱动的动态样式fill、stroke、stroke-width、opacity、fill-opacity、stroke-opacity、aria-label、title、href是(每个元素不同)
applyDirectStyles子元素mark 的元素级常量样式mix-blend-mode(style)、opacity(attr)否(但必须挂在元素上)
// src/style.js L380-L403(精简)// Note: may mutate selection.node!(注意:可能会改动 selection.node!)exportfunctionapplyIndirectStyles(selection,mark,dimensions,context){applyClip(selection,mark,dimensions,context);applyAttr(selection,"class",mark.className);applyAttr(selection,"fill",mark.fill);applyAttr(selection,"fill-opacity",mark.fillOpacity);applyAttr(selection,"stroke",mark.stroke);applyAttr(selection,"stroke-width",mark.strokeWidth);// …(stroke-linejoin / linecap / miterlimit / dasharray / dashoffset 等)}exportfunctionapplyDirectStyles(selection,mark){applyStyle(selection,"mix-blend-mode",mark.mixBlendMode);applyAttr(selection,"opacity",mark.opacity);}

💡PS:这行 Note 的“元凶”是块内首行的applyClip(style.js L314-L326):当clip: "frame"时,源码会把<g>再包一层不带 transform 的父<g>,并直接改写selection.node(原注释:To simplify the adoption of this fix, mutate the passed-in selection.node to return the parent G element)。自定义 Mark 若在调用applyIndirectStyles前后缓存了selection.node()的引用,要当心这一副作用。

为什么applyDirectStyles只处理两个属性?因为它必须挂在"每个元素"上(opacity不是继承属性),而其余样式都能通过<g>继承——把可继承的样式放在组上、只把不可继承的放在元素上,能把 N 次属性写入降为 1 次。这是 SVG 性能优化的常识,Plot 把它固化成了分层约定。

而通道样式的实现则是一长串"有则写":

// src/style.js L189-L205(节选)exportfunctionapplyChannelStyles(selection,{target,tip},{ariaLabel:AL,title:T,fill:F,fillOpacity:FO,stroke:S,strokeOpacity:SO,strokeWidth:SW,opacity:O,href:H}){if(AL)applyAttr(selection,"aria-label",(i)=>AL[i]);if(F)applyAttr(selection,"fill",(i)=>F[i]);// …(title / fill-opacity / stroke / stroke-opacity / stroke-width / opacity / href)}

注意if (F)这种短路判断:只有用户真的声明了fill通道,才会执行selection.attr("fill", ...)。否则元素就继承<g>上的fill。这就是 8.3 节里那个"组级样式"的真正价值——它是继承体系里的默认值来源。

回顾Dot的默认样式:

// src/marks/dot.js L18-L23constdefaults={ariaLabel:"dot",fill:"none",stroke:"currentColor",strokeWidth:1.5};

fill: "none"与stroke: "currentColor"会被挂到<g>上,于是所有圆点默认是"空心、描边跟随文字颜色"。而只要你写了fill: "category",applyChannelStyles就会在每个<circle>上写入具体的fill,从而覆盖继承值——"默认值 + 通道覆盖"的优先级链,完全由 DOM 继承机制实现,Plot 不需要任何样式合并算法。

8.5create():11 行的依赖注入

create("svg:g", context)是整个渲染层唯一创建 DOM 的入口,实现在context.js里,全文只有 11 行:

// src/context.jsimport{creator,select}from"d3";import{maybeClip}from"./options.js";exportfunctioncreateContext(options={}){const{document=typeofwindow!=="undefined"?window.document:undefined,clip}=options;return{document,clip:maybeClip(clip)};}exportfunctioncreate(name,{document}){returnselect(creator(name).call(document.documentElement));}

短短几行,回答了三个设计问题:

  1. context只是一个"环境包":{document, clip},其余能力(ownerSVGElement、projection、path、filterFacets、getMarkState…)都由plot.js在步骤 13 挂上去。核心渲染代码只依赖这个接口,不认识浏览器全局对象。
  2. document可被替换:createContext({document: jsdomDocument})就能在 Node 里渲染。这不是理论可能性——Plot 的测试就是这么跑的。
  3. creator(name).call(document.documentElement)是 d3 命名空间的技巧:d3.creator("svg:g")返回一个函数,.call(documentElement)让它以正确的命名空间创建元素。它必须用call而不是直接调用,因为命名空间解析依赖this(即所在文档)。

自定义 Mark 时必须通过create(name, context)创建节点,而不是document.createElement。原因不是"规范",而是实际后果:直接创建的元素不会带 SVG 命名空间,在某些渲染路径(如d3.select(...).attr("xlink:href"))下会静默失败。

8.6 三种符号绘制路径

Dot支持symbol(圆、方块、三角…)。它的渲染在"圆"与"路径"之间分流,并且对R(半径通道)与S(符号通道)的四种组合分别处理:

// src/marks/dot.js L99-L133(精简)(selection)=>{selection.attr("transform",template`translate(${X?(i)=>X[i]:cx},${Y?(i)=>Y[i]:cy})${A?(i)=>`rotate(${A[i]})`:rotate?`rotate(${rotate})`:``}`).attr("d",R&&S?(i)=>{constp=path();S[i].draw(p,R[i]*R[i]*Math.PI);returnp;}:R?(i)=>{constp=path();symbol.draw(p,R[i]*R[i]*Math.PI);returnp;}:S?(i)=>{constp=path();S[i].draw(p,size);returnp;}:(()=>{constp=path();symbol.draw(p,size);returnp;})());}

三条结论:

  1. circle是快路径:当symbol === symbolCircle时,直接append("circle")并设cx/cy/r。这是唯一能吃到浏览器原生圆形优化的路径。
  2. 其它符号走<path>+d:用 d3 的pathRound构建路径字符串。注意d属性是每个元素计算一次的——所以当S与R都不存在时,最后一个分支用了一个立即执行的箭头函数(() => ...)(),只算一次字符串,所有元素共用。这个"常量提前求值"是 60 行代码里最精妙的性能细节。
  3. 面积而非半径:R[i] * R[i] * Math.PI与r * r * Math.PI都是面积。因为 d3 的symbol.draw(context, area)接收的参数是面积,而不是半径——这也呼应了第 6 章r走sqrt比例尺的设计:"数值 → 半径"必须开方,才能保证"数值 → 面积"是线性的。

另外那个template标签函数也值得一提。它来自src/template.js,作用是把"含变量的模板字符串"编译成惰性函数:

template`translate(${X?(i)=>X[i]:cx},${Y?(i)=>Y[i]:cy})${...}`

只有在真正需要transform时才会拼接字符串,避免了"先拼一个字符串再发现用不上"的开销。

8.7 常见误区
  1. 「render的第一个参数是数据」——不是,是索引数组。这是自定义 Mark 时最常见的错误来源。想拿数据请用this.data(在initialize里已被物化)或通过channels里的数组。
  2. 「channels里是原始值,需要自己缩放」——render拿到的channels是mark.scale()的产物(第 5 章步骤 17),已经是像素/颜色。再调一次scales.x(value)会双重缩放。
  3. 「样式可以直接写在子元素上」——可以,但你应该先用applyIndirectStyles把可继承的常量挂到<g>上,否则每个元素都要写一遍,属性写入次数从 1 变成 N。
  4. 「必须用document.createElement」——应该用create(name, context)。前者在 SVG 命名空间与可测试性上都会出问题。
  5. 「render返回null是错误处理」——它是合法的"本次不绘制"信号。plot.js步骤 19 里有if (node == null) continue;,所以在数据为空、index.length === 0的情况下,很多 mark 会返回null而不是空<g>,这能避免留下大量空节点。
  6. 「d3.select(...).data(index)会绑定数据对象」——它绑定的是数字。所以selection.attr("cx", (i) => X[i])里的i是数据下标,不是数据。想访问数据要用i反查。
8.8 本章小结
  • 渲染契约是render(index, scales, channels, dimensions, context) → Node | null;数据以索引数组的形式传入,channels里的值已经是像素。
  • Dot.render的 5 个阶段:准备 →create("svg:g", context)→applyIndirectStyles→applyTransform→data(index).append(...)+applyDirectStyles+applyChannelStyles。
  • 样式三层分工:组级常量(继承)+ 元素级常量(不可继承属性)+ 通道级动态;优先级靠 DOM 继承与属性覆盖自然实现。
  • applyTransform会为 band/point 比例尺自动加半个bandwidth(),这是"离散轴上图元自动居中"的真正原因。
  • context只提供{document, clip},是为了让渲染与浏览器全局解耦(可 jsdom 测试);一切 DOM 创建都要走create(name, context)。
  • 性能要点:常量 vs 函数的双形态属性、d字符串的提前求值、symbol.draw(p, area)的面积语义。

到这里,"单张图怎么画出来"已经讲完。但从第 5 章我们知道,plot()还要处理分面、坐标轴、图例这三件"跨越多个 mark"的事——它们分别由facet.js、隐式 axis mark、legends.js负责。下一章进入这三块。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询