- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
导读
本文围绕 JointJS 官方示例counters-js,系统讲解如何在 SVG 图表中实现内容驱动的自定义元素(content-driven elements):元素的尺寸、内部结构与渲染内容完全由数据(计数器名称与数值)驱动,并随数据源实时刷新。通过阅读本文,你将掌握 JointJS 的自定义dia.Element/dia.ElementView扩展范式、基于 presentation attributes 的增量渲染机制(flag 系统)、dry批量更新、uid 多对一关联映射,以及面向大规模图表的虚拟视口渲染等一套完整可复用的实战方案。示例完整源码位于 examples/counters-js/src/main.js。
一、示例概览:什么是“内容驱动的实时更新元素”
counters-js演示了一个由 100 个节点(10 列 × 10 行)组成的图表。每个节点是一个带标题、折叠/展开按钮和若干计数器的卡片状元素,其高度会随计数器数量自动伸缩,数值与状态颜色会按照毫秒级的时间间隔被持续刷新。其核心设计思想可以概括为三点:
- 内容驱动尺寸:节点的高度不是写死的,而是由
counterNames.length(计数器行数)与expanded(是否展开)实时计算得出; - 增量渲染:数据刷新时只更新变化了的 DOM 节点(计数器文本、状态填充色),而非重建整个元素视图;
- 数据与视图解耦:节点通过
uid关联外部“数据源”,同一uid可以对应多个图内元素(本示例中每列创建一条链式连线,节点间用Link连接)。
官方对该示例的定位一句话概括为:“This demo shows an example of content-driven elements with real-time updates.”(见 examples/counters-js/README.md)。
二、环境与运行:从安装到预览
counters-js属于 JointJS monorepo(package.json)中的一个独立示例包(@joint/demo-counters-js),它通过workspace:协议直接依赖@joint/core(见 examples/counters-js/package.json),因此不需要单独安装该库,只需在仓库根目录一次性安装全部依赖并构建。
1. 安装依赖并构建 monorepo
从 monorepo 根目录执行:
yarn install yarn run build两条命令分别完成依赖安装与全仓库构建(yarn run build会构建@joint/core等被依赖的 workspace 包)。
2. 启动开发服务器
进入示例目录并启动 Vite 开发服务器:
cd examples/counters-js yarn dev然后打开终端中打印的 URL(通常是http://localhost:5173)。页面加载后即可看到计数器图表持续运行:节点随机刷新状态颜色与计数器数值,点击节点右上角的箭头按钮可以折叠/展开计数器区域。
说明:
dev脚本即vite(见 examples/counters-js/package.json),@joint/core通过 workspace 依赖解析,无需额外配置。
3. 创建生产构建
yarn build该脚本为tsc && vite build,产物输出到dist/目录。
4. 本地预览生产构建
yarn previewpreview即vite preview,用于在本地起一个静态服务器检查dist/产物的实际表现。
三、项目结构与页面外壳
示例目录结构非常精简:
examples/counters-js/ ├── index.html # 页面入口:挂载 paper 容器 ├── package.json # Vite 脚本与 workspace 依赖 ├── src/ │ ├── main.js # 全部核心逻辑(模型、视图、图表装配、数据流) │ └── styles.css # 页面布局样式 └── assets/ └── jointjs-logo-black.svgexamples/counters-js/index.html 定义了唯一的挂载点:
<div id="paper-container"></div>#paper-container在 examples/counters-js/src/styles.css 中被设置为绝对定位、铺满视口并允许滚动:
#paper-container { position: absolute; right: 0; top: 0; left: 0; bottom: 0; overflow: scroll; }这意味着图表画布本身会超出视口,需要配合滚动与“空白区域拖拽平移”交互(见下文“视口渲染优化”一节)。
四、配置常量速查表
examples/counters-js/src/main.js 顶部集中定义了所有可调参数,理解这些常量是改造示例的第一步:
| 常量 | 默认值 | 含义 |
|---|---|---|
COLS | 10 | 网格列数(每行元素个数) |
ROWS | 10 | 网格行数 |
STATE_UPDATE_MS | 10 | 状态更新定时器间隔(毫秒) |
DATA_POINT_UPDATE_MS | 10 | 数据点更新定时器间隔(毫秒) |
NODE_HEADER_HEIGHT | 30 | 节点标题栏高度(px) |
NODE_COUNTER_HEIGHT | 20 | 每个计数器行高(px) |
NODE_COUNTER_PADDING | 10 | 计数器区上下留白(px) |
NODE_MAX_COUNTERS | 9 | 单个节点最大计数器数量 |
其中高度计算公式为:展开时高度 = HEADER + 2 × PADDING + 行数 × COUNTER_HEIGHT,折叠时高度仅剩标题栏30px。这些常量同时被模型(尺寸计算)与视图(SVG 元素坐标)复用,改动即可全局生效。
五、自定义模型:数据驱动尺寸与批量更新
1. 继承dia.Element并声明数据属性
Node类继承自dia.Element(main.js),通过defaults()声明了模型的全部数据属性:
class Node extends dia.Element { defaults() { return { ...super.defaults, size: { width: 200, height: 0 }, // 高度由内容动态计算 z: 2, type: 'Node', uid: null, // 与外部数据源关联的标识 name: '', // 标题文本 expanded: true, // 是否展开计数器区 counterNames: [], // 计数器名称数组 counterValues: [], // 计数器数值数组(与 names 一一对应) status: null // 状态:null / 'A' / 'B' / 'C' / 'D' }; }注意初始height: 0,模型会在initialize()中立即调用setSize()依据当前数据修正高度。
2. 内容驱动的高度计算
setSize()(main.js)是本示例“内容驱动”的关键实现:
setSize(opt = {}) { const { counterNames, size, expanded, uid } = this.attributes; let height = NODE_HEADER_HEIGHT; const numberOfRows = uid ? counterNames.length : 0; if (expanded) height += 2 * NODE_COUNTER_PADDING + numberOfRows * NODE_COUNTER_HEIGHT; opt.resized = size.height !== height; // 标记是否真的发生了尺寸变化 this.resize(size.width, height, opt); }- 只有
uid存在时才会统计计数器行数,避免未绑定数据源的节点“长出”无意义的高度; - 折叠时高度退化为标题栏高度;
- 通过
opt.resized显式标记尺寸是否变化,供视图层判断。
onChange(main.js)则负责在数据变化时联动:当uid被清空时重置计数器与状态数据;当counterNames或expanded变化时重新计算尺寸。
3. 批量更新(batch)与 dry 更新
为了减少触发中间状态的渲染开销,示例大量使用 JointJS 的batch与dry机制:
changeNode(node)(main.js):用startBatch('change-uid')/stopBatch('change-uid')包裹一次对多个属性的设置,期间产生的change事件被合并为一次;toggle()(main.js):同样用startBatch('change-expanded')包裹,切换expanded后由模型自动重算尺寸;changeStatus(status)(main.js):状态相同则直接return跳过;否则用this.prop('status', status, { dry: true })以dry 模式写入——dry表示只更新模型数据、不立即触发视图更新,视图刷新完全交由后续统一调度;changeDataPoint(names, values)(main.js):先比较新旧数值数组,若无变化直接返回,否则以dry模式一次性写入counterNames与counterValues。
这种“数据先到位、渲染后批量”的做法,为 10ms 间隔的高频刷新提供了性能基础。
4. 精简的toJSON()
Node重写了toJSON()(main.js),只序列化{ id, type, position, uid, name, expanded },刻意排除动态刷新的计数器与状态字段:
toJSON() { const { id, type, position, uid, name, expanded } = this.attributes; return { id, type, position, uid, name, expanded }; }因为counterNames / counterValues / status属于高频变化的运行时数据,不参与序列化,可避免在保存/复制图表时产生大量无效数据。
六、自定义视图:presentation attributes 与增量渲染
视图NodeView继承自dia.ElementView(main.js),是理解 JointJS 高性能渲染的关键部分。
1. 声明 presentation attributes
presentationAttributes()(main.js)把模型属性映射为渲染 flag:
const Flags = { ...dia.ElementView.Flags, STATUS: 'STATUS', LABEL: 'LABEL', VALUES: 'VALUES' }; presentationAttributes() { return dia.ElementView.addPresentationAttributes({ expanded: [Flags.RENDER], counterNames: [Flags.RENDER], counterValues: [Flags.VALUES], status: [Flags.STATUS], name: [Flags.LABEL] }); }映射关系语义清晰:
expanded、counterNames变化 →RENDER:结构变化(展开/折叠、行数增减),需要整体重建;counterValues变化 →VALUES:只刷新数值文本;status变化 →STATUS:只改填充色;name变化 →LABEL:只重绘标题文本。
2.confirmUpdate按需分派更新
confirmUpdate(flag, opt)(main.js)从dia.ElementView.prototype.confirmUpdate继承默认处理(尺寸、位置、变换等),随后逐个检查自定义 flag,执行对应的增量更新函数,并用removeFlag清除已处理的位:
confirmUpdate(flag, opt) { let flags = dia.ElementView.prototype.confirmUpdate.call(this, flag, opt); if (this.hasFlag(flags, Flags.STATUS)) { this.toggleStatus(); // 状态着色 flags = this.removeFlag(flags, Flags.STATUS); } if (this.hasFlag(flags, Flags.LABEL)) { this.updateLabel(); // 标题文本(含截断) flags = this.removeFlag(flags, Flags.LABEL); } if (this.hasFlag(flags, Flags.VALUES)) { this.updateCounters(); // 计数器数值 flags = this.removeFlag(flags, Flags.VALUES); } return flags; }这样,10ms 一次的高频数据刷新只会触发updateCounters或toggleStatus这样的轻量路径,而不会重建整个 SVG 子树。
3. 渲染结构:标题、按钮、分隔线与计数器组
render()(main.js)通过 JointJS 的矢量工具V手工构建 DOM 结构,而非使用 markup JSON:
- 固定部分:
vBody(圆角矩形)、vLabel(标题文本)、vButton(折叠/展开箭头); - 展开时追加
vSeparator(分隔线)与renderCounterGroup()生成的计数器组; - 折叠时清空计数器引用数组。
renderCounterGroup()(main.js)为每一行计数器创建两个text元素:名称左对齐(text-anchor: start),数值右对齐(text-anchor: end),坐标由NODE_COUNTER_HEIGHT、NODE_COUNTER_PADDING与元素宽度计算。整个组被translate(0, NODE_HEADER_HEIGHT)平移到标题栏下方。
折叠/展开按钮的路径由updateButton()(main.js)动态切换:
d: expanded ? 'M -6 6 0 0 6 6' : 'M -6 0 0 6 6 0'展开时为“下箭头”,折叠时为“右箭头”,并通过event: 'node:button:pointerclick'注册了一个自定义交互事件名,随后在 paper 上监听处理。
4. 增量更新:数值缓存与文本截断
updateCounters()(main.js)是高频路径的核心优化:它用counterValuesCache缓存上一次格式化后的文本,仅当新值变化时才调用vCounterValues[i].text(...)更新 DOM:
updateCounters() { const { model, vCounterValues } = this; const values = model.get('counterValues'); const cache = this.counterValuesCache; this.counterValuesCache = []; for (let i = 0, n = vCounterValues.length; i < n; i++) { const formattedValue = this.formatValue(values[i]); if (cache && formattedValue === cache[i]) continue; // 未变化则跳过 this.counterValuesCache[i] = formattedValue; vCounterValues[i].text(formattedValue, { textVerticalAnchor: 'middle' }); } }formatValue(main.js)对非数值显示-,数值保留两位小数(toFixed(2))。
标题文本的更新则使用util.breakText(main.js)进行单行截断:限制可用宽度width - 60(为右侧箭头按钮留白),ellipsis: true表示超长时显示省略号,maxLineCount: 1限制为单行。
5. 状态着色
toggleStatus()(main.js)将status映射为节点底色:
status | 颜色 | 语义(示例自定) |
|---|---|---|
null | #FFFFFF(白) | 无状态 |
'D' | #78A75A(绿) | 正常/就绪 |
'A' | #992B15(红) | 告警 |
其他('B'/'C') | #EAC452(黄) | 中间态 |
映射通过switch实现,新增状态只需扩展分支。
七、底层原理:JointJS 的 flag 渲染管线
NodeView的增量渲染能力建立在 JointJS 核心的 flag 机制之上,该机制定义于 packages/joint-core/src/dia/CellView.mjs:
- flag 位分配:
setFlags(约 CellView.mjs 起)为每个 presentation attribute 分配一个二进制位,最高支持 25 位,超限会抛出dia.CellView: Maximum number of flags exceeded.(CellView.mjs); - 变化检测:视图监听模型
change事件,在onAttributesChange(CellView.mjs)中通过model.getChangeFlag(this._presentationAttributes)计算本次变化命中的 flag 组合,然后调用requestUpdate(CellView.mjs)把更新请求交给 paper 统一调度; - 位运算辅助:
hasFlag(flag, label)判断某一位是否置位(CellView.mjs),removeFlag(flag, label)用异或清除已处理的位(CellView.mjs),getFlag(label)汇总多个 label 的位(CellView.mjs); - 扩展点:
addPresentationAttributes(CellView.mjs)用于把自定义属性并入既有 presentation attributes,这正是NodeView中dia.ElementView.addPresentationAttributes({...})的底层实现。
理解了这条链路,就能明白示例中的dry: true写入为什么安全:数据先落到模型,change事件携带 flag 进入 paper 的更新队列,再由confirmUpdate按位分派——高频、小范围的数据刷新因此被收敛为极小的 DOM 操作。
八、图表装配与实时数据流
1. 命名空间与图表/画布初始化
示例把自定义模型与视图放入统一的namespace(main.js),与shapes合并后分别传给dia.Graph的cellNamespace与dia.Paper的cellViewNamespace:
const namespace = { ...shapes, Node, NodeView }; const graph = new dia.Graph({}, { cellNamespace: namespace }); const paper = new dia.Paper({ model: graph, cellViewNamespace: namespace, width: '100%', height: '100%', gridSize: 20, drawGrid: { name: 'mesh' }, async: true, // 异步渲染 sorting: dia.Paper.sorting.APPROX, // 近似排序,降低重排成本 background: { color: '#F3F7F6' } });几个值得注意的选项:async: true开启异步批量渲染;sorting: APPROX用近似排序替代严格的 DOM 顺序维护;drawGrid: { name: 'mesh' }显示网格背景。
2. 生成网格与 uid 映射
generateCells(graph, c, r)(main.js)创建c × r个Node,位置按{ x: j * 250, y: i * 260 }网格排布;同一行的相邻节点之间创建shapes.standard.Link横向连接(link.unset('labels')去掉默认标签)。最后调用buildUidMap(graph)(main.js)在 graph 上维护一张uidMap:
graph.set('uidMap', uidMap); // { uid: [cellId, ...] }这样同一个uid可以对应多个元素(多对一),外部“数据源”只需广播uid相关事件,即可经getCellsFromUid(main.js)精准定位所有关联节点。
3. 模拟实时数据源
runEvents(main.js)用两个setInterval模拟外部实时数据流,并返回一个清理函数(clearInterval)便于组件卸载:
- 状态事件:每
stateInterval(默认 100ms,示例传STATE_UPDATE_MS)随机选一个节点,将其messageClass随机置为'A' | 'B' | 'C' | 'D',经changeState(main.js)调用node.changeStatus(...)更新状态颜色; - 数据点事件:每
dataInterval(默认 100ms,示例传DATA_POINT_UPDATE_MS)随机选一个节点,若无计数器则随机生成 0~NODE_MAX_COUNTERS个计数器名,再为每个计数器赋0~100的随机值,经changeDataPoint(main.js)批量写入。
changeDataPoint中有一段值得注意的巧妙逻辑:当节点还没有计数器时,用Object.keys(counterPairs)作为names初始化,保证后续数值与名称数组严格对齐。
4. 自定义交互事件
按钮点击事件通过 paper 级监听处理(main.js):
paper.on('node:button:pointerclick', function(nodeView) { const node = nodeView.model; node.toggle(); });node:button:pointerclick这个事件名正是updateButton()中通过event属性声明的,体现了 JointJS “视图元素事件名可自定义、paper 统一监听”的交互模型。
九、面向大规模图表的渲染优化
1. 虚拟视口渲染(viewport)
enableVirtualRendering(paper)(main.js)是本示例面向 100+ 元素规模的关键优化,思路与前端“虚拟列表”一致:
- 通过
paper.clientToLocalRect(paperContainer.getBoundingClientRect())计算视口的本地坐标系矩形; - 监听容器
scroll与 paperscale事件刷新视口区域; - 覆写
paper.options.viewport,对每个 cell view 判断其model.getBBox()是否与视口矩形相交,不相交则隐藏:
paper.options.viewport = (view) => { const { model } = view; const bbox = model.getBBox(); if (model.isLink()) { // 水平/垂直连线宽高为 0,做 1px 外扩以免被误判为不可见 bbox.width += 1; bbox.height += 1; } return viewportArea.intersect(bbox) !== null; };这样滚动到哪就只渲染哪一部分元素与连线,配合async: true的异步渲染,保证了高频实时刷新下的流畅度。
2. 空白区域拖拽平移
由于画布(100 个节点按 250×260 间距排布)远大于视口,示例在 paper 上实现了经典的“空白拖拽平移”(main.js):blank:pointerdown时记录指针坐标与容器滚动位置,blank:pointermove时按指针位移差更新paperContainer.scrollLeft / scrollTop。
3. 自适应视口
初始化结束时调用paper.fitToContent({ useModelGeometry: true, padding: 20, allowNewOrigin: 'any' })(main.js),让 paper 自动适配全部模型几何并留出 20px 边距。
十、运行效果与可调参数总结
启动后你将看到:绿色网格背景上一张 10×10 的节点网,每个节点实时变化底色(白/绿/红/黄),右侧数值以两位小数持续跳动;点击节点右上角的箭头可以折叠/展开计数器区域,折叠后节点高度自动收缩为标题栏;滚动或拖拽空白区域时画布随之平移,且只渲染视口内的元素。
若想快速体验不同规模或节奏,只需调整 main.js 顶部的常量,例如:
- 将
COLS、ROWS调大(如 20×20)验证虚拟视口渲染在大规模下的效果; - 将
STATE_UPDATE_MS、DATA_POINT_UPDATE_MS调小(如 5ms)观察高频刷新下的表现; - 将
NODE_MAX_COUNTERS调大(如 20)测试多行计数器的排版与高度计算。
结语
counters-js虽是一个示例,却浓缩了 JointJS 中多项生产级能力:dia.Element/dia.ElementView的自定义扩展、presentation attributes 驱动的 flag 增量渲染(底层机制见 packages/joint-core/src/dia/CellView.mjs)、batch/dry 数据更新策略、uid 多对一映射,以及虚拟视口渲染。这套“数据驱动内容、按位分派渲染、视口裁剪更新”的组合拳,可以直接迁移到监控大屏、实时仪表盘、运维拓扑等需要高频刷新的图表场景。进一步阅读入口:示例 README、入口源码、页面样式、包配置。
- 前端
- UI组件
【免费下载链接】joint
A proven SVG-based JavaScript diagramming library powering exceptional UIs
相关推荐
ReactTooltip 子元素内容渲染深度解析
ReactTooltip 子元素内容渲染深度解析 引言:为什么需要深入理解子元素渲染? 在现代前端开发中,Tooltip(工具提示)组件已成为提升用户体验的关键
EaselJS WebGL 示例全解析:基于 StageGL 的 Canvas 高性能渲染实战
EaselJS WebGL 示例全解析:基于 StageGL 的 Canvas 高性能渲染实战 导读 本文以 examples/WebGL/README.md
前端UI库/组件图形学Windows 驱动示例 Kcs:基于内核模式性能库(PCW)实现内核计数器集的全流程解析
Windows 驱动示例 Kcs:基于内核模式性能库(PCW)实现内核计数器集的全流程解析 导读 Kcs(Kernel Counter Sample)是 Win
示例工程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考