ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Frappe UI ListView 持久化架构解析:为何库层止步于 View Snapshot(ADR-0007)

Frappe UI ListView 持久化架构解析:为何库层止步于 View Snapshot(ADR-0007) Frappe UI ListView 持久化架构解析为何库层止步于 View SnapshotADR-0007【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe导读本文深入剖析 frappe/frappe 仓库中framework/ui列表视图ListView的持久化设计决策——ADR-0007ui/docs/adr/0007-persistence-deferred-to-host-library-tops-out-at-view-snapshot.md。该决策的核心是可复用的列表视图库只提供保存半snapshot与加载半restore两个持久化出口自身不持有任何存储、自动保存或脏标记逻辑把何时存、存哪里完全交给宿主应用host决定。读完本文你将理解这套库止步于视图快照、持久化上移到宿主的设计原则掌握用watch(view.snapshot, save)一行代码接上任意后端/localStorage的完整实操并看清五个被否决方案的深层理由。一、决策背景从 ADR-0001 一路延续下来的受控组件纪律ADR-0007 不是孤立的决策它是对 ADR-0001List View controls are controlled, meta-driven components 在组合层composite上的延续。ADR-0001 确立的规则是每个控件SortBy / Filter / QuickFilter / ColumnSettings都是受控组件——它只通过v-model拥有自己的状态切片接受doctype从useDoctypeMeta客户端派生字段选项从不自己拉取数据、从不自己持久化取数/持久化/默认值处理全部留给宿主。ADR-0007 把这条纪律从单个控件向上提升一级应用到由四个控件组合而成的整体视图上控件不触碰持久化 → 组合体useListView同样不触碰持久化。在framework/ui中useListView是组合层状态的所有者ui/src/components/ListView/useListView.ts它本身不拥有任何状态而是组合useFilters、useSort、useQuickFilter、useColumns四个子组合式暴露一个命名空间化的表面。既然控件层已经做到了不存不取组合层自然也不必越过这条线——它只需把整个视图的可定制状态打包成一个可持久化对象交出去即可。另一个关键背景是 ADR 的让抽象在第二个消费者迫使它出现时才浮现原则。ADR-0007 原文明确指出今天只有两个消费者CRM 的后端、Shell 故事的localStorage为一个真实后端去设计一整套持久化栈属于过早抽象——这正是 ADR-0001 在下一层拒绝过的事情。二、核心设计库层只暴露两个持久化出口ADR-0007 为useListView定义了恰好两个持久化能力并明确声明库内不存在以下任何东西库中不存在的东西说明ViewStorage契约没有抽象的存储接口定义usePersistedView胶水没有已持久化视图组合式存储适配器没有localStorageView/crmStandardView之类的 adapterdirty追踪没有自上次保存后是否变更的状态存在的只有一对snapshot保存半一个响应式computed类型为ListViewSnapshot——整个视图可定制状态的总和restore加载半一个支持部分加载的函数接受PartialListViewSnapshot。宿主写下的保存 API就是一行watch(view.snapshot, save) // 宿主决定何时存、存哪里宿主拥有when每次变更即存 vs. 显式按钮触发和where哪个后端、哪个 key。源码实现印证了这一对称性ui/src/components/ListView/useListView.tsconst snapshot computedListViewSnapshot(() ({ filters: filters.conditions.value, sort: sort.by.value, columns: columns.shown.value, quickFilterFields: quickFilter.fields.value, })); const restore (snapshot: PartialListViewSnapshot) { if (snapshot.filters) filters.conditions.value snapshot.filters; if (snapshot.sort) sort.by.value snapshot.sort; if (snapshot.columns) columns.shown.value snapshot.columns; if (snapshot.quickFilterFields) quickFilter.fields.value snapshot.quickFilterFields; };注意snapshot读取的是每个控件的有效状态writable computed 会解析自定义或默认值且每个控件都以不可变方式整体重新赋值、绝不原地修改更新其数组因此存储下来的快照不会在稍后的restore之下悄悄改变——这一点在useListView.ts的注释中明确说明。View Snapshot 与 View 的边界ADR-0007 强调了一个重要的概念分界库层只知道当前视图状态View Snapshot永远不知道作为具名/标准/公开/拥有实体的已保存视图View。整个视图概念比如 CRM 的CRM View Settings都留在消费应用中这与术语表glossary围绕 CRM Views 已有的围栏保持一致。概念归属特征View Snapshot库层framework/ui无身份identity-less的可序列化状态对象ViewCRM View Settings宿主CRM 等应用具名、可区分标准/公开属于业务实体三、ListViewSnapshot一个对象装下全部可定制状态ListViewSnapshot是保存与加载的统一契约ui/src/components/ListView/useListView.tsexport interface ListViewSnapshot { /** 共享的过滤条件Filter QuickFilter 的 SoT */ filters: FilterCondition[]; /** 排序规则 */ sort: Sort[]; /** 展示的列顺序、标签、宽度 */ columns: Column[]; /** QuickFilter 条带浮出的字段按展示顺序 */ quickFilterFields: FilterField[]; }几个关键设计点每个成员就是控件自己的状态形状纯 JSON 可序列化、自包含——过滤条件携带自己的字段 MetafieldQuickFilter 字段是完整的FilterField因此往返round-trip不需要额外 Meta 即可无损恢复snapshot捕获的是有效列/有效快速筛选字段自定义或 Meta 默认绝不会捕获临时的customizing编辑态标志库层因此触及天花板它止步于一个无身份的 View Snapshot这正是 ADR-0007 标题tops out at a View Snapshot的含义。测试对契约的验证ui/src/components/ListView/tests/snapshot.test.ts 用四组用例完整锁定了这一契约快照捕获每个控件的有效状态未自定义时列与快速筛选字段回落到 Meta 派生的默认值snapshot.columns/snapshot.quickFilterFields仍包含statusrestore从一个快照播种所有控件恢复的列被视为自定义isCustomized变为trueReset 可用部分快照只应用传入的成员只传{ sort }时filters 保持空、columns 保持 Meta 默认、isCustomized保持falseJSON 往返无损JSON.parse(JSON.stringify(snapshot))之后restore再取snapshot与源状态完全相等——这正是宿主持久化到后端/localStorage的路径。四、宿主如何接上持久化watch(view.snapshot, save)useListView的官方使用指南 ui/src/components/ListView/USAGE.md 给出了完整实操。4.1 一个 watcher 覆盖全部变更因为四个控件全部写入同一个snapshot对象任何变更都会以新对象替换它所以宿主只需要接一个watcherwatch(view.snapshot, (snap) saveView(snap)); // 保存下面所有内容下表列举了触发该 watcher 的全部用户操作——除此之外无需任何额外接线用户操作变更的状态snapshot 变化编辑 Filterview.filters.conditions✅重排/修改 SortByview.sort.by✅添加/移除列view.columns.shown✅拖拽表头调整列宽view.columns.shownwidth✅编辑 Quick Filter 值view.filters.conditions共享✅自定义展示哪些快速筛选view.quickFilter.fields✅snapshot仅在控件状态真正变化时才是新对象因此 watcher 每次真实编辑触发一次——不需要deep: true不会产生虚假保存。列宽拖拽与增删列不是特例它们都编辑view.columns.shown而它就在 snapshot 里。4.2 何时存autosave 与显式保存的两种策略宿主自行决定保存策略库层完全不干预// (a) 每次变更自动保存防抖避免拖拽列宽或快速输入刷爆服务器 // saveView 是你自己的函数 watch(view.snapshot, useDebounceFn(saveView, 500)); // (b) 或者只在显式点击保存按钮时保存 function onSaveClick() { saveView(view.snapshot.value); }ADR-0007 特别用 CRM 举例说明策略为何必须归宿主CRM 对标准视图采用自动保存但对具名视图只在显式按钮触发时保存——这种同应用内两种策略正是库层不该内嵌策略的原因。4.3 存到哪里数据库 RPC 与 localStorage存到数据库时view.snapshot是胖形状条件携带字段 Meta而 DocType 想要紧凑的 wire 形状用库自带的serialize*助手转换后再调用宿主自己的白名单方法import { call } from frappe-ui; import { serializeFilters } from framework/ui/Filter; import { serializeOrderBy } from framework/ui/SortBy; import { serializeColumns } from framework/ui/ColumnSettings; async function saveView(snap: ListViewSnapshot) { await call(my_app.api.save_list_view, { doctype: props.doctype, filters: serializeFilters(snap.filters), // → [[fieldname, op, value], …] order_by: serializeOrderBy(snap.sort), // → modified desc, name asc columns: serializeColumns(snap.columns, fields), // → [{ key, label, width }, …] }); }存到 localStorage则连助手都可以跳过——snapshot 本身就是纯 JSONlocalStorage.setItem(key, JSON.stringify(view.snapshot.value)); view.restore(JSON.parse(localStorage.getItem(key)));4.4 加载回来restore的部分加载能力挂载时读取宿主自己的行记录把 wire 形状用对应的parse*助手转回胖形状再交给restoreonMounted(async () { const saved await call(my_app.api.get_list_view, { doctype: props.doctype }); if (!saved) return; view.restore({ filters: parseFilters(fields, saved.filters), sort: parseOrderBy(saved.order_by), columns: parseColumns(saved.columns), }); });restore是部分的——只应用传入的键其余保持默认因此可以兼作按切片恢复只重置过滤器或分别加载分开保存的切片view.restore({ columns: parseColumns(saved.columns) }); // 只恢复列 view.restore({ filters: parseFilters(fields, saved.filters) }); // 只恢复过滤器由于每个控件都是受控的、v-model 自己的切片也可以直接给 ref 赋值view.columns.shown.value parseColumns(saved.columns)而不走restore。切片filters / sort / columns / quickFilterFields是两侧的原子单位snapshot全部保存restore加载任意子集。完整的可复制示例含:keydoctype重挂载、localStorage自动保存见 ui/src/components/ListView/USAGE.md对应的真实演示组件在 ui/src/components/ListView/stories/ListViewToolbar.vue 与 ui/src/components/ListView/stories/Shell.story.vue。五、wire 翻译是库内纯函数宿主只留自己的存储方言ADR-0007 明确指出胖↔瘦skinny↔fat的通用转换工作已经住在库内——通过已经导出的纯函数助手完成领域序列化胖→瘦解析瘦→胖源码位置过滤serializeFiltersparseFiltersui/src/components/Filter/filters.ts排序serializeOrderByparseOrderByui/src/components/SortBy/orderBy.ts列serializeColumnsparseColumnsui/src/components/ColumnSettings/columns.ts这些实现值得细读它们本身就是库内完成通用转换的证据serializeFilters每个条件变成[fieldname, wireOperator, value]三元组equals用Check 的Yes/No变成布尔列表形式而非 CRM 的字段名键控 dict允许同一字段出现多个条件parseFilters把三元组映射回 UI 算子Check 字段的布尔呈现为Yes/No的 equals字段不在fields里的条件被丢弃控件没有 Meta 无法渲染一行顺序保留以便同一字段的多个条件成对往返serializeOrderBy[{fieldname, direction}]→modified desc, name asc空列表序列化为parseOrderBy逆操作按逗号切分、按空白切分字段与方向非desc一律视为ascserializeColumns把Column[]变成 frappe-ui 渲染形状type/options/align从 Meta 派生不存储无width的列自动分配fr弹性宽度首列使用AUTO_LEADING_FRparseColumns逆操作时丢弃 Meta 派生的type/options/align只保留fieldname、label与字符串width——数字fr自动列映射回无存储 width。所以库层已经把不同宿主间真正共享的那份工作做完并导出了。留在宿主里的只有它自己的存储方言。ADR-0007 用 CRM 举例CRM 把过滤器持久化为遗留的字段名键控dict{status: Open}而不是库的标准三元组 wire 形状因此 CRM 需要写一个薄的 dict↔wire 适配器——而一个全新应用greenfield永远不会需要这种适配器。六、被否决的方案五个当时不做及其深层理由ADR-0007 的Considered Options逐一记录了五条备选路线及否决理由理解它们才能真正把握这条边界6.1 现在就交付完整持久化栈Ship the full stack now设想过ViewStorage契约 usePersistedViewautosave/显式策略localStorageView/crmStandardView适配器的完整方案。否决理由这是针对单一真实后端的投机抽象——与 ADR-0001 在下一层拒绝的正是同一件事。共享形状在第二个持久化后端预见的通用frappeDoctypeView组合式把它具体化之前无法得知届时再抽取。6.2useListView自己持有 autosave /storage选项否决理由这把策略何时存、存哪里烘焙进无头状态里。宿主必须按自己的 UX 决定自动保存还是显式保存CRM 标准视图自动保存、具名视图显式按钮保存。库把snapshot交出去就到此为止。6.3 核心中加入dirty/markClean否决理由自上次保存后是否变更是一个持久化概念而持久化归宿主所有dirty 应当搭在宿主的 watch 上或未来的usePersistedView里而不是进库核心。6.4 接受 wire 形状的restorerestoreWire否决理由这会把现有的纯parse*助手重复实现进组合式并把组合式重新耦合到 ADR-0003 刻意排除在控件之外的 wire 形状上ADR-0003 原文 将 wire 形状排除在控件之外。正确做法是宿主在调用restore之前先调用一个parse*助手——同样的宿主拥有持久化责任。6.5useListView理解一等公民的View实体id、isStandard、isPublic…否决理由这会把 CRM 的 Views 概念拖进共享库正是术语表和 ADR-0001 围栏隔离的那种耦合。库层止步于无身份的 View Snapshot。七、决策闭环这一选择带来的实际收益从仓库现状看这条决策直接落地为清晰的分工库层framework/uiuseListView只做状态组合与两个出口snapshot/restore的契约由 ui/src/components/ListView/useListView.ts 定义并由 ui/src/components/ListView/tests/snapshot.test.ts 以 JSON 往返用例锁定wire 翻译由三个纯函数模块导出宿主层接watch(view.snapshot, save)一行即可接入任意后端策略autosave/显式与存储方言dict、wire、localStorage全部留在宿主未来若出现第二个真实持久化后端再提取usePersistedView与存储适配器也不迟。这与 ADR 谱系ADR-0001、ADR-0005、ADR-0006一脉相承受控、元数据驱动、共享状态无事件管道、抽象等第二个消费者。对想要复用 ListView 的开发者而言结论很直接——库给你一个快照与一个恢复函数剩下的持久化永远是宿主自己的事。【免费下载链接】frappeLow code web framework for real world applications, in Python and Javascript项目地址: https://gitcode.com/GitHub_Trending/fr/frappe创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表