定义组件
Oak 前端组件的入口是 OakComponent(...)。它的真实类型定义在 oak-frontend-base/src/types/Page.ts 中,运行时主要由 oak-frontend-base/src/page.react.tsx、page.mp.ts、page.common.ts 和 features/runningTree.ts 驱动。
如果只从使用层面记忆,Oak 组件可以理解成四件事的组合:
- 定义这个组件在页面数据树中的结点;
- 定义这个结点如何取数、改数、分页和校验动作;
- 把取到的数据整理成渲染层更容易消费的形态;
- 把运行时方法和状态注入到组件中。
因此,写 Oak 组件时,最重要的不是先写 TSX,而是先把 OakComponent 的定义写清楚。
先看一个最小骨架
如果只看 oak-frontend-base/src/types/Page.ts,OakComponent(...) 最常见的骨架大致就是这样:
export default OakComponent({
entity: 'xxx',
isList: false,
projection: {
id: 1,
},
properties: {
someId: '',
},
data: {
open: false,
},
formData({ data, props, features, dirty, modified }) {
return {
row: data,
};
},
features: ['token'],
lifetimes: {
ready() {},
},
listeners: {
someId(prev, next) {},
},
methods: {
doSomething() {},
},
});
真正写业务时,不一定每个字段都要写,但你可以把它理解成六层:
- 结点定义:
entity、isList、path、stale、zombie; - 查询定义:
projection、filters、sorters、pagination、getTotal; - 入参定义:
properties; - 组件内部状态:
data; - 运行时整形:
formData; - 联动与行为:
features、lifetimes、listeners、methods。
先建立一个运行模型
一个典型的 Entity 组件,运行顺序大致是这样的:
- 页面或父组件传入
oakPath,单行组件通常还会传入oakId; - 框架根据
entity、projection、filters、sorters、pagination等配置,在 runningTree 中创建结点; - 结点刷新后拿到对象数据;
- 框架调用
formData(...); formData返回的数据和组件自己的data、外部props一起进入渲染层;- 在渲染层中,通过
props.data和props.methods使用这些数据与方法。
所以 Oak 组件真正的“输入”通常不是一个,而是这三类:
- 页面或父组件传入的参数,如
oakPath、oakId、systemId; OakComponent配置项中声明的查询/行为定义;- 框架在运行时注入的状态和方法。
组件文件通常怎么落地
在 Oak 项目里,OakComponent(...) 的定义通常只放在组件目录下的 index.ts。而真正的渲染文件、样式文件、locale 文件,会和它并排放在同一个目录里。
在 taicang、haina-busi、oak-general-business 里,最常见的目录形态大致是这样:
detail/
├── index.ts
├── index.config.ts
├── web.pc.tsx
├── web.tsx
├── index.xml
├── web.pc.module.less
├── web.module.less
└── locales/
└── zh-CN.json
可以把这几个文件的职责记成下面这样:
index.ts:唯一的 Oak 逻辑入口,负责OakComponent(...)定义、projection、formData、listeners、methods。index.config.ts:页面/组件的结构化配置。页面通常放route、menu、mp,组件通常放mp,用于替代新代码中的小程序index.json。web.pc.tsx/web.tsx:各端渲染层,只消费props.data和props.methods,尽量不要把数据树逻辑再塞回渲染文件。index.xml/index.less:小程序端模板和样式;小程序组件声明、usingComponents等配置优先写在index.config.ts的mp字段中。*.module.less/*.less:平台样式文件。locales/*.json:当前组件自己的文案。
真实项目里常见三种落地方式:
- 只有 PC 端的业务组件:例如
oak-general-business/src/components/system/panel、haina-busi/src/components/business/daemon/config,通常只有index.ts + web.pc.tsx + less + locales。 - 同时覆盖 web / 小程序的页面或复杂组件:例如
taicang/src/pages/console/account/detail、taicang/src/components/spBid/modal、taicang/src/pages/frontend/spAuctionCollection/detail,通常会同时存在web.pc.tsx、web.tsx、index.xml、index.config.ts。旧项目里也可能还保留index.json。 - 纯 Oak 逻辑 + 薄渲染层:最推荐的方式是把查询、监听、订阅、提交、权限判断都放在
index.ts,让各端渲染文件只负责布局和交互。
也就是说,定义 Oak 组件时最重要的分层不是“先写页面再补逻辑”,而是:
- 先在
index.ts把数据树结点和行为定义清楚; - 再让
web.pc.tsx/web.tsx/index.xml去消费这些定义好的数据和方法; - 不要把
projection、订阅、refresh触发条件分散到多个渲染文件里。
大型组件目录本身也可以是一棵局部组件树
Oak 项目里还有一种非常常见的情况:一个“业务组件”本身并不是一个单目录单文件,而是一个根组件目录,下面继续挂很多局部子组件目录。
例如:
oak-general-business/src/components/wechatMenuoak-general-business/src/components/oauth/management
这两类目录都不是“只有一个 index.ts + web.pc.tsx 就结束”,而是会继续拆出:
menuconditionalMenutagListoauthProvideroauthAppsupsert
这类拆法适合:
- 一个业务块本身就有多个 tab / 面板 / 弹窗 / 选择器;
- 子块之间属于同一个业务域,拆太散反而不好维护;
- 你希望外层组件做总编排,内层子目录做局部 Oak 结点或局部展示逻辑。
从工程角度看,可以把它理解成:页面有一棵组件树,复杂业务组件目录内部也可以再有一棵局部组件树。
但即便这样拆,原则还是一样:
- 外层根组件负责整体上下文;
- 子目录组件负责局部路径、局部状态和局部展示;
- 不要因为目录层级变深,就把路径设计和职责分层搞乱。
同一个 index.ts 通常会复用到多个渲染端
在 Oak 项目里,一个组件目录下同时出现:
web.pc.tsxweb.tsxindex.xmlindex.config.ts
是很常见的。这通常不表示“这里有四套不同逻辑”,而是表示:同一套 Oak 逻辑,分别接到多个端的渲染壳上,并通过 index.config.ts 补充平台配置。
例如:
taicang/src/components/spBid/modaltaicang/src/pages/frontend/spAuctionCollection/detailtaicang/src/pages/console/account/detail
它们的共同特点是:
index.ts仍然只有一份,负责 Oak 逻辑;- web PC、web mobile、小程序模板各自只处理平台渲染;
- 多端之间共享同一份
projection、formData、listeners、methods。
这也是 Oak 组件很重要的一条工程纪律:
- 数据树逻辑尽量只写一份;
- 平台差异尽量收敛在渲染文件里;
- 不要把“某端专属的布局差异”升级成“某端专属的一套 Oak 逻辑”,除非业务真的不同。
复用组件逻辑并覆写本地 render
跨项目或跨平台开发中,还有一种更薄的组件目录:逻辑完全沿用现有 Oak 组件,本地只提供自己的 render。例如应用要复用公共业务包的查询、formData 和 methods,但 Web 页面布局要由当前应用定制。
本地 index.ts 应使用 CLI 能明确识别的直接转发形式:
import OakComponent from '@oak-general-business/components/example';
export default OakComponent;
然后在同目录编写本地 web.tsx、web.pc.tsx、render.desktop.tsx 或 Native render,props 参数仍然保持未标注:
export default function Render(props) {
const { title, disabled } = props.data;
return (
<button
disabled={disabled}
onClick={() => props.methods.submit()}
>
{title}
</button>
);
}
这里发生了两件事:
- 运行时复用原组件的 Oak 逻辑合同;
- CLI 和 Oak Assistant 为本地 render 继承原组件对应平台的 props 合同。
如果目标还是工作区源码,编译器会沿转发链找到真正的 OakComponent({...})。如果目标是已安装的发布包,编译器不会用组件 index.d.ts 猜内部 render 数据,因为该文件通常只描述父组件可传入的外部 props;它会读取原组件的平台 render 声明。
平台声明的选择顺序如下:
| 本地 render | 依赖声明查找顺序 |
|---|---|
web.tsx | web.d.ts |
web.pc.tsx | web.pc.d.ts -> web.d.ts |
web.mobile.tsx | web.mobile.d.ts -> web.d.ts |
render.ios.tsx | render.ios.d.ts -> render.native.d.ts |
render.android.tsx | render.android.d.ts -> render.native.d.ts |
render.desktop.tsx | render.desktop.d.ts -> web.pc.d.ts -> web.d.ts |
render.windows/macos/linux.tsx | 对应系统声明 -> render.desktop.d.ts -> web.pc.d.ts -> web.d.ts |
因此,可复用 Oak 业务包应在声明构建中启用 --emit-injection-types。这样生成的 render .d.ts 会保留编译器推导出的精确 props;当另一个项目再覆写 render 时,仍能继续继承,而不需要访问依赖包源码。
这项能力有意只识别下面的稳定语法:
import OakComponent from 'component-module';
export default OakComponent;
默认导入改名、先赋给另一个变量、动态包装或其他间接导出都不会被当成复用合同。识别失败时,编译器不会编造一个宽泛类型来掩盖问题。
最后要区分“透明覆写 render”和“创建新组件合同”:
- 只改变布局和平台交互时,可以直接复用;
- 需要新增
properties、改变查询、增加状态或方法时,应在本地写真正的OakComponent({...}); - 本地存在直接
OakComponent({...})定义时,本地合同优先于复用声明; - 不要手写宽泛
WebComponentProps来假装新增字段已经成为运行时 properties。
新建组件时的推荐起手顺序
如果你现在要从零开始写一个 Oak 组件,最稳妥的顺序通常不是先写页面样式,而是先把下面四个问题答清楚:
- 这个组件是 Virtual,还是 Entity?
- 如果是 Entity,它是单行还是列表?
- 它最终会挂在哪条
oakPath上? - 它的业务参数里,哪些是运行时参数,哪些是业务参数,哪些是 UI 参数?
在真实项目里,一个更实用的起手模板通常是:
- 先写
entity、isList、projection、filters、properties; - 再写
formData,先把渲染层真正需要的数据整理出来; - 然后补
methods和listeners; - 最后再写
web.pc.tsx/web.tsx/index.xml。
这样做的好处是:
- 你会先把结点和数据关系想清楚;
- 渲染层拿到的是已经整理好的字段,而不是一堆原始查询结果;
- 后面拆分组件时,更容易判断哪些逻辑该留在 Oak 层,哪些只属于展示层。
不是所有 Oak 生态组件都由 OakComponent(...) 定义
这一点对新手非常重要:在 Oak 项目里,大家常说“组件”,但它不一定都指 OakComponent(...) 生成的组件。
除了自己写的 Oak 组件之外,项目里还大量使用这几类抽象组件:
FilterPanelListListProDetailUpsert
它们通常来自:
@oak-frontend-base/components/...- 或公共业务包里的
AbstractComponents.ts
例如:
oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsxoak-pay-business/src/components/pay/list/web.pc.tsxoak-general-business/src/components/user/manage/web.pc.tsx
都会直接在渲染层中使用 FilterPanel、ListPro。
你可以这样理解它们的角色:
OakComponent(...)定义“数据树结点、查询、状态、行为”;FilterPanel/ListPro/Detail/Upsert定义“通用的展示与交互骨架”;- 业务页面则把两者拼起来。
所以在真实工程里,一个完整页面经常不是“全都写成 OakComponent”,而是:
- 先用
index.ts定义当前 Oak 结点; - 再在
web.pc.tsx/web.tsx里组合FilterPanel、ListPro、Detail、Upsert; - 最后把局部纯展示块继续拆成普通 React 组件。
这也是为什么文档里要把“定义组件”和“组织组件”分开讲。前者是在讲 Oak 结点怎么定义,后者是在讲这些 Oak 结点和抽象展示组件怎么拼成真正页面。
1. 先分清组件类型
Oak 中常见的前端组件可以先分成两大类:
1.1 Virtual 组件
如果没有声明 entity,这个组件就是 Virtual 组件。它仍然可以:
- 使用生命周期;
- 监听
features; - 使用
t、navigateTo、setMessage等公共方法; - 挂在某个
oakPath上作为纯容器组件。
但它不会自动取某个 Entity 的数据。
1.2 Entity 组件
声明了 entity 的组件,就是 Entity 组件。它又可以继续分成三种最常见形态:
| 类型 | 典型配置 | 适用场景 |
|---|---|---|
| 单行详情组件 | entity + isList: false | 展示一条对象数据 |
| 单行更新组件 | entity + isList: false | 编辑或创建一条对象数据 |
| 列表组件 | entity + isList: true | 展示并操作多条对象数据 |
严格来说,“详情”和“更新”在框架层都属于 isList: false 的单行组件,差别主要在你使用哪些方法:
- 只读展示时,通常只是取数和渲染;
- 更新组件会调用
update/create/remove; - 提交动作通常通过
execute统一完成。
2. OakComponent 常用配置项
最常用的配置项可以先记成下面这张表:
| 配置项 | 用途 | 备注 |
|---|---|---|
entity | 组件关联哪个 Entity | 不写就是 Virtual 组件 |
isList | 是否列表组件 | true 为列表,false 为单行 |
path | 页面级根路径 | 只应在顶层 Page 上声明 |
projection | 取哪些字段 | 语法和查询章节里的 Projection 完全一致 |
filters | 列表过滤条件 | 仅 list 组件有效 |
sorters | 列表排序条件 | 仅 list 组件有效 |
pagination | 分页设置 | 仅 list 组件有效 |
getTotal | 是否额外取总数 | 可按设备宽度差异配置 |
properties | 组件接受的外部参数 | 相当于 props 声明 |
data | 组件自身状态初值 | 相当于 state 初值 |
formData | 将 Oak 数据整理成渲染数据 | 最常用的配置项之一 |
actions | 需要判定合法性的动作 | 行权限结果会进入数据中 |
cascadeActions | 需要判定的级联子对象动作 | 结果会进入 #oakLegalCascadeActions |
cacheInsensativeActions | 某些动作的 checker 校验走 cache-insensitive 模式 | 少见但存在 |
append | 追加模式查询控制 | 仅在特定场景使用 |
features | 要监听哪些 feature | 可指定 reRender / refresh / callback |
stale | 标记为不主动刷新结点 | 常用于完全依赖父结点或外部控制的组件 |
zombie | 页面析构后是否保留结点状态 | 仅顶层 Page 配置项可直接声明 |
ns | i18n 命名空间补充 | 可写单个或多个 |
lifetimes | 生命周期方法 | 如 created、ready、mature |
listeners | 监听 props / state 变化 | 适合联动逻辑 |
methods | 组件自定义方法 | 会注入到 this 和 props.methods |
wechatMp | 小程序额外配置 | 如 externalClasses、组件 options |
下面只展开那些最容易写错的配置项。
2.1 entity、isList、path
entity 决定组件关联哪个对象。支持两种写法:
entity: 'system'
或者:
entity() {
return this.props.entityName as 'system';
}
isList 决定组件是列表结点还是单行结点:
isList: true
或:
isList: false
path 比较特殊。源码里明确限制了:只有页面级根组件才应该直接声明 path。 子组件不要在配置项中写 path,而应通过外部传入 oakPath。
可以这样理解:
- 顶层 page:用
path定义页面根结点; - 普通子组件:由父组件传入
oakPath; - 单行子组件:通常同时传
oakPath和oakId。
另外,运行时还有一个细节值得知道:在 page.common.ts 的 onPathSet(...) 里,如果这是页面根组件并且同时传了 oakId,框架会把它拼到根路径上,最终形成类似:
${path}-${oakId}
的页面根结点。
2.1.1 动态 entity 在项目里是存在的
entity 不一定是一个写死的字符串。框架类型定义允许你写成函数,而业务项目里也确实这样用。
例如 haina-busi/src/components/business/daemon/config/index.ts:
entity() {
return this.props.entity as 'system' | 'room';
}
这种写法适合:
- 同一套组件逻辑服务多个实体;
- 两个实体字段结构足够接近;
- 你想复用同一套渲染和编辑逻辑。
不过要注意,entity 虽然可以动态返回,但一个已经创建好的 runningTree 结点不会在生命周期中随意切换实体。如果你真的需要在不同实体之间切换,更稳妥的做法通常是让父组件条件渲染不同子组件,而不是让同一个已挂载结点反复变实体。
2.2 projection
projection 就是查询章节里的 Projection,本质上不是“组件专用语法”,而是 Oak 查询语法本身。
它支持:
- 查询当前对象字段;
- 级联查询父对象字段;
- 级联查询子对象数组;
$expr到$expr20表达式列;- 关系聚合字段。
例如:
projection: {
id: 1,
name: 1,
system: {
id: 1,
name: 1,
},
domain$system: {
$entity: 'domain',
data: {
id: 1,
url: 1,
},
},
}
这里还有一个非常重要的真实规则:同一条 runningTree 路径上的组件,应共享同一份投影结构。 如果多个组件复用同一个 oakPath,你要保证它们对数据的理解是一致的。
2.2.1 projection 也经常按用户态或 props 动态生成
框架允许把 projection 写成函数,业务项目里这也非常常见。
例如 taicang/src/pages/frontend/spAuctionCollection/detail/index.ts 就会根据当前是否登录,决定是否把:
userRelation$entityspAgent$auctionCollection- 与当前用户投标板相关的数据
一起查出来。
这种写法适合:
- 登录前后看到的字段结构不同;
- 某些字段只在特定模式下需要;
- 某些关联查询代价较高,希望按条件裁剪。
但要记住和上一条规则配套的结论:动态 projection 也要对当前路径上的其它共享组件负责。 如果某条路径上挂了多个子组件,不能一个组件想查一套,另一个组件又假设另一套。
2.3 filters、sorters
它们只对 list 组件有效,语法分别对应查询章节里的 Filter 和 Sorter。
真实结构不是单个对象,而是数组:
filters: [
{
filter() {
return {
systemId: this.props.systemId,
};
},
'#name': 'bySystem',
},
]
sorters: [
{
sorter: {
$attr: {
name: 1,
},
$direction: 'asc',
},
'#name': 'nameAsc',
},
]
这里的几个细节很值得记住:
filter、sorter都可以直接写对象,也可以写函数;'#name'可用于后续按名称替换、删除;filter还支持hot: true,表示前台取数时也持续参与判断。
后续你可以通过组件方法动态调整这些条件,比如:
addNamedFiltersetNamedFiltersremoveNamedFilterByNameaddNamedSorterremoveNamedSorterByName
2.4 pagination、getTotal
分页配置的真实结构是:
pagination: {
currentPage: 0,
pageSize: 20,
}
也可以按设备宽度分开配置:
pagination: [
{ deviceWidth: 'pc', currentPage: 0, pageSize: 20 },
{ deviceWidth: 'mobile', currentPage: 0, pageSize: 10 },
]
getTotal 不是布尔值,而是“最多精确统计多少条”:
getTotal: 500
或者:
getTotal: {
max: 500,
deviceWidth: 'pc',
}
源码里的真实行为还有两点:
- 如果你不显式配置
getTotal,宽屏默认会取100,窄屏默认不取; - runningTree 不会每次刷新都重复查总数,只有必要时才会重算。
2.5 properties、data、formData
properties 用于声明外部传入参数,相当于组件 props 的声明:
properties: {
systemId: '',
}
data 用于声明组件自身状态初值,相当于 state 初值:
data: {
keyword: '',
open: false,
}
它也可以写成函数:
data() {
return {
keyword: '',
};
}
例如 taicang/src/pages/console/news/list/index.ts 就保留了这种写法。源码中,data() 会在组件构造阶段以 this 为上下文执行一次。
这里还有一个非常值得建立的习惯:把组件参数按“运行时参数 / 业务参数 / 交互参数”分开理解。
- 运行时参数:
oakPath、oakId、oakZombie、oakStale、width。这些是 Oak 运行时已经内置的 props,不需要再在properties里重复声明。 - 业务参数:
systemId、applicationId、entity、entityId、tabKey、agentOnly这类业务输入,应明确写在properties里。 - 交互参数:
visible、disabled、onClose、showRecharge这类 UI 或回调型参数,也应该写在properties里,并给出稳定默认值。
例如 taicang/src/components/spBid/modal/index.ts 就很典型:
oakId不是它自己声明的业务参数,而是运行时给它的当前拍品主键;agentOnly、visible、disabled、isLive、onClose、showRecharge才是它自己真正关心的业务/UI 参数。
再比如 oak-general-business/src/components/system/panel/web.pc.tsx 中的 SystemDetail:
oakId、oakPath决定它挂在哪个 Oak 结点上;- 但像
ConfigUpsert、StyleUpsert这类普通业务组件,则更多接收entity、entityId、name、config这种业务参数。
这两个层次不要混在一起。否则新手最容易写出这样的代码:
- 一边把组件当 Oak Entity 组件使用;
- 一边又把
oakPath、oakId当成自己手写的普通业务 props; - 最后把页面级路径、业务参数和 UI 状态耦合在一起。
还有一个很少被文档提到、但在源码中真实存在的行为:如果 data 里的某个值本身是函数,框架会在构造阶段把它绑定到当前组件实例上。
像 taicang/src/pages/frontend/my/password/verify/index.ts 里就有这种历史写法:
data: {
path: '$$password-verify',
onVerified() {
this.navigateBack();
}
}
这里的 data.path 只是组件自己的普通状态字段,和 OakComponent 顶层配置里的 path 不是一回事。这类写法是能工作的,但从可维护性来说,更推荐把真正的行为放进 methods,把 data 留给状态值本身。
formData 是 Oak 组件里最重要的桥接层。它负责把 Oak 数据树里的原始行数据,整理成渲染层更容易消费的结构。
真实入参结构来自 Page.ts,最常用的是这些字段:
| 字段 | 含义 |
|---|---|
data | 当前行或当前行数组 |
origin | 修改前的数据 |
props | 当前组件 props |
features | 所有 features |
legalActions | 当前结点允许的动作 |
originLegalActions | 原始数据上的动作 |
dirty | 当前结点是否有未提交变更 |
modified | 是否真的和原值不同 |
例如:
formData({ data }) {
return {
applications: data || [],
oakExecutable: this.tryExecute(),
};
}
这里顺便说明一个经常被误解的点:oakExecutable 不是所有组件都会自动注入的内置状态。 在很多公共组件里,它都是开发者自己在 formData 中通过 this.tryExecute() 算出来的,例如:
oak-general-business/src/components/system/detail/index.tsoak-general-business/src/components/system/application/index.ts
所以如果你的渲染层需要“当前是否可以提交”这个值,最稳妥的做法就是显式在 formData 里返回它。
再补一个运行时细节:在 React / React Native 渲染层里,框架最终传给渲染函数的 props.data,实际是这样合并出来的:
{
...defaultProperties,
...state,
...props,
}
也就是说:
properties中声明的默认值会进入渲染层;- 组件自己的
state/formData结果会进入渲染层; - 父组件真实传入的 props 也会覆盖进去。
因此在 React 渲染函数中,你经常会在 props.data 里同时拿到:
- 组件计算出来的数据;
- 默认属性值;
- 外部传入参数。
2.6 actions 与 cascadeActions
actions 表示:当前组件希望框架帮你检查哪些动作是否合法。
例如:
actions: ['update', 'remove']
也可以写成带额外约束的动作定义对象。
经过检查后:
- 单行组件上的合法动作会进入
legalActions/oakLegalActions; - 列表组件里,每一行上会带
#oakLegalActions。
cascadeActions 则是对子对象动作的检查。源码中会把它们写回每一行的 #oakLegalCascadeActions,这类数据通常会被 actionBtn、列表操作列等通用组件消费。
这项能力适合这样的场景:
- 当前页面展示的是父对象;
- 但你还想在父对象行上展示“创建某种子对象”“修改某个子对象”等动作按钮;
- 并且希望这些动作同样经过 Oak 权限与 checker 判定。
2.7 features
features 用于声明组件需要监听哪些 feature 的变化。
最简单的写法:
features: ['token']
更完整的写法:
features: [
{
feature: 'token',
behavior: 'refresh',
},
{
feature: 'notification',
behavior: 'reRender',
},
]
行为有三种常见模式:
reRender:重新调用formData并重渲染;refresh:重新发起一次当前结点的数据刷新;callback:自己写处理函数。
另外还有两个默认行为要知道:
- 所有组件都会监听
locales; - 非 Virtual 的 Entity 组件默认会监听
cache。
2.7.1 业务项目里最常见的三种 feature 写法
第一种,最简单:
features: ['token']
这表示该 feature 更新时,组件会默认 reRender()。
第二种,明确声明刷新行为:
features: [
{
feature: 'application',
behavior: 'refresh',
}
]
像 taicang/src/pages/console/news/list/index.ts 就是这种写法。它的含义是:当 application feature 变化时,不只是重跑 formData,而是重新刷新当前 Oak 结点的数据。
第三种,自定义 callback:
features: [
{
feature: 'console',
callback() {
this.refreshAccountInfo();
}
}
]
taicang/src/pages/console/account/detail/index.ts 就采用了这种模式。适合:
- feature 变化后不一定立刻刷新当前结点;
- 需要先查本地 cache,再决定要不要补一次 refresh;
- 想把副作用收拢到自定义方法中。
2.8 stale 与 zombie
stale 用于告诉框架:这个结点在挂载时不需要像普通结点一样主动刷新。源码中,stale 结点会被当成“特殊结点”处理,通常适合:
- 数据完全依赖父结点;
- 数据刷新由外部显式控制;
- 临时性挂载,但不希望一挂载就发请求。
zombie 用于控制页面析构后是否保留 runningTree 上的状态,例如:
- 已增加的 filters / sorters;
- 尚未提交的修改;
- 当前分页位置。
但要注意真实限制:在配置项里直接写 zombie,只能用于顶层 Page。 子组件如果要保留状态,应通过 props 传 oakZombie,而不是在自己的 OakComponent 配置中直接声明。
项目里的真实使用也很能说明它们的差别:
taicang/src/pages/frontend/home/index.ts这种首页型 Virtual 页面,会直接写zombie: true,希望页面离开后再回来时还能保留状态;haina-busi/src/components/business/system/order/list/index.ts、haina-busi/src/pages/business/machine/list/index.ts这类列表片段,会写stale: true,把首轮主动刷新责任交给外层页面或特定 feature 联动。
因此可以这样记:
zombie更像“页面退出后,结点别急着销毁”;stale更像“组件挂上来时,先别自动刷新”。
stale 很有用,但也不要滥用。只有当你明确知道:
- 当前数据已经由父层准备好;
- 或者稍后会通过
features/ 自定义逻辑主动 refresh;
时,才适合这么做。
2.9 lifetimes 与 listeners
Oak 组件支持的核心生命周期包括:
createdattachedmaturereadydetached
以及一些平台相关生命周期:
movederrorshowhideresize
其中新手最需要分清的是这几个:
| 生命周期 | 真实含义 |
|---|---|
created | 组件实例构造阶段,不能依赖数据树已准备好 |
attached | 已挂载,但 oakFullpath 和初始数据不一定完成 |
mature | 当前 Entity 结点初始 refresh 完成 |
ready | 组件真正可安全使用运行树方法的阶段 |
detached | 组件准备销毁 |
listeners 用于监听 props / state 的变化,例如:
listeners: {
'keyword, status'(prev, next) {
if (prev.keyword !== next.keyword) {
this.refresh();
}
},
}
它特别适合:
- 某个 props 改了就刷新列表;
- 某个 state 改了就联动更新别的状态;
- 对输入条件做防抖/联动处理。
这里再补几个源码层面的真实规则:
listeners是在componentDidUpdate阶段跑的;- 每个监听键都可以写成逗号分隔的多个字段,例如
'showPopup,bidPrice'; - web 端当前不支持带
*的通配监听,源码里会直接报错; - 监听时比较的是
prevProps/prevState与当前值,不做深比较。
业务项目里很常见的写法有两类:
第一类,props 变化就刷新:
listeners: {
entity(prev, next) {
if (prev.entity !== next.entity) {
this.refresh();
}
},
entityId(prev, next) {
if (prev.entityId !== next.entityId) {
this.refresh();
}
},
}
例如 haina-busi/src/components/trade/detailList/index.ts。
第二类,state 变化触发额外副作用或订阅管理:
listeners: {
async accountId(prev, next) {
if (prev.accountId === next.accountId) {
return;
}
...
},
}
例如 taicang/src/components/spBid/modal/index.ts 会根据 plate、accountId 的变化去增删数据订阅。
3. 页面如何把组件挂到数据树上
组件能不能正常工作,关键不只是 entity,还取决于它是不是被正确挂到了数据树路径上。
最常见的三个运行时入口参数是:
| 参数 | 用途 |
|---|---|
oakPath | 当前组件所在的数据树路径 |
oakId | 单行结点关联的主键 |
oakZombie / oakStale | 以 props 形式覆盖结点行为 |
一个典型的详情组件嵌套写法如下:
<SystemDetail
oakId={id}
oakPath={oakFullpath}
/>
一个典型的子列表组件嵌套写法如下:
<ApplicationList
oakPath={`${oakFullpath}.application$system`}
systemId={id}
/>
这里要注意两件事:
oakPath不只是“一个字符串”,它表达的是组件之间的数据关系;- 子组件最好尽量沿着真实对象关系去组织路径,这样查询、级联更新和重用都会自然很多。
还有一条实践里很重要的规则:oakPath 和 oakId 最好在组件首次渲染时就稳定下来。
虽然 page.react.tsx 确实对“oakPath 晚一点才传进来”做了兼容处理,但源码里也明确会对“先创建结点、后补路径”的情况发出警告。实际业务里,更稳妥的方式通常是:
- 等主键准备好再渲染子组件;
- 等父组件拿到
oakFullpath再继续往下挂子结点; - 不要让同一个组件在
undefined -> 有值之间反复切换路径和主键。
4. 组件中的数据从哪里取
Oak 组件里常见的数据来源有三层:
props:外部传入;state:组件自己的状态;formData返回的数据:Oak 框架整理后的运行时数据。
4.1 在逻辑层里怎么取
在 formData、lifetimes、methods 中,通常通过 this.props 和 this.state 访问:
const { oakId } = this.props;
const { oakFullpath, oakDirty } = this.state;
4.2 在 React 渲染层里怎么取
在 web / native 渲染函数里,统一通过 props.data 和 props.methods 取:
export default function Render(props) {
const { name, description, oakFullpath, oakExecuting } = props.data;
const { t, execute, clean } = props.methods;
}
传统 TSX render 的 props 类型由 Oak 编译器结合 index.ts 自动注入,不需要再手写 WebComponentProps。properties 是组件对外业务输入的真实声明,formData 决定额外渲染数据,methods 决定自定义方法;三者不能靠 TSX 中的类型标注替代。启用 --emit-injection-types 时,这份推导合同还会写入 web.d.ts、web.pc.d.ts 或 render.native.d.ts,供下游包消费。
4.3 常见内置 props
这些值通常由页面或父组件传入:
| 名称 | 含义 |
|---|---|
oakPath | 当前组件路径 |
oakId | 单行组件主键 |
oakZombie | 子组件是否保留结点状态 |
oakStale | 子组件是否作为 stale 结点 |
oakFilters | 以 props 形式追加过滤条件 |
oakActions | 以 props 形式覆盖动作定义 |
oakCascadeActions | 以 props 形式覆盖级联动作定义 |
width | 当前宽度标识,如 xs、sm、md 等 |
其中 oakActions、oakCascadeActions 在真实类型里是字符串通道,通常由框架或通用组件透传,不建议业务页面手动乱拼。
oakAutoUnmount 是已废弃的历史参数,不再列入新组件可用参数。当前 React 运行时会在组件卸载时自动销毁对应 runningTree 结点;需要控制 UI 是否卸载时,使用条件渲染或 Tabs 自身的卸载策略。
4.4 Render、XML 与样式检查
当前严格构建通常同时启用 render 类型注入、XML 类型检查和 Less Module 检查:
- render 中的
props.data/props.methods必须来自框架合同、properties、formData或methods; - 小程序
index.xml中的数据、方法、组件属性和事件绑定会按同一组件合同检查; Styles.xxx必须在导入的 Less Module 中真实存在,嵌套选择器还要满足 JSX 祖先作用域;- 下拉菜单、弹层等 portal 内容如果脱离原父级 DOM,所用样式应放到实际可达的模块级作用域。
这些错误应通过补齐真实 properties、修正数据合同或调整真实 CSS 作用域解决,不要用 any、never、空样式规则或扩大手写 props 类型绕过。
4.5 常见内置 state / data
框架会把一批运行时状态放进组件数据中:
| 名称 | 含义 |
|---|---|
oakFullpath | 当前组件在 runningTree 中的完整路径 |
oakEntity | 当前组件关联的 entity |
oakLoading | 是否正在加载数据 |
oakLoadingMore | 列表是否正在加载更多 |
oakExecuting | 是否正在提交更新 |
oakDirty | 是否存在未提交修改 |
oakModified | 是否和原始值真实不同 |
oakLegalActions | 当前结点合法动作 |
oakPagination | 列表分页信息 |
oakLocales | 当前语言数据集 |
oakLng / oakDefaultLng | 当前语言与默认语言 |
对于单行组件,在 React 渲染层里还会拿到 oakId。
再次强调:oakExecutable 并不是所有组件自动拥有的内置字段,如果页面要用,建议像公共组件一样,在 formData 中显式返回:
formData({ data }) {
return {
...data,
oakExecutable: this.tryExecute(),
};
}
5. 组件上的方法
在逻辑层里,可以通过 this 使用组件方法;在 React 渲染层里,通过 props.methods 使用。
最常用的方法可以按三组来记。
5.1 通用方法
| 方法 | 作用 |
|---|---|
reRender() | 重新执行 formData |
refresh() | 刷新当前结点数据 |
execute() | 提交当前结点及子孙结点上的修改 |
clean() | 清理未提交修改 |
tryExecute() | 试算当前是否可提交 |
checkOperation() | 检查某个操作是否合法 |
t() | 国际化翻译 |
navigateTo() / redirectTo() / navigateBack() | 页面跳转 |
setMessage() / setNotification() | 发消息或通知 |
select() / aggregate() | 直接在前端缓存/运行态里做选择或聚合 |
5.2 单行组件方法
| 方法 | 作用 |
|---|---|
update(data) | 修改当前单行结点 |
create(data) | 当前单行结点按 create 方式准备数据 |
remove() | 删除当前单行结点 |
getId() / setId() / unsetId() | 管理当前单行结点主键 |
isCreation() | 判断当前单行结点是否处于创建态 |
5.3 列表组件方法
| 方法 | 作用 |
|---|---|
loadMore() | 加载下一页 |
addItem() / addItems() | 在列表结点上新增一条或多条待创建数据 |
updateItem() / updateItems() | 更新列表中某条或某批数据 |
removeItem() / removeItems() | 删除列表中某条或某批数据 |
recoverItem() / resetItem() | 恢复或重置列表项 |
setNamedFilters() / addNamedFilter() | 动态调整过滤条件 |
setNamedSorters() / addNamedSorter() | 动态调整排序条件 |
setPageSize() / setCurrentPage() | 调整分页 |
这些方法还有一个共同点:很多都支持额外传一个 path 参数,表示在当前组件结点下,继续对子路径对应的结点操作。
例如:
update(data, action, path)execute(action, messageProps, path, opers)clean(lsn, dontPublish, path)addItem(data, path)
因此在复杂页面里,父组件完全可以不把所有按钮都下沉到子组件,而是在父组件里直接对某个子路径进行提交、清理或增删改。
如果你看 oak-general-business/src/components/system/application/web.pc.tsx,就会看到一个非常典型的列表组件组合:
- 用
addItem(...)先在列表结点上插入一条待创建数据; - 弹出
ApplicationUpsert,并把它挂到${oakFullpath}.${createId}; - 编辑完成后统一调用
execute()提交。
这是 Oak 里非常常见的一种列表内新增模式。
5.4 列表 + Modal + Upsert 的标准模式
如果你在 Oak 项目里要做“列表里新增一条,再弹窗编辑”的能力,最推荐的不是另起一条完全独立的绝对路径,而是优先使用当前列表结点下的子路径。
oak-pay-business/src/components/apAccount/config/web.pc.tsx、oak-pay-business/src/components/withdrawAccount/list/web.pc.tsx 都是很标准的例子:
- 列表组件自己持有当前 list 结点;
- 点击新增时,先调用
addItem(...)在当前 list 结点上插入一条待创建数据,拿到新 id; - 再把 upsert 组件挂到
${oakFullpath}.${upsertId}; - 如果是编辑已有行,就继续使用同一条子路径,并补上
oakId={upsertId}; - 点击确认后,由列表组件统一
execute(); - 点击取消时,统一
clean()/resetAll()。
这类模式的优点是:
- 创建态和编辑态都挂在同一棵 list 子树里;
- 不需要额外维护一套平行结点;
- 列表组件可以统一决定提交和回滚入口;
- modal 只是 UI 壳,真正的数据仍然留在 runningTree 里。
还有一个容易漏掉的前提:addItem(...) 的第一份数据必须让草稿满足当前列表的归属和过滤条件。假设列表按 accountId 过滤,并且子 Upsert 挂在 ${oakFullpath}.${upsertId},不要只写:
const id = addItem({});
应在创建草稿时写入归属字段和不可空默认值:
const id = addItem({
accountId,
enabled: true,
needReceiving: false,
});
否则草稿可能因为不满足当前 ListNode filter 而从列表视图中消失,子 SingleNode 随后读不到稳定数据,Upsert 会空白或在保存时才暴露外键/非空错误。归属字段由列表 create 操作建立;子 Upsert 只继续编辑其它字段。
如果这个 upsert 本来就是在编辑列表行本身,优先按这个模式来写。只有当它明显不是当前列表子树的一部分时,才考虑独立绝对路径。
6. 生命周期到底该怎么用
如果严格按照 page.react.tsx 的真实执行顺序来看,一个常规 Entity 组件的关键阶段通常是:
- 构造函数中初始化
data、默认properties、自定义methods; - 同步执行
created; componentDidMount中订阅locales、cache和用户声明的features;- 执行
attached; onPathSet(...)创建 runningTree 结点;- 对非
list child、非stale的结点执行首轮refresh(); - 首轮数据回来后执行
mature; - 执行
ready; - 页面显示时执行
show; - 组件销毁时先
destroyNode(...),再执行detached。
Virtual 组件的路径会更简单一些,但也同样遵循“先建立结点,再进入 ready”的总体顺序。
一个简单的使用建议是:
created:只做最轻量的同步初始化;attached:可以做订阅、埋点、非数据树依赖逻辑;mature:适合“首次取数完成后的处理”;ready:最适合依赖oakFullpath、运行树方法、初始数据的逻辑;detached:做清理。
特别注意:不要在 created / attached 中假定 runningTree 结点已经完全就绪。 真实源码里,路径创建和首轮 refresh 是在更后面的阶段完成的。凡是依赖数据树的方法,比如:
refreshgetIdupdatesetNamedFiltersloadMore
都更适合放在 ready 之后使用。
7. 真实项目里的几种定义方式
只看类型定义很容易抽象过头。下面几种写法,都是 haina-busi 和 taicang 里真实存在、而且很值得借鉴的模式。
7.1 Virtual 控制页
例如 taicang/src/pages/console/account/detail/index.ts,它自己不声明 entity,而是:
- 通过
features.application、features.console、features.cache算出当前accountId; - 在渲染层中,再把
oak-pay-business的AccountDetail挂到${oakFullpath}.account。
这种模式很适合:
- 页面本身更像控制器,而不是单一实体页;
- 页面要组合公共业务包组件;
- 需要先根据当前模式、当前用户、当前系统环境推导真正要展示的实体。
7.2 动态实体组件
例如 haina-busi/src/components/business/daemon/config/index.ts,同一组件同时服务 system 和 room:
entity()根据this.props.entity决定当前实体;projection()与formData()也随之复用。
这种模式适合多个实体结构高度相似的场景,但要注意不要在一个已经稳定挂载的结点上频繁切实体。
7.3 用户态驱动的动态 projection
例如 taicang/src/pages/frontend/spAuctionCollection/detail/index.ts:
- 登录时查询投标板、关注关系、代理出价等用户相关数据;
- 未登录时只查询公开详情所需字段。
这种模式很适合详情页,因为详情页经常同时面对:
- 公开访问;
- 登录后增强;
- 某些字段查询成本较高。
7.4 listeners 驱动的复杂联动组件
像 taicang/src/components/spBid/modal/index.ts 这类组件,会在 listeners 中:
- 监听
showPopup,bidPrice; - 监听
offerPrice; - 监听
plate/accountId的变化来建立或释放数据订阅。
这类组件通常已经不只是“查数据然后展示”,而是一个真正的前端状态机。写这类组件时,建议把“哪个字段变化会触发什么副作用”明确集中到 listeners,不要把逻辑散在多个渲染事件里。
7.5 stale 列表片段
例如 haina-busi/src/pages/business/machine/list/index.ts、haina-busi/src/components/business/system/order/list/index.ts:
- 组件本身是 Entity list;
- 但声明
stale: true; - 再通过外层页面或 feature 联动控制真正的 refresh 时机。
这类写法适合大页面里的内嵌列表片段,但只有在你对数据刷新链路非常清楚时才建议使用。
7.6 没有 entity 的业务工具组件
并不是所有 Oak 组件都应该绑定一个实体。oak-pay-business/src/components/withdraw/create/index.ts、oak-pay-business/src/components/withdraw/display/index.ts 就很典型:
- 它们都没有声明
entity; - 主要依赖
properties、formData、features、methods; - 通过
features.cache、features.application、features.token去取业务上下文; - 最终服务的是“提现向导”“提现结果展示”这类业务流程,而不是某个单一实体详情页。
这类组件适合:
- 向导页;
- 纯业务流程表单;
- 结果展示块;
- 选择器、支付器、汇总器这类强交互工具组件。
也就是说,不写 entity 不等于它只能是普通 React 组件。只要你还需要:
- Oak 生命周期;
features;formData;props.methods;
那它依然很适合写成 Virtual Oak 组件。
7.7 单行 Upsert 中继续挂关联子实体
oak-pay-business/src/components/wpAccount/upsert/web.pc.tsx 提供了一个很好的例子:当前组件本身是 wpAccount 的单行 upsert,但它内部还继续挂了一个 WechatPayUpsert:
<WechatPayUpsert
oakPath={`${oakFullpath}.wechatPay`}
systemId={systemId}
/>
如果当前 wpAccount 已经有关联的 wechatPayId,就继续补:
oakId={wpAccount.wechatPayId}
这种模式特别适合:
- 当前对象里内嵌一个强关联的配置对象;
- 父对象和子对象的编辑希望放在同一张表单里;
- 子对象本身仍然值得保留独立 upsert 组件。
可以把它理解成:父单行组件负责主对象,子单行组件负责一个关系明确的子对象。 路径上仍然优先沿关系组织,而不是额外发明新名字。
7.8 业务包里的 AbstractComponents 适配层
在 oak-pay-business/src/components/AbstractComponents.ts 里,可以看到另一种很常见、但不容易被初学者注意到的模式:先把 oak-frontend-base 的抽象组件按当前业务包的 EntityDict 再包一层。
例如里面会把:
FilterPanelListListProDetailUpsert
重新导出成适配当前业务包类型的组件。
这层适配的价值主要有两点:
- 业务包内部直接使用时,不用每次都手工补完整的泛型;
- 项目代码和公共包代码里,
entity、列定义、RowWithActions的类型会更稳定。
如果你在自己的公共业务包里也准备封一组常用抽象组件,推荐沿用这种思路:
- 先以框架抽象组件为基础;
- 再用当前业务包的
EntityDict做一次类型收口; - 最后让业务页面统一从这一层导入。
这样做不会改变运行时逻辑,但会明显改善项目里的组件书写体验和类型一致性。
7.9 Oak 组件和 pure 展示组件怎么分工
oak-pay-business/src/components/account/detail/web.pc.tsx 里有一个非常典型的分层:
- 外层
AccountDetail仍然是 Oak 组件渲染层,负责拿account、权限、弹窗状态和NewDeposit; - 其中账户流水这块,并没有继续写成一个需要
oakPath的 Oak 子组件,而是直接交给accountOper/pure/List.pc.tsx这样的纯展示组件。
pure/List.pc.tsx 只接收:
- 已经整理好的
accountOpers t
然后专心把列表画出来。
这类拆法很适合下面这些场景:
- 子块只负责展示一段已经查好的数据;
- 子块不需要
refresh()、execute()、listeners、features; - 你希望这个子块在多个 Oak 页面里被反复复用;
- 你不想让页面里每个小块都额外再长出一棵子结点。
可以把判断标准记成一句话:如果子块需要“数据树能力”,写 Oak 组件;如果子块只需要“展示能力”,就让它退回普通 React 组件。
7.10 FilterPanel / ListPro / Detail / Upsert 的标准装配方式
再往前走一步,Oak 页面里最常见的渲染层装配,其实就是下面这几种骨架组合:
第一种,FilterPanel + ListPro 的标准列表页。
像:
oak-general-business/src/components/user/manage/web.pc.tsxoak-pay-business/src/components/pay/list/web.pc.tsxoak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx
都在用这个模式。它的关键点是:
FilterPanel和ListPro共享同一条oakFullpath;FilterPanel负责筛选条件;ListPro负责表格、按钮组、行操作;- 外层 Oak 组件负责把列表数据先整理成渲染层需要的结构。
第二种,Detail + Upsert 的配置/详情页。
像:
oak-pay-business/src/components/ship/wechatMpShip/web.pc.tsxoak-pay-business/src/components/wpProduct/config/web.pc.tsx
会先用 Detail 展示当前行,再用 Upsert 放进 Modal 里做编辑。它适合:
- 配置项结构比较标准;
- 展示和编辑字段基本对应;
- 想复用一套统一的详情/编辑骨架。
第三种,List/卡片 + Upsert Modal 的片段管理页。
这类页面通常不是标准大表格,而是卡片、列表项或配置块,但编辑仍然通过 ${oakFullpath}.${upsertId} 这套子路径模式完成。
所以可以把这些抽象组件的职责简单记成:
FilterPanel:负责筛选输入;ListPro/List:负责列表骨架和行动作;Detail:负责把一行对象按字段定义展示出来;Upsert:负责把一行对象按字段定义编辑出来。
它们本身不替代 Oak 数据树,而是和 Oak 组件互相配合。最常见的组合就是:外层 Oak 组件产数据,渲染层抽象组件消费数据。
8. 实战建议
- 页面级根组件负责稳定的
projection和根路径,子组件尽量复用这条路径,不要重复造一套平行节点。 - 单行详情组件和更新组件如果共享同一条
oakPath,就要有意识地让它们共享同一份对象上下文。 - 列表组件里,过滤、排序、分页优先定义成命名条件,后续更容易动态替换。
- 需要“是否可提交”时,不要想当然依赖某个框架字段,最稳妥的是在
formData中显式返回this.tryExecute()的结果。 path和zombie的配置项只在页面级直接声明;子组件请改用oakPath、oakZombie。- 如果组件只是父组件某个对象片段的展示壳,而不需要自己主动刷新,可以认真考虑是否应该设为
stale或直接做成 Virtual 组件。 - 如果组件依赖
oakId、oakPath、上游异步结果,优先条件渲染,避免让路径和主键在挂载后才补上。 - 如果你的页面只是做环境判断、权限判断、业务包拼装,完全可以把它写成 Virtual 控制页,再把真正的 Entity 组件挂到子路径上。
- 如果你做的是“列表新增/编辑弹窗”,优先用
addItem + ${oakFullpath}.${id} + execute/clean这套标准模式,不要一上来就新开绝对路径。 - 如果你做的是向导、结果页、选择器这类业务流程组件,可以先问自己:是否真的需要
entity,还是写成 Virtual Oak 组件更合适。 - 如果当前单行组件里还要编辑一个强关联子对象,优先把子 upsert 挂到
${oakFullpath}.关系名这样的相对路径上。 - 如果一个子块只是消费已经整理好的数据,不需要 Oak 生命周期和运行树能力,就把它拆成
pure展示组件,而不是继续往下挂 Oak 子结点。 - 如果你的业务包会大量复用
FilterPanel、ListPro、Detail、Upsert,可以考虑先做一层AbstractComponents类型适配,再统一对外使用。 - 如果你在渲染层使用
FilterPanel、ListPro、Detail、Upsert,先想清楚它们消费的是哪条 Oak 路径、哪份结构化数据,不要把筛选、列表、编辑挂到三条互不相干的路径上。 - 遇到“这个配置项到底能不能这样写”的问题,先对
oak-frontend-base/src/types/Page.ts,再对page.react.tsx和page.common.ts,不要只凭旧文档猜。