Pressidian
花园入口
笔记
项目
关于
实验室
GitHub
花园入口
笔记
项目
关于
实验室
GitHub

KNOWLEDGE PATHS

笔记库
当前位置
笔记库/前端/项目笔记/代达罗斯/模式

Anatomy

5 分钟阅读 · Note

目录树 578 篇

            • 表单最佳实践指南
            • 双层级导航结构
            • 以schema为中心的
            • 异步三态切换
            • 用neverthrow进行错误处理
            • Anatomy
            • cn
            • LoadingState&useList的组合
            • procedure中的service位置
            • scrollbar-gutter
            • TailwindCSS
            • TanStack Router 路由模式
            • useDebounce
            • useForm
            • void
          • 项目待做
          • 性能优化
          • UI设计
      • 前端技术栈
    • 笔记目录
    • CLAUDE.md
    • Vue 组件与 Render 函数

关联笔记 6

↗表单最佳实践指南同一路径↗双层级导航结构同一路径↗以schema为中心的同一路径↗异步三态切换同一路径↗用neverthrow进行错误处理同一路径↗cn同一路径
  • Anatomy

Anatomy

针对这个需求,最合适的方案不是直接保存一个递归 JSON,而是:

> 数据库和业务层使用扁平的邻接表结构 parentId + sortOrder,接口展示时转换为树,前端编辑时使用标准化数据。

这样既能表达多层目录,也方便拖拽排序、移动、删除和校验父子关系。


一、核心领域模型

建议拆成三个实体关系:

Crate
  └── anatomyId

Anatomy
  └── AnatomyNode[]
         └── parentId

关系为:

一个 Anatomy
├── 包含多个 AnatomyNode
└── 可以被多个 Crate 引用

Anatomy 本身可以视为代码包的“虚拟根目录”,不需要额外保存一个根节点。


二、Anatomy 基础定义

type AnatomyStatus = "active" | "archived"

interface Anatomy {
  id: string

  name: string
  description?: string

  status: AnatomyStatus

  createdAt: string
  updatedAt: string
}

归档不等于删除:

  • active:可以继续选择和编辑。
  • archived:不能被新的 Crate 选择,但已经关联的 Crate 仍然可以查看。
  • 被 Crate 引用的 Anatomy 不建议允许硬删除。

三、目录节点的数据结构

名称规则

不要同时设计成:

name?: string
pattern?: string

因为可能出现两个字段同时存在或者都不存在。

更推荐使用可辨识联合类型:

type NodeNameRule =
  | {
      type: "exact"
      value: string
    }
  | {
      type: "glob"
      value: string
    }

例如:

const exactName: NodeNameRule = {
  type: "exact",
  value: "src",
}

const patternName: NodeNameRule = {
  type: "glob",
  value: "*.test.ts",
}

第一阶段建议只支持:

  • exact:精确名称。
  • glob:通配符模式。

例如:

src
components
*.test.ts
*.stories.tsx
use*.ts

不建议第一阶段直接使用正则表达式,因为正则的校验、转义和跨语言执行都更复杂。


四、推荐的持久化节点结构

type AnatomyNodeType = "directory" | "file"

interface AnatomyNode {
  id: string

  anatomyId: string
  parentId: string | null

  nodeType: AnatomyNodeType
  nameRule: NodeNameRule

  required: boolean
  description?: string

  sortOrder: number

  createdAt: string
  updatedAt: string
}

其中:

  • anatomyId:节点属于哪个 Anatomy。
  • parentId:父目录节点 ID。
  • parentId === null:表示 Anatomy 根层级节点。
  • nodeType:文件或目录。
  • nameRule:精确名称或命名模式。
  • required:必需或可选。
  • sortOrder:同一父节点下的显示顺序。

五、完整数据示例

下面的数据描述了这样的结构:

src/                         必需
├── components/              必需
│   └── *.tsx                可选
├── hooks/                   可选
│   └── use*.ts              可选
└── index.ts                 必需
README.md                    可选

扁平数据可以保存为:

const anatomy = {
  id: "anatomy-react-library",
  name: "React Component Library",
  description: "React 组件库标准目录结构",
  status: "active",
} satisfies Anatomy

const nodes: AnatomyNode[] = [
  {
    id: "node-src",
    anatomyId: anatomy.id,
    parentId: null,
    nodeType: "directory",
    nameRule: {
      type: "exact",
      value: "src",
    },
    required: true,
    description: "源代码目录",
    sortOrder: 0,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-components",
    anatomyId: anatomy.id,
    parentId: "node-src",
    nodeType: "directory",
    nameRule: {
      type: "exact",
      value: "components",
    },
    required: true,
    description: "组件目录",
    sortOrder: 0,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-component-file",
    anatomyId: anatomy.id,
    parentId: "node-components",
    nodeType: "file",
    nameRule: {
      type: "glob",
      value: "*.tsx",
    },
    required: false,
    description: "React 组件文件",
    sortOrder: 0,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-hooks",
    anatomyId: anatomy.id,
    parentId: "node-src",
    nodeType: "directory",
    nameRule: {
      type: "exact",
      value: "hooks",
    },
    required: false,
    description: "自定义 Hooks",
    sortOrder: 1,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-hook-file",
    anatomyId: anatomy.id,
    parentId: "node-hooks",
    nodeType: "file",
    nameRule: {
      type: "glob",
      value: "use*.ts",
    },
    required: false,
    description: "Hook 文件",
    sortOrder: 0,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-index",
    anatomyId: anatomy.id,
    parentId: "node-src",
    nodeType: "file",
    nameRule: {
      type: "exact",
      value: "index.ts",
    },
    required: true,
    description: "代码包导出入口",
    sortOrder: 2,
    createdAt: "",
    updatedAt: "",
  },
  {
    id: "node-readme",
    anatomyId: anatomy.id,
    parentId: null,
    nodeType: "file",
    nameRule: {
      type: "exact",
      value: "README.md",
    },
    required: false,
    description: "代码包说明文档",
    sortOrder: 1,
    createdAt: "",
    updatedAt: "",
  },
]

六、为什么持久化使用扁平结构

不建议直接将整个目录保存成嵌套 JSON:

{
  name: "src",
  children: [
    {
      name: "components",
      children: [],
    },
  ],
}

因为编辑时会遇到很多问题:

  • 移动节点需要修改两棵子树。
  • 查找节点需要递归。
  • 删除节点需要递归查找。
  • 很难单独更新一个节点。
  • 数据库难以给单个节点建立约束。
  • 多人编辑或增量保存困难。
  • 排序和拖拽更新不方便。

使用 parentId 后,移动一个节点只需要改变:

{
  parentId: "new-parent-id",
  sortOrder: 2,
}

因此,邻接表结构是这个需求当前阶段最合适的持久化模型。


七、接口展示时转换成树

接口可以返回扁平节点,也可以由后端转换为树。

树节点 DTO 可以定义为:

interface AnatomyTreeNode {
  id: string
  nodeType: "directory" | "file"
  nameRule: NodeNameRule
  required: boolean
  description?: string
  sortOrder: number
  children: AnatomyTreeNode[]
}

转换方法:

function buildAnatomyTree(nodes: AnatomyNode[]): AnatomyTreeNode[] {
  const nodeMap = new Map<string, AnatomyTreeNode>()
  const roots: AnatomyTreeNode[] = []

  for (const node of nodes) {
    nodeMap.set(node.id, {
      id: node.id,
      nodeType: node.nodeType,
      nameRule: node.nameRule,
      required: node.required,
      description: node.description,
      sortOrder: node.sortOrder,
      children: [],
    })
  }

  for (const node of nodes) {
    const treeNode = nodeMap.get(node.id)

    if (!treeNode) {
      continue
    }

    if (node.parentId === null) {
      roots.push(treeNode)
      continue
    }

    const parent = nodeMap.get(node.parentId)

    if (parent) {
      parent.children.push(treeNode)
    }
  }

  const sortNodes = (treeNodes: AnatomyTreeNode[]): void => {
    treeNodes.sort((a, b) => a.sortOrder - b.sortOrder)

    for (const node of treeNodes) {
      sortNodes(node.children)
    }
  }

  sortNodes(roots)

  return roots
}

时间复杂度约为:

O(n)

适合当前业务。


八、前端编辑器状态

前端做层级编辑、拖拽排序时,不建议直接频繁修改递归数组。

可以保存成标准化状态:

interface AnatomyEditorNode {
  id: string
  parentId: string | null

  nodeType: "directory" | "file"
  nameRule: NodeNameRule

  required: boolean
  description: string

  sortOrder: number
}

interface AnatomyEditorState {
  anatomy: {
    name: string
    description: string
  }

  nodesById: Record<string, AnatomyEditorNode>
}

例如:

const editorState: AnatomyEditorState = {
  anatomy: {
    name: "React Component Library",
    description: "React 组件库结构",
  },
  nodesById: {
    "node-src": {
      id: "node-src",
      parentId: null,
      nodeType: "directory",
      nameRule: {
        type: "exact",
        value: "src",
      },
      required: true,
      description: "源代码目录",
      sortOrder: 0,
    },
    "node-index": {
      id: "node-index",
      parentId: "node-src",
      nodeType: "file",
      nameRule: {
        type: "exact",
        value: "index.ts",
      },
      required: true,
      description: "导出入口",
      sortOrder: 0,
    },
  },
}

渲染时根据 parentId 分组:

function getChildren(
  nodesById: Record<string, AnatomyEditorNode>,
  parentId: string | null,
): AnatomyEditorNode[] {
  return Object.values(nodesById)
    .filter((node) => node.parentId === parentId)
    .sort((a, b) => a.sortOrder - b.sortOrder)
}

这里需要注意:

> parentId + sortOrder 应该是唯一的数据来源,不要同时让 parentId 和 childrenIds 都可以被修改。

否则容易出现:

父节点说自己有某个子节点
但是子节点的 parentId 指向另一个目录

九、必须实现的业务校验

1. 文件节点不能有子节点

function canContainChildren(node: AnatomyNode): boolean {
  return node.nodeType === "directory"
}

添加或移动节点时:

if (parent.nodeType !== "directory") {
  throw new Error("Only directory nodes can contain child nodes")
}

2. 父节点必须属于同一个 Anatomy

必须保证:

parent.anatomyId === node.anatomyId

不允许把一个 Anatomy 的节点挂到另一个 Anatomy 下。


3. 禁止循环引用

例如禁止:

A/
└── B/
    └── A/

移动目录前,需要判断目标父节点是否是当前目录的后代。

function isDescendant(
  nodes: AnatomyNode[],
  candidateParentId: string,
  nodeId: string,
): boolean {
  const nodeMap = new Map(nodes.map((node) => [node.id, node]))

  let currentId: string | null = candidateParentId

  while (currentId !== null) {
    if (currentId === nodeId) {
      return true
    }

    currentId = nodeMap.get(currentId)?.parentId ?? null
  }

  return false
}

4. 删除目录时检查子节点

function hasChildren(nodes: AnatomyNode[], nodeId: string): boolean {
  return nodes.some((node) => node.parentId === nodeId)
}

推荐交互:

删除目录 “components”?

该目录包含 8 个子节点,删除后这些节点也会被删除。

用户必须明确确认。

后端仍然需要再次校验,不能只依赖前端确认框。


5. 名称不能为空

function validateNameRule(rule: NodeNameRule): boolean {
  return rule.value.trim().length > 0
}

6. 精确名称不能在同级重复

例如不能出现:

src/
├── index.ts
└── index.ts

建议校验同一个 parentId 下:

node.nameRule.type === "exact"

的名称唯一。

对于 Glob 规则,第一阶段至少禁止完全相同的模式重复。模式之间是否重叠,可以先提示警告而不是强制阻止。


7. 顺序只在兄弟节点之间有效

sortOrder 的作用域是:

anatomyId + parentId

推荐保存后重新整理为:

0, 1, 2, 3...

如果预计拖拽操作特别频繁,也可以后续改成字符串排名字段,例如 LexoRank。但当前阶段普通整数已经足够。


十、Crate 与 Anatomy 的关系

目前需求看起来是:

> 一个 Crate 最多选择一个 Anatomy,一个 Anatomy 可以被多个 Crate 使用。

因此直接在 Crate 上保存外键即可:

interface Crate {
  id: string
  name: string

  anatomyId: string | null
}

不建议保存:

interface Crate {
  metadata: {
    anatomyTree: unknown
  }
}

也不建议把整棵 Anatomy 复制到 Crate 中。

接口数据可以是:

interface CrateDetail {
  id: string
  name: string

  anatomy: {
    id: string
    name: string
    description?: string
    status: AnatomyStatus
    tree: AnatomyTreeNode[]
  } | null
}

十一、归档和删除策略

为了满足:

> 删除或归档 Anatomy 时不得静默破坏现有 Crate 的阅读体验。

建议规则如下。

归档

归档后:

  • 已关联的 Crate 仍然可以读取 Anatomy。
  • Crate 详情正常显示目录结构。
  • Anatomy 选择器默认不再展示该 Anatomy。
  • 已经选择该 Anatomy 的 Crate 编辑页面需要继续显示它,并标注“已归档”。
  • 用户可以将 Crate 改选为其他 Anatomy。

例如:

React Component Library
已归档

删除

建议本阶段不提供普通硬删除。

如果 Anatomy 被 Crate 引用:

无法删除该 Anatomy,因为它仍被 12 个 Crate 使用。
请先修改这些 Crate 的 Anatomy,或者归档该 Anatomy。

如果确实需要删除能力,只允许删除:

没有任何 Crate 引用的 Anatomy

十二、Anatomy 修改后的语义

这里需要在产品层明确一个重要语义:

当前推荐:实时引用

Crate 引用 Anatomy:

crate.anatomyId = anatomy.id

Anatomy 修改后,所有引用它的 Crate 都显示最新结构。

这符合“可复用结构规范”的定位,也最适合当前阶段。

暂不推荐:结构快照

如果未来要求:

> Crate 必须保留选择 Anatomy 当时的历史版本。

那就需要引入:

Anatomy
└── AnatomyVersion
    └── AnatomyNode

Crate 保存:

anatomyVersionId: string

而不是 anatomyId。

但当前 Issue 没有明确提出版本冻结,因此现在直接引用 Anatomy 更简单,不需要提前引入版本模型。


十三、推荐的接口形式

创建 Anatomy

POST /anatomies
{
  "name": "React Component Library",
  "description": "React 组件库标准结构"
}

批量保存结构节点

PUT /anatomies/:anatomyId/nodes
{
  "nodes": [
    {
      "id": "node-src",
      "parentId": null,
      "nodeType": "directory",
      "nameRule": {
        "type": "exact",
        "value": "src"
      },
      "required": true,
      "description": "源代码目录",
      "sortOrder": 0
    }
  ]
}

层级编辑器通常会发生移动、删除、排序等多个操作,第一阶段采用一次性批量保存,比为每个拖拽操作设计独立接口简单。

后端应在一个事务中:

  1. 校验全部节点。
  2. 校验父子关系。
  3. 校验循环引用。
  4. 校验排序。
  5. 写入节点。
  6. 删除本次提交中已不存在的旧节点。

十四、最终推荐模型

核心模型可以确定为:

interface Anatomy {
  id: string
  name: string
  description?: string
  status: "active" | "archived"
}

type NodeNameRule =
  | {
      type: "exact"
      value: string
    }
  | {
      type: "glob"
      value: string
    }

interface AnatomyNode {
  id: string
  anatomyId: string
  parentId: string | null

  nodeType: "directory" | "file"
  nameRule: NodeNameRule

  required: boolean
  description?: string

  sortOrder: number
}

interface Crate {
  id: string
  anatomyId: string | null
}

整体数据流是:

数据库保存扁平节点
        ↓
接口读取 AnatomyNode[]
        ↓
转换为目录树
        ↓
前端递归展示

前端编辑标准化节点
        ↓
拖拽修改 parentId 和 sortOrder
        ↓
批量提交
        ↓
后端校验并保存

这套设计能够完整覆盖 T1~T4,并为以后增加扫描、匹配、版本管理保留扩展空间。