资讯动态

Frappe UI 列表视图控件架构:受控(Controlled)与元数据驱动(Meta-driven)组件设计决策

发布时间:2026/9/15 17:47:48 来源:尧图企业网站定制
Frappe UI 列表视图控件架构受控Controlled与元数据驱动Meta-driven组件设计决策【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe本文以仓库内架构决策记录 ui/docs/adr/0001-listview-controls-are-controlled-meta-driven.md 为骨架展开。Frappe 在将 CRM 中的列表视图体验SortBy / Filter / ColumnSettings / QuickFilter 四个控件抽取为framework/ui共享库组件时确立了受控组件 元数据驱动的设计原则每个控件通过v-model独占一份状态切片字段选项由 doctype 的 Meta 在客户端派生数据的获取、持久化与默认值处理一律留给宿主应用。读完本文你将理解这一决策的动机、四个控件的受控契约、纯函数助手parseOrderBy/serializeOrderBy/getSortOptions的实现与测试以及共享状态而非事件管道的跨控件同步模式。背景CRM 原实现为何不能直接进共享库在抽取之前CRM 的 SortBy / Filter / ColumnSettings / QuickFilter 四个控件都直接绑定v-modellist——一个 frappe-ui resource 对象随后通过修改list.params.*来改变查询行为。字段选项Field Options则来自 CRM 自己的后端端点如crm.api.doc.sort_options。这种实现把控件与两样东西强耦合在一起CRM 的数据形态控件直接读写list.params意味着它们深度依赖 frappe-ui resource 的具体结构CRM 的 Views 概念排序、过滤等行为被绑定在 CRM 特有的视图抽象之上离开 CRM 无从复用。当这组控件被抽取到framework/uiFrappe 仓库内的共享 UI 库时若原样搬入等于把 CRM 的数据结构与 Views 概念一起拖进一个通用库任何非 CRM 的宿主都无法干净地接入。这正是 ADR-0001 要解决的核心问题。设计核心每个控件都是受控组件ADR-0001 给出的方案可以概括为一句话把每个控件改造成受控controlled组件。其完整契约由四条规则组成见 ui/src/components/ListView/USAGE.md 中对心智模型的总结受控Controlled每个控件通过v-model只拥有自己那一份状态切片并接收一个doctype参数。它不主动获取数据也不持久化任何东西——你给它状态它把编辑后的状态还给你。元数据驱动Meta-driven每个控件从 doctype 的 Meta经共享的useDoctypeMeta在客户端派生 Field Options即它提供哪些字段而不是从后端端点拉取。共享状态零事件管道需要互相一致的控件绑定同一个 ref。Filter 与 QuickFilter 都v-model同一个FilterCondition[]列设置与表格拖拽缩放都绑定同一个Column[]天然同步。宿主拥有剩余的一切数据获取fetching、持久化persistence、默认值处理default-handling以及跨控件装配都是宿主你的应用的职责。以 SortBy 为例受控契约落在类型上见 ui/src/components/SortBy/types.ts/** A single ordering rule: a field plus a direction. A lists ordering is an * ordered list of Sorts. See CONTEXT.md (Sort). */ export interface Sort { fieldname: string; direction: asc | desc; } /** Props for SortBy. The ordering rules are a separate v-model (Sort[]). */ export interface SortByProps { /** Doctype whose Meta drives the sortable field options. */ doctype: string; /** Hide the Sort label/prefix. */ hideLabel?: boolean; }控件自身不持有任何数据 resourcev-model绑定的是一个Sort[]数组。宿主拿到这个数组后用导出的serializeOrderBy把它序列化成 Frappe 的order_by字符串再重新请求数据。作为对照CRM 侧的modified desc 未排序这类业务默认规则同样由宿主决定控件完全不感知。源码级剖析SortBy 的受控实现ui/src/components/SortBy/SortBy.vue 是 ADR-0001 原则最完整的落地样例值得逐点拆解。v-model 与 Meta 的接入script setup langts import { computed } from vue; import { Button, Combobox, Popover } from frappe-ui; // ts-ignore — vuedraggable ships no bundled types import Draggable from vuedraggable; import { useDoctypeMeta } from ../../composables/useDoctypeMeta; import { getSortOptions } from ./getSortOptions; import type { Sort, SortByProps, SortOption } from ./types; const props withDefaults(definePropsSortByProps(), { hideLabel: false, }); // v-model is the ordered list of Sorts. The component is controlled: it only // reads and re-emits this array, never a data resource. const model defineModelSort[]({ default: () [] }); const { meta } useDoctypeMeta(props.doctype); // Field Options derived client-side from Meta — no CRM endpoint. const allOptions computedSortOption[](() getSortOptions(meta.value?.fields ?? [])); /script注意两处关键设计defineModelSort[]声明v-model即Sort[]数组注释明言组件是受控的它只读取并重新发射这个数组从不触碰数据 resource字段选项来自useDoctypeMeta(props.doctype)而非任何 CRM 端点。空状态纯字面量无默认语义ADR-0001 特别强调默认值处理留给宿主。SortBy 的空状态就是纯粹的字面状态当model为空数组时渲染一个普通的 Sort 按钮点击展开字段选择器任何默认即未排序的业务规则属于宿主。这保证了控件在不同产品中复用时不携带任何隐式业务假设。操作函数纯状态变换数组上的增、改、删、翻转、重排、清空都是对model的纯变换function addSort(option: unknown) { const fieldname fieldnameOf(option); if (!fieldname || model.value.some((s) s.fieldname fieldname)) return; model.value [...model.value, { fieldname, direction: asc }]; } function toggleDirection(index: number) { model.value model.value.map((s, i) i index ? { ...s, direction: s.direction asc ? desc : asc } : s ); } function removeSort(index: number) { model.value model.value.filter((_, i) i ! index); } function reorder(next: Sort[]) { model.value next; } function clearSort(close: () void) { model.value []; close(); }所有这些函数都只做一件事计算新的Sort[]并写回v-model。控件从不自己触发数据请求宿主监听model变化后自行处理查询。交互细节空状态与多排序状态分别渲染空时是单个 Sort 按钮多排序时按钮带数量角标badge单排序时是方向切换按钮 字段名按钮组合见模板中model.length 1分支。字段选择器复用 frappe-ui 的Combobox源码注释说明Autocomplete在上游已被弃用排序行的拖拽重排使用vuedraggable图标使用 lucide 名称。addableOptions通过计算属性排除已被选中的字段避免重复添加行内选择器通过optionsFor(fieldname)额外保留当前行字段本身使已选值保持可选。字段选项与排序行的 label 解析都走getSortOptions的结果labelFor找不到时回退为 fieldname。纯函数助手parseOrderBy 与 serializeOrderByADR-0001 提到 SortBy 伴随导出的parseOrderBy/serializeOrderBy字符串助手。它们的定位是纯函数、与 frappe-ui 无关因此可以被独立单元测试。完整实现见 ui/src/components/SortBy/orderBy.tsimport type { Sort } from ./types; /** Parse a Frappe order_by string (e.g. modified desc, name asc) into an * ordered list of Sorts. */ export function parseOrderBy(orderBy: string): Sort[] { if (!orderBy.trim()) return []; return orderBy.split(,).map((rule) { const [fieldname, direction] rule.trim().split(/\s/); return { fieldname, direction: direction desc ? desc : asc }; }); } /** Serialize a list of Sorts into the Frappe order_by wire string. The inverse * of {link parseOrderBy}; an empty list serializes to . */ export function serializeOrderBy(sorts: Sort[]): string { return sorts.map((s) ${s.fieldname} ${s.direction}).join(, ); }两个方向的转换规则parseOrderBy按逗号切分规则每条规则用空白切分出fieldname与directiondirection只有恰好为desc才视为降序否则一律默认asc无方向字段的规则默认升序空串或纯空白返回空数组。serializeOrderBy把Sort[]拼成field1 dir1, field2 dir2形式的 Frappeorder_by字符串空数组序列化为这与 Frappe 后端约定一致——空字符串即无排序。测试 ui/src/components/SortBy/tests/orderBy.test.ts 用 Vitest 覆盖了四条解析规则与三条序列化规则包括关键的往返round-trip性质it(round-trips with parseOrderBy, () { const orderBy status asc, modified desc; expect(serializeOrderBy(parseOrderBy(orderBy))).toBe(orderBy); });这组测试验证了宿主持有order_by字符串 ↔ 控件持有Sort[]数组两种形态之间的无损互转是受控组件与字符串型宿主如 CRM 存储order_by字符串对接的契约保证。此外ui/src/components/SortBy/useSort.ts 为字符串型宿主提供了一个更省心的接入方式它自持by: RefSort[]并用computed实时推导出orderBy: Refstring——宿主只需把by交给SortBy v-model拿orderBy去请求即可无需自己写serializeOrderBy调用。Field Options 的客户端派生getSortOptionsADR-0001 明确指出控件从现有的useDoctypeMeta派生 Field Options经由纯的、与 frappe-ui 无关的助手。实现位于 ui/src/components/SortBy/getSortOptions.ts它的注释声明自己是 CRM 的crm.api.doc.sort_options的纯前端移植版。/** Fieldtypes Frappe stores no value for — they cant be sorted on. Ported from * Frappes Python frappe.model.no_value_fields (not exposed client-side). */ const NO_VALUE_FIELDS new Set([ Section Break, Column Break, Tab Break, HTML, Table, Table MultiSelect, Button, Image, Fold, Heading, ]); /** Standard fields every doctype can be sorted on, appended after the meta * fields (matches crm.api.doc.sort_options). */ const STANDARD_FIELDS: ReadonlyArray{ label: string; fieldname: string } [ { label: Name, fieldname: name }, { label: Created On, fieldname: creation }, { label: Last Modified, fieldname: modified }, { label: Modified By, fieldname: modified_by }, { label: Owner, fieldname: owner }, ]; export function getSortOptions(fields: RawMetaField[]): SortOption[] { const fieldOptions fields .filter((f) !NO_VALUE_FIELDS.has(f.fieldtype) f.label f.fieldname) .map((f) toOption(f.label as string, f.fieldname)); const standard STANDARD_FIELDS.map((f) toOption(f.label, f.fieldname)); return [...fieldOptions, ...standard]; }它的派生逻辑分三步剔除无值字段类型Section Break、Column Break、Tab Break、HTML、Table、Button、Image、Fold、Heading等 Frappe 不存储值的字段类型无法排序被NO_VALUE_FIELDS集合过滤该集合是 Frappe Python 侧frappe.model.no_value_fields的前端移植。过滤无 label 或无 fieldname 的字段只有同时具备label与fieldname的字段才会成为可排序选项。追加标准字段name、creation、modified、modified_by、owner是每个 doctype 都具备的标准排序字段追加在业务字段之后与 CRM 的sort_options一致。SortOption的形状刻意与 CRM 的sort_options行保持一致value fieldname这样存量宿主可以零改动接入见 types.ts 中相关注释。由于getSortOptions是纯函数输入RawMetaField[]输出SortOption[]它同样不依赖 frappe-ui可独立测试与复用。Meta 从哪来useDoctypeMetaField Options 的源头是 doctype 的 Meta。ui/src/composables/useDoctypeMeta.ts 通过frappe.desk.form.load.getdoctype带with_parent: 1从而把子表 doctype 的 Meta 一并取回用于解析Table列获取元数据暴露meta、metas、loading、error、reload等响应式成员export function useDoctypeMeta( doctype: MaybeRefOrGetterstring ): UseDoctypeMeta { // Warm the current entry at call time; the computed tracks it from there. const current () entries.get(toValue(doctype)); current(); const entry computed(current); ... }实现上使用memoizedState按 doctype 字符串做记忆化每个 doctype 的 Meta 每个会话只请求一次所有调用方共享同一份缓存。这带来一个重要的工程结论因为 Meta 是按 doctype 缓存的宿主在切换 doctype 时用:keydoctype重挂载控件即可廉价地重建状态Meta 命中缓存无需重新请求从而完全不需要在 composable 内部写监听 doctype 变化并重置状态的 watcherADR-0005 的备选方案讨论中明确拒绝了后者。注意useDoctypeMeta只负责获取 Meta构建布局 schema 是buildLayoutFromMeta或存储式 Form Layout 路径上的joinLayout的职责——二者职责分离。跨控件同步共享 ref 优先共享 composable 延迟引入ADR-0001 中一个容易被忽略但很关键的决策是跨控件同步Filter ↔ Quick Filter、resize ↔ column width被刻意推迟到某个共享 composable 只在两个控件真正需要它时才被引入。原文档原文为Cross-control sync ... is deferred to a shared composable introduced only once two controls actually need it.从仓库后续的 ADR 可以看到这一决策如何被验证为正确ADR-0005 正是那个关键时刻QuickFilter 与 Filter 需要同步于是useListView(doctype)composable 出现它拥有Filter[]数组外加sorts与 surface 的快速过滤字段把同一个 ref 交给两个控件——Filter ↔ QuickFilter 的同步因此是自动的因为两者操作的是同一个数组无需任何事件管道。该 ADR 明确写道这就是 ADR-0001 刻意延迟的共享 composableQuickFilter 就是它应运而生的契机。ADR-0006 沿用了同样的模式ColumnSettings 与表格拖拽缩放都编辑同一列的宽度因此useListView拥有columns: RefColumn[]ColumnSettingsv-model它缩放处理器按fieldname直接把新宽度写回对应条目——两个控件绑定同一个 ref同步自动发生。换句话说共享 composable 不是第一天就建好的基础设施而是当第二个控件真正需要与第一个同步时才自然涌现的抽象。在它出现之前每个控件保持纯粹受控、彼此独立、可单独测试。受控组件模式与 frappe-ui 控件的对比ADR-0003Filter control ports CRMs Filter onto shared value-inputs从反面印证了这一原则的价值Filter 控件在抽取时曾评估过直接包装 frappe-ui 的ListFilter但因它使用{ fieldname: [operator, value] }字典模型、结构上无法对同一字段过滤两次如amount 10 AND amount 100且每类型只有 2–4 个运算符、仅支持 select/Link/text 输入最终被拒绝。受控组件用列表模型Filter[]承载条件序列化时映射为 Frappe 的[fieldname, operator, value]三元组列表直接可作为get_list的filters参数传给后端。备选方案为什么放弃另外三条路ADR-0001 在 Considered Options 一节评估了三种备选方案其取舍本身就是架构决策的核心完整继承如下备选方案结论理由绑定 CRM resource维持现状拒绝会把 CRM 的数据形态与 Views 概念拖进共享库与抽取初衷背道而驰宿主显式传入 options state完全展示式拒绝作为默认方案每个消费者都要自己重建字段列表从 Meta 派生更即插即用且与既有 meta-driven 布局构建器如 FormLayout的思路一致第一天就引入共享 composable暂时拒绝属于过早抽象先构建受控组件让 composable 在真正出现同步需求时自然涌现从 Meta 派生而非宿主显式传入这一决定与framework/ui内其他布局构建器保持一致——例如 FormLayout 的字段属性重写机制在 ADR-0009 中被设计为布局节点上的override键hidden/readOnly/reqd同样遵循schema 携带规则、渲染器统一解析的元数据驱动路线。模块组织与集成方式受控组件的工程组织遵循 ADR-0002每个控件一个独立模块文件夹SortBy/、Filter/、ColumnSettings/、QuickFilter/各自包含index.ts独立导出子路径、.vue组件、纯助手.ts以及stories/tests/子目录隔离演示与单元测试。ui/src/components/SortBy/index.ts是典型样例// SortBy — the controlled, meta-driven list-view sort control plus its pure, // frappe-ui-free helpers. String-based hosts (CRM stores a Frappe order_by // string) convert with parseOrderBy / serializeOrderBy; Field Options are // derived from doctype Meta with getSortOptions. export { default as SortBy } from ./SortBy.vue; export { parseOrderBy, serializeOrderBy } from ./orderBy; export { getSortOptions } from ./getSortOptions; export type { Sort, SortByProps, SortOption } from ./types;framework/ui并非发布到 npm 的公开包消费方通过文件系统link引用如framework/ui: link:../../frappe/ui因此它与 frappe 主仓库的 checkout 同步演进接口变更在构建期即暴露。集成时的最小形态摘自 ListView/USAGE.md让useListView/useListData两个 composable 持有状态与数据获取控件绑定到它们暴露的切片上script setup langts import { useListView, useListData } from framework/ui/ListView; const props defineProps{ doctype: string }(); const view useListView(props.doctype); // owns filter/sort/column/quick-filter state const data useListData(props.doctype, view); // turns wire projections into rows /script单控件接入则直接绑定状态切片例如SortBy v-modelview.sort.by.value :doctypedoctype /。宿主若持有的是字符串型order_byCRM 的存量形态则用parseOrderBy转成Sort[]喂给控件、用serializeOrderBy转回字符串去请求。结语ADR-0001 把从 CRM 抽取列表视图控件这一工程任务收敛为四条可复用的架构原则受控v-model持有单一状态切片、元数据驱动Field Options 客户端派生自 doctype Meta、共享状态替代事件管道需要同步的控件绑定同一 ref、以及宿主负责获取与持久化。它在源码中的落地SortBy.vue 及其纯助手与测试证明了这套契约的可实现性与可测试性而后续 ADR-0005 与 ADR-0006 则验证了共享 composable 延迟引入的预判。对于任何打算从业务应用中抽取可复用 UI 的团队这份 ADR 都提供了一个值得参照的取舍样本先做边界清晰的受控组件让抽象在真正的复用需求出现时自然生长。【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价