本文档说明仪表盘小工具系统的各个部分如何相互关联:编写约定、构建流水线、服务端注册表、以 @wordpress/widget-primitives 形式发布的客户端契约,以及负责渲染小工具的宿主。
概述
一个小工具会经历若干阶段,每个阶段由代码库的不同部分负责:

每个阶段只向下一个阶段交付一件产物,此外再无其他:先是文件夹约定,然后是构建清单(build/widgets/registry.php),接着是内存中的注册表(WP_Widget_Type_Registry),再是 REST 记录,最后是 WidgetType。没有任何阶段会插手另一个阶段的运作方式。
这种分离使每个阶段都能独立演进。这也正是客户端契约要放在独立包中的原因。
编写:小工具即文件夹
小工具是 widgets/ 下的一个目录,按约定被自动发现;无需编写任何注册调用:
widgets/hello-world/
├── widget.json 静态元数据(name、title、description、help、icon、actions、attributes、keywords、category、presentation、textdomain)
├── widget.ts 可选的元数据模块:默认导出 example,以及需要代码实现的属性 props
├── render.tsx 渲染模块:默认导出 React 组件
├── style.module.css 可选,由构建在运行时注入
└── report.csv 可选的静态资源;action 的 `href` 仅在 `widgets/` 随之发布的地方才会解析它
将 widget.json 与 widget.ts 分开是有意为之。widget.json 是构建时输入:流水线无需执行代码即可读取的纯 JSON,其中包含服务端通过 textdomain 进行本地化的可翻译字符串(title、description、help、keywords、属性标签)。
与其他可翻译字符串不同,help 是一个对象:content 加上可选的 links,适用于工具提示等紧凑界面。
actions:声明式动词(id、label、可选的 icon 和 relevance,外加恰好一个履行键)。目前唯一的键是 href,即链接目标,可附带可选的 download / openInNewTab。宿主挂载该原语并为其安排位置;仪表盘则根据 relevance 进行路由:'high' 和 'medium' 路由到常驻页脚,其余路由到“更多”菜单。存在于 widgets/{name}/ 之下的相对 href 在注册时会转换为插件 URL;缺失的相对非管理文件会被丢弃。data: 和 javascript: 形式的 href 会被拒绝。对于必须在插件 zip 中正常工作的资源(插件 zip 不包含 widgets/),请优先使用绝对 URL。
icon:已注册的图标名称(collection/icon-name),在客户端通过站点的 Icons API 解析。格式错误的名称会在注册时被丢弃。
attributes:宿主据此渲染设置界面的 schema,每个属性一个条目,包含 DataViews Field 的 JSON 可表达子集(id、type、label、elements、不含 custom 的 isValid 等),外加可选的 relevance 提示。type 可以指定一个已注册的字段类型。标签、标题、描述、占位符,以及选项的标签和描述,都会在服务端翻译。
widget.ts 是元数据中“动态”的那一半:只存在于 JavaScript 中的值,例如 example 数据,或某个属性的 Edit 组件或自定义验证器。它是可选的:完全由其清单声明的小工具不会包含该文件,仅凭其记录即可完成解析。
其渲染组件接收小工具的 attributes,以及可选的 setAttributes:
export default function HelloWorld( { attributes } ) { ... }
构建:从文件夹到脚本模块
@wordpress/build(packages/wp-build/)是面向包、路由和小工具的通用构建工具。具体到小工具,它会:
- 发现
widgets/下的每个目录并读取其widget.json。 - 为每个小工具最多编译两个 ES 脚本模块:
render(来自render.*)和widget(来自widget.*)。二者均可缺失;源文件缺失则不会生成模块。每个编译出的模块都附带一个*.asset.php,其中列出其模块依赖项和一个用于缓存失效的版本号。 - 生成
build/widgets/registry.php,即清单:每个小工具一个条目,包含其目录名、元数据以及构建了哪些模块。 - 生成
build/widgets.php,它会在init时为每个已构建的模块调用wp_register_script_module(),模块 ID 由文件夹名称派生(<prefix>/widgets/<dir>/render和<prefix>/widgets/<dir>/widget)。
服务端注册表
WP_Widget_Type_Registry(lib/experimental/dashboard-widgets/)是一个单例,在 init 时根据清单完成填充。每个条目都会成为一个 WP_Widget_Type,带有 name、render_module、widget_module、presentation、category、icon,以及可翻译的 title、description、help、actions、attributes 和 keywords(在注册时使用小工具的 textdomain 完成本地化)。
这一填充过程是一次确定性的复制,中间没有任何过滤器介入。widgets/ 文件夹是本代码库中小工具编写的唯一来源。
该注册表是服务端掌握的站点小工具类型权威列表。有两个消费方会读取它:
- REST 控制器(
WP_REST_Widget_Modules_Controller)将其暴露于/wp/v2/widget-modules,每条记录返回{ name, render_module, widget_module, presentation, category, title, description, help, icon, actions, attributes, keywords }。 - 仪表盘页面挂接其
dashboard-wp-admin_boot_dependencies过滤器(通用的{page-slug}-wp-admin_boot_dependencies的按页面实例),并将每个已注册模块作为dynamic依赖项加入其导入映射(import map)。动态依赖项可以通过import()访问,但绝不会被立即执行。
注册只是让 WordPress 知晓这些模块;加载它们则是单独的、由各宿主自行决定的事项。如今仪表盘正是通过针对导入映射的动态 import() 来加载小工具。宿主也可以换一种方式加载:立即将模块入队(wp_enqueue_script_module())、将其声明为自身模块的 static 依赖项,或者在 WordPress 之外跳过导入映射并通过自己的 ResolveWidgetModule 来解析模块。
注册表之所以以类的形式存在,而不是让 REST 直接读取清单,是为了让小工具类型的来源始终保持为实现细节。如今该来源只有构建清单。未来的来源(例如面向插件的注册 API)将指向注册表,而不会触及构建流水线。
客户端契约:@wordpress/widget-primitives
REST 记录之后的一切都由 @wordpress/widget-primitives 负责,它是小工具作者与宿主共同使用的契约。其完整接口(契约类型、发现钩子和渲染组件)已在 Widget Primitives / Introduction 一文中介绍。在这条流水线中,它做两件事。
useWidgetTypes( records ) 接收宿主提供的记录,导入每条记录的 widget_module 以获取动态元数据,并将其与该记录合并为 WidgetType[]。不带元数据模块的记录只要携带 render_module,就能仅凭自身字段完成解析。记录的 presentation、category、title、description、help、actions、attributes 和 keywords 全部来源于 widget.json(其中 title、description、help、actions、attributes 和 keywords 已在服务端完成本地化),它们优先于模块中的对应值。attributes 按 id 逐条目合并:双方共有的键以记录为准,模块条目补充 JSON 无法表达的内容,isValid 逐条规则合并,仅在模块中存在的条目随后并入。记录的 icon 是一个已注册的图标名称:该钩子通过应用注册的解析器(registerIconResolver)对其进行解析,解析得到的元素优先于模块中的元素。该钩子不会访问任何 store 或端点;像仪表盘这样的宿主会读取自己的 widgetModule core-data 实体(由 /wp/v2/widget-modules 支撑)并将这些记录传入。
属性还可以通过名称引用字段类型("type": "location")。应用程序会预先通过 registerFieldType() 注册这些定义(dashboard 路由在启动时注册自己的定义),而 useWidgetTypes 在构建每个 WidgetType 时会通过该注册表解析每一个具名引用:注册的定义在其 DataViews baseType 之上提供该字段的行为,宿主收到的则是普通的 DataViews 字段。小部件声明保持可序列化;解析只发生一次,就在这个边界处。
随后,<WidgetRender> 会通过宿主提供的 ResolveWidgetModule 解析 WidgetType.renderModule,并按 attributes / setAttributes 契约挂载组件。在 WordPress 页面上,解析器可以简单到 ( id ) => import( id );采用其他加载策略的宿主则提供自己的解析器。
若要逐层了解小部件通过该契约声明了哪些内容,请参阅小部件类型剖析。
宿主
dashboard 引擎(@wordpress/widget-dashboard,由 routes/dashboard/ 挂载)是本仓库当前交付的宿主。它展示了宿主拥有什么:
- 在其页面初始化模块中注册
widgetModule这一 core-data 实体,@wordpress/boot会在 dashboard 渲染之前运行该模块。 - 读取该实体的记录并将其传给
useWidgetTypes( records )。 - 拥有布局数组及其持久化。
- 为每个实例包裹自己的外壳(chrome):页头、工具栏、错误边界、Suspense 回退。
- 通过其上下文向下传递
resolveWidgetModule,并可在测试和 Storybook 中覆写。 - 在其路由层提供
links宿主能力:凡是它识别为自身路由之一的操作 href,都会挂载路由器的链接并执行客户端导航。 - 将
actions宿主能力出借给每个实例:已挂载的小部件通过useWidgetActions声明的操作会汇入宿主外壳中已声明的操作。
治理与渲染是分离的:挂载在一个或多个 dashboard 之上的 WidgetDashboard.Policy 会针对每项操作(进入定制模式、在 inserter 中提供某种小部件类型、移除、移动、调整大小或编辑某个实例)应答 canPerform,而不会触碰那个让已放置的小部件持续渲染的注册表。
同一个 WidgetType 也可以由任何其他宿主来渲染,而在哪里渲染、如何渲染完全属于宿主的职责。每个宿主都是该包的消费者,但并非每个消费者都是宿主:测试、Storybook 或仅列出小部件类型的选择器,都会在不渲染任何内容的情况下消费同一份契约。
为什么是一个独立的包
上面的流水线只在一处一分为二:直到 REST 端点为止的一切都是 WordPress 基础设施,而 WidgetType[] 之后的一切都是宿主的关切。介于两者之间的契约小巧、稳定,且双方都需要——这正是包应有的形态:
- 小部件作者依赖它为元数据标注类型并渲染组件。
- 宿主依赖它来发现并挂载小部件,而无需了解它们是如何构建或注册的。
- 任何一方都不需要另一方的依赖:该包将自身足迹保持在最低(
element,外加仅类型的dataviews);发现机制与数据源无关,因此它不再依赖core-data/data。