组织组件
前面几节已经说明了单个组件怎么写,这一节继续往前走一步:在 Oak 中,页面通常不是靠一个超大组件完成,而是靠多个组件围绕同一棵数据树协同工作。
因此,“怎么组织组件”在 Oak 里不是纯前端工程问题,而是和对象关系、查询路径、提交范围直接相关。
1. 先理解组件树
Oak 会把页面上的组件映射到 runningTree 上的结点。你可以把它理解成:
- 每个 Page 对应一棵树;
- 页面内每个 Oak 组件都是树上的一个结点;
- 这些结点之间的关系,由
oakPath决定。
根页面通常有自己的页面级根路径;子组件则通过:
oakPath={`${oakFullpath}.${relative}`}
挂到父组件的某个子路径下。
这里有两个运行时名称需要分清:
| 名称 | 含义 |
|---|---|
oakPath | 组件被挂载时传入的路径 |
oakFullpath | 框架最终确定的完整路径 |
一般来说:
- 父组件负责把
oakPath传给子组件; - 子组件自己拿
oakFullpath继续往下挂孙组件。
2. 路径不是随便写的
oakPath 当然可以写任意字符串,但在 Oak 项目里,最推荐的不是“随便起一个唯一名字”,而是让路径尽量对应对象关系。
2.1 共享路径
如果父子组件其实处理的是同一行对象,最常见的做法是共享同一条路径:
oakPath={oakFullpath}
典型场景:
- 详情组件和编辑组件操作的是同一条
System; - 一个大表单被拆成多个编辑块;
- 一个 panel 下面有多个子块都在编辑同一条对象数据。
例如在 oak-general-business 中:
system/detailsystem/upsert
就会通过共享路径的方式协同工作。详情组件负责打开弹窗和执行提交,编辑组件负责把改动写到同一个结点上。
2.2 关联路径
如果子组件处理的是父对象关联出去的另一个对象,最推荐的做法是沿着真实对象关系写路径。
例如 System 下挂 Application 列表:
<ApplicationList
oakPath={`${oakFullpath}.application$system`}
systemId={id}
/>
这里的 application$system 就不是随便命名,而是 Oak 编译出来的真实关系路径。
这类写法的好处是:
- 路径本身就表达了对象关系;
- 查询和级联更新更容易推导;
- 页面拆分更自然;
- 后续别人读代码时,一眼就知道这个子组件的数据是从哪里来的。
2.3 列表行路径
如果父组件是 list,子组件处理的是列表中的某一行,通常会把该行 id 拼到当前路径下面:
oakPath={`${oakFullpath}.${item.id}`}
这类路径最常用于:
- 列表项详情块;
- 列表行内编辑弹窗;
- 列表行动作面板;
- 列表项对应的子组件。
3. 三种最常见的父子组件关系
如果父组件和子组件都是 Entity 组件,最常见的是下面三类关系。
3.1 单行父组件 + 单行子组件
这是“当前对象详情里再展示一个父对象或同对象编辑块”的场景。
常见例子:
Application详情里展示它所属的System;System详情里打开一个共享路径的SystemUpsert;- 一个对象的配置块、样式块、基本信息块都拆成单独组件。
这种场景要先判断子组件到底属于哪一种:
- 如果是同一条对象:共享路径;
- 如果是父对象/关联对象:走关系路径,例如
.system。
3.2 单行父组件 + 列表子组件
这是最常见的“一对多管理页”模式。
例如:
System下挂Application列表;Application下挂某种模板、菜单、标签列表;- 订单详情页下挂退款记录、物流记录、支付记录。
这时父组件一般负责:
- 当前对象主键;
- 子列表入口位置;
- 某些共用上下文。
子列表组件则负责:
- 列表 projection;
- 列表交互;
- 新建/删除/分页/筛选等具体行为。
3.3 列表父组件 + 单行子组件
这是列表行内再挂一个详情或编辑组件的模式。最常见的路径形式就是:
oakPath={`${oakFullpath}.${row.id}`}
这一类最需要注意性能问题:如果子组件是无条件批量渲染的,父列表的 projection 最好能覆盖子组件需要的数据。
否则就会出现:
- 父组件先查列表;
- 每个子组件再补一次自己的字段;
- 最终形成一屏几十个附加请求。
所以对这类页面,一个很重要的优化习惯是:先把“行内子组件需要哪些字段”想清楚,再回头补父列表的 projection。
3.4 Virtual 父组件 + Entity 子组件
前面三类都默认父子双方本身就是 Entity 组件。但在真实项目里,还有一类非常常见的组织方式:
- 父组件本身不绑定任何实体;
- 父组件只负责根据当前模式、feature、cache 推导上下文;
- 真正的 Entity 组件挂在它的某个子路径下。
taicang/src/pages/console/account/detail 就很典型:
- 页面自己不声明
entity; - 先通过
features.application、features.console、features.cache算出accountId; - 然后在渲染层里挂:
<AccountDetail
oakId={accountId}
oakPath={`${oakFullpath}.account`}
/>
这种组织方式特别适合:
- 页面只是业务控制器;
- 页面要复用
oak-general-business、oak-pay-business里的现成组件; - 页面要根据当前用户、当前应用、当前控制台模式,决定真正展示哪个实体。
可以把它理解成:Virtual 父组件负责“判上下文”,Entity 子组件负责“跑 Oak 数据树”。
4. panel 组件怎么组织
Oak 项目里很多复杂页面,最终都会演化成一个 panel 容器组件。
4.1 panel 的职责
一个好的 panel 通常负责:
- 取当前对象的核心数据;
- 提供 tab、步骤条、弹窗、左右布局等页面骨架;
- 决定子组件是共享路径、关系路径还是绝对路径;
- 决定哪些能力要在当前页统一
execute/clean。
4.2 真实例子:SystemPanel
oak-general-business/src/components/system/panel 的写法很典型:
- panel 自己先取
system的核心字段; SystemDetail共享当前路径;ApplicationList走application$system关联路径;DomainList走domain$system;- 部分 tab 使用独立绝对路径,因为它们不希望和当前结点绑定得太死。
4.3 真实例子:ApplicationPanel
oak-general-business/src/components/application/panel 也用了相同模式:
ApplicationDetail共享当前路径;- 配置、样式、COS 等能力挂在各自 tab 中;
- 微信公众号/小程序专属能力按
type条件拼接不同 tab; - 某些 tab 使用独立绝对路径,避免与当前主对象结点混在一起。
这说明 panel 的价值不只是“做个 Tabs”,而是把对象页面的结构和数据树结构一并组织起来。
5. 共享路径什么时候最好用
共享路径适合下面几类场景:
- 多个子组件编辑同一条对象;
- 一个详情组件打开同对象的编辑弹窗;
- 父组件本身只是 page wrapper,真正逻辑写在子组件里;
- 你要复用
oak-frontend-base的抽象detail/upsert组件。
但要注意一条真实规则:先创建这条路径结点的组件,决定了这个结点的基础取数方式。 后续共享同一路径的组件,应尽量和它对齐。
更直接地说:
- 如果第一个组件负责取数,后面的共享路径组件通常就不应再假设自己有完全独立的一套 projection;
- 如果后面的组件确实需要更多字段,最好回到“第一个组件”那里统一补 projection。
5.1 共享路径并不等于所有逻辑都写在一个组件里
很多新手会把“共享路径”误解成“那就做一个超大组件”。实际项目里更推荐的反而是:
- 父组件负责稳定 projection;
- 一个子组件负责展示;
- 一个子组件负责编辑;
- 必要时再有一个子组件负责动作按钮或局部配置。
也就是说,共享路径更多是在共享对象上下文,而不是要求共享组件职责。
6. 绝对路径什么时候用
有时子组件和父组件并没有直接对象关系,或者你明确不希望它参与父组件的级联刷新 / 级联提交,这时可以给它一条独立的绝对路径,例如:
oakPath={`$system-passport-${id}`}
或者:
oakPath={`#application-panel-cos-${id}`}
这种写法在公共业务组件里是实际存在的。
绝对路径适合:
- 与当前对象没有直接级联关系的工具块;
- 某个 tab 需要独立管理自己的结点状态;
- 不希望父组件的
execute()把它也一起提交; - 页面里存在多个相似子组件,但它们应彼此独立。
一旦使用绝对路径,就要清楚它意味着:
- 它不再是父结点的真正子树;
- 父组件的刷新、提交、清理,不会天然覆盖它;
- 你需要自己决定它的刷新与提交入口。
6.1 Tab 里的独立结点怎么选
很多复杂 panel 都是按 Tab 组织的,这时最容易犯的错,就是把所有 tab 都硬塞进同一条共享路径里。
更稳妥的判断方法是:
- tab 如果展示或编辑的是当前主对象本身,就共享路径;
- tab 如果展示的是主对象的真实子关系,就走关系路径;
- tab 如果只是挂一个相对独立的工具块、配置块、管理块,就给它独立绝对路径。
oak-general-business 里的两个 panel 都很典型:
system/panel中,SystemDetail共享oakFullpath;ApplicationList、DomainList分别走application$system、domain$system;Passport、OAuthManagement这类 tab 则直接使用$system-passport-${id}、$system-oauth-${id}这样的绝对路径。
application/panel 也是同样的组织方式:
ApplicationDetail共享当前路径;Cos这类配置块使用#application-panel-cos-${id};- 微信菜单、自动回复、标签、模板等 tab,则各自持有独立的
$application-panel-xxx-${id}路径。
所以,Tab 组织的关键不是“看起来都在一个页面里”,而是看它们是否应该共享同一个 runningTree 结点。
6.2 Tab 卸载与 runningTree 清理
Tabs 的卸载选项和 Oak 结点清理是两件事:Tabs 或条件渲染决定 React 子树是否卸载;当前 Oak React 运行时会在组件卸载时自动调用 runningTree 的结点销毁逻辑。oakAutoUnmount 已废弃,新代码不要再传。需要切换 Tab 时保留还是销毁 UI,应使用当前组件库提供的卸载选项,并让每个 Tab 使用稳定、清晰的 oakPath。
6.3 条件渲染通常比“晚一点再补路径”更稳妥
oak-frontend-base/src/page.react.tsx 里确实对 oakPath、oakId 后到做了兼容处理,但从源码行为和项目经验看,更推荐的组织方式仍然是条件渲染。
也就是说,像下面这样:
{!!accountId && (
<AccountDetail
oakId={accountId}
oakPath={`${oakFullpath}.account`}
/>
)}
通常比“先挂组件,等数据回来后再把 oakId / oakPath 补进去”更稳妥。原因是:
- 组件首次挂载时路径和主键就稳定;
- 不容易出现 create / update 语义错位;
- 子组件生命周期更清晰;
- 也更符合 Oak 数据树“结点先定,再刷新”的节奏。
7. 相同关系下渲染多个子组件怎么办
有时你会遇到这样的需求:
- 都是
application$system这条关系; - 但你想拆成两个不同列表,例如“Web 应用”和“小程序应用”。
这时它们虽然都来自同一条对象关系,但又不能简单共享结点,因为:
- 过滤条件不同;
- 渲染目的不同;
- 交互状态也不同。
这类场景可以在相对路径后面追加区分后缀,例如:
`${oakFullpath}.application$system:1`
和:
`${oakFullpath}.application$system:2`
这样它们仍然语义上挂在同一条关系下,但在组件树中是两个不同结点。
8. Virtual 组件适合放在哪里
Virtual 组件最适合拿来做:
- 页面壳;
- tab 容器;
- dashboard;
- 多实体混排容器;
- 只组织子组件,不直接关联 Entity 的 wrapper。
它们虽然没有 entity,但依然可以:
- 使用
oakPath; - 承载子组件;
- 调用
refresh()、execute()覆盖子树; - 使用公共方法和生命周期。
所以不要把 Virtual 组件理解成“没有 Oak 能力的普通 React 组件”。它更像是“不绑定具体对象的数据树容器”。
8.1 Virtual 组件很适合做跨业务包的拼装层
在 haina-busi、taicang 这类项目里,经常会看到这样的结构:
- 页面自己是 Virtual;
- 页面内部组合
oak-general-business、oak-pay-business或项目私有组件; - 页面先根据业务模式判断,再决定挂哪些 Entity 子组件。
这类页面的价值不是自己直接取一条数据,而是:
- 组织页面壳;
- 组织标题、筛选、Tab、弹窗;
- 统一处理跳转、环境判断、权限判断;
- 决定子组件各自应该挂在哪条路径上。
如果你发现一个页面“业务编排很多,但单个实体逻辑不集中”,通常就很适合做成 Virtual 控制页。
8.2 后台页面的推荐装配顺序
如果你在项目里要拼一个典型后台页,可以优先按下面这个顺序来组织:
PageHeader/PageHeader2作为页面最外层壳;FilterPanel、Search这类筛选组件挂到当前 list 路径上;ListPro或List负责主体列表;- 行内新增、编辑通过 Modal +
Upsert完成; - 详情页或复杂配置页再拆成
panel + detail + upsert + list的组合。
像 taicang/src/pages/console/order/list/web.pc.tsx 这种页面,基本就是这个思路:
- 页头壳负责标题和内容容器;
FilterPanel和ListPro共享同一条oakFullpath;- 行动作里再决定跳详情、开弹窗还是更新某一行。
这也是 Oak 后台页面里最稳定、最容易维护的一种组织方式。
8.3 stale 子组件在组织层面怎么理解
从组织角度看,stale 组件通常表示:
- 这个结点虽然存在;
- 但它不负责在挂载瞬间主动刷新;
- 真实刷新时机由外层页面、feature 回调或其它显式操作决定。
像 haina-busi/src/pages/business/machine/list/index.ts 这类页面里,就会把某些列表片段定义成 stale: true。这种设计适合:
- 大页面中嵌套多个列表片段;
- 希望把刷新责任集中在外层;
- 当前子组件更多承担展示和局部交互,而不是数据入口。
但如果你还不确定页面的数据刷新链路,优先不要急着上 stale。普通路径组织先写清楚,往往更安全。
8.4 公共业务包里的“注册槽位”怎么组织
除了直接把子组件写死在页面里,Oak 公共业务包里还有一种很值得借鉴的组织方式:预留注册槽位,让项目侧把自己的组件挂进来。
oak-pay-business 里有两个很典型的例子:
payConfig/system/web.pc.tsx暴露了registerPayChannelComponent(...);ship/system/web.pc.tsx暴露了registerShipSettingComponent(...)。
它们的思路基本一致:
- 公共业务包先定义一个注册表;
- 项目侧在初始化阶段注册自己的渠道组件或物流设置组件;
- 公共页面在渲染时遍历注册表;
- 再把这些组件挂到
${oakFullpath}.${entity}$system这样的关系路径下。
这种模式特别适合:
- 公共业务包知道“这里应该出现一类组件”,但不知道项目最终会接哪几个具体实现;
- 各项目会接入不同的支付渠道、物流实体、配置实体;
- 希望公共页面结构稳定,但把具体扩展点开放给项目层。
组织时要注意两点:
- 注册进来的组件最好仍然遵守当前页面的数据树规则,优先使用公共页面传下来的
oakPath、systemId等上下文; - 如果注册组件实际上对应某个真实关系,路径也应继续沿关系命名,而不是重新发明一套和页面脱节的绝对路径。
项目里通常会在初始化代码或业务入口处完成注册,思路大致像这样:
import { registerShipSettingComponent } from '@oak-pay-business/registry.frontend';
import WechatMpShipSetting from '@oak-pay-business/components/ship/wechatMpShip';
registerShipSettingComponent('wechatMpShip', WechatMpShipSetting);
然后公共页面继续负责组织路径:
<Comp
systemId={oakId}
oakPath={`${oakFullpath}.${entity}$system`}
/>
这样项目侧只决定“接入哪个组件”,而公共页面仍然掌握“组件应该挂到哪条 Oak 路径上”。
8.5 registry 适合作为项目整合入口
在 oak-pay-business/src/registry.backend.ts、registry.frontend.ts 里,还能看到另一层更完整的组织方式:按运行端把可注册能力集中导出。
例如这里统一导出了:
registerPayChannelComponentregisterFrontendPayRoutineregisterShipSettingComponentregisterSysAccountCardTopComponentregisterSysAccountDetailComponent
这种做法的价值在于:
- 项目侧只需要记住一个整合入口;
- 公共业务包可以把“哪些位置允许扩展”集中暴露出来;
- 初始化代码更清楚,不用到处找具体组件内部的注册函数。
如果你自己的公共包也有很多可插拔组件、流程或页面片段,推荐按运行端把注册函数统一汇总到:
registry.backend.tsregistry.frontend.ts
再由项目侧在初始化阶段统一接入。
8.6 注册槽位不只用来挂页面,还能注入流程和局部渲染
继续看 oak-pay-business 会发现,注册式组织不只用于“在某个 tab 下挂一个组件”。
至少还有两类很典型的扩展点:
第一类,前端支付流程注入。
components/pay/detail/index.ts 暴露了 registerFrontendPayRoutine(...),项目侧可以按支付实体注册:
- 如何补充
pay页面额外需要的projection - 如何判断当前前端是否能发起支付
- 真正的前端拉起支付流程怎么执行
这类扩展点说明:有些公共页面的主体结构是稳定的,但核心业务流程会按项目实体而变化。这时就不该把逻辑写死在一个页面里,而应让项目侧按实体注册进去。
第二类,局部卡片/详情渲染注入。
components/sysAccount/survey/web.pc.tsx 暴露了:
registerSysAccountCardTopComponent(...)registerSysAccountDetailComponent(...)
它不是把一个整页替换掉,而是把“卡片顶部如何画”“详情弹窗如何画”这类局部渲染开放给项目层。
所以可以把注册槽位再细分成三种:
- 页面级槽位:给 panel / tab / setting 页面挂完整子组件;
- 流程级槽位:给支付、确认、跳转等前端流程注入逻辑;
- 局部渲染槽位:给卡片、详情、头部、局部块注入 UI。
这样在设计公共业务组件时,就不必只想着“要么全写死,要么全开放”。更常见也更稳妥的做法,是只把真正需要项目差异化的那一层开放出来。
8.7 registry.backend.ts 和 registry.frontend.ts 怎么分工
oak-pay-business 里同时存在:
registry.backend.tsregistry.frontend.ts
它们按运行端严格分工:
registry.backend.ts只导出registerPayClazz,用于后端渠道实现注册;registry.frontend.ts导出配置组件、前端支付流程、物流设置和系统资金展示注册函数。
不要从前端入口导入后端渠道类,也不要让后端入口聚合 React 组件。需要增加新的注册能力时,先判断它属于哪个运行端,再放入对应入口。
从组织角度看,这样分层的价值是:
- 项目初始化代码更清楚;
- 前后端边界更清楚;
- 注册能力不会散落在各个组件内部,被项目层到处直接引用。
9. 目录层面的推荐拆分
前面这些讨论主要解决的是“运行时怎么组织组件树”。但在真实项目里,组件还涉及一个非常实际的问题:目录怎么拆,页面和组件怎么分层。
结合 oak-general-business、oak-pay-business、haina-busi、taicang 的写法,可以优先按下面这套方式拆:
- 路由入口页放在
src/pages/...,它负责页面级path、页面级zombie、环境判断、标题和路由参数处理。 - 可复用的实体业务块放在
src/components/...,例如detail、list、upsert、panel、modal、tab。 - 同一实体相关的组件尽量就近放在同一棵目录下,不要把
detail、list、upsert散落到完全不同的业务目录里。 - 如果某个页面主要是在组合
oak-general-business、oak-pay-business和项目私有组件,它通常更适合做成 Virtual 页面壳,而不是再塞一堆实体逻辑进去。 - 如果某个子块根本不需要
entity、oakPath、lifetimes、listeners,那它就继续做普通 React 展示组件,不要强行 Oak 化。
最常见的目录分层,大致可以理解成下面这样:
src/pages/console/account/detail/ # 路由入口 / 控制页
src/components/account/detail/ # 单行详情
src/components/account/list/ # 列表
src/components/account/upsert/ # 单行编辑
src/components/account/panel/ # 详情页容器
src/components/spBid/modal/ # 强交互弹窗片段
这种拆法的好处是:
- 路由层和实体层职责清楚;
- 一个实体相关的 detail / list / upsert / panel 很容易互相复用;
- 业务包组件和项目私有组件更容易组合;
- 页面在后期演化成 panel、tab、多片段结构时,不需要推翻重写目录。
再结合前面的路径组织规则,可以把职责简单记成:
pages负责“页面从哪里进来、当前业务上下文是什么”;components负责“这个 Oak 结点怎么查、怎么改、怎么展示”;- 纯展示子组件负责“某块 UI 怎么画”,但不直接承担数据树职责。
这里还可以再往下细分一层:
components/.../detail、list、upsert、panel这类目录,通常仍然是 Oak 组件目录;components/.../pure、components/common/.../*.tsx这类目录,更适合放纯展示组件或平台组件;AbstractComponents.ts、registry.frontend.ts或registry.backend.ts这类文件,则更像业务包的基础设施层,不直接承载某个实体页面,而是服务整个组件体系。
再补一个在真实项目里非常常见、但容易忽略的层次:复杂业务组件目录本身也可以继续包含子组件树。
像:
oak-general-business/src/components/wechatMenuoak-general-business/src/components/oauth/management
都属于“一个大业务组件目录,下面继续挂多个局部子目录”的结构。它说明组件组织不一定只有两层:
pagescomponents
很多时候还会出现第三层:
components/某业务根组件/局部子组件/...
这类目录适合:
- 同一业务块下有多个 tab、选择器、预览块、编辑块;
- 这些子块共享同一业务上下文,但各自又值得独立维护;
- 外层根组件更像一个局部 panel / controller。
10. 一个实用的组织原则
如果你不确定一个页面该怎么拆,可以直接按下面的顺序思考:
- 先找出页面主对象是谁;
- 再决定页面根 panel 是否应该先把主对象取出来;
- 判断每个子块是在处理同一条对象,还是在处理关联对象;
- 同一条对象就优先共享路径;
- 关联对象就优先沿对象关系写相对路径;
- 只有在确实不想级联时,才考虑绝对路径。
按这个顺序来拆,绝大多数 Oak 页面都会比较清晰,也更容易维护。