Frappe UI 列设置与表格拖拽调宽同步方案:共享 columns 引用与 frappe-ui ListView 复用(ADR-0006 全解)
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
本文解读 Frappe 前端 UI 库(
ui/目录,下称@framework/ui)中的架构决策记录ADR-0006:Column Settings(列设置)弹层与列表表格的拖拽调宽(drag-resize)如何通过一个共享的columns引用实现双向自动同步,以及表格外壳为何直接复用 frappe-ui 的ListView/ListHeader/ListHeaderItem组件。读完你将掌握这套"共享状态、零事件总线"模式的完整实现链路:从useListView组合式函数到useColumns、再到serializeColumns/applyColumnWidth等纯函数与对应测试,可直接用于你自己实现可拖拽、可持久化的列配置功能。
一、决策背景:两个控件要改同一个状态
在 CRM 列表页中,"列"这个概念有两个不同的操作入口:
- ColumnSettings 弹层:用户在这里添加/移除列、调整列顺序、重命名列标题;
- 表格表头:用户直接拖拽表头分隔线调整某列的宽度。
两者编辑的是同一列的同一份数据(是否显示、顺序、宽度),如果各自维护一份状态,就必须设计一套事件同步机制,这既容易产生时序问题,也难以维护。
ADR-0006 给出的答案是:让它们共享同一个Column[]引用(ref)。这与 ADR-0005 中 Filter 与 QuickFilter 共享同一份FilterCondition[]的做法一脉相承——这也是useListView组合式函数被"推迟"到两个控件真正需要共享状态时才落地的原因(见 useListView.ts 的注释)。
二、核心方案:一个columnsref,两个消费者
方案可以概括为三句话:
- 状态只存一份:
useListView拥有的columns(Ref<Column[]>)是唯一数据源(single source of truth,SoT); - 两端都绑这一个 ref:ColumnSettings 通过
v-model读写它,拖拽调宽处理器把新宽度按fieldname直接写回它; - 同步是自动的:因为是同一个响应式引用,一端写入,另一端立即感知,无需任何跨控件事件。
┌────────────────────────────────────────┐ │ useListView / useColumns │ │ columns: Ref<Column[]> ←── SoT ──┐ │ └────────────────────────────────────────┘ ▲ v-model │ setWidth() 写入 │ ▼ ┌──────────────────┐ ┌──────────────────────┐ │ ColumnSettings │ │ ListView / ListHeader│ │ (受控弹层) │ │ (frappe-ui 表格拖拽) │ └──────────────────┘ └──────────────────────┘代码层面的证据非常直接。useListView内部把列状态组合进来:
// ui/src/components/ListView/useListView.ts const columns = useColumns(doctype, { synthetic: options.synthetic });而useColumns返回的shown是一个可写计算属性,注释明确写着它就是 ColumnSettings 与表格拖拽调宽共同绑定的那一个 ref:
// ui/src/components/ColumnSettings/useColumns.ts /** 列表当前展示的列(顺序 = 展示顺序,width = 调宽写入的切片)。 * 默认取 doctype 中 in_list_view 的字段(来自 Meta),直到被用户自定义。 * ColumnSettings 通过 v-model 绑定它,表格的拖拽调宽也写回它—— * 两端绑定同一个 ref,无需跨控件事件即可保持同步(ADR-0006)。 */ shown: WritableComputedRef<Column[]>;shown的状态本身保存在一个customColumns = ref<Column[] | null>(null)中:null表示"使用 Meta 派生默认值",一旦有任何写入(无论是 ColumnSettings 编辑还是拖拽调宽),值就"固化"下来,不再跟随 Meta 变化——这正是"首个写入即生效"的设计。
三、调宽处理器放在 ListView 组合层,而不是 ColumnSettings
ADR-0006 明确记录了一个关键决策:拖拽调宽的处理器(resize handler)放在ListView组合层,而不是 ColumnSettings 内部。理由有两点:
- 调宽是表格行为(table behaviour),属于渲染层;
- 共享的
columns状态本来就归组合层所有,处理器放在这里可以就近访问。
这样 ColumnSettings 保持为一个专注的、受控的弹层(focused, controlled popover):它只负责展示和编辑Column[],不关心表格如何渲染、如何拖拽。
实际代码中,组合层的处理器就是useColumns返回的setWidth:
// ui/src/components/ColumnSettings/useColumns.ts /** frappe-ui 表头拖拽会发出 { key, width };把它写回 `shown`, * 正是让新宽度在 ColumnSettings 中呈现出来的关键。 */ const setWidth = (fieldname: string, width: string) => { shown.value = applyColumnWidth(shown.value, fieldname, width); };而表格侧的事件接线发生在 Shell/story 集成层。需要注意一个实现细节:frappe-ui 的ListView不会向上转发columnWidthUpdated,所以要在ListHeader上自己捕获(见 ListViewToolbar.vue 的注释):
<!-- ui/src/components/ListView/stories/ListViewToolbar.vue --> <ListHeader @columnWidthUpdated="onColumnWidthUpdated" />// frappe-ui 的 ListHeaderItem 在拖拽过程中发出 { key, width, save } // 组合层持有处理器(ADR-0006);这里忽略 save 防抖标志—— // 持久化是宿主(host)的职责——只把宽度写进共享 ref。 function onColumnWidthUpdated(event: { key: string; width: string }) { view.columns.setWidth(event.key, event.width); }四、复用 frappe-ui 的 ListView 外壳:拖拽数学与像素级对齐免费到手
第二个关键决策是:表格外壳直接复用 frappe-ui 的ListView/ListHeader/ListHeaderItem——也就是 CRM 渲染所用的同一批组件——而不是手写一个可调整宽度的自定义表头。这样可以得到三样东西:
- 拖拽数学:50px 最小宽度、px 字符串宽度等规则直接来自 frappe-ui;
- 网格布局(grid layout):列宽通过 CSS grid 的
grid-template-columns生效; - 像素级对齐:与 CRM 完全一致的渲染效果(pixel-parity)。
在ListViewToolbar的完整集成中可以看到这套复用:
<template #table> <ListView :columns="view.columns.wire.value" :rows="data.rows.value" row-key="name" :options="{ selectable: true, resizeColumn: true }" > <ListHeader @columnWidthUpdated="(e) => view.columns.setWidth(e.key, e.width)" /> <ListRows /> </ListView> </template>resizeColumn: true打开 frappe-ui 表头的拖拽调宽能力;而表格渲染所用的列(view.columns.wire.value)正是从共享的Column[]序列化而来(详见下文)。
ADR 还记录了两个被否决的备选方案,可以帮助理解设计取舍:
| 被否决的方案 | 否决理由 |
|---|---|
| 把 CRM 的拖拽数学移植进自定义表头 | 重复实现 frappe-ui 已经拥有的定位逻辑,且存在与 CRM 产生像素漂移(pixel drift)的风险;ui/CLAUDE.md明确要求优先寻找 frappe-ui 等价物 |
| 把调宽处理器放进 ColumnSettings | 会把弹层控件与表格渲染耦合;组合层本就持有共享columns和表格,处理器理应归组合层 |
在控件与表格之间用columnWidthUpdated事件同步 | 共享 ref 模式(ADR-0005)已经让事件管道成为多余 |
五、数据形状:Column只存三样东西,其余全部派生自 Meta
Column的状态形状被刻意保持精简(types.ts):
/** 列表中展示的一列:fieldname、可被用户覆盖的 label,以及可选的固定 CSS width * ("11rem"、"120px")。ColumnSettings 控件的状态就是一个有序的 Column 列表: * 存在即表示显示,数组顺序即显示顺序,width 是列调宽共同写入的切片。 * 没有 width 的列是 auto:它会弹性填满可用空间(serializeColumns 输出数值 fr); * 只有被调过宽的列才携带固定 width,去掉它(双击调宽手柄)即恢复 auto。 * 列的 align/type/options 不存这里,而是在渲染/序列化时从 Meta 派生。 * 参见 CONTEXT.md("Column")。 */ export interface Column { fieldname: string; label: string; width?: string; }关键设计要点:
width是 CSS 字符串(如"11rem"、"120px"),不是数字;- 无
width= auto:列弹性填满剩余空间,序列化时输出一个数值fr(frappe-ui 的getGridTemplateColumns把数字渲染为Nfr); align/type/options从不存进Column:它们在渲染/序列化时从 doctype 的Meta派生。例如右对齐完全由字段类型决定(Int、Float、Currency、Percent、Duration右对齐,其余左对齐,见 columns.ts 的RIGHT_ALIGNED_FIELDTYPES)。
六、双向同步的两个纯函数:applyColumnWidth与clearColumnWidth
写入共享 ref 的具体逻辑被实现为不可变更新的纯函数。拖拽 → 设置方向使用applyColumnWidth:
// ui/src/components/ColumnSettings/columns.ts /** * 把调宽后的新 width 按 fieldname 写回匹配的 Column,返回新列表 * (其余列不动;没有匹配项则原样返回)。这是 ADR-0006 同步中 * "调宽 → 设置" 的一半:ListView 组合层的 columnWidthUpdated * 处理器在共享的 columns ref 上调用它——该 ref 同样被 ColumnSettings * v-model——于是弹层里的宽度跟着拖拽走,无需任何事件管道。 */ export function applyColumnWidth( columns: Column[], fieldname: string, width: string ): Column[] { return columns.map((c) => (c.fieldname === fieldname ? { ...c, width } : c)); }**调宽的反向(恢复 auto)**使用clearColumnWidth——对应"双击调宽手柄恢复自动宽度"的手势:
/** * 按 fieldname 去掉列的固定 width,使其恢复 auto(弹性)。 * 没有 width 后,serializeColumns 会让它重新回到弹性 fr。 * 返回新列表(其余列不动;无匹配或原本就是 auto 则原样返回)。 */ export function clearColumnWidth( columns: Column[], fieldname: string ): Column[] { return columns.map((c) => { if (c.fieldname !== fieldname) return c; const { width: _omit, ...rest } = c; return rest; }); }宿主如何把"双击恢复 auto"接上?frappe-ui 的ListHeaderItem把拖拽调宽绑定在调宽手柄(resizer)的mousedown上,既不暴露双击事件也不暴露startResizing,所以 ListViewToolbar.vue 采用委托方案:在表头网格上捕获双击,找到.cursor-col-resize手柄,映射其位置到对应列,再调用resetWidth:
function onResizerDoubleClick(event: MouseEvent) { const resizer = (event.target as HTMLElement).closest(".cursor-col-resize"); const header = resizer?.closest(".grid"); if (!resizer || !header) return; const index = Array.from(header.querySelectorAll(".cursor-col-resize")).indexOf(resizer); const column = view.columns.wire.value[index]; if (column) view.columns.resetWidth(column.key); }resetWidth即useColumns中对clearColumnWidth的封装:
const resetWidth = (fieldname: string) => { shown.value = clearColumnWidth(shown.value, fieldname); };七、渲染形状与序列化:serializeColumns/parseColumns
共享的Column[]是"富状态"(rich),而 frappe-ui 表格需要的是"渲染形状"(wire shape)。两者之间的双向转换由一对纯函数负责(columns.ts):
serializeColumns(columns, fields, synthetic)→WireColumn[]:为表格生成渲染列。每个 Column 保留存储的label和width,同时从匹配的 Meta 字段派生type/options/align;无width的列输出数值fr(首列更大,AUTO_LEADING_FR = 2,让标题优先可读);不在meta.fields里的标准字段(如name)回退为左对齐的Data列。parseColumns(wire)→Column[]:逆向转换,丢弃 Meta 派生的type/options/align,只保留fieldname(来自key)与label;数值fr(auto 列)映射回"无 width",字符串width原样保留。
WireColumn的形状对应 frappe-uiListView的渲染契约,其中key相当于 CRM 中的列标识:
// ui/src/components/ColumnSettings/types.ts export interface WireColumn { label: string; key: string; width: string | number; // 固定 CSS 字符串("150px")或 auto 列的数值 fr align: "left" | "right"; type: string; options?: string; }useColumns中wire是响应式派生的,因此共享 ref 一变、表格立刻重排:
const wire = computed(() => serializeColumns(shown.value, meta.value?.fields ?? [], synthetic.value) );八、测试验证:纯函数层面锁死同步契约
同步契约有专门的单测覆盖,且刻意不挂载任何组件、只在纯函数层面验证(columnSync.test.ts)。三个用例正好对应双向同步的三个关键路径:
describe("resize ↔ ColumnSettings sync over the shared columns", () => { it("resize → settings: 一个 columnWidthUpdated 宽度落到匹配的 Column 上", () => { // 模拟组合层处理器对 { key, width } 调宽事件做的事 const next = applyColumnWidth(COLUMNS, "amount", "260px"); expect(next.find((c) => c.fieldname === "amount")?.width).toBe("260px"); // ……并且表格会以新宽度重渲染该列 expect(serializeColumns(next, FIELDS)[1].width).toBe("260px"); }); it("settings → resize: 弹层里编辑的宽度驱动渲染轨道", () => { // ColumnSettings 直接把宽度写到其 v-model 的 Column 上 const edited = COLUMNS.map((c) => c.fieldname === "status" ? { ...c, width: "6rem" } : c ); expect(serializeColumns(edited, FIELDS)[0].width).toBe("6rem"); }); it("调宽后的宽度经 parseColumns 往返后不变", () => { const resized = applyColumnWidth(COLUMNS, "status", "320px"); expect(parseColumns(serializeColumns(resized, FIELDS))).toEqual(resized); }); });测试数据还体现了另一条规则:状态里的width使用 CSS 字符串("10rem"),而测试断言里拖拽写入的"260px"/"320px"也是字符串——印证了"宽度是 CSS 字符串"的约定。
九、两个明确的边界(Consequences)
ADR-0006 最后划清了两条职责边界:
1. 持久化是宿主的职责(呼应 ADR-0001)
控件无视frappe-uisave: false/true的防抖标志,只做实时状态更新——也就是调宽事件发出后立即写进共享 ref。需要防抖保存的宿主自己监听columns(更准确地说是view.snapshot)。
useListView的snapshot把整视图状态打包成单个可序列化对象,宿主只需一个 watcher 就能覆盖所有改动(USAGE.md):
const view = useListView(props.doctype); // 任何改动——过滤、排序、列增删、拖拽调宽、快捷筛选——都会触发它 watch(view.snapshot, useDebounceFn((snap) => saveView(snap), 500));注意:snapshot只在某个控件状态真正变化时才产生新对象,因此 watcher 每次真实编辑只触发一次,不需要deep: true。拖拽调宽与增删列都不是特例——它们都编辑view.columns.shown,而它就在 snapshot 里(见 USAGE.md 的完整表格)。
如果只想把列单独存到localStorage,甚至不需要序列化辅助函数——snapshot 是纯 JSON:
localStorage.setItem(key, JSON.stringify(view.snapshot.value)); // 读取时: view.restore(JSON.parse(localStorage.getItem(key)));2. CRM 的rows与"重置/恢复默认"不属于控件
rows(要拉取的字段):fetchFields会从 wire 列集合推导出get_list需要的字段列表(name加每个列的 key,并剔除合成列;见 columns.ts),但拉取本身、以及最终返回的行数据,都是宿主/故事层的职责;- reset / reset-to-defaults:受控组件不持有"已保存/默认"状态,所以默认列集(来自 Meta 的
in_list_view字段)由useColumns提供(getDefaultColumns),ColumnSettings 只发出reset意图,由宿主调用view.columns.reset()恢复:
<ColumnSettings v-model="view.columns.shown.value" :doctype="doctype" :can-reset="view.columns.isCustomized.value" @reset="view.columns.reset()" />reset的实现就是把customColumns置回null,让shown的计算回落到 Meta 默认值:
const reset = () => { customColumns.value = null; };isCustomized(customColumns.value !== null)则驱动canReset:只有存在可撤销的自定义时才显示 Reset 按钮。
十、实践速查:如何在你的页面里接上这套同步
把 ADR-0006 的模式落到实际页面,核心只需四步(完整示例见 USAGE.md 与 ListViewToolbar.vue):
- 创建共享状态:
const view = useListView(doctype),其中view.columns.shown就是共享 ref; - 绑定弹层:
<ColumnSettings v-model="view.columns.shown.value" :doctype="doctype" />; - 绑定表格:
<ListView :columns="view.columns.wire.value" :options="{ resizeColumn: true }">,并在<ListHeader>上捕获columnWidthUpdated调view.columns.setWidth(key, width); - (可选)接上双击恢复 auto:在表头网格上委托双击事件,映射到手柄对应列后调
view.columns.resetWidth(key)。
由于 ColumnSettings 与表格绑的是同一个 ref,第 2、3 步之间不需要任何事件线——这正是 ADR-0006 与 ADR-0005 一以贯之的"共享状态优先于事件同步"的架构主张。
延伸阅读
- ADR-0001:列表控件是受控的、Meta 驱动的
- ADR-0005:QuickFilter 与 Filter 共享同一份条件列表
- ADR-0007:持久化推迟给宿主,库只产出视图快照
- ListView 使用指南(含完整可复制集成示例)
- 列状态组合式函数 useColumns 实现
- 列序列化与宽度写入纯函数
- 同步契约单测
- 集成示例 ListViewToolbar(含双击恢复 auto 的实现)
【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考