TinaCMS v4 `array` 字段插件全解析:重复字段组的建模、校验与递归实现 TinaCMS v4array字段插件全解析重复字段组的建模、校验与递归实现【免费下载链接】tinacmsTinaCMS is the leading open-source headless CMS that supports Markdown and Visual Editing. Your content is stored in your own GitHub repo ❤️项目地址: https://gitcode.com/GitHub_Trending/ti/tinacmsTinaCMS v4位于packages/v4/tinacms/tinacms将每个字段类型实现为一个独立的 field plugin其中array是唯一的“复合字段”compound field示例它重复一组固定结构的子字段供内容作者自由增删、排序与编辑。本文以 v4 文档_docs/array-field.md为骨架结合 array-field.schema.ts、array-field.client.tsx、array-field.ui.tsx 等源码完整讲解t.array()的配置方式、基数校验规则、嵌套数组的递归校验、ingest/digest 数据转换以及ArrayField组件的渲染与交互实现。读完本文你将掌握在 TinaCMS v4 中建模“作者列表、标签组、多行特性”等重复结构并理解复合字段如何复用普通字段契约实现任意深度的自由组合。array字段是什么array是 v4 提供的 field plugin 之一。它重复一组固定的 item fields条目字段作者可以添加add、删除remove、重排reorder其中的条目每个条目的结构完全相同。它是典型的复合字段——关于复合字段共享的底层机制渲染、parse/serialize、校验参见 field-plugins.md。与string、number这类“单值字段”不同array存储的值是对象数组每个条目是一个普通对象键为条目内各字段自己的name。例如下方配置存储的结构是[ { name: Ada Lovelace, role: Author }, { name: Grace Hopper, role: Reviewer } ]组成array字段的四个文件和所有 field plugin 一样array的实现集中在src/plugins/fields/array/目录下的四个小文件各自职责单一文件职责array-field.schema.ts供作者调用的t.array()辅助函数以及arraySchema校验器基于 Zodarray-field.client.tsx客户端描述符descriptor以array为注册键array-field.ui.tsxArrayFieldReact 组件及其条目行item rowarray-field.plugin.ts插件清单manifest名称为tina:field:array文件如此拆分的原因与其余字段一致浏览器 bundle 只在client()动态导入生效时才加载体积较大的.ui.tsxUI 文件参见 field-plugins.md。Authoring用t.array()声明重复字段组在 collection 的fields中调用t.array({...})即可声明一个数组字段。t.array()会给配置加上type: array源码常量ARRAY_FIELD_TYPE array其实现只是把配置展开并补上类型// array-field.schema.ts export const array ( config: OmitArrayFieldSchema, type ): ArrayFieldSchema ({ ...config, type: ARRAY_FIELD_TYPE });t.array()的fields属性就是条目的模板——一个普通的FieldSchema节点数组其形状与 collection 自身的fields完全一致import { t } from tinacms/tinacms; const collection { name: post, fields: [ t.array({ name: authors, label: Authors, required: true, fields: [ t.string({ name: name, label: Name, required: true }), t.string({ name: role, label: Role }), ], }), ], };上面这个配置会渲染出一个 “Authors” 区块作者可添加多行每行包含name与role两个输入框。ArrayFieldSchema的专属属性ArrayFieldSchema继承自BaseFieldSchema额外增加了三个属性键类型作用fieldsFieldSchema[]必填条目模板每个条目都具有这一相同的形状minnumber最少允许的条目数仅设置required时隐含min: 1maxnumber最多允许的条目数值得注意min与required在源码中的处理是叠加的。arraySchema会先按min/max施加边界然后只要required为真且没有显式min 0就再补一条min: 1规则注释明确说明required是“至少 1 条”的底线min只会抬高这条底线即使写required: true, min: 0也仍然要求至少 1 条// array-field.schema.ts节选 if (field.required !(field.min field.min 0)) { schema schema.min(1, ${labelOf(field)} needs at least 1 item); }同时校验器用z.preprocess((value) value ?? [], schema)把缺失值undefined/null归一为空数组避免空值直接触发校验错误。描述符array键的客户端契约array的客户端描述符由defineClientPlugin声明。它没有defaultValue——字段缺失就保持缺失这与number、datetime的行为一致见 field-plugins.md// array-field.client.tsx export default defineClientPlugin({ field: { Component: ArrayField, // No defaultValue — an absent field stays absent, same as number/datetime. metadata: { layout: block, labelable: false }, schema: arraySchema, parse, serialize, // recurse into item fields validateChildren, // recurse into item fields, at any depth }, });metadata.layout: block是布局提示metadata.labelable: false则是因为该字段没有一个“单一输入框”可供行的htmlFor指向组件改用aria-labelledby携带自己的可访问名称见 CLAUDE.md 的 Accessibility 一节。parse / serialize逐条递归转换parse与serialize都是递归的对每条已存条目parse调用ingestDocument(item, field.fields, registry, context)对每条被编辑的条目serialize调用digestDocument(item, field.fields, registry, context)两者都定义在 core/form/ingest.ts。这意味着条目内的某个字段如果自带parse/serialize例如嵌套的datetime字段需要把字符串与日期互转会与它在顶层时走完全相同的转换路径——因为ingestDocument本身就是顶层表单初始化使用的同一套种子逻辑// core/form/ingest.ts节选 export const ingestDocument (storedDocument, fields, context) { const { registry } context; const values: TinaDocument {}; for (const node of fields) { const descriptor registry.get(node.type); const stored storedDocument?.[node.name]; if (stored ! undefined) { values[node.name] descriptor?.parse ? descriptor.parse(stored, node, context) : stored; } else if (descriptor?.defaultValue ! undefined) { values[node.name] descriptor.defaultValue; } } return values; };两个转换函数都从context.registry读取注册表FieldTransformContext定义于 core/field/contract.ts该注册表由表单 provider 与保存路径设置。若parse/serialize运行时context.registry缺失invariant会抛出array-field-no-registry错误。另外parse通过asItemArray对存储内容做严格守卫内容必须为“对象数组”否则抛出array-field-content-invalid——注释说明这里拒绝强制转换成[]是为了避免下次保存时把空数组写回文件覆盖掉原内容。validateChildren条目级校验的入口validateChildren(value, node, address, registry)负责递归校验每个条目里的每个字段。其核心循环对每个条目的每个子字段调用validateFieldTree并合并返回值core/validation.ts// array-field.client.tsx节选 validateChildren: (value, node, address, registry) { const field asArrayFieldSchema(node); const items Array.isArray(value) ? value : []; const errors: Recordstring, string[] {}; items.forEach((item, index) { for (const subfield of field.fields) { const descriptor registry.get(subfield.type); Object.assign( errors, validateFieldTree( subfield, descriptor, item?.[subfield.name], ${address}.${index}.${subfield.name}, registry ) ); } }); return errors; },关键点在于条目地址由validateChildren收到的address参数拼接而成${address}.${index}.${subfield.name}而不是node.name。这样嵌套在数组里的数组不会用它的裸名如members寻址而只会用它的真实位置如groups.0.members寻址。校验的两层体系与递归机制array的校验遵循 v4 的两层校验模型见 field-plugins.md 的 “Validation in two layers” 一节Zod 层arraySchema(node)用z.array(...).min(...).max(...)检查数组自身的基数cardinality这是第一层validateField的一部分。自定义/子字段层条目内各字段自己的规则通过validateChildren递归执行。arraySchema的基数规则及对应消息如下表配置规则消息required未设min少于 1 条label needs at least 1 itemmin少于min条label needs at least min itemsmax多于max条label allows at most max items需要强调arraySchema只检查数组自身形状不深入条目内部——条目内部各字段的规则全部经由validateChildren→validateFieldTree逐条执行。validateFieldTree正是“数组套数组也能正确校验”的根源它对条目字段先跑validateField然后——因为条目字段本身可能又是一个array——再调用该条目字段自己的validateChildren并把自身的嵌套地址传下去// core/validation.ts export const validateFieldTree (node, descriptor, value, address, registry) { const errors: Recordstring, string[] {}; const messages validateField(node, descriptor, value); if (messages.length 0) errors[address] messages; const childErrors descriptor?.validateChildren?.(value, node, address, registry); for (const [childAddress, childMessages] of Object.entries(childErrors ?? {})) { if (childMessages.length 0) errors[childAddress] childMessages; } return errors; };所以这套递归不是“数组专属代码伸进另一个数组”而是“每个复合字段调用同一个函数、各自携带自己的当前地址”。将来若有新的复合字段例如内嵌了被引用文档部分字段的reference它能以完全相同的方式免费获得这套递归并与array自由组合数组里嵌引用、引用里嵌数组两个字段的代码互不知晓对方存在。顶层解析器editor/resolver.ts对每个顶层字段发起同样的调用再把返回值合并到数组自身的基数消息旁边。因此任意深度的条目字段都会通过useFieldErrors在自己的地址上报错与顶层字段完全一致。错误消息的上卷为什么外层地址也能看到内层错误一个条目的校验消息还会到达数组自身的地址以及其上每一层祖先数组的地址——因为“折叠在某个已关闭嵌套数组里的条目”仍需要从外部可见。这并非validateChildren把消息写进树里react-hook-form 会把useFieldArray注册的名字如authors表示成一个真实的错误数组同时丢弃并排放在数组旁边的、数组自身携带的type/message——所以一旦有条目出错数组自己的地址就不再是一个独立条目。useFieldErrorseditor/hooks.ts靠读而不是写来解决这个问题collectFieldErrorMessageseditor/field-errors.ts遍历某个地址之下 react-hook-form 实际保留的树上的每个节点收集下面的每一条消息。于是useFieldErrors(authors)能看到条目的消息和useFieldErrors(authors.0.name)看到的一样对于groups/members嵌套useFieldErrors(groups)同样能看到来自groups.0.members.0.name的消息。组件实现useFieldArray驱动增删排序ArrayFieldarray-field.ui.tsx直接使用 react-hook-form 自带的useFieldArray({ control, name: address })来完成添加、删除与重排——这正是“做这件事的现成工具”且提供稳定的条目 key保证重排后 React 不会把输入框错位对齐const { fields: items, append, remove, move, } useFieldArray({ control, name: address });addItem为新条目生成默认形状调用ingestDocument({}, field.fields, { documentPath, registry })——与全新顶层表单获取初始值的种子逻辑复用同一条代码路径而不是重新实现。每个条目、每个条目字段都会渲染一行——一个指向条目字段自身嵌套地址的label除非该字段的描述符同样设置labelable: false紧接着是FieldNode address nodefunction ItemFieldRow({ address, node }: { address: string; node: FieldSchema }) { const labelable useFieldRegistry().get(node.type)?.metadata?.labelable ! false; return ( div Label id{${address}-label} htmlFor{labelable ? address : undefined} {node.label ?? node.name} /Label FieldNode address{toFieldAddress(address)} node{node} / /div ); }FieldNodeeditor/field.tsx是Field中负责解析描述符并供应FieldAddressContext/FieldSchemaContext的那一部分——它不按名字查找节点因此可以接受一个不在 collection schema 内的节点。这也是条目字段自己的useFieldActivation能直接用于可视化编辑visual editing的原因FieldNode在聚焦时把自身地址标记为 active与Field对顶层字段的行为完全一致只是这个地址恰好是嵌套地址。此外UI 为每条目提供 “Up / Down / Remove” 操作按钮并在首尾条目处禁用越界的移动按钮条目整体包在带rolegroup与aria-labelledby的容器中保证可访问性。注册连接manifest 与t.array清单文件是 array-field.plugin.ts名称为tina:field:arrayfield: { type: array, contractVersion: 1 }导出arrayFieldPlugin。contractVersion会被 codegen 锁文件记录见 codegen/compile-schema.ts。注册发生在 plugins/fields/index.ts该文件把arrayFieldPlugin加入corePlugins并把array辅助函数挂到t对象上export const t { string, boolean, number, datetime, select, array, richText }。测试覆盖array-field.test.tsx 对这些行为做了完整验证渲染每个条目的字段添加条目、删除条目、重排条目把对条目字段的编辑以嵌套地址写回 store拒绝必填字段条目过少、以及超过max的条目过多拒绝非法的条目字段值错误消息落在条目自身的嵌套地址通过直接调用validateChildren拒绝嵌套数组内部的非法值错误消息落在双重嵌套地址把条目字段的错误上卷到数组自身地址并在双重嵌套场景下继续上卷到每一层祖先数组重排后表单进入 dirty 状态编辑恢复原值后回到 clean条目经 ingest/digest 往返转换包括带自有parse/serialize的条目字段检查描述符的 metadata。这些测试正是“任意普通字段都可以免费作为array条目字段”这一契约的守护者——一个新字段类型直到被放进array的fields里并嵌套渲染之前都不能算确认可用参见 field-plugins.md “Write a new field plugin” 第 5 步。小结array字段是 TinaCMS v4 复合字段机制的完整样板作者侧用t.array()一行声明重复结构min/max/required控制基数条目内的每个字段通过ingestDocument/digestDocument走与顶层完全一致的转换路径通过validateFieldTree实现任意深度的递归校验而useFieldErrors的“读树式”上卷让外层地址也能看到折叠条目内的错误。理解array的实现等于同时理解了 v4 字段插件体系中“普通字段可免费嵌套”的设计核心。【免费下载链接】tinacmsTinaCMS is the leading open-source headless CMS that supports Markdown and Visual Editing. Your content is stored in your own GitHub repo ❤️项目地址: https://gitcode.com/GitHub_Trending/ti/tinacms创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考