Penpot 数据模型详解:从 Profile 到 Shape 的完整实体体系
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
本篇技术指南围绕 Penpot 官方技术文档 数据模型说明 展开,系统讲解设计文档(File)背后从用户(Profile)、团队(Team)、项目(Project)到页面(Page)与图层(Shape)的全链路概念模型,并结合仓库源码与数据库迁移脚本,揭示这些概念在common共享层、后端 SQL 与前端之间各自的真实表现形式。读完本文,你将能够看懂 Penpot 的数据组织方式、理解"概念实体在应用与数据库中的表现形式略有差异"这一核心设计思想,并学会把 UI 中的任何图层追溯到其数据与存储层级的依据。
概览:一套概念,多种形态
Penpot 是一套全栈开源设计平台,其数据模型文档首先强调了一个关键前提:本文描述的是"概念模型"(conceptual data model)。这些实体在不同运行环境中有着略微不同的物理表示——前端应用里是内存中的状态结构,后端 RPC 调用中是以 JSON 形式传输的消息体,而 SQL 数据库里则可能被拆分成多张关联表。但无论形态如何变化,概念始终一致,这是理解整个代码库数据流的关键线索。
对应到仓库中,这套"概念模型"最直接的实现证据位于共享的 Clojure(Script) 类型与工具层 common/src/app/common/types/ 目录下:profile.cljc、team.cljc、project.cljc、file.cljc、page.cljc、component.cljc、container.cljc、shape.cljc、shape_tree.cljc等文件一一对应文档中出现的各个概念实体;而概念模型的数据库落地形式,则可从 backend/src/app/migrations/sql/ 中 160+ 个递增编号的 SQL 迁移脚本中溯源(例如0002-add-profile-tables.sql、0003-add-project-tables.sql、0031-add-conversation-related-tables.sql、0063-add-share-link-table.sql等)。原文档中的 UML 类图采用 PlantUML 基本标记绘制(见 plantuml 类图说明),下文将逐个拆解。
用户、团队与项目:组织结构的三级体系
实体关系
原文档给出了第一张 UML 图,其核心关系链如下:
Profile与Team之间是多对多关系(*-*),一个用户可以隶属于多个团队;Team组合(*-->)多个Project;Profile与Project、Profile与File之间也存在直接的多对多关联,表示"拥有者 / 参与者"的授权关系;Project组合多个File;File之间以 "libraries" 关系互相引用,即共享资源库;- 一个
File各自组合多个StorageObject(media_objects)、CommentThread(comment_threads)与ShareLink(share_links); CommentThread组合多个Comment。
各实体职责
- Profile:系统内任何用户的个人信息载体。用户归属于团队(Team),并可在其中创建项目。
- Team / Project / File:团队内所有用户都可以看到团队内项目与文件;同时,任何项目和文件都至少有一个 owner 用户,但也可与其他用户建立带不同角色的更多关系(这套多对多授权关系在数据库层面由
0028-add-team-project-profile-rel-table.sql引入的关系表支撑)。 - 共享库(libraries):文件(File)可以引用其他文件作为共享资源库,即
File "*" <-- "*" File : libraries这条自关联。 - StorageObject:代表存放在外部存储中的一个文件对象(目前主要是图片与 SVG 图标,未来可能扩展更多媒体类型),它被嵌入到某个设计文件之中。
- CommentThread 与 Comment:用户针对文件添加的评论体系,评论以"线程"聚合。
- ShareLink:包含一个 token、一个 URL 以及若干权限,用于把文件分享给外部用户(对应迁移脚本
0063-add-share-link-table.sql引入的分享链接表)。
源码印证
在共享类型层,profile.cljc、team.cljc 与 project.cljc 分别定义了schema:profile、schema:team、schema:project等 malli schema(本项目用sm/register!注册共享 schema,供前后端共同校验)。文档中"Project 与 File 上至少有一个 owner、但可有多角色用户"的描述,与数据库迁移中多次出现的关系表(team-profile-rel、project-profile-rel、team-project-profile-rel,见0088-mod-team-profile-rel-table.sql、0089-mod-project-profile-rel-table.sql、0091-mod-team-project-profile-rel-table.sql)相符——在关系型数据库中,多对多关系正是通过这类中间关系表落地。
值得注意的还有0031-add-conversation-related-tables.sql(评论相关表)、0033-mod-comment-thread-table.sql、0077-mod-comment-thread-table.sql、0136-mod-comments-mentions.sql等一长串针对评论体系的迭代迁移,说明评论实体在实际演进中不断补充字段(如提及 / mentions),恰好印证了文档"概念模型不变、物理形态持续演化"的论述。
File data:设计文件的核心载荷
实体关系
File实体除了组织层面的元信息之外,其主要内容存放在"file data"属性中。第二张 UML 图刻画了 file data 内部的结构:
File组合多个Page(pages),而(File, Page)共同建模出PagesList;File组合多个Component,(File, Component)建模出ComponentsList;File组合多个Color,建模出ColorsList;File组合多个MediaItem,建模出MediaItemsList;File组合多个Typography,建模出TypographiesList。
也就是说,file data 中既包含页面(Pages),也包含文件级资源资产(library assets)——即 Components、MediaItems、Colors、Typographies 四类资源库。
为什么把这些"列表"也建模成实体
文档特别解释了一个看似"过度设计"的点:页面和资产的列表本身也被建模为实体(PagesList、ComponentsList、ColorsList 等)。原因在于这些列表承载了大量函数与业务逻辑——例如排序、查找、索引、增删改时的联动等,把它们抽象成独立实体可以让代码在只关心"集合操作"时复用同一套逻辑,而不是每次都在多个容器里重复写遍历代码。
源码印证
这个设计在 file.cljc 中有着精确的 schema 对应:
(def schema:data [:map {:title "FileData"} [:pages [:vector ::sm/uuid]] ; 页面 id 的有序向量 [:pages-index schema:pages-index] ; id -> page 的索引映射 [:options {:optional true} schema:options] [:colors {:optional true} schema:colors] [:components {:optional true} schema:components] [:typographies {:optional true} schema:typographies] [:plugin-data {:optional true} schema:plugin-data] [:tokens-lib {:optional true} schema:tokens-lib]])可以看到 file data 中pages是UUID 向量(有序列表),而pages-index是id → page 的映射(索引)——这正对应文档中 PagesList 实体的双重职责;同理,colors、components、typographies都建模为map-of ::sm/uuid -> schema的映射。同一文件中的schema:media(file.cljc)则描述了媒体对象字段(name、width、height、mtype、media-id、thumbnail-id、is-local等),对应文档中 File 携带的媒体资源。
列表类型的文件则单独放置在同目录下:pages_list.cljc、components_list.cljc、typographies_list.cljc 等——它们在文件命名上就直接体现了"列表作为一种一等实体"的建模思想。
页面与组件:Container 抽象
实体关系
第三张 UML 图引入了一个精妙的抽象:
Container是Page与Component的共同父类(Container <|- Page、Container <|- Component);Container组合多个Shape(objects),(Container, Shape)建模为ShapeTree;Shape通过 parent 指针自关联形成层级树(Shape <-- Shape : parent)。
设计动机
Page(页面)与Component(组件)内部都包含一棵 shape 树,并共享大量函数与逻辑。为避免重复,Penpot 引入Container实体作为抽象层:凡是既能用于页面又能用于组件的代码,就统一操作 Container。这在源码层的印证非常直观:
- 文件类型工具 file.cljc 中把页面与组件"归一化"为容器序列:
(defn containers-seq [file-data] (concat (map #(ctn/make-container % :page) (ctpl/pages-seq file-data)) (map #(ctn/make-container % :component) (ctkl/components-seq file-data))))以及上面展示的
update-container、get-container等同名逻辑,正是文档"用统一抽象覆盖二者"的落地。 - 类型目录下存在独立的 container.cljc、shape_tree.cljc,前者提供
make-container工厂(同时支持:page与:component两种 kind),后者封装整棵 shape 树的遍历与操作。
ShapeTree 的构成规则
ShapeTree表示一组在层级上相互关联的 shapes:顶部的 frame 包含顶层 shapes(frame 与其他 shape);frame 与 group 内部可以包含任何"非 frame"的 shape。换言之,树形结构的父节点只能是 frame 或 group,普通 shape 不能直接作为其它 shape 的父容器。
Shapes:数据模型中最重要的一等公民
实体关系
第四张 UML 图聚焦于单个 shape 的组成。Shape与以下属性对象全部是聚合关系(o-->):
| 属性对象 | 说明 |
|---|---|
Selrect | 图中位置与包围盒(bounding box),含 x、y、width、height 等几何量 |
Transform | 2D 变换矩阵,用于旋转或拉伸 shape |
Constraints | 当容器 shape 尺寸变化时 shape 的"响应式"变化规则 |
Interactions | 在 viewer 中展示时的交互行为定义 |
Fill | 填充颜色及其选项 |
Stroke | 描边颜色及其选项 |
Shadow | 阴影选项 |
Blur | 模糊选项 |
Font | 文本类型 shape 的字体现有选项 |
Content | 文本类型 shape 的文本内容块 |
Exports | 针对该 shape 定义的导出设置 |
同时,Shape通过parent自关联,即每个 shape 持有对其父容器的引用以及全部子节点引用。
Shape 的双重身份:SVG 节点的超集
文档强调:Shape 是模型中最重要、最基本的实体,它对应 Penpot 设计稿中的一个 图层,并且对应一个经过 Penpot 特殊能力增强的 SVG 节点。
这种"形状即增强 SVG"的设计带来了方向相反的两套渲染/解析管线:
- 渲染方向:仓库中存在把 Shape 渲染为 SVG 标签的代码,且渲染结果随使用环境不同而增减内容——在工作区中可编辑、在 viewer 中可交互、在 shape exporter 或 handoff(交付)中最小化、在文件导出时附带元数据。
- 导入方向:存在把任意 SVG 文件导入并反向转换为 shapes 的代码。如果该 SVG 是 Penpot 导出的,则读取其内嵌元数据以精确还原形状;如果不是 Penpot 导出的,则以 best effort(尽力而为)方式推断属性。
属性聚类与源码对应
除了标识性属性(id、name、type)之外,Shape 拥有大量属性。文档指出 Penpot 倾向于将它们组织成相关聚类(cluster),这正是面向对象化建模而非扁平字段化建模的原因。这些属性类在源码层有各自独立的类型定义文件:
- shape.cljc:核心 Shape schema 本体,把多组属性 schema 以 merge 的方式组合(如
schema:shape-base-attrs、schema:shape-generic-attrs、schema:shape-geom-attrs的叠加),并针对 rect/group/image/text 等不同类型使用不同 attr 组合,结构上即体现了"按聚类组织属性、按类型差异化"的思想; - shape/attrs.cljc、
shape/radius.cljc等对应几何与样式细节; - 变换矩阵相关:modifiers.cljc;
- 布局与约束:shape/layout.cljc;
- 交互:shape/interactions.cljc;
- 填充/描边/阴影/模糊:fills.cljc、stroke.cljc、shape/shadow.cljc、shape/blur.cljc 与
shape/background_blur.cljc; - 文本(字体与内容):font.cljc、shape/text.cljc、text.cljc;
- 导出:shape/export.cljc。
需要说明的是,各源码文件中的 schema 会随版本演进而与文档中的概念分组存在细微差异(例如 blur 已区分为普通 blur 与 background blur),阅读时可相互参照。
形状与库的联动
文档虽未展开,但结合仓库可补充一个与组件体系直接相关的点:shape 通过组件引用与库发生关联。例如 file.cljc 中get-component类函数先解析 shape 上的component-id/ 所属库,再深入(dm/get-in libraries [library-id :data])找到库容器里的组件定义。这说明主文件中的 shape 可以"借"库文件中的组件定义来渲染,正是文档 File 关系图中libraries自关联的实际用途之一。
从概念到落地:一条数据链路小结
综合原文档与仓库源码,可以梳理出 Penpot 数据的宏观链路:
- 组织层:
Profile(用户)⇄Team(团队)→Project(项目)→File(设计文件),由共享类型 profile.cljc、team.cljc、project.cljc、file.cljc 定义概念,由 SQL 迁移(0002、0003、0028、0063等)在 PostgreSQL 中建表落地。 - 文件内容层:
File的data属性携带pages向量与pages-index索引,以及components、colors、typographies、媒体与 tokens 等资产集合。 - 容器层:
Page与Component统一抽象为Container(见 container.cljc),各自持有一棵 shape 树。 - 元素层:树中的每个节点都是
Shape——一个属性按几何、变换、约束、交互、样式、文本、导出等聚类组织、以 parent/children 关联的增强型 SVG 节点(见 shape.cljc)。
由于概念在各端的表现形式略有差异,同一份设计意图在前端表现为可编辑的响应式对象、在后端 RPC 中表现为校验过的消息体、在数据库中被拆分为 file / file-data / storage-object / comment-thread / share-link 等多张关联表。理解这一"概念模型"层,是深入 Penpot 后端 RPC 服务、文件导入导出管线与前端 workspace 渲染架构的共同起点。后续若需深入某一部分,可继续阅读技术指南中对应的数据访问、RPC 与存储章节,并在 common/src/app/common/types/ 中逐一对照各实体的真实 schema 定义。
【免费下载链接】penpotPenpot: The open-source design platform for Product teams that need scalable collaboration.项目地址: https://gitcode.com/GitHub_Trending/pe/penpot
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考