小工具系统尚处于实验阶段,需通过 `gutenberg-dashboard-widgets` 实验特性启用。API 和文件约定可能会发生变化。

本文档说明仪表盘小工具系统的各个部分如何相互关联:编写约定、构建流水线、服务端注册表、以 @wordpress/widget-primitives 形式发布的客户端契约,以及负责渲染小工具的宿主。

概述

一个小工具会经历若干阶段,每个阶段由代码库的不同部分负责:

小工具流水线:从 widgets 文件夹,经由构建与服务端注册表,到客户端包和宿主

每个阶段只向下一个阶段交付一件产物,此外再无其他:先是文件夹约定,然后是构建清单(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/)是面向包、路由和小工具的通用构建工具。具体到小工具,它会:

  1. 发现 widgets/ 下的每个目录并读取其 widget.json。
  2. 为每个小工具最多编译两个 ES 脚本模块:render(来自 render.*)和 widget(来自 widget.*)。二者均可缺失;源文件缺失则不会生成模块。每个编译出的模块都附带一个 *.asset.php,其中列出其模块依赖项和一个用于缓存失效的版本号。
  3. 生成 build/widgets/registry.php,即清单:每个小工具一个条目,包含其目录名、元数据以及构建了哪些模块。
  4. 生成 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。