Dify 前端组件架构守则:从 React 所有权、边界到 Effects 的评审规则解析
本篇基于 Dify 仓库内置的 frontend-code-review 技能规则包,系统讲解其中专门约束 React 组件结构、所有权(ownership)、props、Effects 与状态建模的 component-architecture.md 规则文档。读完后你将掌握:在 Dify 这类大型 Next.js + TanStack Query 应用中,如何判断"状态该放在哪一层"、"组件该拆到哪里"、"Effect 何时才允许存在",并能按仓库既定的评审口径(P0–P3 严重级,见 SKILL.md)产出可复现、可验证的前端架构评审结论。
规则包定位:评审阶段的路由入口
该文档并非面向普通开发者的泛泛指南,而是 Dify 为 AI Agent 与人类评审者设计的代码评审规则包(rule pack)。在 SKILL.md 中,前端评审被明确限定在 web/ 与 packages/dify-ui/ 两个目录范围内,并按 diff 特征路由到 8 个子规则包:组件所有权、props、状态、Effects、导航或模块边界相关的问题,统一路由到 references/component-architecture.md,与 accessibility-ui.md、dify-ui.md、data-query-contracts.md、performance.md 等并列。
评审方法论遵循 "Evidence First"(证据优先):
- 从请求的文件或当前 diff 确定评审范围;
- 阅读被改动的行、其行为属主(behavior owner)以及最近的
AGENTS.md作用域文档; - 仅当公共消费者、生成契约、基础组件 API 或运行时配置影响正确性时,才继续追踪它们;
- 只报告绑定到可观察故障、被违反的契约、安全边界或被证明的维护风险的发现。
评审结论按四级严重度排序输出:P0(安全/隐私泄露、数据丢失、生产崩溃、关键流程不可访问)、P1(用户可见回归、非法 API 或鉴权契约、hydration 失败、主交互损坏)、P2(具体维护性/性能/测试/可访问性缺陷)、P3(轻微可操作清理项)。这套输出规范意味着:组件架构问题若不能落到具体文件行号与失败路径,就不应进入报告。
所有权(Ownership):状态与行为的最低必要层级
规则的第一章回答一个核心问题:状态、查询、mutation、事件处理器应该放在哪一层? 评审口径是"flag"(标记为问题)以下五种模式:
- 状态/查询/mutation/处理器被提升(hoist)到了真正使用它们的最低组件之上——即父层"代管"了本应由子层拥有的东西;
- 父组件拥有行/条目级动作(row/item actions),但这些动作并不在协调一个工作流(workflow);
- props 穿透(prop drilling)穿过多个纯转发层,仅为了把一个值或回调递给最底层的渲染者;
- 页面/tab 级区块组件变成数据属主,却并不需要共享快照,也不共享 loading/error/empty 的 UI;
- 功能代码仅因为"只出现了一次"或"以后可能被复用"就被提升到 shared 层。
同时,规则给出一条明确的反直觉豁免:兄弟组件各自重复发起相同的 TanStack Query 调用是可接受的,只要每个组件独立消费该数据;缓存去重本身不构成把数据提升到公共父层的理由。
这条规则与 Dify 仓库的实际技术栈直接对应。Dify Web 端在 Next.js App Router 之上大量使用 TanStack Query 管理数据,例如 layout-main.tsx/app/(appDetailLayout)/[appId]/layout-main.tsx)、view.tsx/app/(appDetailLayout)/[appId]/overview/view.tsx) 等页面级组件均直接引入 @tanstack/react-query 的 hooks;web/features/ 下的新特性目录(如 agent-v2、skills)中 useQuery/useMutation 也分散在各叶子组件内,而非集中到页面容器。从源码结构看,仓库的既有做法正是"谁消费、谁查询",与本文档的 ownership 条款互为印证——评审时若发现数据被提升到并不共享加载态的父层,即可依据本规则给出 P2 级维护性发现。
组件边界(Component Boundaries):什么时候该拆、什么时候不该包
第二章给出六条应被标记的边界问题:
- 超过 300 行的 React 组件文件,且该文件混合了多个可拆分为聚焦的**同置(colocated)**组件、hooks 或工具函数的职责。注意规则的两个限定词:行数只是触发器,前提还必须"存在可拆分的多职责混合";
- 浅层包装组件(shallow wrapper)——仅仅重命名 props 或隐藏真正的基础组件(primitive);
- 多余的 DOM 包裹层,它不提供布局、语义、可访问性、状态属主或库集成中的任何一项价值;
- Dialog/dropdown/popover 的隐藏面(hidden surface) 遮挡了父级流程,却本应被提取为小的本地组件;
- 业务表单、菜单主体或一次性 helper 被移离其属主组件,且既无复用也无语义价值。
结论性建议只有一句:优先按真实的数据与状态需求拆分成同置组件("Prefer colocated components split by actual data and state needs")。
这条规则在 Dify 中有明确的落点约束:Web 端对 Dify UI 基础组件的使用受 web/AGENTS.md 强制要求——优先使用 @langgenius/dify-ui/* 子路径导出的 primitive、数据属性和设计令牌,且"不要添加会隐藏这些契约的 Web 层包装器"(Button、IconButton、Overlay 等均有此禁令)。也就是说,"浅层 wrapper"不仅违反组件架构规则包,还会直接违反 Dify UI 的包级契约,评审时属于双重命中。packages/dify-ui/README.md 中列出的 primitive 清单(./dialog、./dropdown-menu、./popover、./drawer 等 overlay 类别)正是规则第 4 条"隐藏面应提取为小本地组件"所指的触发场景:overlay 的开启状态与内容往往比宿主按钮复杂得多,留在父组件里会让父级流程被弹窗状态"污染"。
不良组件设计模式:交互契约与"泛化"陷阱
第三章是六章中条目最多的一章,覆盖了 9 种应标记的设计模式,可以归纳为三组:
交互契约破坏组:
- 对既有导航、侧边栏、下拉、webapp 列表、应用切换 UI 的重构,没有保留行为敏感交互——展开/收起箭头、hover 持久化、置顶/删除控件、路由、键盘/焦点处理、开启状态的属主;
- 一个组件混合了数据获取、mutation 副作用、弹窗状态、表单校验、布局与行渲染,且没有清晰属主。
"假泛化"组:
- 带有大量 boolean props 的通用组件,实际编码的是某一个功能的工作流(用开关注入而非真实抽象);
- shared 组件导入了功能特定的文案、路由或 API 契约——方向性错误,依赖应指向更底层而非功能层;
- 功能组件接收预渲染的 fragments(render props 滥用),仅仅是为了避免把属主放对位置;
- 包装组件改变了被包裹 primitive 的可访问语义——与 accessibility-ui.md 规则包交叠,属于 P1/P2 级别的复合问题。
数据流组:
- 子组件对同一概念同时接收原始服务端数据和派生的标志位(例如同时传
row.status和row.isDisabled),产生两个事实来源; - 组件暴露受控 props(controlled props)却为同一值保留了一个竞争的私有 state——受控/非受控混用,是典型的 stale-state 缺陷源;
- 组件无法在调用方不做预处理的情况下渲染空态、加载态或缺失的可选 API 字段——健壮性契约缺失。
该章还给出两条处置原则:当既有组件已拥有交互逻辑时,优先复用或扩展而非重写;若重构不可避免,必须保留旧的交互契约,并为变更行为添加或更新聚焦测试(测试要求路由到 testing.md)。
在 Dify 仓库中,这类"行为敏感交互"正是评审高风险区:web/app/(commonLayout)/ 下的应用列表、导航、切换类 UI 以及 web/features/agent-v2/agent-detail/ 这类含多 tab、多 overlay 的详情页,其展开/收起、hover 持久化、焦点管理行为一旦被重构破坏,就会落到 P1(用户可见回归)而非 P3。规则要求"先找属主、再谈重构",本质上是在保护这些交互契约的可追溯性。
Props 与类型:反对无契约变更的形式统一
第四章针对 props 设计给出 5 条标记规则:
- 仅为风格统一而重写声明或导出,但没有改变所拥有行为或契约的变更;
- 对琐碎一次性 props 起命名的
Props类型(内联类型更清晰时); - props 按 UI 实现命名(如
showArrow)而非按领域/API 角色命名; - API 数据过早转换、或转换后丢失可追溯性(generic name 让人无法回溯到后端字段);
- 调用方重复了最低渲染组件已经处理的 fallback 检查——防御逻辑下沉后上层应信任属主。
本章最重要的一条反噪音条款是:不要仅凭语法形式标记 FC、React.FC、函数声明、箭头函数、命名导出或默认导出;只有当所选形式造成具体的类型、生命周期、导出、框架或强制包契约缺陷时,才允许报告。这条显式压制了评审中最高频的"品味型噪音",与 SKILL 层"只报告绑定到可观察故障的发现"的原则一致。
Effects:默认不存在,除非同步具名外部系统
第五章对 useEffect 采取了近乎"有罪推定"的立场。以下行为都应被标记:
- 在 effect 中转换 props/state 用于渲染(这属于渲染期派生,不是 effect);
- 把一个 state 值拷贝进另一个表示同一概念的 state(事实来源重复,stale-state 之源);
- 在 effect 中处理本应属于事件处理器的用户动作;
- 在 props 或可见性变化时重置本地状态——而派生、稳定的语义身份(semantic identity)或预期的挂载属主已经表达了该生命周期;
- 在 effect 中获取本应属于框架 API 或 TanStack Query 的数据。
而一个 effect 若要合法存在,必须同步一个具名外部系统,规则列举了合法清单:浏览器 API、订阅(subscription)、定时器(timer)、可见性触发的分析上报(analytics-on-visibility)、非 React 组件、命令式 DOM 集成。清单之外的 effect 一律视为问题。
这条规则与 Dify 的数据层约定高度自洽:web/AGENTS.md 要求新的后端调用使用 @/service/client 生成的 consoleQuery/consoleClient API,禁止手写 REST helper——即所有服务端数据获取被集中到生成的 Query 层,effect 中 fetch 数据因此失去了合法性依据,评审时可直接引用两个证据点。
状态建模:生命周期先于存储机制
第六章是全文概念密度最高的部分。应标记的状态反模式包括:
- 存储派生布尔值、disabled 标志、默认 tab、加载文案——这些可以从当前 query/feature 状态直接计算;
- 把会话级状态放在更长寿的可见性协调器(visibility coordinator)里,再通过 open-state Effect 或生成的 key 清理,而 primitive 的挂载内容生命周期本来就已匹配预期状态寿命;
- 把一个 DOM 字段镜像成相互竞争的 prop、default 和 React state 三个来源,而编辑并不需要这些来源同步;
- 用本地 state 伪造服务端数据或生成的契约字段;
- 把本应是**实时应用状态(live app state)**的 UI 状态持久化到 localStorage;
- 在真实 API 确认前,把功能本地的 mock 外壳接到不相干的既有 API 上。
随后是三段关键的方法论陈述,值得逐条展开:
1. 先评审状态生命周期,再评审存储机制。 对隐藏面(hidden surface,即 dialog/drawer/popover 一类),要区分"可见性协调器"与"挂载内容"两个角色:私有于某次挂载会话的状态,属主就应该是挂载内容本身;只有当草稿必须在该内容属主卸载后依然存活、或另一个属主在协调它时,才提升(promote)状态。稳定的语义身份 key 可以在所表示的身份变化时创建新快照,但生成的 key 不是例行的 reset 命令——key={Math.random()} 式重置正是上文第二条标记项。
2. 优先渲染期派生。 真正的本地 state 只留给:用户选择、瞬态输入、受控弹窗、以及没有服务端来源的功能 UI 状态。只提交(submit-only)的 DOM 字段可以保持非受控;只有当 React 必须拥有当前值来驱动渲染或协同时,才使用本地受控 state。观察变更事件或追踪"dirty 与否"这类派生事实,并不要求镜像字段值——这直接呼应第五章"拷贝同一概念到第二个 state"的禁令。同时明确:不能仅因某处使用了受控 state 就提出发现,必须有具体的竞争来源、stale-state 或属主缺陷作支撑。
3. 落点在 Dify 的形态上。 从 web/context/ 目录结构(14 个 context/provider 文件)看,Dify Web 端存在大量页面级 provider;本章的"可见性协调器 vs 挂载内容"正是针对这类 provider 持有过多会话状态的场景。评审时若发现某 modal 的草稿状态挂在页面级 provider 且靠 effect 清理,而弹窗内容本应自管,即可按本章口径给出属主错误发现。
导航:Link 优先,URL 承载可分享状态
第七章给出三条标记规则:
- 对普通链接使用命令式路由跳转(
router.push式的普通导航); - 用 button 语义承担导航(应为
Link/a); - 导航状态藏在组件 state 里,而可分享的 filter、tab、分页本质上需要 URL 状态。
正向规则是:普通导航用 Link;仅在 mutation 成功后的跳转、守卫式重定向、命令流(command flow)、表单提交副作用四类场景才使用 router API。这与 Dify 的 App Router 结构(web/app/ 下大量 (commonLayout)、(shareLayout) 路由组页面)相匹配:应用详情页、webapp 分享页等大量场景涉及深链与分享,把 tab/filter 塞进组件 state 会直接丢失可分享性,属于可验证的用户可见缺陷(P1 级别)。
小结:把六章规则压缩为评审检查表
| 章节 | 一句话核心 | 典型 Dify 证据锚点 |
|---|---|---|
| Ownership | 数据与行为放在真正使用它的最低组件;查询重复不算提升理由 | web/features/** 中 useQuery 散布于叶子组件 |
| Boundaries | 300 行只是触发器,可拆分职责混合才是缺陷;拒绝浅层 wrapper | web/AGENTS.md 禁止 Web 层 wrapper 隐藏 Dify UI 契约 |
| Bad Patterns | 重构必须保留交互契约;警惕 boolean props 假泛化与双事实来源 | 导航/侧边栏/webapp 列表等既有交互组件 |
| Props & Types | 按领域角色命名;不为风格统一重写导出;语法形式不构成发现 | 生成客户端 @/service/client 的字段可追溯性 |
| Effects | 默认不存在;合法者必须同步具名外部系统 | 数据获取集中于生成的 Query 层,effect fetch 无合法依据 |
| State Modeling | 生命周期先于存储机制;协调器不代管挂载内容私有状态;优先渲染期派生 | web/context/ 页面级 provider 的状态归属 |
| Navigation | 普通导航用 Link;可分享状态进 URL |
App Router 深链页面结构 |
需要强调的是该规则包的使用前提与边界:它服务于显式的评审/审计请求,作用于 web/ 与 packages/dify-ui/ 范围;发现必须绑定到文件行号、失败契约或复现路径,严重度遵循 P0–P3 分级;若评审无任何发现,输出就是 "No issues found." 加上实质性验证缺口说明——不添加赞扬段落、不猜测风险。换言之,这份文档的价值不在于给出更多规则,而在于精确界定了什么算问题、什么只是品味,让组件架构评审在人与 Agent 之间具备可复现的判定基线。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00