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 前端组件的入口是 OakComponent(...)。它的真实类型定义在 oak-frontend-base/src/types/Page.ts 中,运行时主要由 oak-frontend-base/src/page.react.tsxpage.mp.tspage.common.tsfeatures/runningTree.ts 驱动。

如果只从使用层面记忆,Oak 组件可以理解成四件事的组合:

  1. 定义这个组件在页面数据树中的结点;
  2. 定义这个结点如何取数、改数、分页和校验动作;
  3. 把取到的数据整理成渲染层更容易消费的形态;
  4. 把运行时方法和状态注入到组件中。

因此,写 Oak 组件时,最重要的不是先写 TSX,而是先把 OakComponent 的定义写清楚。

先看一个最小骨架

如果只看 oak-frontend-base/src/types/Page.tsOakComponent(...) 最常见的骨架大致就是这样:

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() {},
    },
});

真正写业务时,不一定每个字段都要写,但你可以把它理解成六层:

  1. 结点定义:entityisListpathstalezombie
  2. 查询定义:projectionfilterssorterspaginationgetTotal
  3. 入参定义:properties
  4. 组件内部状态:data
  5. 运行时整形:formData
  6. 联动与行为:featureslifetimeslistenersmethods

先建立一个运行模型

一个典型的 Entity 组件,运行顺序大致是这样的:

  1. 页面或父组件传入 oakPath,单行组件通常还会传入 oakId
  2. 框架根据 entityprojectionfilterssorterspagination 等配置,在 runningTree 中创建结点;
  3. 结点刷新后拿到对象数据;
  4. 框架调用 formData(...)
  5. formData 返回的数据和组件自己的 data、外部 props 一起进入渲染层;
  6. 在渲染层中,通过 props.dataprops.methods 使用这些数据与方法。

所以 Oak 组件真正的“输入”通常不是一个,而是这三类:

  • 页面或父组件传入的参数,如 oakPathoakIdsystemId
  • OakComponent 配置项中声明的查询/行为定义;
  • 框架在运行时注入的状态和方法。

组件文件通常怎么落地

在 Oak 项目里,OakComponent(...) 的定义通常只放在组件目录下的 index.ts。而真正的渲染文件、样式文件、locale 文件,会和它并排放在同一个目录里。

taicanghaina-busioak-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(...) 定义、projectionformDatalistenersmethods
  • index.config.ts:页面/组件的结构化配置。页面通常放 routemenump,组件通常放 mp,用于替代新代码中的小程序 index.json
  • web.pc.tsx / web.tsx:各端渲染层,只消费 props.dataprops.methods,尽量不要把数据树逻辑再塞回渲染文件。
  • index.xml / index.less:小程序端模板和样式;小程序组件声明、usingComponents 等配置优先写在 index.config.tsmp 字段中。
  • *.module.less / *.less:平台样式文件。
  • locales/*.json:当前组件自己的文案。

真实项目里常见三种落地方式:

  • 只有 PC 端的业务组件:例如 oak-general-business/src/components/system/panelhaina-busi/src/components/business/daemon/config,通常只有 index.ts + web.pc.tsx + less + locales
  • 同时覆盖 web / 小程序的页面或复杂组件:例如 taicang/src/pages/console/account/detailtaicang/src/components/spBid/modaltaicang/src/pages/frontend/spAuctionCollection/detail,通常会同时存在 web.pc.tsxweb.tsxindex.xmlindex.config.ts。旧项目里也可能还保留 index.json
  • 纯 Oak 逻辑 + 薄渲染层:最推荐的方式是把查询、监听、订阅、提交、权限判断都放在 index.ts,让各端渲染文件只负责布局和交互。

也就是说,定义 Oak 组件时最重要的分层不是“先写页面再补逻辑”,而是:

  1. 先在 index.ts 把数据树结点和行为定义清楚;
  2. 再让 web.pc.tsx / web.tsx / index.xml 去消费这些定义好的数据和方法;
  3. 不要把 projection、订阅、refresh 触发条件分散到多个渲染文件里。

大型组件目录本身也可以是一棵局部组件树

Oak 项目里还有一种非常常见的情况:一个“业务组件”本身并不是一个单目录单文件,而是一个根组件目录,下面继续挂很多局部子组件目录

例如:

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

这两类目录都不是“只有一个 index.ts + web.pc.tsx 就结束”,而是会继续拆出:

  • menu
  • conditionalMenu
  • tagList
  • oauthProvider
  • oauthApps
  • upsert

这类拆法适合:

  • 一个业务块本身就有多个 tab / 面板 / 弹窗 / 选择器;
  • 子块之间属于同一个业务域,拆太散反而不好维护;
  • 你希望外层组件做总编排,内层子目录做局部 Oak 结点或局部展示逻辑。

从工程角度看,可以把它理解成:页面有一棵组件树,复杂业务组件目录内部也可以再有一棵局部组件树。

但即便这样拆,原则还是一样:

  • 外层根组件负责整体上下文;
  • 子目录组件负责局部路径、局部状态和局部展示;
  • 不要因为目录层级变深,就把路径设计和职责分层搞乱。

同一个 index.ts 通常会复用到多个渲染端

在 Oak 项目里,一个组件目录下同时出现:

  • web.pc.tsx
  • web.tsx
  • index.xml
  • index.config.ts

是很常见的。这通常不表示“这里有四套不同逻辑”,而是表示:同一套 Oak 逻辑,分别接到多个端的渲染壳上,并通过 index.config.ts 补充平台配置。

例如:

  • taicang/src/components/spBid/modal
  • taicang/src/pages/frontend/spAuctionCollection/detail
  • taicang/src/pages/console/account/detail

它们的共同特点是:

  • index.ts 仍然只有一份,负责 Oak 逻辑;
  • web PC、web mobile、小程序模板各自只处理平台渲染;
  • 多端之间共享同一份 projectionformDatalistenersmethods

这也是 Oak 组件很重要的一条工程纪律:

  • 数据树逻辑尽量只写一份;
  • 平台差异尽量收敛在渲染文件里;
  • 不要把“某端专属的布局差异”升级成“某端专属的一套 Oak 逻辑”,除非业务真的不同。

复用组件逻辑并覆写本地 render

跨项目或跨平台开发中,还有一种更薄的组件目录:逻辑完全沿用现有 Oak 组件,本地只提供自己的 render。例如应用要复用公共业务包的查询、formDatamethods,但 Web 页面布局要由当前应用定制。

本地 index.ts 应使用 CLI 能明确识别的直接转发形式:

import OakComponent from '@oak-general-business/components/example';

export default OakComponent;

然后在同目录编写本地 web.tsxweb.pc.tsxrender.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>
    );
}

这里发生了两件事:

  1. 运行时复用原组件的 Oak 逻辑合同;
  2. CLI 和 Oak Assistant 为本地 render 继承原组件对应平台的 props 合同。

如果目标还是工作区源码,编译器会沿转发链找到真正的 OakComponent({...})。如果目标是已安装的发布包,编译器不会用组件 index.d.ts 猜内部 render 数据,因为该文件通常只描述父组件可传入的外部 props;它会读取原组件的平台 render 声明。

平台声明的选择顺序如下:

本地 render依赖声明查找顺序
web.tsxweb.d.ts
web.pc.tsxweb.pc.d.ts -> web.d.ts
web.mobile.tsxweb.mobile.d.ts -> web.d.ts
render.ios.tsxrender.ios.d.ts -> render.native.d.ts
render.android.tsxrender.android.d.ts -> render.native.d.ts
render.desktop.tsxrender.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 组件,最稳妥的顺序通常不是先写页面样式,而是先把下面四个问题答清楚:

  1. 这个组件是 Virtual,还是 Entity?
  2. 如果是 Entity,它是单行还是列表?
  3. 它最终会挂在哪条 oakPath 上?
  4. 它的业务参数里,哪些是运行时参数,哪些是业务参数,哪些是 UI 参数?

在真实项目里,一个更实用的起手模板通常是:

  1. 先写 entityisListprojectionfiltersproperties
  2. 再写 formData,先把渲染层真正需要的数据整理出来;
  3. 然后补 methodslisteners
  4. 最后再写 web.pc.tsx / web.tsx / index.xml

这样做的好处是:

  • 你会先把结点和数据关系想清楚;
  • 渲染层拿到的是已经整理好的字段,而不是一堆原始查询结果;
  • 后面拆分组件时,更容易判断哪些逻辑该留在 Oak 层,哪些只属于展示层。

不是所有 Oak 生态组件都由 OakComponent(...) 定义

这一点对新手非常重要:在 Oak 项目里,大家常说“组件”,但它不一定都指 OakComponent(...) 生成的组件。

除了自己写的 Oak 组件之外,项目里还大量使用这几类抽象组件:

  • FilterPanel
  • List
  • ListPro
  • Detail
  • Upsert

它们通常来自:

  • @oak-frontend-base/components/...
  • 或公共业务包里的 AbstractComponents.ts

例如:

  • oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx
  • oak-pay-business/src/components/pay/list/web.pc.tsx
  • oak-general-business/src/components/user/manage/web.pc.tsx

都会直接在渲染层中使用 FilterPanelListPro

你可以这样理解它们的角色:

  • OakComponent(...) 定义“数据树结点、查询、状态、行为”;
  • FilterPanel / ListPro / Detail / Upsert 定义“通用的展示与交互骨架”;
  • 业务页面则把两者拼起来。

所以在真实工程里,一个完整页面经常不是“全都写成 OakComponent”,而是:

  1. 先用 index.ts 定义当前 Oak 结点;
  2. 再在 web.pc.tsx / web.tsx 里组合 FilterPanelListProDetailUpsert
  3. 最后把局部纯展示块继续拆成普通 React 组件。

这也是为什么文档里要把“定义组件”和“组织组件”分开讲。前者是在讲 Oak 结点怎么定义,后者是在讲这些 Oak 结点和抽象展示组件怎么拼成真正页面。

1. 先分清组件类型

Oak 中常见的前端组件可以先分成两大类:

1.1 Virtual 组件

如果没有声明 entity,这个组件就是 Virtual 组件。它仍然可以:

  • 使用生命周期;
  • 监听 features
  • 使用 tnavigateTosetMessage 等公共方法;
  • 挂在某个 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 配置项可直接声明
nsi18n 命名空间补充可写单个或多个
lifetimes生命周期方法createdreadymature
listeners监听 props / state 变化适合联动逻辑
methods组件自定义方法会注入到 thisprops.methods
wechatMp小程序额外配置externalClasses、组件 options

下面只展开那些最容易写错的配置项。

2.1 entityisListpath

entity 决定组件关联哪个对象。支持两种写法:

entity: 'system'

或者:

entity() {
    return this.props.entityName as 'system';
}

isList 决定组件是列表结点还是单行结点:

isList: true

或:

isList: false

path 比较特殊。源码里明确限制了:只有页面级根组件才应该直接声明 path 子组件不要在配置项中写 path,而应通过外部传入 oakPath

可以这样理解:

  • 顶层 page:用 path 定义页面根结点;
  • 普通子组件:由父组件传入 oakPath
  • 单行子组件:通常同时传 oakPathoakId

另外,运行时还有一个细节值得知道:在 page.common.tsonPathSet(...) 里,如果这是页面根组件并且同时传了 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$entity
  • spAgent$auctionCollection
  • 与当前用户投标板相关的数据

一起查出来。

这种写法适合:

  • 登录前后看到的字段结构不同;
  • 某些字段只在特定模式下需要;
  • 某些关联查询代价较高,希望按条件裁剪。

但要记住和上一条规则配套的结论:动态 projection 也要对当前路径上的其它共享组件负责。 如果某条路径上挂了多个子组件,不能一个组件想查一套,另一个组件又假设另一套。

2.3 filterssorters

它们只对 list 组件有效,语法分别对应查询章节里的 Filter 和 Sorter。

真实结构不是单个对象,而是数组:

filters: [
    {
        filter() {
            return {
                systemId: this.props.systemId,
            };
        },
        '#name': 'bySystem',
    },
]
sorters: [
    {
        sorter: {
            $attr: {
                name: 1,
            },
            $direction: 'asc',
        },
        '#name': 'nameAsc',
    },
]

这里的几个细节很值得记住:

  • filtersorter 都可以直接写对象,也可以写函数;
  • '#name' 可用于后续按名称替换、删除;
  • filter 还支持 hot: true,表示前台取数时也持续参与判断。

后续你可以通过组件方法动态调整这些条件,比如:

  • addNamedFilter
  • setNamedFilters
  • removeNamedFilterByName
  • addNamedSorter
  • removeNamedSorterByName

2.4 paginationgetTotal

分页配置的真实结构是:

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 propertiesdataformData

properties 用于声明外部传入参数,相当于组件 props 的声明:

properties: {
    systemId: '',
}

data 用于声明组件自身状态初值,相当于 state 初值:

data: {
    keyword: '',
    open: false,
}

它也可以写成函数:

data() {
    return {
        keyword: '',
    };
}

例如 taicang/src/pages/console/news/list/index.ts 就保留了这种写法。源码中,data() 会在组件构造阶段以 this 为上下文执行一次。

这里还有一个非常值得建立的习惯:把组件参数按“运行时参数 / 业务参数 / 交互参数”分开理解。

  • 运行时参数:oakPathoakIdoakZombieoakStalewidth。这些是 Oak 运行时已经内置的 props,不需要再在 properties 里重复声明。
  • 业务参数:systemIdapplicationIdentityentityIdtabKeyagentOnly 这类业务输入,应明确写在 properties 里。
  • 交互参数:visibledisabledonCloseshowRecharge 这类 UI 或回调型参数,也应该写在 properties 里,并给出稳定默认值。

例如 taicang/src/components/spBid/modal/index.ts 就很典型:

  • oakId 不是它自己声明的业务参数,而是运行时给它的当前拍品主键;
  • agentOnlyvisibledisabledisLiveonCloseshowRecharge 才是它自己真正关心的业务/UI 参数。

再比如 oak-general-business/src/components/system/panel/web.pc.tsx 中的 SystemDetail

  • oakIdoakPath 决定它挂在哪个 Oak 结点上;
  • 但像 ConfigUpsertStyleUpsert 这类普通业务组件,则更多接收 entityentityIdnameconfig 这种业务参数。

这两个层次不要混在一起。否则新手最容易写出这样的代码:

  • 一边把组件当 Oak Entity 组件使用;
  • 一边又把 oakPathoakId 当成自己手写的普通业务 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.ts
  • oak-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 actionscascadeActions

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 stalezombie

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.tshaina-busi/src/pages/business/machine/list/index.ts 这类列表片段,会写 stale: true,把首轮主动刷新责任交给外层页面或特定 feature 联动。

因此可以这样记:

  • zombie 更像“页面退出后,结点别急着销毁”;
  • stale 更像“组件挂上来时,先别自动刷新”。

stale 很有用,但也不要滥用。只有当你明确知道:

  • 当前数据已经由父层准备好;
  • 或者稍后会通过 features / 自定义逻辑主动 refresh;

时,才适合这么做。

2.9 lifetimeslisteners

Oak 组件支持的核心生命周期包括:

  • created
  • attached
  • mature
  • ready
  • detached

以及一些平台相关生命周期:

  • moved
  • error
  • show
  • hide
  • resize

其中新手最需要分清的是这几个:

生命周期真实含义
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 会根据 plateaccountId 的变化去增删数据订阅。

3. 页面如何把组件挂到数据树上

组件能不能正常工作,关键不只是 entity,还取决于它是不是被正确挂到了数据树路径上。

最常见的三个运行时入口参数是:

参数用途
oakPath当前组件所在的数据树路径
oakId单行结点关联的主键
oakZombie / oakStale以 props 形式覆盖结点行为

一个典型的详情组件嵌套写法如下:

<SystemDetail
    oakId={id}
    oakPath={oakFullpath}
/>

一个典型的子列表组件嵌套写法如下:

<ApplicationList
    oakPath={`${oakFullpath}.application$system`}
    systemId={id}
/>

这里要注意两件事:

  1. oakPath 不只是“一个字符串”,它表达的是组件之间的数据关系;
  2. 子组件最好尽量沿着真实对象关系去组织路径,这样查询、级联更新和重用都会自然很多。

还有一条实践里很重要的规则:oakPathoakId 最好在组件首次渲染时就稳定下来。

虽然 page.react.tsx 确实对“oakPath 晚一点才传进来”做了兼容处理,但源码里也明确会对“先创建结点、后补路径”的情况发出警告。实际业务里,更稳妥的方式通常是:

  • 等主键准备好再渲染子组件;
  • 等父组件拿到 oakFullpath 再继续往下挂子结点;
  • 不要让同一个组件在 undefined -> 有值 之间反复切换路径和主键。

4. 组件中的数据从哪里取

Oak 组件里常见的数据来源有三层:

  • props:外部传入;
  • state:组件自己的状态;
  • formData 返回的数据:Oak 框架整理后的运行时数据。

4.1 在逻辑层里怎么取

formDatalifetimesmethods 中,通常通过 this.propsthis.state 访问:

const { oakId } = this.props;
const { oakFullpath, oakDirty } = this.state;

4.2 在 React 渲染层里怎么取

在 web / native 渲染函数里,统一通过 props.dataprops.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 自动注入,不需要再手写 WebComponentPropsproperties 是组件对外业务输入的真实声明,formData 决定额外渲染数据,methods 决定自定义方法;三者不能靠 TSX 中的类型标注替代。启用 --emit-injection-types 时,这份推导合同还会写入 web.d.tsweb.pc.d.tsrender.native.d.ts,供下游包消费。

4.3 常见内置 props

这些值通常由页面或父组件传入:

名称含义
oakPath当前组件路径
oakId单行组件主键
oakZombie子组件是否保留结点状态
oakStale子组件是否作为 stale 结点
oakFilters以 props 形式追加过滤条件
oakActions以 props 形式覆盖动作定义
oakCascadeActions以 props 形式覆盖级联动作定义
width当前宽度标识,如 xssmmd

其中 oakActionsoakCascadeActions 在真实类型里是字符串通道,通常由框架或通用组件透传,不建议业务页面手动乱拼。

oakAutoUnmount 是已废弃的历史参数,不再列入新组件可用参数。当前 React 运行时会在组件卸载时自动销毁对应 runningTree 结点;需要控制 UI 是否卸载时,使用条件渲染或 Tabs 自身的卸载策略。

4.4 Render、XML 与样式检查

当前严格构建通常同时启用 render 类型注入、XML 类型检查和 Less Module 检查:

  • render 中的 props.data / props.methods 必须来自框架合同、propertiesformDatamethods
  • 小程序 index.xml 中的数据、方法、组件属性和事件绑定会按同一组件合同检查;
  • Styles.xxx 必须在导入的 Less Module 中真实存在,嵌套选择器还要满足 JSX 祖先作用域;
  • 下拉菜单、弹层等 portal 内容如果脱离原父级 DOM,所用样式应放到实际可达的模块级作用域。

这些错误应通过补齐真实 properties、修正数据合同或调整真实 CSS 作用域解决,不要用 anynever、空样式规则或扩大手写 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.tsxoak-pay-business/src/components/withdrawAccount/list/web.pc.tsx 都是很标准的例子:

  1. 列表组件自己持有当前 list 结点;
  2. 点击新增时,先调用 addItem(...) 在当前 list 结点上插入一条待创建数据,拿到新 id;
  3. 再把 upsert 组件挂到 ${oakFullpath}.${upsertId}
  4. 如果是编辑已有行,就继续使用同一条子路径,并补上 oakId={upsertId}
  5. 点击确认后,由列表组件统一 execute()
  6. 点击取消时,统一 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 组件的关键阶段通常是:

  1. 构造函数中初始化 data、默认 properties、自定义 methods
  2. 同步执行 created
  3. componentDidMount 中订阅 localescache 和用户声明的 features
  4. 执行 attached
  5. onPathSet(...) 创建 runningTree 结点;
  6. 对非 list child、非 stale 的结点执行首轮 refresh()
  7. 首轮数据回来后执行 mature
  8. 执行 ready
  9. 页面显示时执行 show
  10. 组件销毁时先 destroyNode(...),再执行 detached

Virtual 组件的路径会更简单一些,但也同样遵循“先建立结点,再进入 ready”的总体顺序。

一个简单的使用建议是:

  • created:只做最轻量的同步初始化;
  • attached:可以做订阅、埋点、非数据树依赖逻辑;
  • mature:适合“首次取数完成后的处理”;
  • ready:最适合依赖 oakFullpath、运行树方法、初始数据的逻辑;
  • detached:做清理。

特别注意:不要在 created / attached 中假定 runningTree 结点已经完全就绪。 真实源码里,路径创建和首轮 refresh 是在更后面的阶段完成的。凡是依赖数据树的方法,比如:

  • refresh
  • getId
  • update
  • setNamedFilters
  • loadMore

都更适合放在 ready 之后使用。

7. 真实项目里的几种定义方式

只看类型定义很容易抽象过头。下面几种写法,都是 haina-busitaicang 里真实存在、而且很值得借鉴的模式。

7.1 Virtual 控制页

例如 taicang/src/pages/console/account/detail/index.ts,它自己不声明 entity,而是:

  • 通过 features.applicationfeatures.consolefeatures.cache 算出当前 accountId
  • 在渲染层中,再把 oak-pay-businessAccountDetail 挂到 ${oakFullpath}.account

这种模式很适合:

  • 页面本身更像控制器,而不是单一实体页;
  • 页面要组合公共业务包组件;
  • 需要先根据当前模式、当前用户、当前系统环境推导真正要展示的实体。

7.2 动态实体组件

例如 haina-busi/src/components/business/daemon/config/index.ts,同一组件同时服务 systemroom

  • 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.tshaina-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.tsoak-pay-business/src/components/withdraw/display/index.ts 就很典型:

  • 它们都没有声明 entity
  • 主要依赖 propertiesformDatafeaturesmethods
  • 通过 features.cachefeatures.applicationfeatures.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 再包一层。

例如里面会把:

  • FilterPanel
  • List
  • ListPro
  • Detail
  • Upsert

重新导出成适配当前业务包类型的组件。

这层适配的价值主要有两点:

  • 业务包内部直接使用时,不用每次都手工补完整的泛型;
  • 项目代码和公共包代码里,entity、列定义、RowWithActions 的类型会更稳定。

如果你在自己的公共业务包里也准备封一组常用抽象组件,推荐沿用这种思路:

  1. 先以框架抽象组件为基础;
  2. 再用当前业务包的 EntityDict 做一次类型收口;
  3. 最后让业务页面统一从这一层导入。

这样做不会改变运行时逻辑,但会明显改善项目里的组件书写体验和类型一致性。

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()listenersfeatures
  • 你希望这个子块在多个 Oak 页面里被反复复用;
  • 你不想让页面里每个小块都额外再长出一棵子结点。

可以把判断标准记成一句话:如果子块需要“数据树能力”,写 Oak 组件;如果子块只需要“展示能力”,就让它退回普通 React 组件。

7.10 FilterPanel / ListPro / Detail / Upsert 的标准装配方式

再往前走一步,Oak 页面里最常见的渲染层装配,其实就是下面这几种骨架组合:

第一种,FilterPanel + ListPro 的标准列表页
像:

  • oak-general-business/src/components/user/manage/web.pc.tsx
  • oak-pay-business/src/components/pay/list/web.pc.tsx
  • oak-pay-business/src/components/withdrawTransfer/list/web.pc.tsx

都在用这个模式。它的关键点是:

  • FilterPanelListPro 共享同一条 oakFullpath
  • FilterPanel 负责筛选条件;
  • ListPro 负责表格、按钮组、行操作;
  • 外层 Oak 组件负责把列表数据先整理成渲染层需要的结构。

第二种,Detail + Upsert 的配置/详情页
像:

  • oak-pay-business/src/components/ship/wechatMpShip/web.pc.tsx
  • oak-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() 的结果。
  • pathzombie 的配置项只在页面级直接声明;子组件请改用 oakPathoakZombie
  • 如果组件只是父组件某个对象片段的展示壳,而不需要自己主动刷新,可以认真考虑是否应该设为 stale 或直接做成 Virtual 组件。
  • 如果组件依赖 oakIdoakPath、上游异步结果,优先条件渲染,避免让路径和主键在挂载后才补上。
  • 如果你的页面只是做环境判断、权限判断、业务包拼装,完全可以把它写成 Virtual 控制页,再把真正的 Entity 组件挂到子路径上。
  • 如果你做的是“列表新增/编辑弹窗”,优先用 addItem + ${oakFullpath}.${id} + execute/clean 这套标准模式,不要一上来就新开绝对路径。
  • 如果你做的是向导、结果页、选择器这类业务流程组件,可以先问自己:是否真的需要 entity,还是写成 Virtual Oak 组件更合适。
  • 如果当前单行组件里还要编辑一个强关联子对象,优先把子 upsert 挂到 ${oakFullpath}.关系名 这样的相对路径上。
  • 如果一个子块只是消费已经整理好的数据,不需要 Oak 生命周期和运行树能力,就把它拆成 pure 展示组件,而不是继续往下挂 Oak 子结点。
  • 如果你的业务包会大量复用 FilterPanelListProDetailUpsert,可以考虑先做一层 AbstractComponents 类型适配,再统一对外使用。
  • 如果你在渲染层使用 FilterPanelListProDetailUpsert,先想清楚它们消费的是哪条 Oak 路径、哪份结构化数据,不要把筛选、列表、编辑挂到三条互不相干的路径上。
  • 遇到“这个配置项到底能不能这样写”的问题,先对 oak-frontend-base/src/types/Page.ts,再对 page.react.tsxpage.common.ts,不要只凭旧文档猜。