首页
/ Strapi Content Manager Layouts:列表视图与编辑视图的布局数据结构及插件 Hook 扩展机制

Strapi Content Manager Layouts:列表视图与编辑视图的布局数据结构及插件 Hook 扩展机制

2026-09-05 14:20:34作者:明树来

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.layoutArray<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.tsuseDocumentLayout Hook 中,核心调用链为:

  1. 通过 useGetContentTypeConfigurationQuery(model) 拉取该模型持久化的配置(FindContentTypeConfiguration 响应,包含 contentType.layoutscontentType.metadatascontentType.settings 以及各组件的配置);
  2. 调用 normalizeContentManagerLayout(data, { schemas, schema, components }) 归一化;
  3. 分别执行 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.iconinfo.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];

这里有三个设计要点:

  1. 被排除的 7 种类型是 CM 专属的mediablocksrichtextuiddynamiczonecomponentrelation 不会由通用表单输入组件渲染,它们由 CM 自己的专用输入接管,因此不出现在 InputProps.type 的联合里。EditFieldLayout 用一个映射类型对 Attribute.Kind 全体做枚举,保证每个属性类型都携带对应的 attribute 原始 schema 与 type 标识。

  2. 字段值不放在布局里:注意这些输入不会收到 valueonChangeerror props——它们由 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}
          />
        );
      }
    );
    
  3. 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 时,利用 EditLayoutcomponents 属性递归渲染布局"。

真实的 CM 版 InputRenderer 在文档描述的原则之上还承担了大量 CM 特有的职责,源码注释给出了准确概括:"理解完整的 EditFieldLayout,并处理 RBAC 条件以及 Blocks / Relations 等 CM 专属组件的渲染"。具体行为包括:

  • 权限裁剪:通过 useDocumentRBACcanReadFields / 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 进行操纵"。源码链路分三步:

  1. Hook 注册:应用启动时在 StrapiApp.tsx 中执行 this.createHook(MUTATE_EDIT_VIEW_LAYOUT),Hook 名称常量定义于 constants.ts

  2. 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 ?? truesortable: 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 */]);

这段代码完整展示了文档所述机制的两个关键设计:

  1. label 的类型双态labelstring 时说明该列对应内容类型自身属性,CM 用 content-manager.content-types.{model}.{name} 的命名空间去查翻译;labelMessageDescriptor(react-intl 对象)时说明是插件注入的列(i18n、content-releases、review-workflows 即注释中点名的三类插件),直接使用该对象自带的翻译。这也解释了类型定义中 label: string | MessageDescriptor 的由来;
  2. { 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.tsxListViewPage.tsx

由此可记住三条扩展原则:

  1. 改编辑视图 → 挂载 Admin/CM/pages/EditView/mutate-edit-view-layout,你拿到的是完整 EditLayout(含 components 字典),可以自由增删改任何层级的 EditFieldLayout
  2. 改列表视图 → 挂载 Admin/CM/pages/ListView/inject-column-in-table,向 displayedHeaders 追加带 cellFormatter 的列定义;
  3. 字段值永远不进布局:布局只描述"结构 + 元数据",value / onChange / error 由表单内部经 useField(name) 解析,RBAC 裁剪与 custom field 分发集中在 CM 版 InputRenderer

这条链路也是理解 Strapi CM 一切高级功能(多语言状态列、发布工作流状态、审核工作流列等插件注入列)的共同基础:它们全部只是上述两个 waterfall 上的不同参与方。

登录后查看全文
热门项目推荐
相关项目推荐