Strapi Content Manager Layouts:列表视图与编辑视图的布局数据结构及插件 Hook 扩展机制
Layouts 是 Strapi 内容管理器(Content Manager, 简称 CM)渲染列表视图(List View)与编辑视图(Edit View)的底层驱动结构:两个视图本质上都是对一份可迭代的数据结构做三层遍历,再由统一的字段渲染组件把每个"字段布局"映射成真实的表单输入或表格列。本文基于 官方 Layouts 文档 梳理这套机制的核心概念,并结合 packages/core/content-manager 的真实源码,讲清楚布局是如何由内容类型的 schema 与配置文件合成的、编辑视图如何被插件 Hook 改写,以及插件如何通过 cellFormatter 向列表表格注入自定义列。读完本文,你可以掌握 CM 布局的完整数据链路(schema + 配置 → EditLayout/ListLayout → Hook waterfall → 渲染),并具备为 CM 扩展视图字段与表格列的能力。
什么是 Layout:List 与 Edit 两种同构的数据结构
文档的定义非常直接:Layout 就是"我们遍历它以理解视图如何渲染的数据结构"。List View 与 Edit View 的结构在设计上被刻意做成相似的,这样可以用同一套遍历逻辑渲染两种视图:
interface ListLayout {
layout: ListFieldLayout[];
components?: never;
metadatas: object;
settings: object;
}
interface EditLayout {
layout: Array<Array<EditFieldLayout[]>>;
components: Record<string, Omit<EditLayout, 'metadatas' | 'components'>>;
metadatas: object;
settings: object;
}
两者的关键差异在于 layout 的嵌套层级与 components 属性:
ListLayout.layout是一维数组,每个元素对应表格的一列(因为列表视图"本质上是一张巨大的表格");EditLayout.layout是Array<Array<EditFieldLayout[]>>,三层结构分别对应面板(Panel)、行(Row)、字段(Field);EditLayout.components是一个以组件 UID 为键的字典,每个值"本质上与其父结构相同,只是按组件做了嵌套与组织"。这种自相似(self-similar)的设计让 CM 可以用完全相同的方式迭代"主表单布局"和"组件内部布局",从而递归渲染任意深度的嵌套组件(component)与动态区块(dynamic-zone)。
在源码中,这些类型并非停留在文档层面,而是真实定义在 useDocumentLayout.ts 中,且比文档版本更具体:
interface ListFieldLayout
extends Table.Header<Document, ListFieldLayout>,
Pick<Filters.Filter, 'mainField'> {
attribute: SchemaUtils.Attribute.AnyAttribute | { type: 'custom' };
}
interface ListLayout {
layout: ListFieldLayout[];
components?: never;
metadatas: { [K in keyof Metadatas]: Metadatas[K]['list'] };
options: LayoutOptions;
settings: LayoutSettings;
}
interface EditLayout {
layout: Array<Array<EditFieldLayout[]>>;
components: {
[uid: string]: {
layout: Array<EditFieldLayout[]>;
settings: ComponentConfiguration['settings'] & { displayName?: string; icon?: string };
};
};
metadatas: { [K in keyof Metadatas]: Metadatas[K]['edit'] };
options: LayoutOptions;
settings: LayoutSettings;
}
注意 ListFieldLayout.attribute 允许 { type: 'custom' } 这个特殊值——从源码结构看,这就是为"非 schema 属性"(例如 draft & publish 的 status 列、插件注入的自定义列)预留的类型通道;文档中也明确注释了 custom 属性"预期使用 cellFormatter"。
Layout 是如何生成的:schema 与配置文件的合成
文档指出:Layout 是"内容类型的 schema 与其配置文件(configuration file)的组合",尽管两者的数据结构相似,但在根接口层面存在差异。生成过程发生在 useDocumentLayout.ts 的 useDocumentLayout Hook 中,核心调用链为:
- 通过
useGetContentTypeConfigurationQuery(model)拉取该模型持久化的配置(FindContentTypeConfiguration响应,包含contentType.layouts、contentType.metadatas、contentType.settings以及各组件的配置); - 调用
normalizeContentManagerLayout(data, { schemas, schema, components })归一化; - 分别执行
formatEditLayout(...)与formatListLayout(...)产出两种布局。
编辑视图的面板划分规则
formatEditLayout 中最值得关注的逻辑是面板(panel)的切分:
const panelledEditAttributes = convertEditLayoutToFieldLayouts(
data.contentType.layouts.edit,
schema?.attributes,
data.contentType.metadatas,
{ configurations: data.components, schemas: components },
schemas
).reduce<Array<EditFieldLayout[][]>>((panels, row) => {
if (row.some((field) => field.type === 'dynamiczone')) {
panels.push([row]); // dynamic zone 独占一个面板
currentPanelIndex += 2;
} else {
if (!panels[currentPanelIndex]) {
panels.push([row]);
} else {
panels[currentPanelIndex].push(row);
}
}
return panels;
}, []);
规则非常明确:只有 dynamic-zone 会强制开启新面板,其余字段行按配置的 panel 归属归入同一面板。组件的布局则在同一个 reduce 中遍历 data.components,对每个组件 UID 用其自身的 layouts.edit 调用同样的 convertEditLayoutToFieldLayouts,并附带组件 schema 的 info.icon 与 info.displayName 作为设置——这正呼应了前文"components 字典与父结构同构"的设计。
另外两个值得了解的实现细节:
-
默认设置兜底:布局未加载完成时,Hook 会返回
DEFAULT_SETTINGS兜底的空布局,其默认值为 useDocumentLayout.ts:const DEFAULT_SETTINGS = { bulkable: false, filterable: false, searchable: false, pagination: false, defaultSortBy: '', defaultSortOrder: 'asc', mainField: 'id', pageSize: 10, relationOpenMode: 'modal' as const, }; -
解析结果缓存:为避免每次渲染都重算布局对象导致引用变化,该文件维护了一个模块级 LRU 缓存(
RESOLVED_LAYOUT_CACHE_LIMIT = 25,见 useDocumentLayout.ts),以components/data/model/schema/schemas五个引用相等作为命中条件,保持布局对象身份的稳定性。
EditView:从 EditFieldLayout 到 InputRenderer
文档指出,编辑视图布局的最底层单元是 EditFieldLayout,它"主要派生自表单组件的 InputProps":
interface InputProps {
disabled?: boolean;
hint?: string;
label: string;
name: string;
placeholder?: string;
required?: boolean;
type: Exclude<
Attribute.Kind,
'media' | 'blocks' | 'richtext' | 'uid' | 'dynamiczone' | 'component' | 'relation'
>;
}
interface EditFieldSharedProps extends Omit<InputProps, 'type'> {
mainField?: string;
size: number;
unique?: boolean;
visible?: boolean;
}
/**
* Map over all the types in Attribute Types and use that to create a union of new types where the attribute type
* is under the property attribute and the type is under the property type.
*/
type EditFieldLayout = {
[K in Attribute.Kind]: EditFieldSharedProps & {
attribute: Extract<Attribute.Any, { type: K }>;
type: K;
};
}[Attribute.Kind];
这里有三个设计要点:
-
被排除的 7 种类型是 CM 专属的:
media、blocks、richtext、uid、dynamiczone、component、relation不会由通用表单输入组件渲染,它们由 CM 自己的专用输入接管,因此不出现在InputProps.type的联合里。EditFieldLayout用一个映射类型对Attribute.Kind全体做枚举,保证每个属性类型都携带对应的attribute原始 schema 与type标识。 -
字段值不放在布局里:注意这些输入不会收到
value、onChange、errorprops——它们由 React Hook Form 的useField(name)在组件内部取回。通用StringInput的例子(源码见 String.tsx)印证了这一点:export const StringInput = forwardRef<HTMLInputElement, InputProps>( ({ disabled, label, hint, name, placeholder, required }, ref) => { const field = useField(name); return ( <TextInput ref={ref} disabled={disabled} hint={hint} label={label} name={name} defaultValue={field.initialValue} onChange={field.onChange} placeholder={placeholder} required={required} value={field.value} /> ); } ); -
size即 12 列网格中的跨列数:在 FormLayout.tsx 中,普通字段行渲染为 12 列响应式网格,每个字段占grid-column: span {size}:<ResponsiveGridRoot key={gridRowIndex} gap={{ initial: 6, medium: 4 }}> {row.map(({ size, ...field }) => ( <ResponsiveGridItem col={size} key={field.name} s={12} xs={12} ...> <InputRenderer {...field} label={getLabel(field.name, field.label)} document={document} /> </ResponsiveGridItem> ))} </ResponsiveGridRoot>而含 dynamic-zone 的面板则整块独占一列渲染,与
formatEditLayout的面板切分规则一一对应。
CM 专属的 InputRenderer
文档说明:CM 的 EditView 域拥有自己的 InputRenderer 组件(历史名称为 helper-plugin 的 GenericInputs),"EditView 只是简单地遍历它的布局,把 props 传给 InputRenderer;遇到 dynamic-zone 或 component 时,利用 EditLayout 的 components 属性递归渲染布局"。
真实的 CM 版 InputRenderer 在文档描述的原则之上还承担了大量 CM 特有的职责,源码注释给出了准确概括:"理解完整的 EditFieldLayout,并处理 RBAC 条件以及 Blocks / Relations 等 CM 专属组件的渲染"。具体行为包括:
- 权限裁剪:通过
useDocumentRBAC取canReadFields/canUpdateFields/canCreateFields/canUserAction,无读权限的字段直接渲染NotAllowedInput占位,无编辑权限或表单禁用时置disabled; - Custom Fields 优先:若
attribute带有customField属性,则通过useLazyComponents懒加载对应的自定义字段输入(<CustomInput {...props} {...field} />),完全绕开通用输入分发——这就是文档所说"额外处理 custom-fields 等属性"的实现位置; - 值与状态仍来自
useField(props.name),与InputProps设计保持一致; - 组件/动态区块递归:遇到
component/dynamiczone时,借助renderComponentInput回调(内部递归渲染MemoizedInputRenderer)与useDocumentLayout(currentDocumentMeta.model)取回的edit.components字典继续下钻,实现文档强调的"以完全相同的方式遍历任意深度的嵌套字段"。
插件改写编辑布局:mutate-edit-view-layout Hook
文档开篇即点明:编辑视图的布局"可以通过插件使用 'Admin/CM/pages/EditView/mutate-edit-view-layout' Hook 进行操纵"。源码链路分三步:
-
Hook 注册:应用启动时在 StrapiApp.tsx 中执行
this.createHook(MUTATE_EDIT_VIEW_LAYOUT),Hook 名称常量定义于 constants.ts; -
waterfall 触发点:在 useDocumentLayout.ts 中,格式化完成的
editLayout会被送入 Hook waterfall:const { layout: edit } = React.useMemo( () => runHookWaterfall(HOOKS.MUTATE_EDIT_VIEW_LAYOUT, { layout: editLayout, query, }), [editLayout, query, runHookWaterfall] );每个挂载该 Hook 的插件按注册顺序接收
{ layout, query }并返回改写后的布局(还可携带查询参数以区分场景),最终返回的edit即被FormLayout消费。
CM 侧完整的 Hook 清单集中在 hooks.ts,除编辑布局外还包括列表视图列注入、列表过滤器注入,以及集合类型/单例类型侧边栏链接的改写:
export const HOOKS = {
INJECT_COLUMN_IN_TABLE: 'Admin/CM/pages/ListView/inject-column-in-table',
INJECT_LIST_VIEW_FILTERS: 'Admin/CM/pages/ListView/inject-in-filters',
MUTATE_COLLECTION_TYPES_LINKS: 'Admin/CM/pages/App/mutate-collection-types-links',
MUTATE_EDIT_VIEW_LAYOUT: 'Admin/CM/pages/EditView/mutate-edit-view-layout',
MUTATE_SINGLE_TYPES_LINKS: 'Admin/CM/pages/App/mutate-single-types-links',
};
ListView:ListFieldLayout 与 cellFormatter 列注入
文档指出:由于列表视图本质上是一张巨大的表格,其数据结构比编辑视图"简得多",当前阶段插件能做的"只有向表格注入列"。核心类型如下:
interface ListFieldLayout {
/**
* The attribute data from the content-type's schema for the field
*/
attribute: Attribute.Any | { type: 'custom' }; // custom attributes are expected to use `cellFormatter`.
/**
* Typically used by plugins to render a custom cell
*/
cellFormatter?: (
data: { [key: string]: unknown },
header: Omit<ListFieldLayout, 'cellFormatter'>
) => React.ReactNode;
label: string | MessageDescriptor;
/**
* the name of the attribute we use to display the actual name e.g. relations
* are just ids, so we use the mainField to display something meaningful by
* looking at the target's schema
*/
mainField?: string;
name: string;
searchable?: boolean;
sortable?: boolean;
}
各字段职责:
attribute:取自内容类型 schema 的属性原始数据;{ type: 'custom' }为插件自定义列的占位;cellFormatter:自定义单元格渲染函数,收到整行数据与注入的表头定义,返回React.ReactNode;mainField:用于"属性本身只是一个 ID"的场景(典型如 relation),通过查看目标 schema 找到有意义的展示字段;searchable/sortable:控制该列是否参与全局搜索与排序。文档中convertListLayoutToFieldLayouts的实现印证了默认值:searchable: metadata.searchable ?? true、sortable: metadata.sortable ?? true(见 useDocumentLayout.ts)。
列注入 waterfall 与标签翻译
列表页的消费点在 ListViewPage.tsx:
const tableHeaders = React.useMemo(() => {
const headers = runHookWaterfall(INJECT_COLUMN_IN_TABLE, {
displayedHeaders,
layout: list,
});
const formattedHeaders = headers.displayedHeaders.map<ListFieldLayout>((header) => {
/**
* When the header label is a string, it is an attribute on the current content-type:
* Use the attribute name value to compute the translation.
* Otherwise, it should be a translation object coming from a plugin that injects
* into the table (ie i18n, content-releases, review-workflows):
* Use the translation object as is.
*/
const translation =
typeof header.label === 'string'
? {
id: `content-manager.content-types.${model}.${header.name}`,
defaultMessage: header.label,
}
: header.label;
return {
...header,
label: formatMessage(translation),
name: `${header.name}${header.mainField?.name ? `.${header.mainField.name}` : ''}`,
};
});
if (schema?.options?.draftAndPublish) {
formattedHeaders.push({
attribute: { type: 'custom' },
name: 'status',
label: formatMessage({ id: getTranslation(`containers.list.table-headers.status`), defaultMessage: 'status' }),
searchable: false,
sortable: !hasStatusFilter,
} satisfies ListFieldLayout);
}
return formattedHeaders;
}, [/* deps */]);
这段代码完整展示了文档所述机制的两个关键设计:
label的类型双态:label为string时说明该列对应内容类型自身属性,CM 用content-manager.content-types.{model}.{name}的命名空间去查翻译;label为MessageDescriptor(react-intl 对象)时说明是插件注入的列(i18n、content-releases、review-workflows 即注释中点名的三类插件),直接使用该对象自带的翻译。这也解释了类型定义中label: string | MessageDescriptor的由来;{ type: 'custom' }的真实用例:开启了 draft & publish 的内容类型,CM 自身就会追加一个status列——它不属于 schema,因此走 custom 通道。
实战示例:i18n 插件注入 "Available in" 列
仓库内 i18n 插件提供了一个标准的 cellFormatter 注入实现(listView.tsx):
const addColumnToTableHook = ({ displayedHeaders, layout }: AddColumnToTableHookArgs) => {
const { options } = layout;
const isFieldLocalized = doesPluginOptionsHaveI18nLocalized(options)
? options.i18n.localized
: false;
if (!isFieldLocalized) {
return { displayedHeaders, layout };
}
return {
displayedHeaders: [
...displayedHeaders,
{
attribute: { type: 'string' },
label: {
id: getTranslation('list-view.table.header.label'),
defaultMessage: 'Available in',
},
searchable: false,
sortable: false,
name: 'locales',
cellFormatter: (props, _header, meta) => (
<LocaleListCell {...props} {...meta} documentId={props.documentId} />
),
},
],
layout,
};
};
这个例子恰好覆盖了 cellFormatter 设计的动机:本地化列表(locales)数据并不来自内容类型的可渲染 schema 组件,插件必须自带 LocaleListCell 组件来呈现,因此列定义里直接内嵌渲染函数;同时它演示了"按 layout.options 判断是否激活"(仅 i18n.localized 的内容类型才注入列)与"用 MessageDescriptor 携带插件自身翻译"两个惯例。对应的测试用例见 listView.test.ts。
小结:CM 布局机制的完整心智模型
将文档与源码串起来,CM 视图渲染可以归纳为一条清晰的单向数据流:
| 阶段 | 输入 | 输出 | 关键位置 |
|---|---|---|---|
| 配置合成 | schema + 持久化配置 + metadatas + 组件配置 | EditLayout / ListLayout |
useDocumentLayout.ts |
| 插件改写 | 格式化后的布局 + query | 被 waterfall 改写后的布局 | mutate-edit-view-layout / inject-column-in-table |
| 视图渲染 | 最终布局 | 面板/行/字段三层遍历 → InputRenderer;表格列 → Table |
FormLayout.tsx、ListViewPage.tsx |
由此可记住三条扩展原则:
- 改编辑视图 → 挂载
Admin/CM/pages/EditView/mutate-edit-view-layout,你拿到的是完整EditLayout(含components字典),可以自由增删改任何层级的EditFieldLayout; - 改列表视图 → 挂载
Admin/CM/pages/ListView/inject-column-in-table,向displayedHeaders追加带cellFormatter的列定义; - 字段值永远不进布局:布局只描述"结构 + 元数据",
value/onChange/error由表单内部经useField(name)解析,RBAC 裁剪与 custom field 分发集中在 CM 版InputRenderer。
这条链路也是理解 Strapi CM 一切高级功能(多语言状态列、发布工作流状态、审核工作流列等插件注入列)的共同基础:它们全部只是上述两个 waterfall 上的不同参与方。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00