博客

💬 为「Gutenberg 与 Decoupled 应用」提出新方案

Leonardo Losoviz
作者:Leonardo Losoviz ·

几天前,WPGraphQL 的作者 Jason Bahl 发表了 Gutenberg and Decoupled Applications,分析了将 GraphQL 与 Gutenberg 集成的三种方案的优缺点。

在此一周前,他还在 Twitter 上表示,Gato GraphQL 对 Gutenberg 建模的方式是不恰当的:

在我看来,这不是值得炫耀的事情。GraphQL 通过类型化 Schema 所要解决的问题之一,是为客户端提供可预测性和一致性,让客户端能够精确控制它想要的内容,细化到字段级别。

返回一个没有可预测结构的通配符「Object」类型,意味着服务器与客户端之间不再存在契约,客户端应用随时可能崩溃。服务器已经从客户端手中夺走了控制权。

通过这篇文章,我加入这场讨论。我将回应 Jason 的批评,并在此过程中介绍我的插件方案,说明为何我认为它实际上非常适合 Gutenberg。

使用 COPE 提取 Gutenberg 元数据

我的解决方案可以被视为第四种方案,具体如下:

为了获取驱动 GraphQL 的 Gutenberg 数据,不在 PHP 端创建额外的 schema,也不复制任何现有数据。而是使用 COPE(「Create Once, Publish Everywhere」)策略,从块的存储内容中提取数据。

(COPE 是一种策略,能够拥有单一的内容可信来源,并将其暴露给不同的应用。在我们的案例中,单一可信来源就是存储在数据库中的 Gutenberg 块数据。我已在这篇文章中描述了 COPE 及其在 WordPress 中的实现。)

最终,我们可以使用 GraphQL,通过将所有块映射到单一的 Block 类型,来检索任意 Gutenberg 块的提取数据。

这一策略是权衡,而非最终解决方案

这一策略并不能解决 Jason 所指出的问题:服务器端缺少 schema,无法在服务器与客户端之间建立契约。

COPE 无法解决这个问题,因为仅凭存储的内容无法重建 schema:

  • 存储的内容不说明字段的类型
  • 存储的内容不说明字段的约束(是否可为 null?是否为正整数?字符串是电子邮件还是 URL?)
  • 可为 null 的字段可能有默认值,而该值不会出现在存储的内容中

然而,通过使用 COPE 策略和单一的 Block 类型来表示所有块,Gato GraphQL 可以与 Gutenberg 构建出相当不错的集成,克服现有的局限。

我将在本文中逐一说明。

Gato GraphQL 与 Gutenberg 的集成

该解决方案仍在开发中,但我已经可以解释它的工作方式。

Gato GraphQL 不依赖每个块一种不同的类型(WPGraphQL 在使用 WPGraphQL for Gutenberg 插件时就是这么做的),而是提供单一的 Block 类型来表示所有块。

在此 Query 中,字段 Post.blockDataItems 从文章中获取 Block 元素列表(包含段落、图片、列表等各种 Gutenberg 块):

{
  post(by: { id: 1499 }) {
    title
    blockDataItems
  }
}

如果想检索特定块的数据,可以根据块的名称(core/paragraphcore/quote 等)进行过滤。

此 Query 只检索图片块:

{
  post(by: { id: 1177 }) {
    title
    blockDataItems(
      filterBy: { include: "core/image" }
    )
  }
}

审视单一的 Block 类型

采用这种方案,响应内容会因存储的内容而异,而非由 schema 决定。这一特性既是其优点(使 API 更灵活),也是其缺点(无法强制执行服务器-客户端契约)。

每个 Block 元素包含两个属性:

  • name:块的名称(core/paragraphcore/quote 等)
  • meta:块中包含的元数据

每个 Gutenberg 块各不相同,包含不同的数据(段落内容、YouTube 视频、图片来源 URL 及尺寸等)。因此,meta 字段的响应中所包含的数据也会不同。

为此,meta 字段通过 GraphQL schema 中对应的 JSONObject 类型,被简单地映射为一个 JSON 对象(可以包含"原始"数据)。

它会产生如下响应:

{
  "data": {
    "post": {
      "title": "COPE with WordPress: Post demo containing plenty of blocks",
      "blockDataItems": [
        {
          "name": "core/paragraph",
          "attributes": {
            "content": "Lorem ipsum dolor sit amet"
          }
        },
        {
          "name": "core/image",
          "attributes": {
            "src": "https://ps.w.org/gutenberg/assets/banner-1544x500.jpg"
          }
        },
        {
          "name": "core/quote",
          "attributes": {
            "quote": "Etiam tempor orci eu lobortis elementum nibh tellus molestie",
            "cite": "Aristoteles"
          }
        },
        {
          "name": "core/heading",
          "attributes": {
            "size": "xl",
            "heading": "Welcome to my site"
          }
        },
        {
          "name": "core/list",
          "attributes": {
            "items": [
              "First element",
              "Second element",
              "Third element"
            ]
          }
        },
      ]
    }
  }
}

可以看到,不同的块检索到了不同的属性:

  • core/paragraph 具有属性 content
  • core/image 具有属性 src,以及可选属性 widthheightcaption(上面的响应中未出现)
  • core/quote 具有属性 quotecite(用于被引用的人物)
  • core/heading 具有属性 headersize(值 xl 表示 <h2>,因为 COPE 将值与目标应用解耦,此处即网站)
  • core/list 具有属性 items,它是一个元素列表

为何 JSONObject 类型不在规范中

上文描述的 JSONObject 类型允许 GraphQL 检索"动态"字段(如我们预先不知道的字段),或可以有多种配置的字段(Gutenberg 块可能就是如此)。

目前,GraphQL 规范不支持 JSONObjectMap 类型。添加支持的请求已经提出,原因包括

[...] 缺少这一特性尤为棘手,因为它在 GraphQL 所对接的许多类型系统和服务中都受支持。

这导致在服务器端实现自定义解析器,在客户端实现自定义转换,以应对服务器发送 Map、客户端期望 Map、而 GraphQL 居中却不支持 Map 的情况。是的,这是可行的,我也确实这样做过,但这需要相当多的样板代码和抽象层,似乎违背了__用 GraphQL 编写 API 规范__的初衷。

这一特性未被规范支持,是因为处理动态字段违背了 GraphQL 的强类型行为,破坏了服务器与客户端之间的契约。

尽管如此,这一类型对 Gutenberg 仍然可能是有益的,后文将予以说明。

每块一种不同类型加服务器端注册表的问题

如果为每个块创建一种新的 GraphQL 类型,那么所有插件都必须将其块添加到 GraphQL schema 中。这可以通过让所有块在提议的新服务器端注册表中定义其属性来自动实现。

如果不这样做,这些块将无法通过 API 访问,并可能产生更多后果。在某些情况下,被查询文章的整体内容可能变得不可靠。

当 GraphQL 与外部云服务交互,并对文章中所有块应用某些功能(例如翻译、语法修正、SEO 建议、分析等)时,就会出现这种情况。

让我们看一个例子。

由于多语言功能将在 Gutenberg 第四阶段加入,让我们模拟如何通过 @strTranslate 指令调用 Google Translate API,来翻译插件中的所有块。

(在这次基于 API 的初始翻译后,用户可以继续在 WordPress 编辑器中用翻译后的语言编辑博客文章。)

不同的块包含需要翻译的不同信息:

  • core/paragraph:文本
  • core/image:说明文字
  • core/quote:引用内容,以及被引用的人物(因为可能是此人的头衔,例如「校长」)
  • core/heading:标题
  • core/list:列表中的所有项目

使用每块一种不同类型的方案,结果 Query 可能如下所示:

{
  post(by: { id: 1 }) {
    blocks {
      ... on CoreParagraphBlock {
        content @strTranslate
      }
      ... on CoreImageBlock {
        caption @strTranslate
      }
      ... on CoreQuoteBlock {
        quote @strTranslate
        cite @strTranslate
      }
      ... on CoreHeadingBlock {
        heading @strTranslate
      }
      ... on CoreListBlock {
        items @strTranslateList
      }
      ... on EmbedTwitterBlock {
        caption @strTranslate
      }
      ... on EmbedYoutubeBlock {
        caption @strTranslate
      }
      ... on EmbedVimeoBlock {
        caption @strTranslate
      }
    }
  }
}

以此类推。块越多,这个 Query 就越长,轻松超过一百行甚至更多。

显而易见的问题是,这个 Query 会变成一头需要我们持续维护的猛兽。

此外,我们还需要为每个块引入自定义功能才能使其正常工作。例如,@strTranslate 不适用于 CoreListBlock.items,因为它返回的是字符串列表(即返回 [String],而指令期望 String),因此我们不得不创建 @strTranslateList

然后 core/table 还需要自己的自定义指令(@strTranslateTable?)。

第三方自定义块也可能需要各自的自定义指令。

除此之外,我还看到了另外两个问题。

全有或全无

一篇博客文章可能包含 WordPress 编辑器中安装的任意块。而在编写 Query 时,我们无法提前知道文章使用了哪些块。

这样一来,每块一种类型的方案下,Query 中需要处理的类型数量不会等于文章中块的数量,而是等于 WordPress 编辑器中已安装的块的数量

如果我们的网站(包括 WordPress 核心和插件)有 100 个块,会怎样?那就需要将 100 种类型映射到 GraphQL schema。只要有一个没有映射,就会破坏"内容契约",导致部分块从英语翻译成法语,而其他块仍保持英语。

结果,无论翻译后的文章是否包含问题块,我们都无法再信任它们。因此,如果并非所有块都添加到了注册表,应用可能就会变得不可靠。

每次安装新块都必须更新 Query

同样,每个块都必须在 GraphQL Query 中得到处理。这意味着每次安装新块时,我们都需要回到应用代码,更新它,然后重新部署。

这不仅仅是额外的繁文缛节:在所有 Query 更新完成之前,我们将无法在生产站点上安装一个块而不担心应用崩溃。

GraphQL 应服务于 WordPress,而非相反

重新审视 JSONObject 未被添加到 GraphQL 规范的原因,是因为它不符合 GraphQL 的做事方式。

然而,我们在这里真正关心的并不是 GraphQL。我们只关心 WordPress,更具体地说,是这里的 Gutenberg。

将 GraphQL 与 Gutenberg 集成时,GraphQL 将在 WordPress 的上下文中运行。这意味着 WordPress 需要满足 GraphQL 的要求。但更重要的是,GraphQL 需要满足 WordPress 的要求。

发生冲突时,WordPress 优先

如果某个特性不适合 GraphQL,但适合 Gutenberg,是否应该考虑它?

我认为应该。

让我们看看单一的 Block 类型如何能更好地服务于 Gutenberg。

通过单一的 Block 类型解决之前的问题

沿用前面的例子,使用单一的 Block 类型将文章中的所有块从英语翻译成法语,方式如下(或接近这一概念):

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
    }
  }
}

就这些?整个 Query?用来翻译所有块?是的。

它能适用于所有块吗——无论是核心还是插件的,已有的还是尚未创建的?是的。

这个 Query 看起来有点奇怪?如果是的话,那是因为它使用了仅 Gato GraphQL 支持的非标准 GraphQL 特性:

  • {{ translatablePaths }} 是一个可嵌入字段,用于将一个字段的值作为参数传入另一个字段或指令(在此例中,Block 类型将有一个字段 translatableFields,其值被注入到指令 @advancePointersInArray 中)
  • 指令可以由其他指令组合

如果某个特性恰好满足 CMS 的需求,但该特性是非标准的,我们仍然应该使用它吗?我认为应该。

我也已就这些特性向 GraphQL 规范提出了请求(尽管它们不会被接受):

单一 Block 类型的工作原理

注意:以下为技术性内容。

Block 类型将有一个字段 translatablePaths,返回 JSONObject 中需要翻译的属性路径数组:

  • core/paragraph 返回 ["content"]
  • core/image 返回 ["caption"]
  • core/quote 返回 ["quote", "cite"]
  • core/heading 返回 ["header"]
  • core/list 返回 ["items.0", "items.1", "items.2", ...]

@advancePointersInArray 是一个元指令:它修改后续指令的上下文,使后续指令能够接收被查询 JSONObject 中的子元素,例如段落块的 content 属性。路径列表通过对同一被查询实体求值的字段 translatablePaths 获得。

然后,@underEachArrayItem 是另一个元指令,它遍历被查询实体的元素列表,并将对迭代元素的引用传递给下一个指令。在这种情况下,它获取所有实体中需要翻译的所有属性列表,每个属性均为 String 类型,并将单个 String 元素逐一向下传递。

最后,指令 @strTranslate 接收 JSONObject 中包含的 String 类型元素,并直接在 JSONObject 内部对其进行翻译。

请注意这个解决方案有多灵活。只需提供 JSONObject 中字符串的路径,就足以访问该值,用 @strTranslate(或任何其他指令)修改它,甚至可以将修改后的值重新存储到数据库(实现这一点的工作目前正在进行中)。

它已经适用于 core/list,因为列表中的所有元素都可以通过各自的路径访问(items.0 是数组中的第一个元素,以此类推)。然后,它可以从每个元素中访问 String 值,并传递给 @strTranslate,因此无需创建 @strTranslateList

同样,它也适用于 core/table。我们只需通过属性 cells 暴露数据,这将是一个二维数组(行数组包含列数组)。之后,translatablePaths 就可以通过 ["cells.0.0", "cells.0.1", "cells.1.0", ...] 访问所有元素。

它同样适用于任何第三方块。为此,我们只需关注块数据的存储方式,进而推断出其属性的路径。

单一 Block 需要基于 PHP 代码的配置

通过配置来映射块,从而了解其元数据属性所在位置,这种方式非常灵活。

在 Gutenberg 中,块的属性可以存储在两处:作为属性,或存储在渲染内容内部。

例如,core/image 块的存储方式如下:

<!-- wp:image {"id":1670,"sizeSlug":"large","linkDestination":"none"} -->
<figure class="wp-block-image size-large">
<img src="https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp" alt="" class="wp-image-1670"/>
</figure>
<!-- /wp:image -->

在这种情况下:

  1. 属性 idsizeSluglinkDestination 以属性形式存储
  2. 属性 src 存储在渲染内容中

现在,当查询 API 时,core/image 块的响应如下:

{
  "data": {
    "blocks": [
      {
        "name": "core/image",
        "meta": {
          "id": 1670,
          "sizeSlug": "large",
          "linkDestination": "none",
          "src": "https://newapi.getpop.org/wp/wp-content/uploads/2021/01/dynamic-include-first-query.webp"
        }
      }
    ]
  }
}

API 通过解析存储在 Gutenberg 中的块来获取属性(这就是 COPE 策略)。这个过程可以在一定程度上自动完成,之后再通过钩子进行手动输入或通过用户界面操作。

直接作为属性映射的属性获取起来很简单。GraphQL 服务器已经可以检索块的所有属性,并将其作为属性提供。或者,如果想明确定义要暴露哪些属性,可以通过过滤器钩子实现:

$attrs = apply_filters("blockPropsAsAttr:core/image", []);
 
add_filter("blockPropsAsAttr:core/image", function ($attrs) {
  return array_merge($attrs, ['id', 'sizeSlug', 'linkDestination']);
})

存储在内容中的属性可以通过正则表达式提取:

$propRegexes = apply_filters("blockPropsAsRegex:core/image", []);
 
add_filter("blockPropsAsRegex:core/image", function ($propRegexes) {
  $propRegexes['src'] = '/<img src="(.*?)"/';
  return $propRegexes;
})

最后,我们指定块的可翻译属性,供 @strTranslate 作用于:

$propRegexes = apply_filters("translatableProperties:core/image", []);
 
add_filter("translatableProperties:core/image", function ($properties) {
  $properties[] = 'caption';
  return $properties;
})

这些属性仍然需要有人来维护,最有可能的是插件开发者。因此,拥有服务器端注册表将有助于实现这一目标。

但如果 WordPress 社区不想添加提议中的服务器端注册表,该怎么办?这一策略可以轻松适应,因为映射可以通过 PHP 代码完成(如上所示)。

如果某个块尚未映射,用户只需对 Gutenberg 有一点了解(无需了解 GraphQL 或 schema),也可以自行完成映射。

此外,我们还可以让 GraphQL 在存在未映射的块(因此无法翻译)时向用户发出警告。这可以通过添加 @if 元指令来实现,条件成立时执行 @sendEmail 指令:

{
  post(by: { id: 1 }) {
    blocks {
      name
      meta
        @advancePointersInArray(paths: "{{ translatablePaths }}")
          @underEachArrayItem
            @strTranslate(from: "en", to: "fr")
        @if(condition: "{{ isTranslatablePathsUnmapped }}")
          @sendEmail(
            to: "{{ root.adminEmail }}",
            subject: "Block with name {{ name }} has 'translatablePaths' unmapped"
          )
    }
  }
}

这个解决方案灵活而简单,使 GraphQL 能够服务于 WordPress,而无需开发者学习新技术或改变 Gutenberg 的工作方式。

结论

在思考 GraphQL 与 Gutenberg 可能的集成方式(从潜在的 WordPress 核心集成角度)时,我们必须确保 GraphQL 能够处理 Gutenberg 的所有未来需求,包括对以下方面的完整支持:

  • 多语言块
  • Full Site Editing
  • 协作编辑
  • 在生产站点上与第三方服务的交互

所有这些都必须在尽量不修改 Gutenberg(至少不大幅修改)的前提下实现,并减少对插件开发者的新任务要求。

综合考虑这些因素,我相信我在此提出的第四种方案确实可以很好地发挥作用。


订阅我们的新闻通讯

及时了解 Gato GraphQL 的所有更新。