深入理解 TinaCMS v4 的插件清单:从 definePlugin 到字段注册表
【免费下载链接】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
本指南围绕 plugins.md 展开,讲解 TinaCMS v4(@tinacms/tinacms)中唯一的插件形态——交给definePlugin的清单(manifest),包括其name、provides、field、client与overrides各属性的职责、Capability 取值体系,以及清单如何被解析为可用的字段注册表。读完你将能读懂任何 v4 插件的源码结构,知道字段插件为什么必须有field提供项、contractVersion与tina-lock.json的关系,以及如何用overrides替换内置字段,并进一步迈入编写自定义字段插件的实战。
v4 插件模型:一种清单,取代所有专用插件函数
TinaCMS v4 对插件系统做了一个根本性的简化:v4 只有一种类型的插件,即一个交给definePlugin的清单(manifest)。在旧版本中,你会看到defineFieldPlugin、defineMediaPlugin等一整套专用函数;v4 全部废弃了这些入口,统一收敛为单个definePlugin。插件的能力(capability)由清单中provides属性的取值决定,能力的类型由此推断,不再靠函数名区分。
这一点直接体现在入口源码上:core/plugin.ts 中definePlugin只有一个参数manifest,没有任何按能力拆分的重载:
export const definePlugin = ( manifest: PluginManifestInput ): PluginManifest => ({ ...manifest, provides: manifest.provides ?? [], dependsOn: manifest.dependsOn ?? [], overrides: manifest.overrides ?? [], });文档给出的最小字段插件清单如下:
// core/plugin.ts import { definePlugin } from '@tinacms/tinacms'; definePlugin({ name: 'tina:field:string', // unique identity provides: ['field'], // capabilities it satisfies field: { type: 'string', contractVersion: 1 }, // the field it provides client: () => import('./string-field.client'), // lazy client segment });definePlugin 是恒等函数
definePlugin是一个恒等函数(identity function):它把 TypeScript 类型应用到清单上,然后原样返回这个清单。从上面的源码可以看到,它真正做的只有三件事——为可选的provides、dependsOn、overrides填充空数组默认值(避免后续消费方反复判空),其余属性全部透传。因此:
- 它不校验、不注册、不执行任何副作用,只负责让清单拥有正确的类型;
- 清单的类型从输入侧
PluginManifestInput提升为完整的PluginManifest(core/plugin.ts),provides、dependsOn、overrides在输出侧成为必填数组; - 真正的"注册"发生在别处——清单会被收集进插件数组,交给
TinaProvider/ 注册表解析流程处理(见下文"从清单到注册表")。
清单(PluginManifest)的四个核心属性
字段插件清单有四个核心属性,文档用下表概括:
| 属性 | 作用 |
|---|---|
name | 唯一身份标识。允许任意字符串;核心插件采用tina:<capability>:<key>格式。 |
provides | 该插件提供的能力。字段插件使用['field']。 |
field | 字段提供项:{ type, contractVersion }。type是该插件拥有的 schema 类型,也是注册表键(registry key);contractVersion是 codegen 锁文件(codegen/compile-schema.ts)为该类型记录的数字。 |
client | 对客户端分段(client segment)的懒加载导入,分段中持有描述符(descriptor)。 |
name:唯一身份
name是插件的全局唯一标识,任意字符串都被允许。Tina 内置插件遵循tina:<capability>:<key>约定——例如tina:field:string表示"能力为 field、键为 string"。第三方插件可自由选择命名空间,rating-field.tsx示例中自定义插件即命名为example:field:rating(见 rating-field.tsx)。
provides:声明满足哪些能力
provides声明插件满足的能力集合。字段插件填['field']。能力的具体取值见下文"Capabilities"一节,field在其中属于键控能力(keyed capability)。
field:字段提供项,字段插件的必需品
field提供项形如{ type, contractVersion }:
type:该插件拥有的 schema 类型(如string、image),同时是注册表键——注册表以Map<type, FieldDescriptor>的形式组织(见 core/field/registry.ts);contractVersion:一个数字,由 codegen 锁文件为每个类型记录(详见下文"contractVersion与 codegen 锁文件")。
字段插件必须提供field。如果插件有字段描述符却没有在清单上声明field,注册表会在客户端分段中一发现字段描述符就抛出field-plugin-no-provision错误(实现在 core/field/registry.ts):
Plugin "<name>" has a field descriptor but declares no `field: { type, contractVersion }` on its manifest.值得强调的是:type存在于清单(manifest)上,而不在描述符(descriptor)上。描述符只描述"如何渲染与校验",类型归属则由清单声明。这一分工在注册表构建时被强制检查——fieldEntryOf同时校验两侧:清单缺field抛field-plugin-no-provision,客户端分段缺field描述符则抛field-plugin-no-descriptor。
client:懒加载的客户端分段
client是一个返回 Promise 的懒加载导入,指向客户端分段(client segment)。分段中持有描述符,其类型为ClientSegment(core/plugin.ts):
export interface ClientSegment { field?: FieldDescriptor; slice?: ClientSlice; screens?: AdminScreen[]; }字段插件主要用到field(字段描述符);slice(store 切片)与screens(管理界面屏幕)是其他能力可能用到的分段内容。关于字段描述符与客户端分段的完整讲解,参见 field-plugins.md 第 2 节。
contractVersion与 codegen 锁文件
contractVersion并非装饰性字段,它直接参与 schema 编译与锁文件校验。在 codegen/compile-schema.ts 中,compileSchema会遍历所有集合里用到的字段类型,把每个类型的提供项(provision)记录进primitives映射:
for (const type of usedFieldTypes(config.schema.collections).sort()) { const provision = provisions.get(type); invariant( provision, 'schema-unknown-field-type', `The schema uses the field type "${type}", but no installed plugin provides ` + 'the `field` capability at that type.' ); primitives[type] = provision.contractVersion; }由此可以得出几个关键结论:
type是注册表键,contractVersion是该键对应的版本号。同一个 schema 类型如果升级了契约(例如描述符的字段结构发生变化),就应递增contractVersion;- schema 中出现的每个类型都必须有插件提供,否则
compileSchema直接抛出schema-unknown-field-type——这保证了锁文件tina-lock.json里的primitives永远有据可依; - 锁文件(
TinaLock,包含version、schema、primitives,当前LOCK_VERSION = 5)记录了每个原始类型的契约版本。checkLock(compile-schema.ts)会比对锁文件与当前配置,返回current/unreadable/stale/incompatible四种状态——若锁文件版本高于当前 tinacms 可写版本,会拒绝降级重写,保护团队已提交的锁文件。
测试代码同样印证了这一点:compile-schema.test.ts构造{ type, contractVersion }的插件清单用于编译验证,registry.test.ts也以{ type: 'image', contractVersion: 1 }之类的形式构造字段插件(见 compile-schema.test.ts 与 registry.test.ts)。
第五个属性overrides:替换内置字段
字段插件还可以有第五个属性overrides。当你想在某个已被占用的键上替换内置字段时,需要声明它:
definePlugin({ name: 'my:field:string', provides: ['field'], field: { type: 'string', contractVersion: 1 }, client: () => import('./my-string.client'), overrides: [{ capability: 'field', key: 'string' }], });overrides的类型为CapabilityOverride(core/plugin.ts),对字段能力是{ capability: 'field', key }的形式——key指明要替换的注册表键;对单例能力(auth、content、media、search)则是{ capability: SingletonSliceCapability },无需指定键。
为什么需要overrides?因为注册表不允许同一类型注册两个插件。如果第二个插件在同一type上注册,注册表会抛出冲突错误(core/field/registry.ts):
- 若冲突来自两个插件都声明了同一键的
overrides,报错为duplicateOverride:"Only one may replace the built-in."(只能有一个替换内置插件); - 若冲突来自普通重复注册,报错提示明确给出解法:"Declare
overrides: [{ capability: "field", key }]to replace a built-in."
这一机制在overridesFieldKey(registry.ts)与composeOverridableRegistry的配合下工作:overrides标记的条目会被视为对既有键的合法覆盖而非冲突。如何编写一个完整的替换插件,参见 field-plugins.md 的 Replace a built-in field 一节。
Capabilities:五种能力取值
Capability只有五个取值(core/plugin.ts):
export type Capability = 'field' | 'content' | 'auth' | 'media' | 'search';| 取值 | 含义 |
|---|---|
field | 字段能力,键控能力:同一时刻可以注册多个字段插件,每种 schema 类型(如string、image)一个插件 |
content | 内容能力(单例) |
auth | 认证能力(单例) |
media | 媒体能力(单例) |
search | 搜索能力(单例) |
field是唯一的键控能力,其余四种在源码中被归类为单例切片能力(SINGLETON_SLICE_CAPABILITIES,core/plugin.ts):每个单例能力在同一时刻只允许一个插件提供,而field能力则按type键并存多个插件。isSingletonSliceCapability帮助函数(core/plugin.ts)在解析阶段区分这两类能力。
清单上不止四个属性:PluginManifestInput的完整视野
虽然字段插件最常使用四个(或加overrides五个)属性,但完整的清单输入类型PluginManifestInput(core/plugin.ts)还声明了更多可选属性,供其他能力与运行时生命周期使用:
dependsOn?: Capability[]:声明插件依赖的其他能力;server?: () => Promise<{ default: ServerSegment }>:服务端分段的懒加载导入(ServerSegment是Record<string, ServerOp>,即一组服务端操作);permissions?: { name: string; description?: string }[]:权限声明(源码注释指出其类型待 codegen 的Permission联合类型落地后对齐);requires?: { permission: string }:插件运行所需权限;onInit?: () => void | Promise<void>与onDestroy?: () => void | Promise<void>:插件初始化与销毁的生命周期钩子。
这些属性说明 v4 的"单一清单"模型并非能力上的倒退,而是把所有能力入口统一收拢到一份清单里,用provides+ 可选的client/server分段来表达。文档的核心四属性表格描述的是字段插件的最小必要集。
从清单到注册表:解析与冲突检测
definePlugin只返回清单,真正的装配发生在解析阶段。梳理 architecture.md 与源码,链路如下:
<TinaProvider plugins={[...]}>调用resolveFieldPlugins(core/field/registry.ts);resolveFieldPlugins→resolveClientSegments(core/plugin.ts)逐个await每个清单的client()导入,期间做两处校验:- 声明了
field却没有client→ 抛field-plugin-no-client; - 客户端模块没有 default 导出 → 抛
plugin-client-no-default;
- 声明了
- 每个成功解析的分段被组装成
{ manifest, segment },进入createFieldRegistry,最终生成FieldRegistry,即Map<type, FieldDescriptor>(registry.ts); - 如果两个插件声明了相同的
type且没有overrides,composeOverridableRegistry依据fieldConflictError抛出冲突错误。
这一设计解释了文档中两个看似反常的约定:
client必须懒加载——浏览器只有在字段真正被渲染时才拉取体积庞大的 UI 组件(.ui.tsx),这是四个文件拆分的直接动因;type放在清单而非描述符——因为注册表键必须在所有插件解析完成后全局唯一,而描述符只是键对应的值。
端到端示例:barebones 中的五角星评分字段
仓库在 packages/v4/examples/barebones/tina/rating-field.tsx 提供了一个完整、可运行的单文件字段插件示例,把清单、描述符与组件放在一起演示了上述全部概念:
- 清单:
name: 'example:field:rating'、provides: ['field']、field: { type: 'rating', contractVersion: 1 }、client懒加载返回defineClientPlugin({ field: {...} }); - schema 辅助函数:
rating = (config) => ({ ...config, type: 'rating' as const }),供作者在集合里调用; - 描述符:
Component用useFieldAddress/useFieldValue/useFieldErrors三个 hook 实现无 props 组件,defaultValue: 0,metadata: { layout: 'inline' },并带一个自定义validate——校验值为 0 到 5 的整数,否则返回错误文案'A rating is 0 to 5 whole stars.'。
这个示例完整映射了本文档讲解的每个概念:恒等函数返回的清单、field提供项、懒加载客户端分段、以及描述符在分段内的形态。把它与上文"从清单到注册表"的解析链路对照阅读,即可形成从"写插件"到"插件生效"的闭环认知。
进一步阅读
插件系统在 v4 中是一个更大的知识体系的一部分,文档末尾的导航为你指出了后续深入方向(以下链接均已转换为仓库根目录相对路径):
- Field plugins —— 如何编写一个字段插件(四个文件、两层校验、复合字段、地址机制)
- The
stringfield —— v4 内置的文本输入 - The
booleanfield —— v4 内置的复选框 - The
numberfield —— v4 内置的数字输入 - The
datetimefield —— v4 内置的 datetime-local 输入 - The
arrayfield —— v4 内置的可重复字段 - The
selectfield —— v4 内置的固定选项选择器 - The
rich-textfield —— v4 内置的 Plate 编辑器及其控制的 markdown 正文
- The
- Architecture —— 一个插件从清单走到屏幕的完整旅程
【免费下载链接】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),仅供参考