分页
分页可能是一个看似简单却复杂的话题。很容易陷入陷阱而没有遵循最佳实践。本页将帮助你“正确”地实现分页。换句话说,如果你阅读并理解本页的内容,我们认为你的客户端会更加健壮、具有前瞻性,并且让你未来的开发更轻松。
如果你只记住本指南中的一件事,那就是:不要自己构造分页 URL。
JSON:API 模块返回的每个分页响应中,已经包含了指向集合下一页的链接,你只需使用该链接即可。
在本文开头,我们将介绍 API 的一些重要特性,以及如何“正确”实现分页。在文末,你会找到一些常见问题与陷阱的解答。
如何实现?
JSON:API 模块返回的每个分页响应中都内置了分页链接。下面是一个小示例:
{
"data": [
{"type": "sample--type", "id": "abcd-uuid-here"},
{"type": "sample--type", "id": "efgh-uuid-here"}
],
"links": {
"self": "<collection_url>?page[offset]=3&page[limit]=3",
"next": "<collection_url>?page[offset]=6&page[limit]=3",
"prev": "<collection_url>?page[offset]=0&page[limit]=3"
}
}
几点需要注意:
- 在
links
键下有 3 个分页链接:self
:当前页面的 URL。next
:下一页的 URL。prev
:上一页的 URL。
page[limit]
设置为 3,但结果中只有 2 个资源(?!)。
分页链接的存在与否非常重要。 需要注意以下几点:
- 如果存在
next
链接,说明还有更多页面。 - 如果
next
链接不存在,说明你在最后一页。 - 如果存在
prev
链接,说明你不在第一页。 - 如果既没有
next
也没有prev
链接,说明只有一页。
即使页面限制为 3,这里也只有 2 个资源! 这是因为某个实体因安全原因被移除了。我们知道这不是因为资源不足而无法填充响应,因为我们可以看到 next
链接。如果你想了解更多,请阅读下文详细解释。
现在我们已经明确了一些重要事实。接下来我们考虑如何构建客户端。下面使用一些伪 JavaScript 来帮助理解。🧐
假设你要展示站点上的最新内容,并且有一些“高级”内容。只有付费订阅用户才能查看高级内容。同时我们决定要展示一个“前 5 条”组件,如果还有更多内容,用户可以点击“下一页”查看接下来的 5 条。
一个天真的实现可能如下:
const baseUrl = 'http://example.com';
const path = '/jsonapi/node/content';
const pager = 'page[limit]=5';
const filter = `filter[field_premium][value]=${user.isSubscriber()}`;
fetch(`${baseUrl}${path}?${pager}&${filter}`)
.then(resp => {
return resp.ok ? resp.json() : Promise.reject(resp.statusText);
})
.then(document => listComponent.setContent(document.data))
.catch(console.log);
然而,即使忽略糟糕的错误处理,我们也知道这并不是一个健壮的实现。
正如上文所示,我们无法确保响应中一定会有 5 条内容。如果其中 2 个实体不可访问(例如未发布),那么我们的“前 5 条”组件可能只会显示 3 条!
此外,这里还有一个不必要的过滤条件。服务器本应已经移除用户无权访问的内容。否则,我们的应用可能存在访问绕过的安全漏洞,因为恶意用户可以轻易修改查询来查看“高级”内容。务必确保在服务器端强制执行访问控制;不要依赖查询参数。
下面是改进后的实现:
const listQuota = 5;
const content = [];
const baseUrl = 'http://example.com';
const path = '/jsonapi/node/content';
const pager = `page[limit]=${listQuota}`;
const getAndSetContent = (link) => {
fetch(link)
.then(resp => {
return resp.ok ? resp.json() : Promise.reject(resp.statusText);
})
.then(document => {
content.push(...document.data);
listContent.setContent(content.slice(0, listQuota));
const hasNextPage = document.links.hasOwnProperty("next");
if (content.length <= listQuota && hasNextPage) {
getAndSetContent(document.links.next);
}
if (content.length > listQuota || hasNextPage) {
const nextPageLink = hasNextPage
? document.links.next
: null;
listComponent.showNextPageLink(nextPageLink);
}
})
.catch(console.log);
}
getAndSetContent(`${baseUrl}${path}?${pager}`)
首先,你会注意到 filter
被移除了。这是因为我们假设访问检查在服务器端完成,而不是依赖过滤器。这是唯一安全的解决方案。我们可以作为性能优化再加回来,但通常没必要。
其次,由于服务器会移除用户无权访问的资源,我们确实需要检查响应中实际包含多少资源。
在“天真”的实现中,我们假设每个响应一定有 5 个项目。而在改进后的示例中,我们设置了一个“配额”为 5 个资源。发出请求后,我们检查是否达到了配额,以及服务器是否还有更多页面(通过 next
链接判断)。
如果未达到配额且不是最后一页,我们就使用从文档中提取的 next
链接发起另一个请求。重要的是,我们没有手动构造下一页的 URL。无需重复造轮子,JSON:API 服务器已经为我们生成好了!
另一个有趣的点是,由于 fetch
是异步的,我们可以在第一个请求完成后立即将内容添加到组件中,而无需等待所有请求完成。当第二个请求返回时,我们再更新组件以包含新结果。
最后,我们确保虚拟的 listComponent
知道是否需要显示“下一页”链接。只有在已有额外内容或服务器还有更多页面时才显示。
第一种情况是:如果第一次请求只有 4 项,第二次请求有 5 项,但没有next
链接。此时总共有 9 项,但 listComponent
只显示前 5 项。所以我们仍然希望组件显示“下一页”链接,但实际上不会再发出请求。这时我们将 nextPageLink
设置为 null
。
第二种情况是:如果确实有 next
链接,我们将它传递给组件,组件可以在用户点击“下一页”时发起请求。如果用户不点击,就不会发送请求。
最后几段说明了一个非常重要的概念:HTML 中的“下一页”链接不需要与 API 的分页完全对应!事实上,如果两者严格对应,这反而可能是错误的做法。
常见问题
… 为什么不能设置大于 50 的页面限制?
首先,请阅读上面的示例。理解 JSON:API 必须对响应中的每个实体执行单独的访问检查。其次,理解 JSON:API 模块的目标是“零配置”。你不需要安装、修改或配置任何东西就能使用该模块。
原因是为了保护你的应用免受 DDoS 攻击。如果恶意客户端设置页面限制为 200,000 个资源,JSON:API 模块将需要为每个实体执行访问检查。这会迅速导致内存溢出错误和响应缓慢。服务器必须设置上限。限制 50 是一个相对合理的选择。
请理解,这个决定经过了许多讨论,需要在支持成本、合理默认值和前端性能之间做出权衡。虽然 JSON:API 模块维护者知道这对某些用例可能不理想,但他们有信心如果客户端遵循本文档中的建议,这对你几乎没有影响 :)
如果你仍然希望更高的限制,可以使用 JSON:API Page Limit 模块。
… 为什么响应中资源数量与期望不符?
JSON:API 模块允许你指定页面限制,但这经常被误解为保证响应中一定会包含相应数量的资源。例如,你可能知道有足够的资源来“填满”响应,但结果却少于预期。
原因与上文类似。JSON:API 仅运行数据库查询以返回 page[limit]
参数指定数量的项目。这只是一个最大值。如果查询结果中的某些资源用户无权访问,这些资源会被移除。因此,响应中的资源数量可能少于预期。
这种情况在请求可能包含未发布实体(如节点)时很常见,而这些实体并未通过 filter
参数提前过滤。
文章来源:Drupal 文档。