内容层:深度解析

作者
Matt Kane

本周,我们发布了 Astro 5 的第一个 Beta 版本,其中包含一种全新的 Astro 内容处理方式。本文将深入探讨内容层 API,展示其工作原理以及如何使用它来构建您的网站。

Astro 为创建内容驱动的网站而生。虽然它现在也可以用于构建各种动态应用程序,但对于围绕大量内容构建的网站来说,它仍然是最佳选择。从使用 Starlight 构建的全功能文档网站,如 CloudflareStackBlitz,到为 PorscheNetlify 等品牌打造的精美营销网站,每天都有数百万用户正在体验使用 Astro 构建的快速且可访问的网站,成千上万的工程师喜爱业界最佳的开发体验。

在 Astro 2 中,我们引入了内容集合(Content Collections),作为一种强大的新方式来组织本地内容、以类型安全的方式构建并扩展到数千个页面。内容集合为 Markdown 和 MDX 等本地文件提供了业界一流的开发体验,但我们听取了您的反馈,您希望所有内容(包括远程 API)都能享受到相同的优势。同样清楚的是,尽管许多人正在构建拥有数千个页面的网站,但我们的内容集合 API 在扩展到数万个页面时遇到了困难,表现为构建速度变慢和内存占用过多。

今年六月,我们分享了关于通过全新的内容集合类型(由内容层 API 提供支持)解决这些问题及更多问题的早期计划预览,该 API 提供了您所要求的灵活性。我们设想将集合扩展到不仅仅是 src/content 中的文件,而是允许您从任何地方加载内容。我们一直在测试能够扩展到前所未有规模的集合。自 Astro 4.14 中的首次实验性发布以来,我们一直致力于稳定这一新 API,以期在 Astro 5 中发布。

什么是内容层

内容层 API 是您所熟悉和喜爱的内容集合的未来。它允许您在构建网站时从任何来源加载数据,然后通过简单、类型安全的 API 在页面上访问它。

内容层 API 不关心您的数据存储在哪里。一个集合可以是本地的 Markdown 文件,另一个可以调用 API,还有另一个可以存在于您文件系统的其他位置。使用与以前相同的 getEntry()getCollection() 函数,您可以从同一页面的多个来源加载数据。性能没有损失:Astro 会在构建之间本地缓存数据,这意味着更新速度快,并且可以最大限度地减少所需的 API 调用次数。当然,所有内容仍然是类型安全的,TypeScript 类型会根据您的 schema 自动生成。

如果您之前使用过内容集合,您会认出以下许多概念和术语。事实上,我们并没有对项目中使用集合的方式进行太多更改!**集合**仍然是我们对共享公共 schema 的一组条目的术语。每个条目都有一个唯一的 ID。它类似于关系数据库中的一个表。

但现在,每个集合都使用一个**加载器**(loader),它定义了如何加载条目以填充该集合。加载器可以是一个返回条目数组的基本内联函数,也可以是一个处理自身缓存和数据存储的更高级对象。第一批加载器已经作为 npm 上的模块发布。

每当您的网站构建时,就会调用每个集合的加载器,然后更新本地**数据存储**。您可以使用熟悉的 **getCollection()** 和 **getEntry()** 函数查询该数据存储。这在构建时用于预渲染页面,或在使用按需适配器时在服务器渲染期间完成。在每种情况下,都可以获得相同的数据,即构建时的数据快照。这里需要注意的重要一点是,**数据存储只在构建时更新:部署的网站无法更改数据存储。** 如果数据源需要更新集合,它必须通过触发新的构建来实现。

创建集合

src/content/config.ts 中定义您的内容集合,如果您之前使用过集合,则应该已经有了这个文件。新的 loader 属性定义了数据源,它可以像返回项目数组的异步函数一样简单

import { defineCollection, z } from 'astro:content';
const countries = defineCollection({
loader: async () => {
const response = await fetch('https://restcountries.com/v3.1/all');
const data = await response.json();
// Must return an array of entries with an id property, or an object with IDs as keys and entries as values
return data.map((country) => ({
id: country.cca3,
...country,
}));
},
// optionally define a schema using Zod
schema: z.object({
id: z.string(),
name: z.string(),
capital: z.array(z.string()),
population: z.number(),
// ...
}),
});
export const collections = { countries };

然后,这些数据将像以前一样在您的 .astro 组件中可用

src/pages/countries/[id].astro
---
import type { GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
export const getStaticPaths: GetStaticPaths = async () => {
const collection = await getCollection('countries');
if (!collection) return [];
return collection.map((country) => ({
params: {
id: country.id,
},
props: {
country,
},
}));
};
const { country } = Astro.props;
---
<h1>{craft.data.name}</h1>
<p>Capital: {craft.data.capital}</p>

我们对在页面上查询和渲染数据的这种体验已经非常满意,所以我们将注意力转向了该过程背后的机制。让我们深入探讨内容层 API 如何组织、管理和使用您的内容!

内容层生命周期

当运行 astro buildastro dev 时,每个集合的加载器会并行调用。这些加载器会更新其各自作用域的数据存储,数据存储在构建之间会保留。

Astro 组件和页面随后可以使用 getCollectiongetEntry 查询数据。数据在此时是不可变的,因此所有页面都在查询相同的快照,该快照在构建时编译。这适用于页面在构建时预渲染,或按需进行服务器渲染。这里需要注意的重要一点是,**数据存储只在构建时更新:部署的网站无法更改数据存储。** 如果数据源需要更新集合,它必须通过触发新的构建来实现。

尽管在生产环境中是不可变的,但在运行 astro dev 时,数据存储可以通过 s+enter 热键或通过集成进行按需更新。它们可以通过多种方式实现这一点,例如注册一个开发刷新端点或打开一个套接字连接到 CMS 以监听更新。

加载器的工作原理

第一个示例展示了如何构建一个简单的内联加载器,但您不必止步于此。借助对象加载器 API,您可以构建功能更强大的加载器。对象加载器通过数据存储对象与内容层交互。这是一个作用域限定为单个集合的键值存储。集合只能访问自己的条目,但对其拥有完全控制权。如果加载器知道其数据源未更改,则可以完全跳过更新,或者只更新已更改的条目。

内容层 API 提供了一些工具来简化此操作。首先是元数据存储,它可用于存储任意值,例如“上次修改”时间或同步令牌。比较这些值将允许加载器执行条件 API 请求或使用增量同步 API 等操作。

此示例展示了如何使用 RSS 提要加载器执行此操作。它将上次修改的标头存储在元数据存储中,然后在下次加载提要时使用它来发出条件请求

src/content/config.ts
export function feedLoader({ url }: FeedLoaderOptions): Loader {
const feedUrl = new URL(url);
// Return a loader object
return {
// The name of the loader. This is used in logs and error messages.
name: 'feed-loader',
// The load method is called to load data
load: async ({ store, logger, meta }) => {
// Check if there's a last-modified time already stored
const lastModified = meta.get('last-modified');
// If so, make a conditional request for the feed
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
const res = await fetch(feedUrl, { headers });
// If the feed hasn't changed, you do not need to update the store
if (res.status === 304) {
logger.info('Feed not modified, skipping');
return;
}
if (!res.ok || !res.body) {
throw new Error(`Failed to fetch feed: ${res.statusText}`);
}
// Store the last-modified header in the meta store so we can
// send it with the next request
meta.set('last-modified', res.headers.get('last-modified'));
// ... now store the data
},
};
}

如果内容*确实*发生了变化,我们可以选择清除存储并完全替换它,或者如果数据源提供了这种级别的详细信息,则可以增量更新单个条目。

src/content/config.ts
export function feedLoader({ url }: FeedLoaderOptions): Loader {
const feedUrl = new URL(url);
// Return a loader object
return {
// The name of the loader. This is used in logs and error messages.
name: 'feed-loader',
// The load method is called to load data
load: async ({ store, logger, meta }) => {
// Check if there's a last-modified time already stored
const lastModified = meta.get('last-modified');
// If so, make a conditional request for the feed
const headers = lastModified ? { 'If-Modified-Since': lastModified } : {};
const res = await fetch(feedUrl, { headers });
// If the feed hasn't changed, you do not need to update the store
if (res.status === 304) {
logger.info('Feed not modified, skipping');
return;
}
if (!res.ok || !res.body) {
throw new Error(`Failed to fetch feed: ${res.statusText}`);
}
// Store the last-modified header in the meta store so we can
// send it with the next request
meta.set('last-modified', res.headers.get('last-modified'));
const feed = parseFeed(res.body);
// If the loader doesn't handle incremental updates, clear the store before inserting new entries
// In some cases the API might send a stream of updates, in which case you would not want to clear the store
// and instead add, delete, or update entries as needed.
store.clear();
for (const item of feed.items) {
// The parseData helper uses the schema to validate and transform data
const data = await parseData({
id: item.guid,
data: item,
});
// The generateDigest helper lets you generate a digest based on the content. This is an optional
// optimization. When inserting data into the store, if the digest is provided then the store will
// check if the content has changed before updating the entry. This will avoid triggering a rebuild
// in development if the content has not changed.
const digest = generateDigest(data);
store.set({
id,
data,
// If the data source provides HTML, it can be set in the `rendered` property
// This will allow users to use the `<Content />` component in their pages to render the HTML.
rendered: {
html: data.description ?? '',
},
digest,
});
}
},
};
}

何时*不*使用内容集合

以前,内容集合何时是一个好主意很清楚:只要您在页面中使用本地内容!内容层 API 为您提供了更多强大功能和灵活性,因为您可以将其用于任何内容源,包括实时 API。然而,重要的是要记住数据只在网站构建时更新,因此它不适用于所有用例。

这意味着当您的数据不经常变化时(例如博客),集合是完美的。如果您正在撰写一个托管在 CMS 中的博客,您可以在发布新帖子时通过 webhook 触发构建。内容层的增量更新应该会使构建速度变快。

这也适用于大多数电子商务网站,当产品被编辑时可以触发构建。如果您不介意等待部署网站以发布更新所需的时间,那么内容集合仍然是您的明确选择。您将获得最佳性能和出色的开发体验。

如果您需要页面近似实时更新或显示个性化内容,那么最好使用按需渲染适配器,最好配合 CDN 缓存头以确保页面加载速度超快。您甚至可以使用服务器岛屿将两者结合起来,获得两全其美的效果——使用内容集合渲染主要内容,并使用服务器岛屿处理实时或个性化内容。

下一步

内容层 API 是 Astro 的一大进步,但我们才刚刚开始。目前,数据存储只是一个键值存储,过滤功能有限。这虽然速度快,但内存效率不高,并且查询不够灵活。我们的目标是在未来版本中引入基于 Astro DB 的后端,以帮助其扩展到数十万个页面。它还将使我们能够支持更复杂的查询,甚至可能实现实时更新。

与此同时,我们非常乐意听取您对内容层 API 的反馈!尝试将您现有的网站迁移到使用它。(我们保证,这很容易!)并告诉我们进展如何。试用社区构建的一些新加载器,或者为您喜欢的 API 构建您自己的加载器。我们很高兴看到您用它构建出什么!