Gutenberg core-data 实体记录类型系统:面向 WordPress REST API 上下文与编辑场景的 TypeScript 类型设计
Gutenberg core-data 实体记录类型系统:面向 WordPress REST API 上下文与编辑场景的 TypeScript 类型设计
发布时间:2026/9/17 11:44:40
Gutenberg core-data 实体记录类型系统面向 WordPress REST API 上下文与编辑场景的 TypeScript 类型设计【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg导读本文深入剖析 Gutenberg 仓库中packages/core-data/src/entity-types目录下的实体记录类型系统。这套类型体系为通过 WordPress REST API 获取的实体记录Post、Page、Term、User、GlobalStyles 等提供了精确的 TypeScript 类型提示与文档核心解决两大问题不同 REST APIcontext参数下的字段差异view/edit/embed三种口味与编辑场景中字段形状的变化对象变为字符串。读完本文你将掌握ContextualField、OmitNevers、RenderedText、Updatable等核心工具类型的实现原理理解getEntityRecord()、getEditedEntityRecord()等选择器的类型契约并学会如何为自定义实体类型扩展这套映射。背景core-data 中的实体记录在 Gutenberg 的编辑器架构中packages/core-data是管理 WordPress 数据实体帖子、页面、媒体、分类、主题等的核心状态管理包。它内置了 WordPress 数据 store通过wp.data.select(core)暴露一系列选择器与操作器其中最重要的就是getEntityRecord()、getEntityRecords()与getEditedEntityRecord()。实体记录类型系统正是服务于这些 API 的类型层其设计目标在文档中明确为两个用例为在各种 REST API 上下文中获取的实体记录提供类型提示与文档——让 IDE 能自动补全并解释每个字段的含义类型检查我们用于编辑实体记录的值——即发送回服务器作为更新的值。需要注意的是文档对此给出了一个重要警告这些类型建模的是期望的 API 响应这与API 操作具备完整类型安全并非同一回事。API 响应被类型断言type-cast为这些定义因此可能并不完全匹配期望——例如插件可能修改响应或者 API 端点的实现存在细微差异如有时用字符串代替数字。这提醒我们这套类型是合理的契约而非运行时的保证。上下文感知context参数决定字段的口味WordPress REST API 会根据context查询参数返回不同的响应该参数通常为view、edit或embed三者之一。core-data 的解析器resolver如getEntityRecord()和getEntityRecords()正是利用这些上下文来获取数据的相应口味。三种上下文的实际响应差异以请求/wp/v2/posts/1为例文档给出了三种上下文的完整对比请求?contextview时{ content: { protected: false, rendered: \npWelcome to WordPress. This is your first post. Edit or delete it, then start writing!/p\n }, title: { rendered: Hello world! } // other fields }请求?contextedit时{ content: { block_version: 1, protected: false, raw: !-- wp:paragraph --\npWelcome to WordPress. This is your first post. Edit or delete it, then start writing!/p\n!-- /wp:paragraph --, rendered: \npWelcome to WordPress. This is your first post. Edit or delete it, then start writing!/p\n }, title: { raw: Hello world!, rendered: Hello world! } // other fields }请求?contextembed时{ // Note content is missing title: { rendered: Hello world! } // other fields }对比三者可以清晰地看出规律contexttitle.rawtitle.renderedcontent字段content.block_versionview❌✅✅含protected❌edit✅✅✅含protected与block_version✅embed❌✅❌❌edit上下文额外暴露raw原始字段用于编辑表单回填embed上下文则做最大程度的精简仅保留跨站点嵌入展示所需字段。类型如何感知上下文由于不同上下文返回不同字段描述实体记录的类型必须感知相关 API 上下文。系统通过Context类型参数实现这一点。文档中展示了Post类型的实现骨架interface PostC extends Context { /** * A named status for the post. */ status: ContextualField PostStatus, view | edit, C ; // ... other fields ... }status字段在请求上下文为view或edit时是PostStatus类型但如果以embed上下文请求则该字段根本不会出现在Post对象上——类型层会让它消失。核心工具类型Helpers逐解析这些工具类型定义在 helpers.ts 中是整套类型系统的基石。Contextexport type Context view | edit | embed;即 REST API 的context参数。它是所有实体类型共用的第一个泛型参数。ContextualFieldContextualField使字段仅在指定的上下文中可用并在处于不同上下文时确保字段从对象中缺席export type ContextualField FieldType, AvailableInContexts extends Context, C extends Context, AvailableInContexts extends C ? FieldType : never;其核心技巧是条件类型当请求上下文C是可用上下文集合AvailableInContexts的子集时字段解析为实际的FieldType否则解析为never即字段不存在。文档给出了modified与password的对比示例interface Post C extends Context { … modified: ContextualField string, edit | view, C ; password: ContextualField string, edit, C ; … } const post: Postedit … // post.modified exists as a string // post.password exists as a string const post: Postview … // post.modified still exists as a string // post.password is missing, undefined, because were not in the edit context.modified在view和edit下都存在而password仅在edit上下文存在——因为密码字段只有在编辑时才有意义。OmitNeversOmitNevers递归移除所有类型为never的属性包括深层嵌套的type MyType { foo: string; bar: never; nested: { foo: string; bar: never; } } const x {} as OmitNeversMyType; // x is of type { foo: string; nested: { foo: string; }} // The never properties were removed entirely查看 helpers.ts 中的实现它是通过条件映射类型对每个属性键做判断若ExcludeT[K], undefined extends never则标记为never否则递归处理对象类型的属性最终用Pick只保留非never的键。该工具在Post等实体类型的导出处被使用export type Post C extends Context edit OmitNevers _BaseEntityRecords.Post C ;即内部定义的BaseEntityRecords.PostC经过OmitNevers处理后所有不可用上下文中的never字段被剔除成为外部消费的干净Post类型。RenderedTextRenderedText描述由服务器渲染的字符串——这种字符串往往与原始源字符串存在差异。文档举例包含注释定界符的块 HTML 存在于post_content中但在页面视图中渲染时这些注释会被剥离类似地插件可能修改内容或替换短代码。在 helpers.ts 中其实现为export type RenderedText C extends Context OmitNevers { /** * The source string which will be rendered on page views. */ raw: ContextualField string, edit, C ; /** * The output of the raw source after processing and filtering on the server. */ rendered: string; } ;raw仅在edit上下文中可用因为只有编辑时才需要把原始标记回填到编辑器rendered则在所有上下文都存在。源码注释还解释了一个微妙的设计决策raw在edit上下文之外被丢弃而非留作never——因为OmitNevers只递归到具有索引签名的属性接口不满足该条件留作never会在记录上存活且跨上下文联合类型时never | string会坍缩回string导致调用方读取到响应实际上会省略的字段。字段被彻底移除比留下一个never更安全。UpdatableUpdatableEntityRecord描述已编辑的实体记录Edited Entity Records。它们与常规实体记录类似但叠加了所有本地编辑。文档指出它会把某些字段从对象变为字符串。实现位于 helpers.tsexport type Updatable T extends EntityRecord edit { [ K in keyof T ]: T[ K ] extends RenderedText any ? string : T[ K ]; };这是一个映射类型遍历记录的所有键凡是类型为RenderedTextany即包含raw/rendered结构的字段的属性一律变为string。文档给出示例type Post C extends Context { title: RenderedText C ; } const post {} as Post; // post.title is an object with raw and rendered properties const updatablePost {} as Updatable Post ; // updatablePost.title is a string为什么编辑时的字段会变成字符串这背后的业务逻辑是文档强调的重点像 Post 这样的实体其title、excerpt、content等字段只能由服务器渲染。REST API 会同时暴露这些字段的原始标记raw和渲染版本rendered。在块编辑器中content.rendered可用于视觉预览content.raw可用于填充代码编辑器。然而当从 JavaScript 更新这些渲染字段时JS 无法正确地渲染任意的块标记因此它只存储没有渲染部分的原始标记又因为这是字符串整个字段就变成了字符串。API 期望我们发送的正是这种更简单的string形式——即需要存入数据库的原始形式。具体到代码层面const post wp.data.select(core).getEntityRecord( postType, post, 1, { context: view } ) // post.content is an object with two fields: protected and rendered而getEditedEntityRecord选择器返回的则是Updatable版本的记录const post wp.data.select(core).getEditedEntityRecord( postType, post, 1 ); // post.content is a string源码印证选择器与类型的对应关系getEntityRecord按上下文缓存完整记录在 selectors.ts 中getEntityRecord的实现展示了运行时如何区分上下文export const getEntityRecord ( EntityRecord extends ET.EntityRecord any | Partial ET.EntityRecord any , ( state: State, kind: string, name: string, recordId?: EntityRecordKey, query?: GetRecordsHttpQuery ): EntityRecord | undefined { logEntityDeprecation( kind, name, getEntityRecord ); const queriedState state.entities.records?.[ kind ]?.[ name ]?.queriedData; if ( ! queriedState ) { return undefined; } const context query?.context ?? default; ...可见store 的queriedData按kind → name → context分层缓存记录不同上下文含未指定时的default下的同一记录被分开存放这与类型层的上下文决定字段口味一一对应。泛型约束EntityRecord extends ET.EntityRecordany正是类型系统接入选择器的入口。getEditedEntityRecord原始记录叠加编辑在 selectors.ts 中getEditedEntityRecord使用createSelector包装其返回类型明确标注为ET.UpdatableEntityRecordexport const getEditedEntityRecord createSelector( EntityRecord extends ET.EntityRecord any ( state: State, kind: string, name: string, recordId?: EntityRecordKey ): ET.Updatable EntityRecord | false { logEntityDeprecation( kind, name, getEditedEntityRecord ); const raw getRawEntityRecord( state, kind, name, recordId ); const edited getEntityRecordEdits( state, kind, name, recordId ); // Never return a non-falsy empty object. ... if ( ! raw ! edited ) { return false; } return { ...raw, ...edited, }; }, ...其运行时语义与类型的Updatable完美对齐先把原始记录取出再把本地编辑getEntityRecordEdits铺展在其上最终结果的结构就是UpdatableEntityRecord——RenderedText字段的编辑值必然是字符串。实体类型注册baseURLParams 中的默认上下文类型层需要回答未显式指定context时记录以哪种口味返回。答案藏在实体注册配置中。entities.js 里每个实体的baseURLParams定义了默认上下文绝大多数实体postType 下的 post、page、attachment 等以baseURLParams: { context: edit }注册entities.js 等多处少数以{ context: view }注册entities.js 附近如 icon、iconCollection 等。在 index.ts 中RootEntityContexts与PostTypeEntityContexts接口明确记录了这一对应关系并通过DefaultContextOf类型推导无 context 调用时的记录类型export interface PostTypeEntityContexts { attachment: edit; page: edit; post: edit; wp_block: edit; wp_navigation: edit; }这正是getEntityRecord(postType, post, 1)不传 context 时返回Postedit的依据。从 kind/name 到类型的映射体系除了基础工具类型index.ts 还构建了一套实体注册表类型的映射体系让选择器能根据kind和name两个字符串推导出具体记录类型。PerPackageEntityRecords 与 EntityRecordPerPackageEntityRecordsC接口将core名下所有已知记录类型Base、Attachment、Comment、FontCollection、GlobalStyles、Icon、Post、Term、User、Widget、WpTemplate等二十余种组合成联合类型export type EntityRecord C extends Context edit PerPackageEntityRecords C [ keyof PerPackageEntityRecords C ];EntityRecord即所有已知记录类型的并集是选择器泛型约束的基础。它同样以edit作为默认上下文——因为编辑器的核心场景就是编辑。EntityRecordTypeskind/name → 记录类型EntityRecordTypesC将kindroot/postType/taxonomy映射到各自的记录类型表每个表再按name映射具体类型export interface EntityRecordTypes C extends Context { root: RootEntityRecordTypes C ; postType: PostTypeEntityRecordTypes C ; taxonomy: TaxonomyEntityRecordTypes C ; }其中PostTypeEntityRecordTypes内置了attachment、page、post、wp_block、wp_navigation五个核心文章类型。由此推导出EntityRecordOfKind, Name, C精确解析函数。源码注释解释了这套映射存在的意义getEntityRecord(postType, post, 1)用两个字符串指名要的记录若没有映射这些字符串只是string选择器只能承诺返回EntityRecord所有类型的并集而读取某个属性时该属性必须存在于所有成员上——这几乎无法使用。映射将字符串对解析为具体类型使post.status、post.title.raw等字段的访问具备精确的类型提示。EntityRecordOfQuery从查询推导记录类型EntityRecordOfQueryKind, Name, Query是更贴近实际调用的类型它从查询对象中解析出context并确定返回的记录查询字面量携带{ context: view }时解析为Postview携带{ context: embed }时解析为Postembed不含context时回退到DefaultContextOf即实体注册的默认上下文携带_fields时返回DeepPartial记录——因为_fields可以省略任意字段含嵌套字段类型无法精确推断取舍只能保守地让所有字段变为可选查询是联合类型时逐个成员独立解析避免keyof对联合取交集导致context被吞掉。类型安全的关键细节与测试验证编译期测试类型即测试types.jsdom.test.ts 是这套类型系统的测试套件——但其断言是类型而非运行时行为每个断言位于一个永不调用的函数中回归表现为类型错误而非测试失败。核心工具是ExpectA, B双向可赋值才解析为true否则为nevertrue satisfies ExpectA, B编译失败即测试失败。测试覆盖的场景极具参考价值EntityRecordOfpostType,post解析为PosteditgetEntityRecord(postType,post,1)通过select()推导为Postedit | undefined{ context: view }查询下password不可读、title.raw不可读有ts-expect-error标注{ context: string }这类无法钉死上下文的宽泛查询解析为Postview | Postedit | Postembed即PostInAnyContext只有三种上下文都序列化的字段可读可复用对象先赋给变量再传入会拓宽context到string同样得到上下文联合类型——这是文档在ContextOfQuery注释中强调的陷阱只有内联对象才能保持context字面量类型_fields查询返回部分记录post?.title?.raw合法而post.title.raw被类型拒绝。字段级验证与 REST schema 对齐测试还验证了字段命名与 REST 序列化 schema 精确对齐例如content.protected是booleancontent.block_version仅在edit上下文存在且为numberts-expect-error确认 view 下访问报错字段名是protected而非is_protected后者有ts-expect-error标注status接受auto-draft乃至插件注册的自定义状态needs-legal-review——这与 helpers.ts 中PostStatus的定义呼应... | (string {})这种字符串字面量联合 兜底的写法让未知状态仍被接受为字符串。扩展机制接口合并与声明合并这套类型系统刻意设计为开放可扩展的这是它服务于插件生态的关键设计。BaseEntityRecords 命名空间base-entity-records.ts 中存在一个空的BaseEntityRecords命名空间唯一目的就是通过声明合并declaration merging让消费者扩展。每个实体文件如 post.ts通过declare module ./base-entity-records将自己的定义注入该命名空间declare module ./base-entity-records { export namespace BaseEntityRecords { export interface Post C extends Context { date: string | null; id: number; link: string; slug: string; status: ContextualField PostStatus, view | edit, C ; // ... } } } export type Post C extends Context edit OmitNevers _BaseEntityRecords.Post C ;Post的定义展示了ContextualField与RenderedText的典型组合用法guid、title是RenderedTextCpassword、permalink_template、generated_slug仅edit上下文可用categories、tags是number[]等。消费方如何扩展自定义实体文档在 index.ts 中演示了插件的扩展路径测试文件 types.jsdom.test.ts 给出了完整的可运行示例declare module wordpress/core-data { // 向已存在的 kind 添加名字 interface PostTypeEntityRecordTypes C extends Context { product: Product C ; } // 注册一个全新的 kind interface EntityRecordTypes C extends Context { myShop: { order: Order C ; coupon: Coupon C }; } // 声明默认上下文 interface PostTypeEntityContexts { product: edit; } interface EntityContextDefaults { myShop: { coupon: view }; } }扩展之后select(coreStore).getEntityRecord(postType, product, 1)会自动推导出Productedit | undefined且product.price具备精确类型。测试同时验证了未声明默认上下文的 kind/name 对回退为三种上下文的联合——这是保守且安全的答案因为实体在运行时注册其baseURLParams无法从类型静态得知。未注册实体的回退行为对不在映射中的 kind/name 对如getEntityRecord(myPlugin, order, 1)选择器通过重载解析回退到更宽泛的原始签名EntityRecordany | PartialEntityRecordany行为与映射出现之前完全一致不会报错——保证运行时注册实体的兼容性。显式传入泛型getEntityRecordPostedit(...)则始终优先于映射。使用中的边界与注意事项综合文档警告与源码细节使用这套类型时有几点值得注意类型是契约而非运行时保证API 响应被类型断言到这些定义插件修改响应或端点实现差异如字符串/数字混用都可能导致运行时与类型不符必要时需要自行做运行时校验。内联字面量查询才能获得精确上下文将{ context: view }赋给变量再传入类型会被拓宽为string返回类型退化为三上下文联合title.raw、password等字段将不可读。需要精确类型时请内联书写查询对象。_fields查询始终返回部分记录由于无法从字符串静态推断字段取舍_fields请求的结果中所有字段都是可选的数组例外其元素类型保持完整读取前需做存在性检查。默认上下文来自实体注册未指定context时postType 类实体默认按edit解析、部分 root 实体按view解析这是 entities.js 中baseURLParams与 index.ts 中EntityContextDefaults共同决定的事实第三方注册实体时应同步声明。小结Gutenberg 的实体记录类型系统以Context为轴心用ContextualField表达字段在不同 REST 上下文中的存在性用OmitNevers彻底剔除不可用字段用RenderedText建模原始标记 服务端渲染的孪生结构并用Updatable精确刻画编辑场景下这些字段坍缩为字符串的事实。在此基础上EntityRecordTypes映射、DefaultContextOf默认上下文解析与EntityRecordOfQuery查询级推导共同让getEntityRecord()与getEditedEntityRecord()等选择器在消费端获得所见即所得的精确类型。配合声明合并机制第三方插件可以无缝地为自定义文章类型、分类法与全新实体 kind 扩展类型映射而 types.jsdom.test.ts 中以类型错误为断言的编译期测试则持续守护着这套复杂类型体系与真实 REST schema 之间的对齐。对于任何基于 core-data 构建编辑器功能的开发者而言理解这套类型设计既是获得 IDE 智能提示的前提也是避免在context与_fields的坑中迷失方向的关键。【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考