Odoo 16 X2ManyField 组件详细解析报告
本文由AI生成+人工校正。
X2ManyField 是 Odoo 16 视图层中处理关系型字段(One2many 和 Many2many)的核心 Owl 组件。它充当了数据管理与渲染器(Renderer)之间的桥梁。
1. 组件概述
- 注册名称:
one2many,many2many(注册于fields注册表) - 文件路径:
addons/web/static/src/views/fields/x2many/x2many_field.js - 支持类型:
one2many,many2many - 核心功能: 管理关联记录的 CRUD 操作、处理分页、控制视图模式(列表或看板)切换,并作为父组件为渲染器提供数据和操作接口。
2. 组件结构解析
2.1 属性定义 (Props)
X2ManyField 继承自 standardFieldProps,并扩展了以下属性:
addLabel: (String) 自定义“添加”按钮的文本。editable: (String) 可选值为'top'或'bottom',决定列表行内新增记录的位置。
2.2 核心 Hooks (Setup 阶段)
组件在 setup() 中通过一系列核心 Hooks 初始化逻辑:
useX2ManyCrud: 封装了基础的增删改查逻辑(saveRecord,updateRecord,removeRecord)。useActiveActions: 解析架构(Arch)中的权限,确定是否允许创建、编辑或删除记录。useAddInlineRecord: 专门处理列表行内(Inline)新增记录的逻辑。useOpenX2ManyRecord: 处理在对话框(Dialog)中打开记录详情的逻辑。useSelectCreate: 针对 Many2many 字段,处理打开记录选择器(SelectCreateDialog)的逻辑。
2.3 关键状态与计算属性 (Getters)
viewMode: 获取当前子视图的模式,通常是'list'或'kanban'。list: 返回props.value,这是一个X2ManyList对象,包含了所有的记录数据和状态。rendererProps: 这是一个非常核心的方法。它在传递给渲染器之前,会预处理archInfo:- 动态列隐藏: 遍历列定义,评估
column_invisible属性。 - 按钮组处理: 过滤掉不可见的按钮。
- 权限注入: 将
activeActions和onAdd回调传递给渲染器。
- 动态列隐藏: 遍历列定义,评估
displayAddButton: 逻辑判断是否在控制面板显示“添加”按钮(主要用于看板视图)。
3. 核心交互流程
3.1 新增记录 (onAdd)
onAdd 方法根据字段类型和配置执行不同策略:
- Many2many: 调用
selectCreate弹出已有记录的选择列表。 - 列表行内编辑: 如果配置了
editable,则调用addInLine在列表中插入空行。 - 表单弹出: 如果未配置行内编辑,则调用
_openRecord弹出空白表单对话框。
3.2 打开记录 (openRecord)
当用户点击列表行 or 看板卡片时触发。它会根据 readonly 属性决定是以编辑模式还是只读模式打开记录对话框。
3.3 分页处理 (Pager)
组件内置了对 Pager 的支持。当关联记录数超过 limit 时,pagerProps 会计算偏移量、限制数和总数,并处理翻页时的异步数据加载。
4. 与 ListRenderer 的关系
X2ManyField 本身不负责具体的表格渲染,它将这一任务委托给 ListRenderer。
- 父子通信:
X2ManyField(父) 通过t-props="rendererProps"向ListRenderer(子) 传递数据。 - 回调机制: 当用户在
ListRenderer中点击“添加一行”或“删除”时,会通过 props 调用X2ManyField定义好的方法,从而实现数据层的更新。 - 动态调整:
X2ManyField在rendererProps中会根据当前记录的上下文(Context)实时计算列的可见性,确保渲染器只显示符合条件的列。
5. 模板结构 (XML)
模板 web.X2ManyField 包含两个主要部分:
- o_x2m_control_panel: 包含可选的“添加”按钮和分页器
Pager。 - 渲染器占位符: 使用
t-if/t-elif根据viewMode动态挂载ListRenderer或KanbanRenderer。
6. 开发建议
- 自定义按钮: 如果需要在 X2M 列表上方添加自定义按钮,建议通过继承
X2ManyField并扩展其模板来实现。 - 动态列控制: 利用
column_invisible可以在不修改 JS 的情况下实现复杂的业务逻辑列隐藏。 - Context 传递: 通过
onAdd和openRecord传递的 context 是处理父子模型数据联动(如默认值填充)的关键。
7. X2ManyField 与 ListRenderer 的详细上下层传递关系
在 Odoo 16 的设计中,X2ManyField 是 控制器(Controller) 角色,而 ListRenderer 是 展示器(Presenter) 角色。两者之间存在严密的双向交互。
7.1 下层传递 (Top-Down: Data & Config)
父组件 X2ManyField 通过 rendererProps 将状态和配置“单向”流向 ListRenderer:
| 传递项 | 来源 | 作用 |
|---|---|---|
list | this.props.value | 核心数据源。包含记录集、字段元数据、排序状态、选中状态等。渲染器通过 list.records 迭代生成表格行。 |
archInfo | this.activeField.views.list | 视图结构。包含列定义(Columns)、分组信息、装饰器(Decorations)等。在传递前,父组件会过滤掉 column_invisible 的列。 |
activeActions | useActiveActions | 操作权限。告知渲染器是否显示删除按钮、是否允许拖拽排序等。 |
editable | archInfo.editable | 编辑模式。决定渲染器是支持行内编辑('top'/'bottom')还是点击弹窗。 |
openRecord | this.openRecord | 打开动作。渲染器点击行时触发的逻辑回调。 |
onAdd | this.onAdd | 新增动作。渲染器点击“添加一行”时触发的回调。 |
7.2 上层回调 (Bottom-Up: Actions & Events)
当用户在 ListRenderer 进行交互时,通过调用父组件传入的 props 方法来通知父组件更新数据:
- 新增请求 (
onAdd):- 触发:点击列表底部的
Add a line链接。 - 流程:
ListRenderer调用this.props.onAdd()-> 执行X2ManyField.onAdd()-> 触发useAddInlineRecord(行内新增) 或selectCreate(M2M选择)。
- 触发:点击列表底部的
- 打开/编辑请求 (
openRecord):- 触发:点击非编辑状态下的数据行。
- 流程:
ListRenderer调用this.props.openRecord(record)-> 执行X2ManyField.openRecord()-> 弹出FormViewDialog。
- 删除请求 (
onDeleteRecord):- 触发:点击行尾的删除/取消关联图标。
- 流程:
ListRenderer调用this.activeActions.onDelete(record)-> 执行useX2ManyCrud中的删除逻辑。
- 状态同步 (
setDirty):- 触发:行内编辑时,字段值发生变化。
- 流程:
Field组件通知渲染器 -> 渲染器调用setDirty-> 父组件追踪记录的变更状态,用于保存逻辑。
7.3 响应式核心:Reactive List
值得注意的是,list 对象是一个由 Odoo 模型层管理的 Reactive 对象。
- 当
X2ManyField增加一条记录到list时,由于 Owl 的响应式机制,ListRenderer会感知到props.list.records的变化并自动重新渲染 DOM。 - 这种机制避免了显式的事件总线(Event Bus)传递,使得上下层在数据一致性上保持同步。
8. 扩展需求
在明细列表上方增加按钮,点击后自动写入多行预设明细。新行只进入父表单的本地草稿,等用户手动保存后才落库;用户丢弃表单时,虚拟行一并丢掉。
本节对应第 6 节“自定义按钮”建议,说明应继承 X2ManyField、走哪条写入路径、以及为什么这条路径不会立刻入库。
8.1 需求边界
| 项 | 约定 |
|---|---|
| 按钮位置 | 列表上方的 o_x2m_control_panel,不是列表底部的 Add a line |
| 字段类型 | 明细行用 one2many |
| 落库时机 | 父表单保存时,随父记录一起写成 (0, 0, vals) |
| 显示条件 | !props.readonly 且 activeActions.create,与原生新增同一套权限 |
| 注册方式 | 新 widget(如 preset_one2many),不要覆盖全局 one2many |
原生 Add a line 由 ListRenderer 画在表格最后一行(o_field_x2many_list_row_add),够不着控制面板。看板视图的“添加”按钮才画在 o_x2m_control_panel 里(见 displayAddButton)。列表模式下要在表格上方放按钮,只能扩展 web.X2ManyField 模板。
8.2 为何不会立刻入库
Odoo 16 的 one2many 列表是 StaticList。按钮应复用它自己的 list.addNew(),不要对子模型发 create。
写入链路:
list.addNew({ context, position })发出CREATE命令。- 底层
_addX2ManyDefaultRecord()只调子模型的default_get,得到一个虚拟 id。 - 列表把
{operation: 'ADD', isNew: true}推进父记录的_changes。 addNew()里那次save({ savePoint: true })只把当前编辑内容记成可回滚的本地存档。BasicModel.save在savePoint为真时直接返回,不发create/write。
用户点父表单保存时,这些新行才被收成 (0, 0, vals),跟父记录同一次写入。
8.3 推荐实现路径
单独做一个小模块,三块东西:
- JS:
class PresetX2ManyField extends X2ManyField,注册成新 widget(如preset_one2many)。只有声明了这个 widget 的字段才出现按钮。 - XML:
t-inherit="web.X2ManyField",t-inherit-mode="primary"。按钮放进已有的o_x2m_control_panel。 - 视图:字段上声明 widget,子 tree 使用行内编辑,例如
editable="bottom"。
预设值从 this.props.record.data 读父记录当前值。many2one 传整数 id。context 里的 default_product_id、default_qty 会进 default_get。
8.4 按钮点击后的处理顺序
按这个顺序,不要改成直接 RPC:
- 若
this.list.editedRecord还在编辑,先switchMode("readonly", { checkValidity: true })。X2ManyField.onAdd就是这样做的。当前行校验不过就停,否则新行可能被丢弃。 - 对每一条预设调用
this.list.addNew({ context, position: "bottom" })。参数必须是对象,不要包数组。不要传mode: "edit",否则新行会进入行内编辑。参数、default_*与常见踩坑见第 9 节。 default_*覆盖不到的字段,再用返回的那条record.update(vals)补上。update只会再触发 onchange,仍然不写库。- 若子模型 onchange 会改写
default_*,以第 3 步的update为准,它发生在默认值和 onchange 之后。
多行按顺序逐条 addNew。列表已经满一页时,position: "bottom" 会把 limit 临时加一,新行留在当前页。list 是 Reactive 对象,插入后 ListRenderer 会自动重绘,不必再手动 render()。
8.5 不要走的路径
| 做法 | 结果 |
|---|---|
子记录上的 orm.create、record.save() | 立刻插入数据库 |
表单上的 type="object" 按钮 | Odoo 会先保存父记录再调方法 |
| many2many 的“新建并关联” | 脏记录会先被 save() |
覆盖全局 one2many widget | 所有 one2many 列表都会出现该按钮 |
子模型 onchange 里如果自己 create,那是服务端副作用,前端拦不住。预设行要满足 tree 上的必填字段,否则父表单保存会被校验拦住。
9. this.list.addNew 实操
按钮点击后真正写入草稿行的入口是 StaticList.addNew(Odoo 16 在 basic_relational_model.js)。它只收 一个对象,不是数组。
9.1 签名与参数
async addNew(params) {
const position = params.position;
const operation = { context: [params.context], operation: "CREATE", position };
await this.model.__bm__.save(this.__bm_handle__, { savePoint: true });
this.model.__bm__.freezeOrder(this.__bm_handle__);
await this.__syncParent(operation);
const newRecord = this.records[position === "bottom" ? this.records.length - 1 : 0];
if (params.mode === "edit") {
await newRecord.switchMode("edit");
}
return newRecord;
}
| 参数 | 类型 | 作用 |
|---|---|---|
context | Object | 追加到 default_get 的 context。default_xxx 会填到对应字段。 |
position | "top" / "bottom" | 新行插入位置。不是 "bottom" 时取 records[0]。 |
mode | "edit" / 其他 | 只有 "edit" 会切到行内编辑。预设多行时应传 "readonly",或干脆不传。 |
返回值是刚插入的那条 Record。default_* 覆盖不到的字段,再对它 await record.update(vals)。
框架里的标准调用也是对象,不是数组:
// useAddInlineRecord
await addNew({ context, mode: "edit", position: editable });
// X2ManyField.onAdd:先合并字段 context,再走 addInLine
context = makeContext([record.getFieldContext(this.props.name), context]);
return this.addInLine({ context, editable });
setup() 里这句容易看错:
this.addInLine = useAddInlineRecord({
addNew: (...args) => this.list.addNew(...args),
});
...args 打印出来是数组,但展开后 addNew 收到的仍是那个对象。自定义代码应直接传对象,不要按打印结果再包一层 []。
9.2 正确调用
const record = this.props.record;
const context = makeContext([
record.getFieldContext(this.props.name),
{ default_distance: 49.99, default_company_id: companyId },
]);
if (this.list.editedRecord) {
await this.list.editedRecord.switchMode("readonly", { checkValidity: true });
if (this.list.editedRecord) {
return; // 当前行校验失败,停
}
}
const newRecord = await this.list.addNew({
context,
mode: "readonly",
position: "top",
});
await newRecord.update({ co2_rmk: "preset" });
要点:
- 传对象
{ context, mode, position }。 - 用
makeContext([字段 context, 额外 default_])合并。第二个参数是求值环境,不会并进返回的 context。 - 多行必须逐条
await。addNew会改 list 的 commands,并发 CREATE 会互相覆盖。 - 当前行还在编辑时,先切回只读并校验,与
X2ManyField.onAdd一致。
9.3 default_ 如何落到字段
addNew 把 params.context 包成 { context: [params.context], operation: "CREATE" }。底层 _addX2ManyDefaultRecord 把每一项当作 additionalContext,再调子模型 default_get。
sanitize_default_values 只会剥掉 列表自身 context 里已有的 default_*,避免旧默认值污染新行。你这次传入的 additionalContext 不会被剥,default_distance 会进 default_get。
| 写法 | 结果 |
|---|---|
makeContext([fieldCtx, { default_distance: 49.99 }]) | distance 有默认值 |
makeContext([fieldCtx], { default_distance: 49.99 }) | 第二参只作求值环境,distance 没有默认值 |
this.list.addNew([{ context, mode, position }]) | params.context 为 undefined,default_* 全部丢失 |
9.4 错误示范:把 rest 参数当成 API
// 错:包了一层数组
this.list.addNew([{
context: { default_distance: 49.99 },
mode: "readonly",
position: "top",
}]);
此时 params 是数组:
| 取值 | 实际结果 |
|---|---|
params.context | undefined |
params.position | undefined |
params.mode | undefined |
CREATE 仍会执行,所以列表里能看到新行,但 default_get 拿不到 default_distance,字段全是空。行能建、默认值没有,就是这个原因。
9.5 完整示例:自定义按钮连插多行
继承 X2ManyField 后,在点击方法里写:
async addMultiLines() {
const record = this.props.record;
const context = makeContext([
record.getFieldContext(this.props.name),
{ default_distance: 49.99 },
]);
for (let i = 0; i < 6; i++) {
await this.list.addNew({
context,
mode: "readonly",
position: "top",
});
}
}
新行只进父表单草稿。用户保存父记录时才写成 (0, 0, vals);丢弃表单则虚拟行一起丢掉。不要对子模型 orm.create,也不要在这之后 newRecord.save()。
日期:2026-09-22