每个页面、组件以及画布草稿区都是一个 PageDoc:一张扁平的节点映射表,节点之间通过 parent 和 children 相互链接。它以每个页面一个 jsonb 值的形式存储,作为 Yjs 文档进行编辑,并在所有地方由同一个渲染器渲染。
结构
ts
type NodeId = string
interface Node {
type: 'body' | 'section' | 'frame' | 'text' | 'link' | 'image' | 'button'
| 'richText' | 'collectionList' | 'form' | 'input' | 'textarea' | 'select'
| 'checkbox' | 'instance' | …
parent: NodeId | null
children: NodeId[]
props: Record<string, unknown> // literals, { $expr }, or { $model }
styles: Styles // { base: {…}, 'base:hover': {…}, tablet: {…} }
}
interface PageDoc {
schema: 1
root: NodeId
nodes: Record<NodeId, Node>
variables?: Record<string, VariableDef>
queries?: Record<string, QueryDef>
workflows?: Record<string, WorkflowDef> // client workflows only
interactions?: Record<string, Interaction>
}
json
{
"schema": 1,
"root": "n_root",
"nodes": {
"n_root": { "type": "body", "parent": null, "children": ["n_hero"], "props": {}, "styles": {} },
"n_hero": {
"type": "section", "parent": "n_root", "children": ["n_h1"],
"props": { "classes": ["cls_hero"] },
"styles": { "base": { "paddingBlock": "120px" }, "mobile": { "paddingBlock": "64px" } }
},
"n_h1": {
"type": "text", "parent": "n_hero", "children": [],
"props": { "tag": "h1", "text": { "$expr": "item.title" } }, "styles": {}
}
}
}
children决定兄弟节点的顺序;parent提供 O(1) 查找。操作层负责保持两者一致。- id 在客户端生成,以支持乐观插入。复制或粘贴子树时会重新映射所有 id。
- 链接带有类型(
{ type: 'page', pageId }在页面重命名后依然有效);资源通过 id 引用,绝不通过 URL。 - 标识类和结构类的属性(
componentId、variant、overrides、query、layout……)不能被绑定。
为什么每个页面一个文档
加载只需读一行,保存只需写一次,发布、版本管理和复制都只是复制文档。Yjs 与它一一对应。移动操作在代码中校验,无需递归 SQL。典型页面有 200–2,000 个节点。由于扁平映射本身就是“行”的形状,将来如果改用节点表,只需一个脚本,而不必重新设计。
操作(ops)
面板、画布手势、AI 和 MCP 都从不直接接触 Yjs 文档。它们构建带类型的 ops,由核心在一个事务中校验并应用。
ts
type Op =
| { t: 'setProp'; id: NodeId; path: string[]; value: unknown }
| { t: 'setStyle'; id: NodeId; key: StyleKey; prop: string; value: string | null }
| { t: 'setStyleBinding'; id: NodeId; key: StyleKey; prop: string; value: { $expr: string } | null }
| { t: 'setMeta'; id: NodeId; name?: string; hidden?: boolean; locked?: boolean }
| { t: 'insert'; id: NodeId; node: Omit<Node, 'parent' | 'children'>; parent: NodeId; index: number }
| { t: 'insertSubtree'; root: NodeId; nodes: Record<NodeId, Node>; parent: NodeId; index: number }
| { t: 'move'; id: NodeId; parent: NodeId; index: number }
| { t: 'delete'; id: NodeId }
| { t: 'setInteraction'; id: string; interaction: Interaction | null }
| { t: 'setVariable'; id: string; variable: VariableDef | null }
| { t: 'setQuery'; id: string; query: QueryDef | null }
| { t: 'setWorkflow'; id: string; workflow: WorkflowDef | null }
editor.document.transact(ops, { mergeKey }) // one undo step; mergeKey joins a drag
校验会拒绝:移动时产生循环、删除根节点、给叶子类型添加子节点,以及不符合结构定义的属性或样式。
Yjs 映射
- 每个节点是一个
Y.Map;children是一个Y.Array,因此并发插入可以合并。 props和styles都是映射,所以两个人编辑同一节点的不同属性时绝不会冲突。- 撤销管理器跟踪
user、ai和mcp这几种来源;每个用户只撤销自己的改动。 - 站点级状态(页面列表、类、令牌、组件、变量)存放在单独的站点文档中。