Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

组织组件

前面几节已经说明了单个组件怎么写,这一节继续往前走一步:在 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/detail
  • system/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.applicationfeatures.consolefeatures.cache 算出 accountId
  • 然后在渲染层里挂:
<AccountDetail
    oakId={accountId}
    oakPath={`${oakFullpath}.account`}
/>

这种组织方式特别适合:

  • 页面只是业务控制器;
  • 页面要复用 oak-general-businessoak-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 共享当前路径;
  • ApplicationListapplication$system 关联路径;
  • DomainListdomain$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
  • ApplicationListDomainList 分别走 application$systemdomain$system
  • PassportOAuthManagement 这类 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 里确实对 oakPathoakId 后到做了兼容处理,但从源码行为和项目经验看,更推荐的组织方式仍然是条件渲染

也就是说,像下面这样:

{!!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-busitaicang 这类项目里,经常会看到这样的结构:

  • 页面自己是 Virtual;
  • 页面内部组合 oak-general-businessoak-pay-business 或项目私有组件;
  • 页面先根据业务模式判断,再决定挂哪些 Entity 子组件。

这类页面的价值不是自己直接取一条数据,而是:

  • 组织页面壳;
  • 组织标题、筛选、Tab、弹窗;
  • 统一处理跳转、环境判断、权限判断;
  • 决定子组件各自应该挂在哪条路径上。

如果你发现一个页面“业务编排很多,但单个实体逻辑不集中”,通常就很适合做成 Virtual 控制页。

8.2 后台页面的推荐装配顺序

如果你在项目里要拼一个典型后台页,可以优先按下面这个顺序来组织:

  1. PageHeader / PageHeader2 作为页面最外层壳;
  2. FilterPanelSearch 这类筛选组件挂到当前 list 路径上;
  3. ListProList 负责主体列表;
  4. 行内新增、编辑通过 Modal + Upsert 完成;
  5. 详情页或复杂配置页再拆成 panel + detail + upsert + list 的组合。

taicang/src/pages/console/order/list/web.pc.tsx 这种页面,基本就是这个思路:

  • 页头壳负责标题和内容容器;
  • FilterPanelListPro 共享同一条 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(...)

它们的思路基本一致:

  1. 公共业务包先定义一个注册表;
  2. 项目侧在初始化阶段注册自己的渠道组件或物流设置组件;
  3. 公共页面在渲染时遍历注册表;
  4. 再把这些组件挂到 ${oakFullpath}.${entity}$system 这样的关系路径下。

这种模式特别适合:

  • 公共业务包知道“这里应该出现一类组件”,但不知道项目最终会接哪几个具体实现;
  • 各项目会接入不同的支付渠道、物流实体、配置实体;
  • 希望公共页面结构稳定,但把具体扩展点开放给项目层。

组织时要注意两点:

  • 注册进来的组件最好仍然遵守当前页面的数据树规则,优先使用公共页面传下来的 oakPathsystemId 等上下文;
  • 如果注册组件实际上对应某个真实关系,路径也应继续沿关系命名,而不是重新发明一套和页面脱节的绝对路径。

项目里通常会在初始化代码或业务入口处完成注册,思路大致像这样:

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.tsregistry.frontend.ts 里,还能看到另一层更完整的组织方式:按运行端把可注册能力集中导出。

例如这里统一导出了:

  • registerPayChannelComponent
  • registerFrontendPayRoutine
  • registerShipSettingComponent
  • registerSysAccountCardTopComponent
  • registerSysAccountDetailComponent

这种做法的价值在于:

  • 项目侧只需要记住一个整合入口;
  • 公共业务包可以把“哪些位置允许扩展”集中暴露出来;
  • 初始化代码更清楚,不用到处找具体组件内部的注册函数。

如果你自己的公共包也有很多可插拔组件、流程或页面片段,推荐按运行端把注册函数统一汇总到:

  • registry.backend.ts
  • registry.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.tsregistry.frontend.ts 怎么分工

oak-pay-business 里同时存在:

  • registry.backend.ts
  • registry.frontend.ts

它们按运行端严格分工:

  • registry.backend.ts 只导出 registerPayClazz,用于后端渠道实现注册;
  • registry.frontend.ts 导出配置组件、前端支付流程、物流设置和系统资金展示注册函数。

不要从前端入口导入后端渠道类,也不要让后端入口聚合 React 组件。需要增加新的注册能力时,先判断它属于哪个运行端,再放入对应入口。

从组织角度看,这样分层的价值是:

  • 项目初始化代码更清楚;
  • 前后端边界更清楚;
  • 注册能力不会散落在各个组件内部,被项目层到处直接引用。

9. 目录层面的推荐拆分

前面这些讨论主要解决的是“运行时怎么组织组件树”。但在真实项目里,组件还涉及一个非常实际的问题:目录怎么拆,页面和组件怎么分层。

结合 oak-general-businessoak-pay-businesshaina-busitaicang 的写法,可以优先按下面这套方式拆:

  • 路由入口页放在 src/pages/...,它负责页面级 path、页面级 zombie、环境判断、标题和路由参数处理。
  • 可复用的实体业务块放在 src/components/...,例如 detaillistupsertpanelmodaltab
  • 同一实体相关的组件尽量就近放在同一棵目录下,不要把 detaillistupsert 散落到完全不同的业务目录里。
  • 如果某个页面主要是在组合 oak-general-businessoak-pay-business 和项目私有组件,它通常更适合做成 Virtual 页面壳,而不是再塞一堆实体逻辑进去。
  • 如果某个子块根本不需要 entityoakPathlifetimeslisteners,那它就继续做普通 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/.../detaillistupsertpanel 这类目录,通常仍然是 Oak 组件目录;
  • components/.../purecomponents/common/.../*.tsx 这类目录,更适合放纯展示组件或平台组件;
  • AbstractComponents.tsregistry.frontend.tsregistry.backend.ts 这类文件,则更像业务包的基础设施层,不直接承载某个实体页面,而是服务整个组件体系。

再补一个在真实项目里非常常见、但容易忽略的层次:复杂业务组件目录本身也可以继续包含子组件树。

像:

  • oak-general-business/src/components/wechatMenu
  • oak-general-business/src/components/oauth/management

都属于“一个大业务组件目录,下面继续挂多个局部子目录”的结构。它说明组件组织不一定只有两层:

  • pages
  • components

很多时候还会出现第三层:

  • components/某业务根组件/局部子组件/...

这类目录适合:

  • 同一业务块下有多个 tab、选择器、预览块、编辑块;
  • 这些子块共享同一业务上下文,但各自又值得独立维护;
  • 外层根组件更像一个局部 panel / controller。

10. 一个实用的组织原则

如果你不确定一个页面该怎么拆,可以直接按下面的顺序思考:

  1. 先找出页面主对象是谁;
  2. 再决定页面根 panel 是否应该先把主对象取出来;
  3. 判断每个子块是在处理同一条对象,还是在处理关联对象;
  4. 同一条对象就优先共享路径;
  5. 关联对象就优先沿对象关系写相对路径;
  6. 只有在确实不想级联时,才考虑绝对路径。

按这个顺序来拆,绝大多数 Oak 页面都会比较清晰,也更容易维护。