关于 breadboarding
这是一种将工作流描述转化为 UI 与代码功能及其关系图的方法,可用于理解现有系统或细化设计,也能同时整理既有部分和新增部分。
主要功能
- 01将工作流整理为 UI 与代码功能表
- 02记录功能之间的连线关系
- 03建模 Place、模式与子 Place
- 04组合现有系统与设计变更
- 05可选用 Mermaid 绘制图示
适用场景
- 01追踪现有工作流的运行方式
- 02细化提议设计中的具体机制
- 03梳理涉及前端和后端的变更
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rjs/ shaping-skills --skill breadboarding安装后在 Claude Code 中输入/breadboarding确认已加载。
详细介绍
面包板设计
面包板设计会将工作流描述转换为一份完整的可供性及其关系图。输出始终是一组表格,展示编号的 UI 和代码可供性,以及它们的「连线至」和「返回至」关系。表格才是事实依据。Mermaid 图是可选的,供人类查看的可视化内容。
用例
面包板设计有两种用途:
1. 映射现有系统
你不了解现有系统的具体运作方式。你想要理解某个工作流,解释某件事如何发生,或为什么没有发生。
输入:
- 要分析的代码仓库
- 工作流描述(始终从操作人员的角度出发,描述他们如何通过 UI 或作为调用方促成某种效果)
输出:
- UI 可供性表
- 代码可供性表
- (可选)Mermaid 可视化图
**注意:**如果工作流跨越多个应用(前端 + 后端),请创建一块面包板,讲清楚完整流程。标记各处,以显示它们所属的系统。
2. 根据已塑形的部分进行设计
你有一个根据塑形结果、由多个部分(机制)组成的新系统草图。你需要详细说明具体机制,并展示这些部分如何作为一个系统相互作用。
输入:
- 部分清单(塑形中的机制)
- 这些部分要实现的 R(需求/结果)
- 现有系统(可选)——如果新部分必须与现有代码互操作
输出:
- UI 可供性表
- 代码可供性表
- (可选)Mermaid 可视化图
混合情况
通常你需要同时处理两种情况:一个必须保持原样的现有系统,以及由塑形定义的新部分或变更。这种情况下,请将两者一起制作成面包板,展示现有可供性和新增可供性,以及它们如何连接。
3. 阅读白板面包板
手绘或白板面包板采用视觉堆叠格式,而非表格。适用的概念相同(Place、可供性、连线),但布局惯例有所不同。
视觉惯例:
| 元素 | 外观 |
|---|---|
| Place | 垂直堆叠顶部的彩色块(通常为粉色/紫色) |
| Place 中的可供性 | 堆叠在 Place 块下方的块——通过堆叠中的垂直位置表示包含关系 |
| 代码可供性 | 通常悬浮在各个 Place 堆叠之间,而非其中 |
| Place 加载器 | 放置在 Place 块左上方的代码可供性——描述渲染该 Place 所需的数据/输入 |
| Wires Out | 块之间的实线箭头 |
| Returns To | 块之间的虚线箭头 |
| 条件分支 | 堆叠中缩进的块,通常使用不同颜色(例如绿色),表示 if/else 分支 |
| Place 引用 | 堆叠中的 Place 名称带有 _ 前缀(与 _PlaceName 惯例相同) |
| 不确定/暂定项 | 可供性名称带有 ? 或 ~ 前缀,或使用虚线边框——表示该可供性尚属推测 |
| 包含框 | 环绕多个堆叠的大型边界框——按系统或职责边界对可供性分组(例如,“HireEZ”框) |
| 备注/注释 | 元素附近的自由文本——用于说明背景、未决问题或理由 |
如何阅读白板面包板:
- 识别 Place — 找到每个堆叠顶部的彩色标题块
- 从上到下阅读每个堆叠 — Place 下方堆叠的所有内容都属于该 Place
- 查找加载器 — Place 块左上方的代码可供性描述渲染所需的数据
- 追踪连线 — 沿着堆叠之间的箭头追踪控制流(实线)和数据流(虚线)
- 注意条件分支 — 不同颜色的缩进块表示 Place 内的分支逻辑
- 查看包含框 — 大型边界框表示系统/职责边界
- 标记推测项 — 带
?和~前缀的项表示不确定,可能不会保留到塑形阶段
转换为表格: 将白板面包板转换为标准可供性表时,将每个堆叠映射到其 Place,按从上到下的顺序列出可供性,并将箭头记录为 Wires Out/Returns To 关系。加载器将成为代码可供性,其 Returns To 指向它们所供给的 UI 可供性。
核心概念
Place
Place 是一个有界的交互上下文。当你处于一个 Place 时:
- 你可以使用一组特定的可供性
- 你无法与该边界之外的可供性交互
- 你必须执行某个操作才能离开
**Place 是感知层面的,而非技术层面的。**它与 URL 或组件无关,而是关乎用户将什么体验为当前上下文。就你此刻能做什么而言,Place 就是“你所在的位置”。
阻断测试
判断某个事物是否属于不同 Place 的最简单方法是:你能与后面的内容交互吗?
| 答案 | 含义 |
|---|---|
| 不能 | 你处于不同的 Place |
| 能 | 同一个 Place,只是局部状态发生了变化 |
示例
| UI 元素 | 是否阻断? | 是否为 Place? | 原因 |
|---|---|---|---|
| 模态框 | 是 | 是 | 无法与后面的页面交互 |
| 确认弹出框 | 是 | 是 | 返回前必须先作出响应(模态框的一种边界情况) |
| 编辑模式(整个屏幕变换) | 是 | 是 | 所有可供性都已改变 |
| 复选框显示额外字段 | 否 | 否 | 周围环境没有变化 |
| 下拉菜单 | 否 | 否 | 可以点击其他地方将其关闭,不会阻断交互 |
| 工具提示 | 否 | 否 | 仅提供信息,不会阻断交互 |
本地状态与页面导航
当某个控件改变状态时,请问:是所有内容都变了,还是只有一部分变了,而周围环境保持不变?
| 类型 | 发生的变化 | 建模方式 |
|---|---|---|
| 本地状态 | 部分 UI 发生变化,周围环境不变 | 同一 Place,条件 N → 依赖的 U |
| 页面导航 | 整个屏幕发生变化,或出现阻塞式覆盖层 | 不同 Place |
基于模式的 Place
当某种模式(例如“编辑模式”)改变整个屏幕时——按钮不同,各处的交互方式也不同——应将其建模为不同的 Place:
PLACE: CMS Page (Read Mode)
PLACE: CMS Page (Edit Mode)
用于在它们之间切换的状态标志(例如 editMode$)是一种导航机制,而不是数据存储。不要将它作为 S 纳入任一 Place。
适用于任何控件的三个问题
对于任何 UI 交互元素,请问:
- 我是从哪里来到这里,才看到它的?
- 我现在在哪里?
- 如果我与它交互,会去哪里?
如果第 3 个问题的答案是“所有内容都会改变”,或“在我作出响应之前,无法与其后方的内容交互”,那么这就是导航到另一个 Place。
命名约定
| 模式 | 用途 |
|---|---|
PLACE: Page Name | 标准页面/路由 |
PLACE: Page Name (Mode) | 页面的基于模式的变体 |
PLACE: Modal Name | 模态对话框 |
PLACE: Backend | API/数据库边界 |
涉及多个系统时,请在名称中标明系统:PLACE: Checkout Page (frontend)、PLACE: Payment API (backend)。
Place ID
Place 是数据模型中的一等元素。每个 Place 都有一个 ID:
| # | Place | 描述 |
|---|---|---|
| P1 | CMS Page (Read Mode) | 仅查看状态 |
| P2 | CMS Page (Edit Mode) | 带有页面级控件的编辑状态 |
| P2.1 | widget-grid (letters) | 子 Place:P2 中用于编辑字母的组件 |
| P3 | Letter Form Modal | 用于添加/编辑字母的表单 |
| P4 | Backend | API 解析器和数据库 |
Place ID 可用于:
- 显式连接导航 — 连接到
→ P2,而不是连接到其中的交互元素 - 跟踪包含关系 — 每个交互元素都声明所属的 Place
- 保持 Mermaid 子图一致 — 子图 ID 与 Place ID 相匹配
Place 引用
当嵌套 Place 有许多内部交互元素,导致父级内容过于杂乱时,可以将其分离:
- 在父级 Place 中使用下划线前缀放置一个引用节点:
_letter-browser - 单独定义完整的 Place,包含其所有内部元素
- 从引用节点连接到该 Place:
_letter-browser --> letter-browser
该引用是一个 UI 交互元素——它表示“此组件/部件在父级上下文中渲染于此”。
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
U_LB["_letter-browser"]
end
subgraph letterBrowser["letter-browser"]
U10["U10: Search input"]
U11["U11: Letter list"]
N40["N40: performSearch()"]
end
U_LB --> letterBrowser
在交互元素表中,将引用列为 UI 交互元素:
| # | 交互元素 | 控件 | 输出连线 |
|---|---|---|---|
| U1 | Edit button | click | → N1 |
| _letter-browser | Widget reference | — | → P3 |
为 Place 引用设置虚线边框,以便区分:
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
class U_LB placeRef
将模式建模为 Place
当组件具有不同模式(阅读与编辑、查看与编辑、折叠与展开)时,应将它们建模为不同的 Place——它们对用户而言是不同的感知状态。
如果一种模式包含另一种模式的全部内容并有所扩展,请在扩展后的 Place 中使用Place 引用来表示:
P3: letter-browser (Read) — base state
P4: letter-browser (Edit) — contains _letter-browser (Read) + new affordances
该引用表示组合关系:“P3 中的所有内容都会显示在这里,此外还有这些新增内容。”
flowchart TB
subgraph P3["P3: letter-browser (Read)"]
U10["U10: Search input"]
U11["U11: Letter list"]
end
subgraph P4["P4: letter-browser (Edit)"]
U_P3["_letter-browser (Read)"]
U3["U3: Add button"]
U4["U4: Edit button"]
end
U_P3 --> P3
P4 的交互元素表中,引用表示继承关系:
| # | 交互元素 | 控件 | 输出连线 | 备注 |
|---|---|---|---|---|
| _letter-browser (Read) | 继承 P3 的所有内容 | — | → P3 | |
| U3 | Add button | click | → N3 | NEW |
| U4 | Edit button | click | → N4 | NEW |
子区域
子区域是某个 Place 中明确界定的子集——一个用于归纳相关交互能力的内部区域。以下情况适合使用子区域:
- 一个 Place 包含多个不同的组件或区块
- 你正在详细说明较大 Place 中的某个部分
- 你想展示哪些内容属于范围之内、哪些不属于
**表示法:**使用分层 ID——例如 P2 的子区域为 P2.1、P2.2 等。
| # | Place | Description |
|---|-------|-------------|
| P2 | Dashboard | Main dashboard page |
| P2.1 | Sales widget | Subplace: sales metrics |
| P2.2 | Activity feed | Subplace: recent activity |
在交互能力表中,使用子区域 ID 表示包含关系:
| U3 | P2.1 | sales-widget | "Refresh" button | click | → N4 | — |
| U7 | P2.2 | activity-feed | activity list | render | — | — |
**在 Mermaid 中:**将子区域子图嵌套在父级内部。使用相同的背景颜色(不设置不同的填充色)——子区域属于父级,而不是单独的 Place:
flowchart TB
subgraph P2["P2: Dashboard"]
subgraph P2_1["P2.1: Sales widget"]
U3["U3: Refresh button"]
end
subgraph P2_2["P2.2: Activity feed"]
U7["U7: activity list"]
end
otherContent[["... other dashboard content ..."]]
end
**范围外内容的占位符:**详细说明某个子区域时,添加一个占位符同级项,表示页面上还有其他内容:
otherContent[["... other page content ..."]]
这会告诉读者:“我们正在聚焦于 P2.1,但 P2 中还有其他内容,这里不作详细说明。”
包含关系与连线
这是数据模型中的两种不同关系:
| 关系 | 含义 | 记录位置 |
|---|---|---|
| 包含关系 | 交互能力属于某个 Place/位于某个 Place 中 | Place 列(集合成员关系) |
| 连线 | 交互能力触发/调用某项内容 | Wires Out 列(控制流) |
包含关系是集合成员关系:U1 ∈ P1 表示 U1 是 Place P1 的成员。每项交互能力都恰好属于一个 Place。
连线是控制流:U1 → N1 表示 U1 会触发 N1。交互能力可以连到任何内容——其他交互能力或 Place。
Place 列回答:“这项交互能力位于何处?” Wires Out 列回答:“这项交互能力会触发什么?”
导航连线
当某项交互能力触发导航(用户“前往”某处)时,应连到 Place 本身,而不是其中的某项交互能力:
✅ N1 Wires Out: → P2 (navigate to Edit Mode)
❌ N1 Wires Out: → U3 (wiring to affordance inside P2)
这样可以在表格中明确表示导航。Place 是目的地;到达后,其中的具体交互能力便可使用。
在 Mermaid 中,这会变成:
N1 --> P2
子图 ID 与 Place ID 相同,因此连线会连接到 Place 的边界。
交互能力
可供操作的内容:
- UI 交互能力 (U):输入框、按钮、显示元素、滚动区域
- 代码交互能力 (N):方法、订阅、数据存储、框架机制
连线
交互能力彼此连接的方式:
Wires Out — 交互能力触发或调用的内容(控制流):
- 调用连线:一项交互能力调用另一项
- 写入连线:代码向数据存储写入数据
- 导航连线:路由到另一个 Place
Returns To — 交互能力的输出流向(数据流):
- 返回连线:函数向调用方返回值
- 读取连线:另一项交互能力读取数据存储
这种区分可以明确表示数据流。Wires Out 显示控制流(什么触发什么)。Returns To 显示数据流(输出流向何处)。
输出:交互能力表
表格才是事实依据。每个面包板都会生成以下表格:
Places 表
| # | Place | 描述 |
|---|---|---|
| P1 | 搜索页面 | 主要搜索界面 |
| P2 | 详情页面 | 单个结果视图 |
UI 交互能力表
| # | Place | 组件 | 交互能力 | 控制方式 | Wires Out | Returns To |
|---|---|---|---|---|---|---|
| U1 | P1 | search-detail | 搜索输入框 | 输入 | → N1 | — |
| U2 | P1 | search-detail | 加载旋转图标 | 渲染 | — | — |
| U3 | P1 | search-detail | 结果列表 | 渲染 | — | — |
| U4 | P1 | search-detail | 结果行 | 点击 | → P2 | — |
代码可供操作项表
| # | 位置 | 组件 | 可供操作项 | 控制方式 | 输出连接 | 返回至 |
|---|---|---|---|---|---|---|
| N1 | P1 | search-detail | activeQuery.next() | 调用 | → N2 | — |
| N2 | P1 | search-detail | activeQuery 订阅 | 观察 | → N3 | — |
| N3 | P1 | search-detail | performSearch() | 调用 | → N4, → N5, → N6 | — |
| N4 | P1 | search.service | searchOneCategory() | 调用 | → N7 | → N3 |
| N5 | P1 | search-detail | loading | 写入 | 存储 | → U2 |
| N6 | P1 | search-detail | results | 写入 | 存储 | → U3 |
| N7 | P1 | typesense.service | rawSearch() | 调用 | — | → N4 |
数据存储表
| # | 位置 | 存储 | 说明 |
|---|---|---|---|
| S1 | P1 | results | 搜索结果数组 |
| S2 | P1 | loading | 布尔型加载状态 |
列定义
| 列 | 说明 |
|---|---|
| # | 唯一 ID(位置使用 P1、P2……;UI 使用 U1、U2……;代码使用 N1、N2……;存储使用 S1、S2……) |
| 位置 | 此可供操作项所属的位置(包含关系) |
| 组件 | 拥有此项的组件/服务 |
| 可供操作项 | 可供操作的具体对象 |
| 控制方式 | 触发事件:点击、输入、调用、观察、写入、渲染 |
| 输出连接 | 此项触发的内容:→ N4、→ P2(控制流,包括导航) |
| 返回至 | 输出流向的位置:→ N3 或 → U2, U3(数据流) |
操作步骤
映射现有系统
请参阅下方的示例 A,查看完整的操作示例。
步骤 1:确定要分析的流程
选择一段具体的用户旅程。始终将其描述为操作者试图完成某件事:
- “访问 /search,输入查询,向下滚动查看更多内容,点击结果”
- “使用卡片令牌调用支付 API,并预期创建一笔扣款”
步骤 2:列出涉及的所有位置
沿着这段旅程,找出用户访问的每个不同位置,或系统跨越的每个边界。
步骤 3:追踪代码以查找组件
从入口点(路由、API 端点)开始追踪代码,找出此流程涉及的所有组件。
步骤 4:列出每个组件的可供操作项
阅读代码,并识别:
- UI:用户可以看到什么、与什么交互?
- 代码:涉及哪些方法、订阅和存储?
步骤 5:写出实际对象,而不是抽象概念
如果你写了“DATABASE”,请停下来想想:实际调用的方法是什么?(userRepo.save())。每个可供操作项的名称都必须对应代码中真实存在、可以指认的对象。
步骤 6:填写“控制方式”列
每个可供操作项由什么触发?(点击、输入、调用、观察、写入、渲染)
步骤 7:填写“输出连接”列
每个可供操作项会触发什么?阅读代码:此方法会调用什么?此按钮的处理程序会调用什么?
步骤 8:填写“返回至”列
每个可供操作项的输出流向哪里?
- 返回值的函数 → 列出接收返回值的调用方
- 数据存储 → 列出读取它的可供操作项
- 没有有意义的输出 → 使用
—
步骤 9:将数据存储添加为可供操作项
当代码写入某个属性,而另一个可供操作项之后会读取该属性时,将此属性作为控制类型为 write 的代码可供操作项添加。
步骤 10:将框架机制添加为可供操作项
包括 cdr.detectChanges() 这类连接代码与 UI 渲染的机制。这些机制展示了状态变化实际上如何传递到 UI。
步骤 11:对照代码进行验证
再次阅读代码,确认每个可供操作项确实存在,且连接关系符合实际情况。
从塑造后的组成部分进行设计
完整的分片示例请参阅下文的示例 B。
第 1 步:列出形状中的每个组成部分
写下塑造过程中识别出的每个机制/组成部分。
第 2 步:将组成部分转换为可供性
对于每个组成部分,确定:
- 该组成部分需要哪些 UI 可供性?
- 哪些代码可供性可以实现该组成部分?
第 3 步:确认每个 U 都有对应的 N
对于每个 UI 可供性,检查:哪个代码可供性为它提供数据或控制其渲染?如果没有,就补上缺失的 N。
第 4 步:将位置归类为现有或新增
对于每个 UI 可供性,确定它属于:
- 要修改的现有位置
- 要创建的新位置
第 5 步:连接可供性
为每个可供性填写「连出」和「返回到」。追踪预期行为——什么调用什么?什么返回到哪里?
第 6 步:连接到现有系统(如适用)
如果存在现有代码库:
- 找出新可供性必须连接到的现有可供性
- 将这些现有可供性添加到表格中
- 将新可供性连接到它们
第 7 步:检查完整性
- 每个 U 都应有一个为其提供数据的 N
- 每个 N 都应有「连出」或「返回到」(或两者)
- 处理程序 → 应有「连出」
- 查询 → 应有「返回到」
- 数据存储 → 应有「返回到」
第 8 步:将用户可见的输出视为 U
用户看到的任何内容(包括电子邮件、通知)都是一种 UI 可供性,需要有一个 N 连接到它。
核心原则
切勿依赖记忆——始终检查数据
逆向追踪流程时,不要沿着你记忆中的路径走。扫描「连出」列,找出所有连接到目标的可供性。
填写表格时,要系统地阅读每一行。不要依赖你自以为了解的内容。
表格才是事实来源。你的记忆并不可靠。
映射时,每个可供性名称都必须真实存在
映射现有代码时,切勿凭空创造抽象。每个名称都必须指向代码库中真实存在的内容。
机制不是可供性
可供性是系统中具有明确身份、可以对其采取行动的对象。有些东西看起来像可供性,实际上只是实现机制:
| 类型 | 示例 | 为什么它不是可供性 |
|---|---|---|
| 视觉容器 | modal-frame wrapper | 你无法对包装器采取行动——它只是一个位置边界 |
| 内部转换 | letterDataTransform() | 调用方的实现细节——不能单独操作 |
| 导航机制 | modalService.open() | 只是前往某个位置的「方式」——应直接连接到目标 |
**初稿中未必总能一眼看出。**检查可供性表格时,要再次确认每个代码可供性,并问:
「这真的是一种可供性,还是只是在描述某件事发生的机制?」
如果它只是「方式」,就跳过它,直接连接到目标位置或结果。
示例:
❌ N8 --> N22 --> P3 (N22 is modalService.open — just mechanism)
✅ N8 --> P3 (handler navigates to modal)
❌ N6 --> N20 --> S2 (N20 is data transform — internal to N6)
✅ N6 --> S2 (callback writes to store)
❌ U7: modal-frame (wrapper — just the boundary of P3)
✅ U8: Save button (actionable)
处理程序导航到 P3。回调将内容写入存储。模态框就是 P3。机制是隐含的。
两种流程:导航和数据
面包板记录两种不同的流程:
| 流程 | 追踪内容 | 连接方式 |
|---|---|---|
| 导航 | 从一个位置移动到另一个位置 | 连出 → 位置 |
| 数据 | 状态如何填充显示内容 | 返回到 → U |
两者相互独立。可以只有导航而不改变数据,也可以改变数据而不导航。
检查面包板时,追踪这两种流程:
- **导航流程:**能否追踪用户从一个位置到另一个位置的过程?
- **数据流程:**对于每个显示数据的 U,能否追踪这些数据的来源?
每个显示数据的 U 都需要数据来源
用于显示数据的 UI 控件必须有数据输入来源——可以是数据存储(S),也可以是能够返回数据的代码控件(N)。
❌ U6: letter list (no incoming wire — where does the data come from?)
✅ S1 -.-> U6 (store feeds the display)
✅ N4 -.-> U6 (query result feeds the display)
如果某个显示数据的 U 没有连接数据来源,那么:
- breadboard 中缺少数据来源
- 这个 U 并不存在
专注于导航时很容易忽略这一点。请始终问自己:“这个 U 会显示数据——这些数据来自哪里?”
每个 N 都必须连接
如果某个代码控件没有输出连线,也没有返回目标,那么就有问题:
- 处理程序 → 应该有输出连线(它们调用或写入的对象)
- 查询 → 应该有返回目标(接收其返回值的对象)
- 数据存储 → 应该有返回目标(读取它们的控件)
副作用需要存储
某个 N 看起来没有连接到任何地方时,就值得怀疑。如果它对系统边界之外的对象产生副作用(浏览器 URL、localStorage、外部 API、分析服务),请添加一个存储节点来表示该外部状态:
❌ N41: updateUrl() (wires to... nothing?)
✅ N41: updateUrl() → S15 (wires to Browser URL store)
这样可以明确呈现数据流。存储节点也可以有返回连线,显示外部状态如何流回系统:
flowchart TB
N42["N42: performSearch()"] --> N41["N41: updateUrl()"]
N41 --> S15["S15: Browser URL (?q=)"]
S15 -.->|back button / init| N40["N40: activeQuery$"]
常见的外部存储建模对象:
Browser URL— 查询参数、哈希片段localStorage/sessionStorage— 持久化的客户端状态Clipboard— 复制/粘贴操作Browser History— 导航状态
将控制流与数据流分开
输出连线 = 控制流(什么触发什么) 返回目标 = 数据流(输出流向何处)
这种分离能让系统行为一目了然。
将导航内联展示,而不是绘制成环路
路由是每个页面都会用到的通用机制。不要把所有导航都画成经过中央 Router 控件,而应在发生导航的位置内联显示 Router navigate(),并直接连线到目标位置。
将存储放在能够支持行为的位置,而不是写入位置
数据存储应属于其数据被使用以支持某种效果的 Place,而不是数据产生的位置。从其他 Place 写入数据,意味着“访问并修改”该 Place 的状态。
确定存储所属位置:
- 追踪读写关系——谁写入?谁读取?
- 由读取方决定位置——行为在该处得以实现
- 如果只有一个 Place 读取,则将存储放在该 Place 中
示例:一个 changedPosts 数组由 Modal 写入(用户确认更改时),但由 PAGE_SAVE 处理程序读取(用户点击 Save 时)。该存储应属于 PAGE_SAVE 处理程序,因为它在那里支持持久化操作。
仅在确实共享时才提取到共享区域
将存储放入单独的 DATA STORES 部分之前,请确认它确实会被多个 Place 读取。如果它只支持一个 Place 中的行为,就应放在该 Place 中。
将存储嵌套在读取它的子组件中
在 Place 内部,应将存储放在能够利用它支持行为的子组件中。如果某个存储由特定处理程序读取,就将其放在该处理程序的组件中,而不是悬置在 Place 这一层。
后端是一个 Place
数据库和解析器并非悬置的基础设施——它们构成一个拥有自身控件的 Place。数据库表(S)应放在 Backend Place 中,与读取和写入这些表的解析器(N)并列。
部件与关系目录
本节完整介绍 breadboard 中可能出现的所有内容。
元素
| 元素 | ID 模式 | 含义 | 判定标准 |
|---|---|---|---|
| 地点 | P1, P2, P3... | 交互的有界上下文 | 阻挡测试:无法与其后方的内容交互 |
| 子地点 | P2.1, P2.2... | 地点内明确定义的子集 | 将较大地点内相关的交互能力归为一组 |
| 地点引用 | _PlaceName | 指向独立地点的 UI 交互能力 | 在其他位置单独定义的复杂嵌套地点 |
| UI 交互能力 | U1, U2, U3... | 用户可以看到或交互的内容 | 输入框、按钮、显示内容、滚动区域 |
| 代码交互能力 | N1, N2, N3... | 代码中可以对其采取操作的内容 | 方法、订阅、处理程序、框架机制 |
| 数据存储 | S1, S2, S3... | 持久化且可读写的状态 | 保存数据的属性、数组、可观察对象 |
| 块 | — | 折叠的子系统 | 一条线接入、一条线接出,包含许多内部组件 |
| 占位符 | — | 范围外内容标记 | 显示上下文,但不作详细说明 |
关系
| 关系 | 语法 | 含义 | 示例 |
|---|---|---|---|
| 包含关系 | Place 列 | 交互能力属于某个地点 | U3 位于地点 P2.1 中 |
| 连线输出 | → X | 控制流:触发/调用 | → N4、→ P2 |
| 返回至 | → X(在 Returns To 列中) | 数据流:输出流向 | → U6、→ N3 |
| 简略流程 | |label| | 省略中间步骤 | S4 -.-> |view query| U6 |
| 父子关系 | 分层 ID | 子地点属于地点 | P2.1 是 P2 的子级 |
包含关系与连线
| 关系 | 含义 | 记录位置 |
|---|---|---|
| 包含关系 | 交互能力属于/位于某个地点中 | Place 列(集合成员关系) |
| 连线 | 交互能力触发/调用某个内容 | Wires Out 列(控制流) |
包含关系是集合成员关系:U1 ∈ P1 表示 U1 是地点 P1 的成员。
连线表示控制流:U1 → N1 表示 U1 触发 N1。
各元素的判定标准
地点(P):
- 通过阻挡测试——无法与其后方的内容交互
- 示例:模态框、编辑模式(整个屏幕发生变化)、路由/页面
- 不包括:下拉菜单、工具提示、用于显示字段的复选框
地点引用(_PlaceName):
- 代表独立地点的 UI 交互能力
- 当嵌套地点包含许多交互能力、会使父级内容杂乱时使用
- 示例:
_letter-browser、_user-profile-widget - 连线指向完整的地点定义:
_letter-browser --> P3
UI 交互能力(U):
- 用户可以看到或与之交互
- 示例:按钮、输入框、列表、微调器、显示的文本
- 不包括:包装元素、布局容器
代码交互能力(N):
- 具有明确的标识——可以在代码中指明它
- 示例:
handleSubmit()、query$ subscription、detectChanges() - 不包括:内部转换、导航机制(见下文)
数据存储(S):
- 可读写的状态
- 示例:
results数组、loading布尔值、changedPosts列表 - 外部存储:
Browser URL、localStorage、Clipboard——表示应用边界之外的状态 - 不包括:只设置一次且永不更改的配置(可考虑作为配置交互能力)
验证检查
| 检查项 | 问题 | 如果否…… |
|---|---|---|
| 每个显示数据的 U | 是否有一条传入连线(通过 Wires Out 或 Returns To)? | 添加数据源 |
| 每个 N | 是否有 Wires Out 或 Returns To(或两者都有)? | 进行调查——可能是无效代码或缺少连线 |
| 每个 S | 是否有内容读取它(Returns To)? | 进行调查——可能未被使用 |
| 导航机制 | 这个 N 是否仅仅是到达某处的“方式”? | 改为直接连线至地点 |
| 具有副作用的 N | 这个 N 是否会影响外部状态(URL、存储、剪贴板)? | 为外部状态添加一个存储 |
分块
分块会将一个子系统折叠为主图中的单个节点,并单独展示其详细信息。当面包板的某个部分具有以下特征时,可使用分块来管理复杂度:
- 一条输入连线(单一入口)
- 一条输出连线(单一输出)
- 两者之间有大量内部结构
何时分块
沿着连线追踪,找出出现“瓶颈”的部分——许多功能点汇集到单一输入和单一输出。这些都是适合分块的自然边界。
示例:dynamic-form 组件接收表单定义,渲染多个字段(U7a-U7k),在变更时进行验证(N26),并发出单个 valid$ 信号。在主图中,它会变成:
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
如何分块
- 在主图中,将子系统替换为单个体育场形节点:
dynamicForm[["CHUNK: dynamic-form"]]
- 使用边界信号连接到分块或从分块连出:
N24 -->|formDefinition| dynamicForm
dynamicForm -.->|valid$| U8
- 创建单独的分块图,用边界标记显示内部结构:
flowchart TB
input([formDefinition])
output(["valid$"])
subgraph chunk["dynamic-form internals"]
N25["N25: generateFormConfig()"]
U7a["U7a: field"]
N26["N26: form value changes"]
N27["N27: valid$ emission"]
end
input --> N25
N25 --> U7a
U7a --> N26
N26 --> N27
N27 --> output
classDef boundary fill:#b3e5fc,stroke:#0288d1,stroke-dasharray:5 5
class input,output boundary
- 在主图中使用不同样式突出分块:
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
class dynamicForm chunk
分块颜色约定
| 类型 | 颜色 | Hex |
|---|---|---|
| 分块节点(主图) | 浅蓝色 | #b3e5fc |
| 边界标记(分块图) | 浅蓝色、虚线 | #b3e5fc,并使用 stroke-dasharray:5 5 |
优点
- 主图保持清晰易读——复杂的子系统变为单个节点
- 保留细节——需要时可通过分块图查看内部结构
- 自然边界——分块通常对应可复用组件
可视化(Mermaid)
表格才是准确信息的来源。Mermaid 图是可选的,仅供人们查看。
基本结构
flowchart TB
U1["U1: search input"] --> N1["N1: activeQuery.next()"]
N1 --> N2["N2: subscription"]
N2 --> N3["N3: performSearch"]
N3 --> N4["N4: searchOneCategory"]
N4 -.-> N3
N3 --> N5["N5: loading store"]
N3 --> N6["N6: results store"]
N5 -.-> U2["U2: loading spinner"]
N6 -.-> U3["U3: results list"]
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3 ui
class N1,N2,N3,N4,N5,N6 nonui
连线约定
| 线条样式 | Mermaid 语法 | 用途 |
|---|---|---|
实线(-->) | A --> B | 连线输出:调用、触发、写入 |
虚线(-.->) | A -.-> B | 返回至:返回值、数据存储读取 |
带 ... 标签 | `A -.-> | ... |
缩写范围外的流程
如果数据流包含与面包板范围无关的中间步骤,可通过从源直接连线到目标,并添加 ... 标签来缩写:
S4 -.->|...| U6
这表示“数据从 S4 流向 U6,中间步骤已省略”。以下情况可使用此方式:
- 流程确实存在,但其内部结构不在范围之内
- 需要展示数据来源,但不详细说明查询链
- 面包板聚焦于一个工作流(例如编辑),但需要提及另一个工作流(例如查看)
ID 前缀
| 前缀 | 类型 | 示例 |
|---|---|---|
| P | 位置 | P1、P2、P3 |
| U | UI 功能点 | U1、U2、U3 |
| N | 代码功能点 | N1、N2、N3 |
| S | 数据存储 | S1、S2、S3 |
颜色约定
| 类型 | 颜色 | Hex |
|---|---|---|
| 位置(子图) | 白色/透明 | — |
| UI 功能点 | 粉色 | #ffb6c1 |
| 代码功能点 | 灰色 | #d3d3d3 |
| 数据存储 | 薰衣草色 | #e6e6fa |
| 分块 | 浅蓝色 | #b3e5fc |
| 位置引用 | 粉色、虚线边框 | #ffb6c1 |
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
classDef store fill:#e6e6fa,stroke:#9370db,color:#000
classDef chunk fill:#b3e5fc,stroke:#0288d1,color:#000,stroke-width:2px
classDef placeRef fill:#ffb6c1,stroke:#d87093,stroke-width:2px,stroke-dasharray:5 5
子图标签和位置 ID
将位置 ID 用作子图 ID,以便导航连线正确连接:
flowchart TB
subgraph P1["P1: CMS Page (Read Mode)"]
U1["U1: Edit button"]
N1["N1: toggleEditMode()"]
end
subgraph P2["P2: CMS Page (Edit Mode)"]
U2["U2: Save button"]
U3["U3: Add button"]
end
%% Navigation wires to Place ID
N1 --> P2
| 类型 | ID 模式 | 标签模式 | 用途 |
|---|---|---|---|
| 位置 | P1、P2…… | P1: Page Name | 用户访问的有界上下文 |
| 触发器 | — | TRIGGER: Name | 启动流程的事件(不可导航) |
| 组件 | — | COMPONENT: Name | 在多个位置出现的可复用 UI+逻辑 |
| 系统 | — | SYSTEM: Name | 涉及多个应用程序时使用 |
要点: 子图 ID(P1、P2)必须与位置表中的位置 ID 一致。这样,N1 --> P2 之类的导航连线才能连接到位置边界。
涉及多个系统时
flowchart TB
subgraph frontend["SYSTEM: Frontend"]
U1["U1: submit button"]
N1["N1: handleSubmit()"]
end
subgraph backend["SYSTEM: Backend API"]
N10["N10: POST /orders"]
N11["N11: orderService.create()"]
end
U1 --> N1
N1 --> N10
N10 --> N11
工作流步骤标注(可选)
为特定工作流制作面包板时,你可以选择添加编号步骤标记,帮助读者直观地了解流程。以下情况适用:
- 图表较复杂,工作流路径不明显
- 你想引导他人完成特定的用户旅程
- 面包板将用作演示教程或教学工具
格式:
在图表前添加工作流指南表:
| Step | Action | Where to look |
|------|--------|---------------|
| **1** | Click "Edit" button | U1 → N1 → S1 |
| **2** | Edit mode activates | S1 → N2 → U3 |
| **3** | Click "Add" | U3 → N3 → N8 |
在 Mermaid 图表中使用体育场形节点添加步骤标记:
flowchart TB
%% Step markers
step1(["1 - CLICK EDIT"])
step2(["2 - EDIT MODE ON"])
step3(["3 - CLICK ADD"])
%% Connect steps to relevant affordances with dashed lines
step1 -.-> U1
step2 -.-> N2
step3 -.-> U3
%% Style step markers green
classDef step fill:#90EE90,stroke:#228B22,color:#000,font-weight:bold
class step1,step2,step3 step
格式说明:
- 使用
"1 - ACTION"格式(数字、空格、连字符、空格、操作) - 避免使用
"1. ACTION"——句点会触发 Mermaid 的 Markdown 列表解析器 - 避免使用
"1) ACTION"——括号也可能导致解析问题 - 使用虚线(
-.->)将步骤标记连接到可操作项 - 将步骤设为绿色,以便与 UI(粉色)和 Code(灰色)可操作项区分
对面包板进行切片
切片是将面包板中的可操作项分组为垂直实现切片的过程。下方的示例 B展示了完整的切片示例。
输入:
- 面包板(包含连线的可操作项表)
- 形状(R + 机制)——用于指导哪些演示内容值得关注
输出:
- 将可操作项分配到切片 V1–V9 的面包板(最多 9 个切片)
什么是垂直切片?
垂直切片是一组能够实现可演示功能的 UI 和 Code 可操作项。它贯穿所有层(UI、逻辑、数据),交付一个可运行的增量。
与之相反的是水平切片——只在某一层开展工作(例如“设置所有数据模型”),且无法从界面中点击使用。
关键约束
每个切片都必须包含可演示的可见 UI。 不含 UI 的切片只是一个水平层,而不是垂直切片。
- ✅“自助签名路径”(演示:结账 → 签名 → 查看签名)
- ❌“数据库架构”(无法演示)
可演示意味着:
- 有入口点(UI 交互或触发器)
- 有可观察的输出(UI 渲染或产生效果)
- 展示了朝 R 迈出的有意义的进展
形状决定了什么算作“有意义的进展”——你不是在随意分组可操作项,而是通过分组来展示各项机制的运行情况。
切片大小
- 太小: 只有 1–2 个 UI 可操作项,无法形成有意义的演示 → 与相关切片合并
- 太大: 有 15 个以上可操作项,或包含多个无关的用户旅程 → 拆分
- 合适的大小: 一个连贯的用户旅程,并且有清晰的“看我完成这件事”演示
目标是不超过 9 个切片。如果需要更多,说明这个形状可能对于一个周期来说太大了。
指向后续切片的连线
切片可能包含一些可操作项,其连线指向后续切片中的可操作项。这些连线存在于面包板中,但尚未实现——在后续切片构建完成之前,它们只是存根或空操作。
这是正常情况。面包板展示完整系统;切片展示实现顺序。
流程
步骤 1:确定最小可演示的增量
查看你的面包板和形状。问问自己:“展示核心机制正常运行所需的最小子集是什么?”
通常包括:
- 核心数据获取
- 基本渲染
- 暂不包含搜索、分页和状态持久化
这就是 V1。
步骤 2:将其他能力分层为切片
查看形状中的机制。每个切片都应展示一种机制正常运行:
- V2:搜索输入(展示搜索机制)
- V3:分页/无限滚动(展示分页机制)
- V4:URL 状态持久化(展示状态保留机制)
- 等等。
最多 9 个切片。 如果更多,就合并相关机制。无法单独成立的功能应放在同一个切片中。
步骤 3:将交互点分配给切片
逐一检查每个交互点,并将其分配给首次需要展示该切片机制的切片:
| 切片 | 机制 | 交互点 |
|---|---|---|
| V1 | 核心显示 | U2、U3、N3、N4、N5、N6、N7 |
| V2 | 搜索 | U1、N1、N2 |
| V3 | 分页 | U10、N11、N12、N13 |
有些交互点可能连有指向后续切片的输出连线——这没问题。它们会在分配给它们的切片中实现;这些连线暂时不起作用。
步骤 4:为每个切片创建交互点表
针对每个切片,仅提取要添加的交互点:
V2:搜索可用
| # | 组件 | 交互点 | 控件 | 输出连线 | 返回到 |
|---|---|---|---|---|---|
| U1 | search-detail | 搜索输入 | 输入 | → N1 | — |
| N1 | search-detail | activeQuery.next() | 调用 | → N2 | — |
| N2 | search-detail | activeQuery 订阅 | 观察 | → N3 | — |
步骤 5:为每个切片撰写演示说明
每个切片都需要一个具体演示,展示其机制如何推动 R 的实现:
- V1:“组件显示来自 API 的真实数据”
- V2:“输入 ‘dharma’,结果实时筛选”
- V3:“向下滚动,加载更多项目”
演示内容应能展示给利益相关者,并体现项目进展。
使用 Mermaid 可视化切片
在每个切片图中展示完整的面包板,但通过样式区分范围:
| 类别 | 样式 | 说明 |
|---|---|---|
| 当前切片 | 鲜艳颜色 | 正在添加的交互点 |
| 已构建 | 实心灰色 | 之前的切片 |
| 未来 | 透明、虚线边框 | 尚未构建 |
flowchart TB
U1["U1: search input"]
U2["U2: loading spinner"]
N1["N1: activeQuery.next()"]
N2["N2: subscription"]
N3["N3: performSearch"]
U1 --> N1
N1 --> N2
N2 --> N3
N3 --> U2
%% V2 scope (this slice) = green
classDef thisSlice fill:#90EE90,stroke:#228B22,color:#000
%% Already built (V1) = grey
classDef built fill:#d3d3d3,stroke:#808080,color:#000
%% Future = transparent dashed
classDef future fill:none,stroke:#ddd,color:#bbb,stroke-dasharray:3 3
class U1,N1,N2 thisSlice
class U2,N3 built
这样利益相关者就能看到:
- 当前正在构建什么(高亮显示)
- 已经有哪些内容(灰色显示)
- 后续还会有哪些内容(淡化显示)
切片摘要格式
| # | 切片 | 机制 | 演示 |
|---|---|---|---|
| V1 | 显示真实数据的组件 | F1、F4、F6 | “组件显示来自 API 的字母” |
| V2 | 搜索可用 | F3 | “输入内容筛选结果” |
| V3 | 无限滚动 | F5 | “向下滚动,加载更多内容” |
| V4 | URL 状态 | F2 | “刷新后保留搜索内容” |
“机制”列引用形状中的部分,显示每个切片会展示哪些机制。
示例
示例 A:绘制现有系统
此示例展示如何为现有系统绘制面包板,以了解数据如何经由多个入口流动。
输入
需要了解的工作流:“admin_organisation_countries 如何被修改并在下游读取?有多个入口:手动编辑、复选框切换和批处理作业。”
输出
UI 交互元素
| # | 组件 | 交互元素 | 控件 | 输出连接 | 返回至 |
|---|---|---|---|---|---|
| U1 | SSO Admin | role_profiles 复选框 | 渲染 | — | — |
| U2 | SSO Admin | “Country Admin”复选框 | 点击 | 切换选择状态 | — |
| U3 | SSO Admin | admin_countries filter_horizontal | 渲染 | — | — |
| U4 | SSO Admin | 可用国家列表 | 渲染 | — | — |
| U5 | SSO Admin | 已选国家列表 | 渲染 | — | — |
| U6 | SSO Admin | Add → / Remove ← | 点击 | 修改选择 | — |
| U7 | SSO Admin | Save 按钮 | 点击 | → N3 | — |
| U20 | DWConnect | “Country admins”部分 | 渲染 | — | — |
| U21 | (未知) | 系统电子邮件“From”字段 | 渲染 | — | — |
代码交互元素
| # | 组件 | 交互元素 | 控件 | 输出连接 | 返回至 |
|---|---|---|---|---|---|
| N1 | sso/accounts/admin | get_fieldsets() | 调用 | → U3(有条件) | — |
| N2 | sso/accounts/models | get_administrable_user_countries() | 调用 | — | → U4 |
| N3 | sso/accounts/admin | save_form() | 调用 | → N4、→ N5 | — |
| N4 | Django Admin | 表单 M2M 保存 | 调用 | → S2 | — |
| N5 | sso/forms/mixins | _update_user_m2m() | 调用 | → S1、→ N6 | — |
| N6 | sso/signals | user_m2m_field_updated 信号 | 信号 | → N10 | — |
| N7 | CLI/调度程序 | manage.py dwbn_cleanup | 调用 | → N15 | — |
| N10 | sso-dwbn-theme | dwbn_user_m2m_field_updated() | 接收 | → N11 | — |
| N11 | sso-dwbn-theme | dwbn_user_m2m_field_updated_task() | 调用 | → N12 | — |
| N12 | sso-dwbn-theme | 已添加 Country Admin 且管理国家为零? | 条件判断 | → N20 | — |
| N15 | sso-dwbn-theme | admin_changes() | 调用 | → N16 | — |
| N16 | sso-dwbn-theme | 对每个 Country Admin:缺少家庭中心所在国家? | 循环 | → N20 | — |
| N20 | sso-dwbn-theme | 获取家庭中心所在国家 | 调用 | → N21 | — |
| N21 | sso-dwbn-theme | admin_organisation_countries.add() | 调用 | → S2 | — |
| N22 | sso-dwbn-theme | update_last_modified() | 调用 | — | — |
| N30 | dwconnect2-backend | findCenterAdmins() | 调用 | — | → U20 |
| N31 | sso/api | get_object_data() | 调用 | — | → 外部 |
数据存储
| # | 存储 | 说明 |
|---|---|---|
| S1 | role_profiles | M2M:用户拥有的角色配置 |
| S2 | admin_organisation_countries | M2M:用户管理的国家 |
| S3 | organisations | 用户的家庭中心 |
Mermaid 图
flowchart TB
subgraph stores["DATA STORES"]
S1["S1: role_profiles"]
S2["S2: admin_organisation_countries"]
S3["S3: organisations"]
end
subgraph ssoAdmin["PLACE: SSO Admin — User Change Page"]
subgraph permissions["Permissions fieldset"]
U1["U1: role_profiles checkboxes"]
U2["U2: 'Country Admin' checkbox"]
end
subgraph userAdmin["User admin fieldset (superuser only)"]
U3["U3: admin_countries filter_horizontal"]
U4["U4: Available countries"]
U5["U5: Selected countries"]
U6["U6: Add → / Remove ←"]
end
U7["U7: Save button"]
N1["N1: get_fieldsets()"]
N2["N2: get_administrable_user_countries()"]
N3["N3: save_form()"]
N4["N4: Form M2M save"]
N5["N5: _update_user_m2m()"]
N6["N6: user_m2m_field_updated signal"]
N1 -->|is_superuser| userAdmin
U3 --> U4
U3 --> U5
U6 --> U5
N2 -.-> U4
U2 --> U7
U6 --> U7
U7 --> N3
N3 --> N4
N3 --> N5
N5 --> N6
end
subgraph trigger["TRIGGER: Batch Cleanup"]
N7["N7: manage.py dwbn_cleanup"]
end
subgraph theme["sso-dwbn-theme"]
N10["N10: dwbn_user_m2m_field_updated()"]
N11["N11: dwbn_user_m2m_field_updated_task()"]
N12["N12: Country Admin added AND zero admin countries?"]
N15["N15: admin_changes()"]
N16["N16: For each Country Admin: home center country missing?"]
N20["N20: Get home center's country"]
N21["N21: admin_organisation_countries.add()"]
N22["N22: update_last_modified()"]
N6 --> N10
N10 --> N11
N11 --> N12
N7 --> N15
N15 --> N16
N12 -->|yes| N20
N16 -->|yes| N20
N20 --> N21
N21 --> N22
end
subgraph dwconnect["PLACE: DWConnect — Center Page"]
N30["N30: findCenterAdmins()"]
U20["U20: 'Country admins' section"]
N30 --> U20
end
subgraph api["TRIGGER: External API Request"]
N31["N31: get_object_data()"]
end
U21["U21: System email 'From' field"]
N4 --> S2
N5 --> S1
N21 --> S2
S1 -.-> N15
S3 -.-> N16
S3 -.-> N20
S2 -.-> U5
S2 -.-> N30
S2 -.-> N31
S2 -.-> U21
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
classDef store fill:#e6e6fa,stroke:#9370db,color:#000
classDef condition fill:#fffacd,stroke:#daa520,color:#000
classDef trigger fill:#98fb98,stroke:#228b22,color:#000
class U1,U2,U3,U4,U5,U6,U7,U20,U21 ui
class N1,N2,N3,N4,N5,N6,N10,N11,N15,N20,N21,N22,N30,N31 nonui
class N12,N16 condition
class N7 trigger
class S1,S2,S3 store
示例 B:从成形部件着手设计
第 1 部分:塑造上下文(面包板设计的输入)
本节展示了塑造过程产出的内容——需求、已识别的现有模式,以及草拟的部分。这些内容是面包板设计接收的输入。
注意: 本示例使用塑造术语。在塑造过程中,你需要定义需求(Rs)、识别可复用的现有模式,并将解决方案草拟为各个部分/机制。面包板设计会将经过塑造的解决方案细化为具体的功能点及其连接方式。
R(需求)
| ID | 需求 |
|---|---|
| R0 | 让内容可以从索引页中搜索 |
| R2 | 从详情页返回时,恢复到之前的分页状态 |
| R3 | 从详情页返回时,恢复到之前的搜索状态 |
| R4 | 页面刷新后,搜索/分页状态仍然保留 |
| R5 | 浏览器后退按钮可恢复之前的搜索/分页状态 |
| R9 | 搜索输入应进行防抖处理(而非每次按键都触发搜索) |
| R10 | 搜索至少需要 3 个字符 |
| R11 | 加载和空状态应向用户提供反馈 |
包含可复用模式的现有系统(S-CUR)
应用已有一个全局搜索页,已实现其中大部分 R。在塑造过程中,它被记录为各个部分/机制:
| 部分 | 机制 |
|---|---|
| S-CUR1 | URL 状态与初始化 |
| S-CUR1.1 | Router 的 queryParams observable 提供 {q, category} |
| S-CUR1.2 | initializeState(params) 根据 URL 设置 query 和 category |
| S-CUR1.3 | 页面加载时,根据 URL 状态触发初始搜索 |
| S-CUR2 | 搜索输入 |
| S-CUR2.1 | 搜索输入绑定到 activeQuery BehaviorSubject |
| S-CUR2.2 | activeQuery 订阅中包含 90ms 的防抖 |
| S-CUR2.3 | 至少 3 个字符时触发 performNewSearch() |
| S-CUR3 | 数据获取 |
| S-CUR3.1 | performNewSearch() 设置加载状态,并调用搜索服务 |
| S-CUR3.2 | 搜索服务构建 Typesense 过滤器,并调用 rawSearch() |
| S-CUR3.3 | rawSearch() 查询 Typesense,返回 {found, hits} |
| S-CUR3.4 | 将结果写入 detailResult 数据存储 |
| S-CUR4 | 分页 |
| S-CUR4.1 | 滚动到底部时,通过 intercomService 触发 appendNextPage() |
| S-CUR4.2 | appendNextPage() 递增页码并调用搜索 |
| S-CUR4.3 | 将新命中项追加到现有命中项中 |
| S-CUR4.4 | sendMessage() 重新启用滚动检测 |
| S-CUR5 | 渲染 |
| S-CUR5.1 | cdr.detectChanges() 触发模板重新评估 |
| S-CUR5.2 | 根据存储显示加载动画、“无结果”提示和结果数量 |
| S-CUR5.3 | *ngFor 为每个命中项渲染一个磁贴 |
| S-CUR5.4 | 点击磁贴时导航到详情页 |
草拟的解决方案:调整 S-CUR 的各个部分
新解决方案的各个部分明确指出了它们调整了哪些 S-CUR 模式:
| 部分 | 机制 | 调整自 |
|---|---|---|
| F1 | 创建组件(组件、定义、注册) | — |
| F2 | URL 状态与初始化(读取 ?q=,在加载时恢复) | S-CUR1 |
| F3 | 搜索输入(防抖、至少 3 个字符、触发搜索) | S-CUR2 |
| F4 | 数据获取(使用过滤器调用 rawSearch()) | S-CUR3 |
| F5 | 分页(滚动到底部、追加页面、重新启用检测) | S-CUR4 |
| F6 | 渲染(加载状态、空状态、结果列表、行) | S-CUR5 |
第 2 部分:搭建面包板(转换部件 → 可供使用的功能)
这里开始搭建面包板。经过整理的部件会变成具体的可供使用功能,并明确标出连线。输出内容是可供使用功能表和示意图。
UI 可供使用功能
| # | 组件 | 可供使用的功能 | 控制方式 | 连出 | 返回至 |
|---|---|---|---|---|---|
| U1 | letter-browser | 搜索输入框 | 输入 | → N1 | — |
| U2 | letter-browser | 加载动画 | 渲染 | — | — |
| U3 | letter-browser | 无结果提示 | 渲染 | — | — |
| U4 | letter-browser | 结果数量 | 渲染 | — | — |
| U5 | letter-browser | 结果列表 | 渲染 | → U6, U7, U8, U9 | — |
| U6 | letter-row | 点击行 | 点击 | → LD | — |
| U7 | letter-row | 日期 | 渲染 | — | — |
| U8 | letter-row | 主题 | 渲染 | — | — |
| U9 | letter-row | 摘要 | 渲染 | — | — |
| U10 | letter-browser | 滚动 | 滚动 | → N11 | — |
| U11 | browser | 返回按钮 | 点击 | → N9 | — |
| U12 | letter-browser | “查看全部 X 条结果” | 点击 | → LP | — |
| LD | — | 信件详情 | 放置 | — | — |
| LP | — | 完整页面 | 放置 | — | — |
代码可供使用功能
| # | 组件 | 可供使用的功能 | 控制方式 | 连出 | 返回至 |
|---|---|---|---|---|---|
| N1 | letter-browser | activeQuery.next() | 调用 | → N2 | → U12 |
| N2 | letter-browser | activeQuery 订阅 | 观察 | → N3 | — |
| N3 | letter-browser | performSearch() | 调用 | → N4, → N6, → N7, → N8 | — |
| N4 | typesense.service | rawSearch() | 调用 | — | → N3, → N12 |
| N5 | letter-browser | parentId(配置) | 配置 | — | → N4 |
| N6 | letter-browser | loading store | 写入 | — | → N8 |
| N7 | letter-browser | detailResult store | 写入 | — | → N8, → N16 |
| N8 | letter-browser | detectChanges() | 调用 | → U2, → U3, → U4, → U5 | — |
| N9 | browser | URL ?q= | 读取 | → N10 | — |
| N10 | letter-browser | initializeState() | 调用 | → N1, → N3 | — |
| N11 | intercom.service | scroll subject | 观察 | → N12 | — |
| N12 | letter-browser | appendNextPage() | 调用 | → N4, → N7, → N8, → N13, → N14 | — |
| N13 | intercom.service | sendMessage() | 调用 | → N11 | — |
| N14 | router | navigate() | 调用 | — | → N9 |
| N15 | letter-browser | if !compact subscribe | 条件 | → N11 | — |
| N16 | letter-browser | if truncated show link | 条件 | → U12 | — |
| N17 | letter-browser | compact(配置) | 配置 | — | → N4, → N15, → N16 |
| N18 | letter-browser | fullPageRoute(配置) | 配置 | — | → U12 |
Mermaid 示意图
flowchart TB
subgraph lettersIndex["PLACE: Letters Index Page"]
subgraph letterBrowser["COMPONENT: letter-browser"]
U1["U1: search input"]
U2["U2: loading spinner"]
U3["U3: no results msg"]
U4["U4: result count"]
U5["U5: results list"]
U12["U12: See all X results"]
N1["N1: activeQuery.next"]
N2["N2: activeQuery sub"]
N3["N3: performSearch"]
N6["N6: loading store"]
N7["N7: detailResult store"]
N8["N8: detectChanges"]
N10["N10: initializeState"]
N16["N16: if truncated show link"]
N5["N5: parentId (config)"]
N17["N17: compact (config)"]
N18["N18: fullPageRoute (config)"]
subgraph pagination["PAGINATION"]
U10["U10: scroll"]
N15["N15: if !compact subscribe"]
N12["N12: appendNextPage"]
end
end
end
subgraph letterRow["COMPONENT: letter-row"]
U6["U6: row click"]
U7["U7: date"]
U8["U8: subject"]
U9["U9: teaser"]
end
subgraph browser["BROWSER"]
U11["U11: back button"]
N9["N9: URL ?q="]
N14["N14: Router.navigate"]
end
subgraph services["SERVICES"]
N4["N4: rawSearch"]
N11["N11: intercom subject"]
N13["N13: sendMessage"]
end
subgraph letterDetail["PLACE: Letter Detail Page"]
LD["Letter Detail"]
end
U1 -->|type| N1
N1 --> N2
N2 -->|debounce 90ms, min 3| N3
N3 --> N4
N3 --> N6
N3 --> N7
N3 --> N8
N4 -.-> N3
N4 -.-> N12
N6 -.-> N8
N7 -.-> N8
N8 --> U2
N8 --> U3
N8 --> U4
N8 --> U5
U5 --> U6
U5 --> U7
U5 --> U8
U5 --> U9
U6 -->|navigate| LD
U11 -->|restore| N9
N9 --> N10
N10 --> N1
N10 --> N3
U10 --> N11
N15 -->|if !compact| N11
N11 --> N12
N12 --> N4
N12 --> N7
N12 --> N8
N12 --> N13
N12 --> N14
N13 -->|re-arm| N11
N5 -.->|filter| N4
N17 -.-> N4
N17 -.-> N15
N17 -.-> N16
N18 -.-> U12
N14 -.->|URL| N9
N1 -.-> U12
N7 -.-> N16
N16 -->|if truncated| U12
U12 -->|navigate with ?q| LP["Full Page"]
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui
class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui
切分面包板
完成整个面包板后,将其切分为纵向增量。每个切片都展示一个正常运行的机制:
切片摘要
| # | 切片 | 机制 | 可供使用的功能 | 演示 |
|---|---|---|---|---|
| V1 | 带真实数据的小组件 | F1, F4, F6 | U2-U9, N3-N8, LD | “小组件显示真实数据” |
| V2 | 搜索正常工作 | F3 | U1, N1, N2 | “输入 ‘dharma’,结果会筛选” |
| V3 | 无限滚动 | F5 | U10, N11-N13 | “向下滚动,加载更多” |
| V4 | URL 状态 | F2 | U11, N9, N10, N14 | “刷新后保留搜索内容” |
| V5 | 紧凑模式 | — | U12, N15-N18, LP | “显示‘查看全部’链接” |
切片示意图
flowchart TB
subgraph slice1["V1: WIDGET WITH REAL DATA"]
U2["U2: loading spinner"]
U3["U3: no results msg"]
U4["U4: result count"]
U5["U5: results list"]
U6["U6: row click"]
U7["U7: date"]
U8["U8: subject"]
U9["U9: teaser"]
N3["N3: performSearch"]
N4["N4: rawSearch"]
N5["N5: parentId (config)"]
N6["N6: loading store"]
N7["N7: detailResult store"]
N8["N8: detectChanges"]
LD["Letter Detail"]
end
subgraph slice2["V2: SEARCH WORKS"]
U1["U1: search input"]
N1["N1: activeQuery.next"]
N2["N2: activeQuery sub"]
end
subgraph slice3["V3: INFINITE SCROLL"]
U10["U10: scroll"]
N11["N11: intercom subject"]
N12["N12: appendNextPage"]
N13["N13: sendMessage"]
end
subgraph slice4["V4: URL STATE"]
U11["U11: back button"]
N9["N9: URL ?q="]
N10["N10: initializeState"]
N14["N14: Router.navigate"]
end
subgraph slice5["V5: COMPACT MODE"]
U12["U12: See all X results"]
N15["N15: if !compact subscribe"]
N16["N16: if truncated show link"]
N17["N17: compact (config)"]
N18["N18: fullPageRoute (config)"]
LP["Full Page"]
end
U1 -->|type| N1
N1 --> N2
N2 -->|debounce| N3
N3 --> N4
N3 --> N6
N3 --> N7
N3 --> N8
N4 -.-> N3
N5 -.->|filter| N4
N6 -.-> N8
N7 -.-> N8
N8 --> U2
N8 --> U3
N8 --> U4
N8 --> U5
U5 --> U6
U5 --> U7
U5 --> U8
U5 --> U9
U6 -->|navigate| LD
U11 -->|restore| N9
N9 --> N10
N10 --> N1
N10 --> N3
U10 --> N11
N11 --> N12
N12 --> N4
N12 --> N7
N12 --> N8
N12 --> N13
N12 --> N14
N13 -->|re-arm| N11
N4 -.-> N12
N14 -.->|URL| N9
N15 -->|if !compact| N11
N17 -.-> N4
N17 -.-> N15
N17 -.-> N16
N18 -.-> U12
N1 -.-> U12
N7 -.-> N16
N16 -->|if truncated| U12
U12 -->|navigate with ?q| LP
style slice1 fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
style slice2 fill:#e3f2fd,stroke:#2196f3,stroke-width:2px
style slice3 fill:#fff3e0,stroke:#ff9800,stroke-width:2px
style slice4 fill:#f3e5f5,stroke:#9c27b0,stroke-width:2px
style slice5 fill:#fff8e1,stroke:#ffc107,stroke-width:2px
classDef ui fill:#ffb6c1,stroke:#d87093,color:#000
classDef nonui fill:#d3d3d3,stroke:#808080,color:#000
class U1,U2,U3,U4,U5,U6,U7,U8,U9,U10,U11,U12,LD,LP ui
class N1,N2,N3,N4,N5,N6,N7,N8,N9,N10,N11,N12,N13,N14,N15,N16,N17,N18 nonui