关于 canvas-data-fetching
面向 Drupal Canvas、Workbench 和 React 头less项目的开发技能,指导组件读取上下文并通过 JSON:API 获取内容。重点包括查询验证、反序列化数据处理与请求安全。
主要功能
- 01使用 React 上下文 Hook
- 02通过 SWR 获取异步数据
- 03用 JSON:API 查询 Drupal 内容
- 04验证查询结果与数据形状
- 05处理关联实体与字段筛选
适用场景
- 01为 React 组件加载 Drupal 内容
- 02构建内容列表或详情组件
- 03排查 JSON:API 查询字段与筛选
- 04迁移旧的数据读取调用
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ drupal-canvas/ skills --skill canvas-data-fetching安装后在 Claude Code 中输入/canvas-data-fetching确认已加载。
详细介绍
数据获取
项目类型
应用此技能前,请检查 package.json,确认是否存在名为
@drupal-canvas/headless 或以 @drupal-canvas/headless- 开头的依赖。如果存在,
则这是一个 Canvas Headless 代码库——请先阅读
canvas-headless。在无头项目中,使用 SDK 的
fetchPage() 获取页面树,并使用其请求感知型 getClient() 获取服务器端内容,
遵循框架惯用的数据加载方式。可移植的 React 组件使用 drupal-canvas/react 中的
useJsonApiClient() 和应用程序的同源 SDK 代理。下方的 React 模式适用于
Drupal、Workbench 和基于 React 的无头前端。有关无头服务器端预取和回退规则,
也请遵循 canvas-headless;原生非 React 组件应使用其框架的数据加载模式,而非
React hooks。如果不存在此类依赖,则这是一个由 Canvas 渲染的 React 代码库:组件
使用 React(index.jsx/.tsx),本技能中的所有内容均适用。这是仅有的两种项目类型。
可移植 React 运行时 API
使用 drupal-canvas/react 中的 useJsonApiClient()、usePageContext() 和
useSiteContext()。Drupal islands、Workbench 和已配置的无头 React 渲染器会提供
这些 provider。Hooks 会读取 context;它们不会获取数据,也不会在每次渲染时创建
客户端。在 Canvas 树之外,集成必须按需提供 CanvasContextProvider 和/或
JsonApiClientProvider。
在组件或自定义 hooks 的顶层无条件调用 hooks,并且要在任何可能的 return 之前调用。
如果 context 不可用,每个 hook 都可能返回 null。保持组件同步,并且不要在组件中
处理后端 URL、Drupal 全局变量、凭据、代理配置和环境分支。将数据或客户端传给辅助
函数;不要在普通辅助函数中调用 hooks。
getPageData()、getSiteData() 和 new JsonApiClient() 已弃用。在 Drupal 和
Workbench 中它们仍可用,但在其他环境中会抛出异常,即使构造函数接收了显式的后端
URL 也一样。不要在新代码中使用它们。getNodePath 和 sortMenu 等现有工具导入保持不变。
迁移已拉取的组件
canvas pull 可以自动将安全的 getPageData() 和 getSiteData() 调用改写为
context hooks。它要求已连接站点的 capabilities.contextHooks 元数据,以及已安装的
drupal-canvas/react 入口将这两个 hooks 导出为运行时值;不要仅凭版本号推断是否支持。
如果无法确认支持情况,请查看报告的限制,而不要强行导入运行时不支持的内容。
每个组件文件的迁移要么全部完成,要么完全不变。不安全的 hook 位置、标识符冲突或
直接解构 getter 返回值,都可能导致文件保持不变。客户端构造和辅助模块会收到诊断信息,
但不会自动转换。--skip-overwrite 文件保持原样。
拉取后,请检查已更改的源文件和诊断信息。Codemod 不会添加 null 检查,也不会证明后续
访问安全;请检查可空结果、hook 顺序、类型和渲染行为。手动迁移剩余的客户端调用。
在组件/自定义 hooks 之外,将数据或客户端传给辅助函数,或在无头服务器端代码中使用
page.context 和请求感知型 getClient()。保留访问控制,绝不要暴露凭据。
页面和站点 context
import { usePageContext, useSiteContext } from 'drupal-canvas/react';
export default function PageHeader() {
const page = usePageContext();
const site = useSiteContext();
if (!page || !site) return null;
return (
<header>
<a href={site.branding.homeUrl}>{site.branding.siteName}</a>
<h1>{page.pageTitle}</h1>
</header>
);
}
页面 context 包含 pageTitle、breadcrumbs 和 mainEntity。即使页面 context 存在,
mainEntity 也可能为 null;使用实体元数据、翻译或其 UUID 发起查询前,请先检查它。
站点 context 包含 branding、baseUrl 和 themeAssets;请处理资源 URL 为空的情况。
除非应用程序明确进行适配,否则请保留 Drupal 生成的导航 URL。
使用 SWR 获取数据
在可移植的 React 组件中进行异步数据获取时,请使用 SWR。它提供缓存、重新验证和简洁的基于 Hook 的 API。读取页面或站点上下文时,无需使用 SWR。
import useSWR from 'swr';
const fetcher = (url) => fetch(url).then((res) => res.json());
export default function Profile() {
const { data, error } = useSWR('https://my-site.com/api/user', fetcher);
if (error) return <div>Failed to load</div>;
if (!data) return <div>Loading...</div>;
return <div>Hello, {data.name}!</div>;
}
使用 JSON:API 获取 Drupal 内容
要从 Drupal 获取内容(例如文章、活动或其他内容类型),请使用 drupal-canvas/react 中的 useJsonApiClient() 读取已配置的客户端,并使用 DrupalJsonApiParams 构建查询。客户端不可用时,请传入 null 作为 SWR 键。请使用包含资源及所有查询输入的稳定键,这样不同的筛选条件、语言和分页就不会共享结果。
请渲染可用数据,包括预取的备用数据,不要仅仅因为 SWR 报告 isLoading 就将其隐藏。常规的 useSWR 渲染不会在服务器上运行或等待其 fetcher。为了生成包含数据的初始 HTML,无头应用会使用服务器端 getClient() 进行预取,并提供匹配的 SWR 备用键;请参阅 canvas-headless。
重要: 请在最终组件代码中保持默认序列化器启用。Canvas 组件的运行时约定是 JsonApiClient 返回的反序列化数据结构,而不是原始 JSON:API 文档结构。
重要: 不要在 Workbench 模拟中伪造 JSON:API 资源负载。除非用户明确要求静态的非获取预览结构,否则获取数据的组件应在 Workbench 中渲染真实的加载、空数据或错误状态。
原始 JSON:API 与反序列化后的 Canvas 数据
不要根据原始 JSON:API 的假设编写组件逻辑,例如 data[0].attributes.title 或 data[0].relationships.field_image。使用 JsonApiClient 的组件接收的是反序列化后的对象。
- 仅在检查连通性和广义的端点是否存在时使用普通 HTTP 请求。
- 使用
JsonApiClient检查组件实际会使用的数据结构。 - 如果为了调试而检查原始 JSON:API 文档,请将其视为辅助诊断视图,而非组件代码的真实数据来源。
- 不要在最终组件代码中禁用序列化器。
验证每个 JSON:API 请求都返回预期结果
你生成的任何 JSON:API 请求——用于新组件、重构、筛选条件更改、添加 include、更改排序,或为现有组件添加新查询——都必须在针对它编写或更改任何渲染逻辑之前执行并验证。不要因为查询“看起来没问题”就假定它正确。构建查询、运行查询,并确认响应符合预期。
只有通过以下所有检查后,请求才算经过验证:
- 请求可以运行。 没有 HTTP 错误、JSON:API 错误文档或客户端异常。
- 结果数量符合预期。 如果存在该类型的内容,列表查询应返回非空集合。筛选查询返回的项目应少于未筛选查询(只有在确实预期为零时,结果才应为零)。单个资源查询应返回一个资源,而不是
null。 - 反序列化对象中包含并填充了预期字段——包括通过
addFields请求的字段。缺失或始终为null的字段需要调查:区分合法的可选值和访问限制,以及错误的查询或字段名称。 - Include 已解析为真实的关联实体,而非简单引用。 如果使用了
addInclude,请确认反序列化对象中组件将读取的关联已填充。 - 筛选和排序按预期生效。 抽查筛选后的项目,确认它们确实符合筛选条件;并检查排序后的项目是否按请求顺序排列。
如果任何检查失败,请在更改渲染逻辑之前调查查询、字段名称、内容模型以及访问权限/会话状态。不要用可选链、回退字符串或“在 UI 中看起来没问题”来掩盖空响应或错误响应。每次修复后都重新运行探测,只有响应符合预期后才能继续。
下一节中的探测模式是执行这些检查的默认方法。探测结果若显示 count: 0、keys: [],或缺少组件所需字段,就不能视为通过,除非该结果确实符合预期且已有说明。
草稿集合读取会为返回的项目填充工作副本,但集合筛选/排序不会使用工作副本的值,也不会发现所有未发布实体。原始响应不会进行工作副本填充。匿名探测只能验证公开结果;请通过预期的、能识别会话的集成来验证草稿行为。绝不要通过以公开用户身份重试,或将静态回退内容视为成功读取草稿,来掩盖 DraftSessionError。
编码前探测反序列化后的数据结构
编写渲染逻辑之前,运行一次性的 JavaScript 探测,使用组件将采用的相同 JsonApiClient 调用和 DrupalJsonApiParams 查询模式。检查第一个返回项,并根据该反序列化后的数据结构编写组件。
此公开数据探测在 Canvas 运行时之外执行。请使用共享的、与框架无关的 createJsonApiClient() 工厂,并传入解析后的后端配置。不要伪造 window、Drupal 设置或运行时标记来绕过旧版守卫。对于无头草稿探测,请在应用的服务端集成中使用能够感知请求的 getClient()。最终的 React 组件代码应使用 useJsonApiClient(),而不是显式构造客户端。
请使用以下模式的命令:
node --input-type=module -e "
import { createJsonApiClient } from 'drupal-canvas/jsonapi-client';
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
const describeShape = (value) => {
if (Array.isArray(value)) {
return value.length > 0 ? [describeShape(value[0])] : ['empty-array'];
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, nestedValue]) => [key, describeShape(nestedValue)]),
);
}
if (value === null) {
return 'null';
}
return typeof value;
};
const client = createJsonApiClient({
baseUrl: 'https://example.ddev.site',
apiPrefix: 'jsonapi',
});
const queryString = new DrupalJsonApiParams()
.addSort('created', 'DESC')
.addFields('node--article', ['title', 'created', 'body', 'path'])
.getQueryString();
const items = await client.getCollection('node--article', { queryString });
const first = items?.[0];
console.log('count:', items?.length ?? 0);
console.log('keys:', first ? Object.keys(first) : []);
console.log('shape:', JSON.stringify(first ? describeShape(first) : null, null, 2));
console.log(JSON.stringify(first, null, 2));
"
将站点根目录作为 baseUrl 传入,而不是 /jsonapi 端点。请根据要构建的组件调整资源类型、筛选条件、include、排序和字段。不要只检查一种查询结构,却在组件中实现另一种。
如果此探测在本地 HTTPS 开发环境中失败,请先检查 Node 是否信任本地证书链,再判断是 JSON:API 客户端或查询出错。
import { getNodePath } from 'drupal-canvas';
import { useJsonApiClient } from 'drupal-canvas/react';
import { DrupalJsonApiParams } from 'drupal-jsonapi-params';
import useSWR from 'swr';
const Articles = () => {
const client = useJsonApiClient();
const { data, error } = useSWR(
client
? [
'node--article',
{
queryString: new DrupalJsonApiParams()
.addSort('created', 'DESC')
.getQueryString(),
},
]
: null,
([type, options]) => client.getCollection(type, options),
);
if (error) return 'An error has occurred.';
if (!data) return 'Loading...';
return (
<ul>
{data.map((article) => (
<li key={article.id}>
<a href={getNodePath(article)}>{article.title}</a>
</li>
))}
</ul>
);
};
export default Articles;
使用 addInclude 包含关联内容
需要关联实体(例如图片、分类术语)时,请使用 addInclude
在单个请求中获取它们。
避免 JSON:API 响应中出现循环引用。 SWR 使用深度相等性检查 比较缓存数据;当响应包含循环引用时,此检查会失败并报出“递归过深”错误。
不要包含自引用字段。 引用所查询的同一实体类型的字段(例如文章查询中的
field_related_articles)会产生循环引用:文章 A 引用文章 B,而文章 B 又引用
文章 A。如果需要同一类型的关联内容,请单独发起查询。
使用 addFields 限制响应内容。 始终只指定所需字段。这能提升性能,并有助于避免循环引用问题:
const params = new DrupalJsonApiParams();
params.addSort('created', 'DESC');
params.addInclude(['field_category', 'field_image']);
// Limit fields for each entity type
params.addFields('node--article', [
'title',
'created',
'field_category',
'field_image',
]);
params.addFields('taxonomy_term--categories', ['name']);
params.addFields('file--file', ['uri', 'url']);
创建内容列表组件
构建用于显示内容条目列表的组件时(例如新闻列表、活动日历或资源库),请遵循以下工作流:
设置检查关卡
在进行任何 JSON:API 探索或内容类型检查之前,请先验证本地设置:
- 在编写代码或探查 Drupal 之前,解析 Canvas 配置值。按以下顺序检查:
- shell 环境变量
- 项目根目录中的
.env ~/.canvasrc
- 确定生效的
CANVAS_SITE_URL。 - 使用项目集成解析 JSON:API 端点。读取公开的
/canvas/api/v0/site-data元数据,获取已发现的前缀;不要假定端点为/jsonapi。在无头项目中,CANVAS_JSONAPI_URL会覆盖发现结果; 如果发现失败,SDK 会使用已配置的前缀或CANVAS_JSONAPI_PREFIX,然后使用jsonapi。保留站点安装路径。 对于单独的 JSON:API 站点,请遵循 SDK 关于CANVAS_JSONAPI_SITE_URL的指南,处理带语言前缀的请求。不要假定 Workbench 支持仅适用于无头项目的配置选项。 - 记录解析出的站点根路径和 JSON:API 端点(绝不记录凭据)。
在独立探查中使用这些值进行匹配;适当时使用工厂的
apiUrl覆盖项, 不要用 API 端点替代baseUrl。 - 验证
CANVAS_SITE_URL是站点根路径,而不是 JSON:API 端点。 例如,应使用https://example.ddev.site,而不是https://example.ddev.site/jsonapi。 - 向解析出的 JSON:API 端点发送 HTTP 请求。成功状态为 HTTP
200。 - 如果请求成功,继续获取 Drupal 数据。
- 如果请求失败(或缺少必需值),询问用户是否希望:
- 立即配置 Drupal 连接,或
- 使用静态内容,而不从 Drupal 获取内容。
- 如果用户选择配置连接,请提供设置说明:
CANVAS_SITE_URL=<their Drupal site root>CANVAS_JSONAPI_PREFIX=jsonapi(可选;默认值为jsonapi)然后等待 用户确认已更新 shell 环境变量、.env或~/.canvasrc,并在重试请求前重新解析这些值。
- 如果用户选择不配置连接,则使用静态内容。
- 在确定生效的
CANVAS_SITE_URL和 JSON:API 端点之前,不要开始内容类型探索、字段检查或组件编码。 - 不要通过更新 Vite 配置(
vite.config.*)来排查连接问题。 必须通过正确的配置值和 Drupal 站点可用性解决连接问题,而不是更改构建工具。
第 1 步:分析列表结构
检查设计,了解每个列表项需要哪些数据:
- 显示哪些字段(标题、日期、图片、类别等)?
- 条目如何排序(最新优先、按字母顺序等)?
- 是否有筛选器或分页?
步骤 2:确定或请求内容类型
编写代码之前,请先确认 Drupal 中是否存在合适的内容类型:
-
检查本地 Drupal 站点的 JSON:API 端点(该端点通过 Setup gate 解析出的
CANVAS_SITE_URL和 JSON:API 前缀进行配置),查找与所需结构匹配的内容类型。通过 Setup gate 后,仅端点发现可以使用普通 HTTP 请求。 -
如果存在匹配的内容类型,请使用该类型,并记录可用字段。
-
使用
JsonApiClient检查样例响应,资源类型和查询模式须与组件将使用的相同。运行一次性探测命令,检查返回的第一项,并在编写渲染逻辑之前确认反序列化后的字段结构。 -
如果不存在匹配的内容类型,请停止并提示用户创建一个。请提供:
- 建议的内容类型名称
- 根据列表设计确定的必需字段结构
步骤 3:构建组件
使用 JSON:API 获取内容并创建内容列表组件。仅使用内容类型中实际存在的字段,以及 JsonApiClient 返回的反序列化对象中实际存在的字段;不要假设原始 JSON:API 字段嵌套结构与运行时数据结构相同。
处理筛选条件
如果列表包含基于实体引用字段的筛选条件(例如按分类或作者筛选):
- 不要硬编码筛选选项。 应使用 JSON:API 动态获取筛选选项。
- 获取每个筛选条件的可用选项(例如某个词汇中的所有分类术语),并使用这些数据填充筛选界面。
这样可以确保筛选条件与 Drupal 中的实际内容保持同步,新增选项也会自动显示,无需更改代码。
导航/菜单组件
页眉、页脚和侧边栏等组件通常需要从 Drupal 获取菜单链接。请使用双重实现:如果 Drupal 中存在菜单,则从中获取链接;如果尚未配置菜单,则回退到静态数组。
这样组件可以立即使用(采用硬编码回退链接),并且在 CMS 编辑器创建对应菜单后,会自动升级为由 Drupal 管理的实时链接。
import { sortMenu } from 'drupal-canvas';
import { useJsonApiClient } from 'drupal-canvas/react';
import useSWR from 'swr';
// Static fallback — always define this; it renders when no Drupal menu exists
const FALLBACK_LINKS = [
{ id: 'home', title: 'Home', url: '/' },
{ id: 'about', title: 'About', url: '/about' },
];
const Navigation = ({ menuName = 'main' }) => {
const client = useJsonApiClient();
const { data, error } = useSWR(
client && menuName ? ['menu_items', menuName] : null,
([type, id]) => client.getResource(type, id),
);
// Do not disguise a rejected draft session as a successful static preview.
if (error?.name === 'DraftSessionError') {
return <div>Preview session expired. Reconnect the preview.</div>;
}
// Keep prefetched/live links visible during revalidation.
const links = data ? Array.from(sortMenu(data)) : FALLBACK_LINKS;
return (
<nav>
{links.map(({ id, title, url }) => (
<a key={id} href={url}>
{title}
</a>
))}
</nav>
);
};
菜单组件规则:
- 始终定义一个
FALLBACK_LINKS常量,其中包含有代表性的链接。这样组件在 Workbench 中以及尚未创建 Drupal 菜单的站点上都能发挥作用。 - 将
menuName设为属性,并在component.yml中注册,这样 CMS 编辑器无需更改代码即可配置要使用的 Drupal 菜单。 menuName = null会禁用数据获取(SWR 键为null),并渲染回退链接;这适用于纯静态预览。- 构建导航类型组件后,请在手动步骤摘要中提醒用户前往
/admin/structure/menu/add在 Drupal 中创建对应的菜单。
menuName 的 component.yml 示例:
props:
properties:
menuName:
title: Menu name
type: string
examples:
- main
- footer