首页
/ Penpot 共享层解析:common/ 目录的 CLJC 架构、命名空间分层规则与跨运行时设计约束

Penpot 共享层解析:common/ 目录的 CLJC 架构、命名空间分层规则与跨运行时设计约束

2026-09-06 15:17:46作者:庞眉杨Will

本文以仓库内的架构记忆文档 .serena/memories/common/core.md 为主体,完整展开 Penpot 共享代码层 common/ 的命名空间地图、分层抽象规则与跨运行时(JVM + CLJS)约束,并结合 common/src/app/common 下的真实源码与 common/deps.edn 中的依赖事实进行纵深印证。读完后你能掌握:app.common.* 各命名空间的职责边界、"泛型数据层不感知业务域"的分层纪律如何在源码中落地,以及组织/团队权限这类跨端共享逻辑(fail-closed 规则)的实际实现。

一、common/ 是什么:一份代码,多个运行时

Penpot 的前端(浏览器端编辑器)、后端(Clojure 服务端)、exporter(导出服务)以及库/文件工具链,都依赖同一份共享逻辑。这份共享代码放在 common/ 目录,使用 CLJC(Clojure/ClojureScript) 编写——同一份 .cljc 源码既编译到 JVM 又编译到 CLJS。正如架构记忆文档所述:"shared CLJC for frontend, backend, exporter, library/file tooling, tests. Small semantic changes can affect multiple runtimes"(共享给前端、后端、exporter、库/文件工具与测试;一处微小的语义变更可能影响多个运行时)。

这不是修辞,而是 common/deps.edn 中可见的工程事实:

  • org.clojure/clojure 1.12.5 与 org.clojure/clojurescript 1.12.145 同时出现,确认同一模块需要双运行时构建;
  • metosin/malli 0.20.1 与 expound 0.9.0 提供跨端一致的数据校验(Malli 同时支持 JVM 与 CLJS,这是共享 schema 层能存在的前提);
  • com.cognitect/transit-cljcom.cognitect/transit-cljs 成对出现,对应同一序列化格式在两个运行时的实现;
  • 测试别名 :test 使用 kaocha(-m kaocha.runner),配合 JS 侧测试入口(pnpm run test:quiet),形成"同一 CLJC 测试、双端各跑一遍"的验证方式。

正因"一份语义、多个消费者",对 common/ 的任何修改都应默认考虑前端、后端、exporter 三个方向的兼容性影响。

二、稳定的命名空间地图(Stable Namespace Map)

架构文档给出了 app.common.* 的职责划分,以下逐条对照 common/src/app/common 的实际目录结构验证:

1. app.common.data 与 app.common.data.macros:与业务域无关的通用数据工具

对应文件 common/src/app/common/data.cljccommon/src/app/common/data/macros.cljc,以及同目录的 common/src/app/common/data/undo_stack.cljc

macros.cljc 为例,可以看到这一层"不依赖 Penpot 域实体"的具体含义:

  • select-keysclojure.core/select-keys 的宏版本,当键集合在编译期已知时展开为逐个 get,注释标明可获得约 600% 的性能提升,且语义上与核心版略有差异(不会删除不存在的键);
  • get-in:宏版本 get-in,键向量为常量时编译为 -> 链式 get,注释标明 20-40% 的性能提升;
  • export:通过 reader conditional(#?(:clj ...))在编译期分别为 CLJS/CLJ 生成"再导出"代码,CLJS 分支甚至调用 cljs.analyzer.api 解析目标 var 的元数据。

这些工具全部操作"任意 map/向量",完全不知道 shape、component、file 为何物——这正是分层规则第一条的活例证。

2. app.common.types.*:单实体域的类型、schema 与谓词

common/src/app/common/types 目录实际包含 27 个命名空间:file.cljcpage.cljcshape.cljcshape_tree.cljccomponent.cljcvariant.cljctoken.cljctokens_lib.cljctypography.cljcgrid.cljccolor.cljcfills.cljcstroke.cljctext.cljcpath.cljclibrary.cljcproject.cljcteam.cljcorganization.cljcprofile.cljcfont.cljcplugins.cljc 等。每个命名空间守住一个领域实体的 schema、谓词与"实体局部操作"。

架构文档特别点名的 types/organization.cljc 值得精读,因为它完整演示了"types.* 保存单实体不变量 + fail-closed 权限规则":

  • schema:organization(第 12-27 行):定义了组织实体的 Malli schema,核心字段 :id:name:slug:owner-id:avatar-bg-url,以及可选的 :permissions 子 map——:create-teams"any"|"onlyMe")、:delete-teams"onlyMe"|"onlyOwners")、:move-teams"always"|"myOrganizations"|"never")、:new-team-members"anyone"|"members")。由于组织逻辑同时运行在浏览器与 JVM 上,权限判断必须共享实现,这正是该 schema 放在 common/ 而非后端的理由。
  • apply-organization(第 40-56 行):把组织字段以嵌套 :organization map 形式合并进 team map。实现细节很讲究——对每个 organization->team-keys 中的字段,值非 nil 则 assoc,否则 dissoc,从而正确处理"挂接组织(字段全有)"与"解绑组织(org 为 nil 或字段全缺)"两个方向。
  • fail-closed 权限规则(第 78-176 行):defaults 给出五个权限键的保守默认值(如 :delete-teams "onlyOwners":send-invitations "ownersAndAdmins");action-rules:create-team:delete-team:move-team:send-invitations:add-anybody-to-team 五个动作映射到各自的 check 函数;allowed? 的文档字符串直接写明 "Returns true only for explicitly allowed actions (fail-closed)"——未知动作一律返回 false。而 can-send-invitations?(第 164 行起)展示了共享逻辑如何读取功能开关:仅当 flags/*current* 包含 :admin-console 且团队挂了组织时才走组织级规则,否则回退到团队级 owner/admin 判断。这种"同一谓词、双端可用、显式降级"的写法是 types.* 层的典型形态。

3. app.common.files.*:文件级操作、shape 树、变更应用与迁移

common/src/app/common/files 目录包含 16 个文件:changes.cljc(变更应用)、changes_builder.cljc(变更构建器)、migrations.cljc(文件数据迁移)、validate.cljc(校验)、repair.cljc(修复)、indices.cljcpage_diff.cljcshapes_builder.cljcshapes_helpers.cljcbuilder.cljcdefaults.cljccomp_processors.cljctokens.cljcvariant.cljcfocus.cljcstats.cljc。它们承担架构文档所说的"file-level operations, shape tree helpers, change application, migrations, validation, and undo/redo-related logic"。

配套的聚焦记忆文档 changes-architecture.md 补充了变更记录的形状(:add-obj/:mod-obj/:del-obj:add-component 等家族)与 changes-builder 的高频 API(pcb/empty-changespcb/update-shapespcb/add-objects 等),并强调测试应通过 thf/apply-changes 走生产变更管线,而非直接改对象 map。

4. app.common.logic.*:跨实体的较高层工作流/算法

common/src/app/common/logic 目录现有五个命名空间:libraries.cljcshapes.cljctokens.cljcvariants.cljcvariant_properties.cljc,对应文档中"files, shapes, components, variants, libraries, tokens 之上的较高层 workflow/algorithm"。它与 types.* 的区别在于:允许协调一个文件内的多个实体,但仍不承载 UI 事件或后端 RPC 层面的业务流程。

5. app.common.geom.*:几何助手与变换

common/src/app/common/geom 目录包含 align.cljcbounds_map.cljcgrid.cljcline.cljcmatrix.cljcpoint.cljcrect.cljcsnap.cljcshapes.cljc 以及子目录 shapes/constraints.cljceffects.cljcfit_frame.cljcflex_layout.cljcgrid_layout.cljcmin_size_layout.cljcpixel_precision.cljc 等)。几何是设计工具的数值核心,flex_layoutgrid_layout 子模块对应 Penpot 的弹性布局/网格布局能力。几何相关的不变量与坐标浮点比较细节,分别由 geometry-invariants.mddecimals-and-coordinates.md 两个聚焦记忆文档覆盖。

6. app.common.schema / app.common.schema.*:Malli 抽象层

common/src/app/common/schema.cljccommon/src/app/common/schema 子目录(desc_js_like.cljcdesc_native.cljcgenerators.cljcopenapi.cljcregistry.cljctest.cljc)构成对 Malli 的统一封装:::sm/uuid::sm/text 等类型别名(organization.cljc 第 12-27 行的 schema 即建立在此层之上),openapi.cljc 支持从 schema 生成 OpenAPI 描述,test.cljc 则把 schema 校验接入测试。这层让所有 types.*files.* 命名空间以一致方式声明与检查数据结构。

7. 跨运行时工具与测试助手

  • app.common.mathapp.common.timeapp.common.uuidapp.common.json:对应 math.cljctime.cljcuuid.cljcjson.cljc。注意 uuid.cljccommon/src/app/common/UUIDv8.javacommon/src/app/common/uuid_impl.js 的组合——同一份 CLJC 逻辑通过平台特定实现文件(JVM 端 Java、JS 端 JavaScript)落地 UUID 生成,这是后文"reader conditional 规则"的典型用例。
  • common/src/app/common/weakweak.cljc:弱引用容器的跨端抽象,impl_weak_map.js / impl_loadable_weak_value_map.clj 按运行时选择实现;
  • app.common.test_helpers.*common/src/app/common/test_helpers 目录提供生产路径测试助手——files.cljc(如 sample-fileapply-changes)、components.cljcvariants.cljcshapes.cljccompositions.cljctokens.cljcids_map.cljc。据 testing.md,测试命名空间惯用 thf/tho/thv/ 等短别名引用这些助手,且使用 label→uuid 助手的测试应以 (t/use-fixtures :each thi/test-fixture) 开头以便在每个用例间重置。

三、分层与跨运行时规则(Layering and Cross-Runtime Rules)

架构文档的核心纪律可归纳为两条,均能在源码中找到支撑。

平台特定代码必须用 reader conditional 隔离

"Use reader conditionals for platform-specific code. Because CLJC runs on JVM and CLJS targets, avoid assuming browser-only or JVM-only behavior unless the reader conditional isolates it."

源码实例:macros.cljc 第 10-14 行用 #?(:cljs (:require-macros ...)) 处理宏自身在 CLJS 下的加载;第 12-14 行 #?(:clj [cljs.analyzer.api :as aapi] :clj [clojure.core ...] :cljs [cljs.core ...]) 在同一 :require 中按运行时选择依赖;export 宏的整个定义被 #?(:clj ...) 包裹(第 54 行起),因为它本身就是编译期工具,仅在 JVM 编译 CLJS 源码时生效。weak.cljc 按运行时分发到 impl_weak_map.js 或 JVM 端实现,也是同一模式。

抽象方向必须自低向高保持

文档给出了五层职责方向(新代码与重构都应遵守):

  1. 泛型数据工具不感知 Penpot 域概念——app.common.data* 只处理任意集合/map(见第二节第 1 点);
  2. types.* 守住单个域实体或 ADT 的不变量——如 organization.cljc 只围绕组织实体的 schema 与权限谓词;
  3. files.* 可协调一个文件内的多个实体并维持引用完整性——变更应用、校验、修复都发生在此层;
  4. changes* 应把可序列化的变更记录适配为低层操作,避免在其中内嵌宽泛业务算法——变更记录本身是持久化载荷与撤销/重做基础(见 changes-architecture.md 的 "A change set is both the persistence payload and the basis for undo/redo");
  5. logic.* 与前端/后端事件层拥有更高层的 workflow/业务行为

文档同时提醒:"Some legacy code violates this layering; do not copy those violations into new code when a focused refactor is practical."——遗留代码存在违反分层的情况,但不应把违反扩散到新代码。

四、记忆路由:修改 common/ 前该读哪份聚焦文档

架构文档的 "Focused memory routing" 节把 common/ 的细粒度知识分发到 13 份聚焦记忆。下表完整继承该路由,并标注对应源文件位置,便于按图索骥:

领域 聚焦记忆(相对仓库根目录) 覆盖内容
模型/持久化形状 data-model-change-checklist.md 文件/页面/shape/组件属性变更的跨模块检查清单、导入导出面、inspector/codegen
Token tokens-schema-subtleties.md token 数据结构、导入导出、active theme/set 语义、schema 强制转换行为
几何与布局 geometry-invariants.md shape 几何不变量、冗余几何字段、几何敏感测试
几何与布局 decimals-and-coordinates.md 坐标漂移与近似浮点比较
几何与布局 layout-grid-subtleties.md 布局/网格的 assign、deassign、元数据清理、自动定位
变更管线 changes-architecture.md 变更记录、undo/redo 架构、changes-builder API、生产路径变更指南
变更管线 file-change-validation-migration-subtleties.md 变更应用、shape 树编辑、校验/修复、迁移、second-pass touched 行为
组件/变体 component-data-model.md 组件/变体数据模型、ref 链、touched 覆盖语义、克隆路径
组件/变体 component-swap-pipeline.md 组件 swap、变体切换、keep-touched 管线
组件/变体 component-debugging-recipes.md 实时检查片段、临时运行时 patch、测试侧调试助手
文本与测试 text-subtleties.md 共享文本数据转换、DraftJS 兼容、现代文本内容、派生定位数据
文本与测试 testing.md 常用测试命令、助手约定、生产路径测试变更、运行时覆盖选择
全局测试纪律 testing.md(memories 根级) 跨切面测试原则、反模式、验证清单

这些记忆与源码目录一一对应:例如 changes-architecture.md 指向 files/changes.cljcprocess-operation 多方法与 files/changes_builder.cljctesting.md 给出的命令(从 common/ 目录执行 clojure -M:dev:test 跑 JVM 全量测试、pnpm run test:quiet 跑 JS 全量测试、--focus common-tests.logic.variants-switch-test 聚焦命名空间)与 common/deps.edn:test 别名、common/scripts/test 等脚本直接呼应。

五、没有聚焦记忆的领域:以源码和测试为准

架构文档的最后一节明确列出"几乎没有专门记忆"的 common/ 领域:colors、media/SVG 助手、path 操作、缩略图助手、通用池、弱引用及部分工具命名空间。对照源码,这些正是 colors.cljcmedia.cljccommon/src/app/common/svgpath.cljcpath/ 子目录)、thumbnails.cljcgeneric_pool.cljweak/ 等文件。文档给出的工作方式很明确:Treat work there as source/test-led unless a focused memory exists——在这些领域直接以源码与测试为主要依据推进,不要期待或虚构不存在的记忆文档。

六、把 common/ 改动落到验证:最小操作路径

综合 testing.mdcommon/deps.edn 的别名定义,验证 common/ 改动的标准动作(均在 common/ 目录下执行):

  1. JVM 全量clojure -M:dev:test(kaocha 驱动,别名 :test 定义于 deps.edn 第 75-77 行);
  2. JS 全量pnpm run test:quiet(始终先构建再运行);
  3. 聚焦单测:JVM 侧 clojure -M:dev:test --focus common-tests.logic.variants-switch-test/test-basic-switch;JS 侧 pnpm run test:quiet -- --focus common-tests.logic.comp-sync-test,可追加 --log-level warn 控制日志;
  4. 新增 JS 测试命名空间须登记到 common_tests/runner.cljc,已有命名空间新增 var 则无需改动;
  5. 几何敏感测试先读 geometry-invariants.md,优先使用保持几何不变量的助手或生产变更助手,而非直接编辑单个字段。

七、小结

common/ 的价值不在于"共享"二字,而在于它把 Penpot 文件数据模型、几何、变更管线与权限规则收敛成一份跨 JVM/CLJS 的语义来源,并用严格的分层方向(data → types → files → changes → logic/事件层)与 reader conditional 纪律约束这份语义只在一处实现。对贡献者而言,架构文档给出的三条行动准则是:先按"记忆路由表"读对聚焦文档,再对照 common/src/app/common 对应命名空间动手;新代码保持抽象方向不自上而下泄漏;在没有聚焦记忆的区域,以源码与测试为唯一事实来源。

登录后查看全文
热门项目推荐
相关项目推荐