用 Gutenberg Data 构建可搜索的 WordPress 页面列表:从 getEntityRecords 到 useSelect 的完整实战
用 Gutenberg Data 构建可搜索的 WordPress 页面列表从 getEntityRecords 到 useSelect 的完整实战【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg本教程是《使用 Gutenberg Data 创建你的第一个应用》系列的第 2 部分前置环境搭建见 1-data-basics-setup.md手把手带你用 React 编写一个可筛选的 WordPress 页面列表从零构建PagesList组件、通过wordpress/core-data的getEntityRecords选择器拉取 REST API 数据、用SearchControl实现实时搜索并借助hasFinishedResolution显示加载状态。学完本部分你将掌握 Gutenberg 数据层wordpress/datawordpress/core-data的核心用法——选择器、解析器resolver、查询参数与解析状态跟踪并理解它相比直接调用apiFetch解决了哪些棘手的异步问题。前置准备确认应用骨架可用在开始之前请确保你已完成 1-data-basics-setup.md 中的全部步骤创建了my-first-gutenberg-app插件目录src/index.js能渲染出 Hello from JavaScript!并且npm start的 watcher 正在运行。本部分的所有代码都写在src/index.js中。同时请进入 WP AdminWordPress 管理后台通过侧边栏菜单打开Pages确认里面至少有四五个页面可供获取如果不足请创建几个页面可以沿用教程截图中的标题注意一定要发布Publish而不是仅保存Save为草稿否则这些页面不会出现在/wp/v2/pages接口的默认查询结果中。Step 1构建最基础的 PagesList 组件首先我们用一个极简的 React 组件把页面列表渲染出来。此时不做任何数据获取先用硬编码数据把界面骨架搭好function MyFirstApp() { const pages [{ id: mock, title: Sample page }] return PagesList pages{ pages }/; } function PagesList( { pages } ) { return ( ul { pages?.map( page ( li key{ page.id } { page.title } /li ) ) } /ul ); }刷新页面后你会看到一个只包含 Sample page 的列表。这里有两个值得注意的细节使用可选链pages?.map(...)当pages还是null或undefined数据尚未加载时不会报错。这一写法在后文配合getEntityRecords首次返回null的行为时会非常关键key{ page.id }React 渲染列表时依赖稳定的key来正确追踪每个条目这里直接使用 REST API 返回的页面id。Step 2通过 getEntityRecords 获取真实数据硬编码的示例页面没有实用价值。我们要展示真实的 WordPress 页面因此需要从 REST API 拉取数据。Gutenberg 为此提供了两个层层嵌套的包wordpress/data通用数据层提供select、dispatch、useSelect等基础能力以及解析器resolver自动触发 结果缓存 解析状态跟踪这套机制wordpress/core-data构建在wordpress/data之上的领域层封装了与 WordPress 核心 API文章、页面、分类、用户、主题等实体打交道的选择器、动作与解析器。选择器 getEntityRecords 的两种返回获取页面列表的核心选择器是getEntityRecords。其签名是getEntityRecords( kind, name, query )——第一个参数是实体种类如postType第二个是实体名称如page第三个是可选的查询参数对象。最基本的调用wp.data.select( core ).getEntityRecords( postType, page )如果你在浏览器的开发者工具里运行这段代码会发现它返回的是null。原因在于 Gutenberg 数据层的惰性解析机制页面数据只有在选择器被首次执行后才会由对应的解析器resolver发起网络请求。此时数据还没到选择器无从返回。提示要直接在浏览器里运行select( core )请确保当前页面是块编辑器实例任意页面均可否则corestore 不可用会直接报错。等待片刻后再执行一次就能拿到所有页面的数组了。这个先null、后数据的行为可以从packages/core-data/src/selectors.ts的实现得到印证选择器会先检查状态树中是否存在该实体查询对应的queriedData若不存在即查询尚未发生就直接返回null只有当解析器把数据写回 store 之后才会通过getQueriedItems返回真实的记录数组。而负责发请求的解析器定义在packages/core-data/src/resolvers.js它内部通过addQueryArgs( baseURL, { ...baseURLParams, ...query } )拼接出类似/wp/v2/pages?searchhome的完整路径再交给apiFetch发起请求并把响应结果合并进 store。用 useSelect 在组件里订阅数据上述数据到了再取一次的流程在组件里不能靠手动重跑解决。useSelect钩子正是为此设计的它接收一个回调函数和一个依赖数组当依赖变化或底层 store 数据变化时会自动重新执行回调。改造如下import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; function MyFirstApp() { const pages useSelect( select select( coreDataStore ).getEntityRecords( postType, page ), [] ); // ... } function PagesList({ pages }) { // ... li key{page.id} {page.title.rendered} /li // ... }两点说明import与wp.data的关系我们在src/index.js里使用 ESNext 的import语句引入useSelect与coreDataStore。构建后插件通过wp_enqueue_script依据依赖清单build/index.asset.php自动加载这些 WordPress 包代码中所有coreDataStore引用最终都编译成与浏览器开发者工具中相同的wp.data引用。这正是1-data-basics-setup.md中load_custom_wp_admin_scripts()读取$asset_file[dependencies]并逐一wp_enqueue_style的原因。useSelect的两个参数第一个是选择器回调第二个是依赖数组。当依赖数组中的值变化或回调访问到的 store 发生变化时useSelect会重新计算结果并触发组件重渲染。其底层实现在packages/data/src/components/use-select/index.ts它会对回调访问过的 store 建立订阅registry.subscribe并在 store 更新时使缓存结果失效、重新计算。更多细节可查阅 data 模块文档。解码 HTML 实体decodeEntitiesREST API 返回的title是title.rendered——它是已经渲染过的 HTML 字符串其中可能包含aacute;这类 HTML 实体。直接显示会得到一堆乱码因此需要用wordpress/html-entities的decodeEntities把它们替换回真实的符号如á。组合起来import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; import { decodeEntities } from wordpress/html-entities; function MyFirstApp() { const pages useSelect( select select( coreDataStore ).getEntityRecords( postType, page ), [] ); return PagesList pages{ pages }/; } function PagesList( { pages } ) { return ( ul { pages?.map( page ( li key{ page.id } { decodeEntities( page.title.rendered ) } /li ) ) } /ul ) }刷新页面即可看到真实的页面标题列表。Step 3把列表升级为表格随着页面增多无序列表的可读性有限。WP Admin 中的表格样式如文章列表页可以复用给table加上 WordPress 内置的样式类wp-list-table widefat fixed striped table-view-list就能免费获得斑马纹、边框等原生外观function PagesList( { pages } ) { return ( table classNamewp-list-table widefat fixed striped table-view-list thead tr thTitle/th /tr /thead tbody { pages?.map( page ( tr key{ page.id } td{ decodeEntities( page.title.rendered ) }/td /tr ) ) } /tbody /table ); }Step 4加入搜索框实现实时筛选列表还短的时候无所谓可一旦页面多起来就难以定位了。WordPress 后台惯用的解决方案是搜索框我们也来实现一个。用 SearchControl 管理搜索词我们不用原生的input而是使用wordpress/components提供的SearchControl组件——它自带清除按钮等无障碍细节。搜索词用useState存进searchTermimport { useState } from react; import { SearchControl } from wordpress/components; function MyFirstApp() { const [searchTerm, setSearchTerm] useState( ); // ... return ( div SearchControl onChange{ setSearchTerm } value{ searchTerm } / {/* ... */ } /div ) }SearchControl是受控组件value绑定searchTerm每次输入都会通过onChange更新状态。把 searchTerm 变成 REST API 的 search 查询参数WordPress REST API 的/wp/v2/pages端点接受search查询参数其语义是仅返回匹配该字符串的结果。getEntityRecords的第三个参数正是透传给 REST API 的查询参数对象。在开发者工具中验证wp.data.select( core ).getEntityRecords( postType, page, { search: home } )这会触发对/wp/v2/pages?searchhome的请求而不是裸的/wp/v2/pages。从前文引用的resolvers.js可以看到解析器就是用addQueryArgs( baseURL, { ...baseURLParams, ...query } )把 query 对象拼进 URL 的。把这个行为镜像到useSelect中当searchTerm非空时构造query.search并把searchTerm放进依赖数组保证搜索词一变、选择器就重跑import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; function MyFirstApp() { // ... const { pages } useSelect( select { const query {}; if ( searchTerm ) { query.search searchTerm; } return { pages: select( coreDataStore ).getEntityRecords( postType, page, query ) } }, [searchTerm] ); // ... }把全部零件接起来MyFirstApp的完整形态import { useState } from react; import { createRoot } from react-dom; import { SearchControl } from wordpress/components; import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; function MyFirstApp() { const [searchTerm, setSearchTerm] useState( ); const pages useSelect( select { const query {}; if ( searchTerm ) { query.search searchTerm; } return select( coreDataStore ).getEntityRecords( postType, page, query ); }, [searchTerm] ); return ( div SearchControl onChange{ setSearchTerm } value{ searchTerm } / PagesList pages{ pages }/ /div ) }现在在搜索框里输入关键词列表就会实时过滤出匹配的页面。为什么不用 apiFetch 直接请求在继续之前值得停下来对比另一种做法——绕开 core-data、直接调用 APIimport apiFetch from wordpress/api-fetch; function MyFirstApp() { // ... const [pages, setPages] useState( [] ); useEffect( () { const url /wp-json/wp/v2/pages?search searchTerm; apiFetch( { url } ) .then( setPages ) }, [searchTerm] ); // ... }这段代码看似简洁但需要你自己解决两个麻烦乱序更新out-of-order updates搜索 About 会依次触发对A、Ab、Abo、Abou、About的五个请求而网络请求的完成顺序并不保证与发起顺序一致——searchA完全可能在searchAbout之后才返回导致界面最终显示错误的数据。Gutenberg 数据层在后台处理了异步部分useSelect只关心最近一次调用对应的结果天然规避了竞态。重复请求与缓存缺失每敲一个字符都会发一次请求。如果你输入 About、删掉、再重新输入总共会发出 10 次请求尽管中间的数据完全可以复用。而getEntityRecords触发的结果会被 core-data 缓存后续相同查询直接命中缓存——当其他组件也依赖同一批实体记录时这种复用尤其重要。简言之core-data 内置的工具就是为了解决这类典型问题让你把精力集中在应用本身。Step 5用 hasFinishedResolution 添加加载指示器现在搜索功能还有一个体验问题当搜索没有匹配结果时我们无法区分还在请求中和确实没有结果这两种状态。补上 Loading… 和 No results 两类提示即可。让 PagesList 感知解析状态首先给PagesList增加一个hasResolved属性据此渲染Spinner或空结果提示——同样加载动画不必自己写wordpress/components的Spinner组件即可import { SearchControl, Spinner } from wordpress/components; function PagesList( { hasResolved, pages } ) { if ( !hasResolved ) { return Spinner/ } if ( !pages?.length ) { return divNo results/div } // ... } function MyFirstApp() { // ... return ( div // ... PagesList hasResolved{ hasResolved } pages{ pages }/ /div ) }用 hasFinishedResolution 查询解析是否完成hasResolved从哪来数据层为每个选择器都维护着解析状态hasFinishedResolution选择器可以查询它。它的签名是hasFinishedResolution( selectorName, args )——第二个参数是传给目标选择器的完全相同的参数数组wp.data.select(core).hasFinishedResolution( getEntityRecords, [ postType, page, { search: home } ] )若数据已加载完毕返回true仍在等待则返回false。从packages/data/src/redux-store/metadata/selectors.ts的实现看它读取的是该选择器参数对应的解析状态只要状态为finished或error出错也视为不再等待就返回true。把它并进useSelectimport { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; function MyFirstApp() { // ... const { pages, hasResolved } useSelect( select { // ... return { pages: select( coreDataStore ).getEntityRecords( postType, page, query ), hasResolved: select( coreDataStore ).hasFinishedResolution( getEntityRecords, [postType, page, query] ), } }, [searchTerm] ); // ... }用共享变量消除参数不一致的隐患这里潜伏着最后一个坑getEntityRecords和hasFinishedResolution接收的参数必须逐字一致否则后者查不到前者的解析记录hasResolved永远是false或相反。手写两遍很容易打错。最稳妥的做法是把参数数组存进一个变量两处共用import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; function MyFirstApp() { // ... const { pages, hasResolved } useSelect( select { // ... const selectorArgs [ postType, page, query ]; return { pages: select( coreDataStore ).getEntityRecords( ...selectorArgs ), hasResolved: select( coreDataStore ).hasFinishedResolution( getEntityRecords, selectorArgs ), } }, [searchTerm] ); // ... }注意getEntityRecords用展开运算符...selectorArgs接收而hasFinishedResolution把整个数组作为第二个参数传入——两处共享同一份selectorArgs参数不一致的风险被彻底消除。完整代码汇总至此所有零件齐备。以下是src/index.js的完整实现含./style.css引入import { useState } from react; import { createRoot } from react-dom; import { SearchControl, Spinner } from wordpress/components; import { useSelect } from wordpress/data; import { store as coreDataStore } from wordpress/core-data; import { decodeEntities } from wordpress/html-entities; import ./style.css; function MyFirstApp() { const [ searchTerm, setSearchTerm ] useState( ); const { pages, hasResolved } useSelect( ( select ) { const query {}; if ( searchTerm ) { query.search searchTerm; } const selectorArgs [ postType, page, query ]; return { pages: select( coreDataStore ).getEntityRecords( ...selectorArgs ), hasResolved: select( coreDataStore ).hasFinishedResolution( getEntityRecords, selectorArgs ), }; }, [ searchTerm ] ); return ( div SearchControl onChange{ setSearchTerm } value{ searchTerm } / PagesList hasResolved{ hasResolved } pages{ pages } / /div ); } function PagesList( { hasResolved, pages } ) { if ( ! hasResolved ) { return Spinner /; } if ( ! pages?.length ) { return divNo results/div; } return ( table classNamewp-list-table widefat fixed striped table-view-list thead tr tdTitle/td /tr /thead tbody { pages?.map( ( page ) ( tr key{ page.id } td{ decodeEntities( page.title.rendered ) }/td /tr ) ) } /tbody /table ); } const root createRoot( document.querySelector( #my-first-gutenberg-app ) ); window.addEventListener( load, function () { root.render( MyFirstApp / ); }, false );createRoot( document.querySelector( #my-first-gutenberg-app ) )对应插件 PHP 文件里输出的div idmy-first-gutenberg-app/div容器挂载逻辑放在window的load事件里确保 DOM 就绪后再渲染。刷新插件管理页你将依次看到搜索时出现 Spinner 加载动画、搜索无匹配时显示 No results、有结果时渲染出带斑马纹的表格。小结与下一步回顾本部分的核心收获选择器 解析器getEntityRecords( postType, page, query )首次调用返回null并自动触发解析器向 REST API 发请求数据到达后自动重渲染选择器实现、解析器实现查询参数透传第三个参数对象会原样拼入 URL如search用于服务端过滤useSelect订阅依赖与 store 变化时自动重算替代手动管理请求时序hasFinishedResolution配合与getEntityRecords完全一致的参数数组驱动加载/空状态展示cache 与竞态core-data 自动缓存请求结果并只呈现最近一次查询避开手写apiFetch时的乱序更新与重复请求问题。下一部分 3-building-an-edit-form.md 将在这个列表基础上构建编辑表单学习如何用getEntityRecord单条记录、editEntityRecord与saveEditedEntityRecord实现页面编辑与保存。【免费下载链接】gutenbergThe Block Editor project for WordPress and beyond. Plugin is available from the official repository.项目地址: https://gitcode.com/GitHub_Trending/gu/gutenberg创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考