实时内容集合:深度解析

作者
马特·凯恩

实时内容集合代表了Astro内容管理的下一次演进,将实时数据能力引入你已熟悉并喜爱的现有内容集合API。

Astro中的内容集合已经经历了几个发展阶段。它们最初推出时,是一种极其简单且强大的方式,用于管理磁盘文件中的结构化内容。最初支持Markdown、MDX和JSON文件,它们让你能够以出色的开发体验和类型安全的数据来构建博客、文档网站等。

随着Astro 5.0的发布,内容集合被扩展为一个成熟的内容层(Content Layer),支持各种数据源的可插拔加载器,包括API、CMS等。

在Astro 5.10中,内容集合迈出了下一步,提供了对实时内容集合的实验性支持。有了这些功能,你现在可以在运行时而不是构建时获取内容,为动态、个性化和实时内容体验开辟了全新的可能性。

无论你正在构建一个库存频繁变化的电子商务网站、一个有突发更新的新闻网站,还是一个带有实时指标的仪表板,Astro全新的实时内容集合都提供了你所需的灵活性,同时保持了Astro独特的类型安全和开发者体验。

实时内容集合的基础

在深入了解实时内容集合之前,有必要先理解其基础:加载器(loaders)。Astro内容集合使用加载器来管理项目中的结构化数据和内容。每个内容集合都依赖其加载器来定义如何填充条目。在astro build期间,这些加载器会运行以获取数据并填充本地数据存储。你的页面随后使用getCollection()getEntry()函数查询这个不可变快照。

实时内容集合将这个概念更进一步:它们不是在构建时获取数据,而是在请求时获取数据,让你能够访问最新鲜的数据。有时你希望获得静态内容的速度和可靠性,但有时你需要实时数据的灵活性和动态性。正如你可以在Astro中选择静态渲染页面和按需渲染页面一样,你现在也可以在构建时内容集合和实时内容集合之间进行选择。

无论你做出何种选择,你都将获得与现有内容集合相同且熟悉的API。如果你知道如何使用getCollection()getEntry(),那么你基本上已经知道如何使用getLiveCollection()getLiveEntry()

实时集合的架构

与在构建过程中填充静态数据存储的构建时内容集合不同,实时内容集合在底层的工作方式截然不同。

当请求使用实时内容集合的页面时

  1. 页面调用getLiveCollection()getLiveEntry()来获取数据。
  2. 数据从外部源(API、数据库等)获取。
  3. 结果根据你的模式进行处理和验证。
  4. 数据返回给你的页面组件。

这种架构意味着你总是使用最新数据,但也意味着每个请求都涉及对数据源的网络调用。这种权衡非常适合数据新鲜度比绝对性能更重要的用例。你可以通过页面缓存来缓解性能问题,实时集合通过提供可用于优化此过程的缓存提示来提供帮助。随着这个实验性功能的开发,Astro最终将为你处理更多这些工作。目前,你可以使用Cache-Control和其他HTTP头来控制数据在浏览器和CDN上的缓存时间。

设置实时内容集合

开始使用实时内容集合需要启用实验性标志并创建实时集合配置。

astro.config.mjs
export default defineConfig({
experimental: {
liveContentCollections: true,
},
// Live collections require an adapter for on-demand rendering
adapter: node({
mode: 'standalone',
}),
});

接下来,创建一个src/live.config.ts文件来定义你的实时集合,指定type: 'live'和集合的loader

在此示例中,我使用了我创建的两个实时加载器包,但你可能需要按照我们的文档为自己的实时数据源创建自己的加载器。我们期待更多社区加载器可用,所以请务必分享你所构建的! (我花了几个小时将实时加载器支持添加到我现有的用于构建时集合的feed加载器和Bluesky加载器包中,所以希望入门不会太难。)

src/live.config.ts
import { defineLiveCollection } from 'astro:content';
import { liveFeedLoader } from '@ascorbic/feed-loader';
import { liveBlueskyLoader } from '@ascorbic/bluesky-loader';
export const astroNews = defineLiveCollection({
type: 'live',
loader: liveFeedLoader({
url: 'https://astro.js.cn/rss.xml',
}),
});
export const socialPosts = defineLiveCollection({
type: 'live',
loader: liveBlueskyLoader({
identifier: 'astro.build',
limit: 10,
}),
});

在页面中获取实时数据

定义实时集合后,在页面中使用它们与现有的构建时内容集合非常相似,但有一些关键区别。特别是,由于你的数据是从外部源实时加载的,你将需要添加一些错误处理

src/pages/news.astro
---
export const prerender = false;
import { getLiveCollection } from 'astro:content';
// Fetch the latest Astro blog posts
const { entries: blogPosts, error } = await getLiveCollection('astroNews');
if (error) {
console.error('Failed to load news:', error);
}
---
<h1>Latest Astro News</h1>
{
error ? (
<p>Unable to load news at this time. Please try again later.</p>
) : (
<div class="news-grid">
{blogPosts.map((post) => (
<article class="news-card">
<h2>
<a href={post.data.url}>{post.data.title}</a>
</h2>
{post.data.description && (
<p class="summary">{post.data.description}</p>
)}
</article>
))}
</div>
)
}

你还可以使用getLiveEntry()通过ID或使用过滤参数来获取单个条目。

src/pages/social/[id].astro
---
export const prerender = false;
import { getLiveEntry, render } from 'astro:content';
const postId = Astro.params.id;
const { entry: post, error } = await getLiveEntry('socialPosts', postId);
if (error) {
console.error('Failed to load post:', error);
return Astro.rewrite('/404');
}
const { Content } = await render(post);
---
<div class="post">
<Content />
<div class="engagement-stats">
<span>❤️ {post.data.likeCount}</span>
<span>🔄 {post.data.repostCount}</span>
<span>💬 {post.data.replyCount}</span>
{post.data.quoteCount > 0 && <span>📝 {post.data.quoteCount}</span>}
</div>
</div>

构建实时内容加载器

创建自定义实时加载器允许你连接到任何API或数据源,从而完全控制数据的获取和处理方式。该API旨在简单、灵活且类型安全,因此你可以构建适合你特定需求的加载器,同时保留Astro闻名的易用性和类型安全性。我们很乐意看到人们尝试这个实验性API并提供反馈,以便我们能在它稳定之前对其进行改进。

创建自定义API加载器

这是一个用于电子商务API的实时加载器示例:

src/loaders/store-loader.ts
import type { LiveLoader } from 'astro:content';
interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
description?: string;
}
interface ProductFilter {
category?: string;
inStock?: boolean;
}
export function createStoreLoader(
baseUrl: string,
): LiveLoader<Product, ProductFilter> {
return {
loadCollection: async (filter) => {
try {
const url = new URL(`${baseUrl}/products`);
if (filter?.category) {
url.searchParams.set('category', filter.category);
}
if (filter?.inStock !== undefined) {
url.searchParams.set('inStock', filter.inStock.toString());
}
const response = await fetch(url);
if (!response.ok) {
return {
error: new Error(
`Failed to fetch products: ${response.statusText}`,
),
};
}
const data = await response.json();
return {
entries: data.map((product: Product) => ({
id: product.id,
data: product,
})),
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
loadEntry: async (id) => {
try {
const response = await fetch(`${baseUrl}/products/${id}`);
if (response.status === 404) {
return { entry: null };
}
if (!response.ok) {
return {
error: new Error(`Failed to fetch product: ${response.statusText}`),
};
}
const product = await response.json();
return {
entry: {
id: product.id,
data: product,
},
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
};
}

然后将其用于你的实时集合配置中

src/live.config.ts
import { defineLiveCollection, z } from 'astro:content';
import { createStoreLoader } from './loaders/store-loader';
export const products = defineLiveCollection({
type: 'live',
loader: createStoreLoader('https://store.example.com'),
schema: z.object({
id: z.string(),
name: z.string(),
price: z.number(),
category: z.string(),
inStock: z.boolean(),
description: z.string().optional(),
}),
});

带有渲染内容的加载器

实时内容加载器可以支持渲染内容,使用户能够轻松显示从API获取的HTML内容。这是一个博客文章加载器示例,它从CMS获取文章并将其内容渲染为HTML:

src/loaders/blog-loader.ts
import type { LiveLoader } from 'astro:content';
interface BlogPost {
title: string;
author: string;
publishDate: Date;
content: string;
excerpt: string;
tags: string[];
}
interface BlogFilter {
status?: 'published' | 'draft';
author?: string;
}
export function createBlogLoader(
baseUrl: string,
): LiveLoader<BlogPost, BlogFilter> {
return {
loadCollection: async (filter) => {
try {
const url = new URL(`${baseUrl}/posts`);
if (filter?.status) {
url.searchParams.set('status', filter.status);
}
if (filter?.author) {
url.searchParams.set('author', filter.author);
}
const response = await fetch(url);
if (!response.ok) {
return {
error: new Error(`Failed to fetch posts: ${response.statusText}`),
};
}
const posts = await response.json();
return {
entries: posts.map((post: any) => ({
id: post.slug,
data: {
title: post.title,
author: post.author,
publishDate: new Date(post.publishDate),
content: post.content,
excerpt: post.excerpt,
tags: post.tags || [],
},
rendered: post.html ? { html: post.html } : undefined,
})),
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
loadEntry: async (slug) => {
try {
const response = await fetch(`${baseUrl}/posts/${slug}`);
if (response.status === 404) {
return { entry: null };
}
if (!response.ok) {
return {
error: new Error(`Failed to fetch post: ${response.statusText}`),
};
}
const post = await response.json();
return {
entry: {
id: post.slug,
data: {
title: post.title,
author: post.author,
publishDate: new Date(post.publishDate),
content: post.content,
excerpt: post.excerpt,
tags: post.tags || [],
},
rendered: post.html ? { html: post.html } : undefined,
},
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
};
}

然后将其用于你的实时集合配置中

src/live.config.ts
import { defineLiveCollection, z } from 'astro:content';
import { createBlogLoader } from './loaders/blog-loader';
export const blogPosts = defineLiveCollection({
type: 'live',
loader: createBlogLoader('https://cms.example.com'),
schema: z.object({
title: z.string(),
author: z.string(),
publishDate: z.date(),
content: z.string(),
excerpt: z.string(),
tags: z.array(z.string()),
}),
});

实时集合与构建时集合的比较

理解何时使用实时集合以及何时使用传统的构建时集合对于构建高性能应用程序至关重要。

在以下情况下使用实时集合:

  • 数据频繁变化:库存水平、用户生成内容、实时指标。
  • 需要个性化:用户特定推荐、仪表板数据。
  • 实时准确性至关重要:新闻源、社交媒体内容、实时比分。
  • 需要动态过滤:搜索结果、过滤后的产品目录。

在以下情况下使用构建时集合:

  • 内容相对静态:博客文章、文档、营销页面。
  • 性能至关重要:流量大的网站,每一毫秒都很重要。
  • 你需要图像转换或MDX渲染:实时集合不支持图像转换或MDX渲染。

混合方法

你可以在同一个项目中,甚至在同一个页面中结合这两种方法。例如,你可以使用构建时集合来处理博客文章等静态内容,同时使用实时集合来处理评论或用户资料等动态功能。

src/pages/blog/[slug].astro
---
export const prerender = false;
import { getEntry, getLiveCollection } from 'astro:content';
// Blog post content is fetched at build time and cached in the data store. The site is rebuilt when new posts are added
const post = await getEntry('blog', Astro.params.slug);
// Live comments are fetched at request time, so they always show the latest comments
const { entries: comments } = await getLiveCollection('comments', {
postId: Astro.params.slug,
});
---
<!-- Static blog post content -->
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
<!-- Live comments section -->
<section class="comments">
<h2>Comments ({comments.length})</h2>
{
comments.map((comment) => (
<div class="comment">
<strong>{comment.data.author}</strong>
<p>{comment.data.content}</p>
</div>
))
}
</section>

这些混合模式与服务器孤岛(server islands)完美结合,让你能够创建真正的混合页面,其中主要内容是静态的,但特定组件会获取实时数据。这为你提供了两全其美的优势:快速的静态内容交付和动态的实时部分。

src/pages/blog/[slug].astro
---
// This page can be prerendered because the main content is static
import { getEntry, getCollection } from 'astro:content';
import Comments from '../components/Comments.astro';
export const getStaticPaths = async () => {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
}));
};
const post = await getEntry('blog', Astro.params.slug);
---
<!-- Static blog post content -->
<article>
<h1>{post.data.title}</h1>
<div class="content">
<Content />
</div>
</article>
<!-- Dynamic comments loaded via server island -->
<Comments server:defer postId={Astro.params.slug} />
src/components/Comments.astro
---
export const prerender = false;
import { getLiveCollection } from 'astro:content';
interface Props {
postId: string;
}
const { postId } = Astro.props;
// This component runs as a server island, fetching live data
const { entries: comments, error } = await getLiveCollection('comments', {
postId,
status: 'approved',
});
// Cache in the CDN for 10 minutes
Astro.response.headers.set('Cache-Control', 'public, s-maxage=600');
---
<section>
<h2>Comments</h2>
{error ? (
<p>Unable to load comments at this time.</p>
) : comments.length === 0 ? (
<p>No comments yet. Be the first to comment!</p>
) : (
<div class="comments-list">
{comments.map((comment) => (
<div class="comment">
<div class="comment-header">
<strong>{comment.data.author}</strong>
<time>{comment.data.createdAt.toLocaleDateString()}</time>
</div>
<p>{comment.data.content}</p>
</div>
))}
</div>
)}
</section>

这种方法提供了多项优势。它能让你在初始页面加载时获得快速的静态内容,同时仍允许特定组件按需获取实时数据。它还能让你有效地缓存实时数据,从而提高性能并减少API上的负载。

错误处理和弹性

实时内容集合提供显式错误处理,使你的应用程序更具弹性。

src/pages/dashboard.astro
---
export const prerender = false;
import { getLiveCollection } from 'astro:content';
// Fetch multiple live collections with individual error handling
const [metricsResult, alertsResult, reportsResult] = await Promise.all([
getLiveCollection('metrics'),
getLiveCollection('alerts', { severity: 'high' }),
getLiveCollection('reports', { recent: true }),
]);
// Handle errors gracefully
const metrics = metricsResult.error ? [] : metricsResult.entries;
const alerts = alertsResult.error ? [] : alertsResult.entries;
const reports = reportsResult.error ? [] : reportsResult.entries;
const hasErrors =
metricsResult.error || alertsResult.error || reportsResult.error;
---
{
hasErrors && (
<div class="error-banner">
Some dashboard data may be outdated. Please refresh to try again.
</div>
)
}
<div class="dashboard">
<section class="metrics">
<h2>Metrics</h2>
{
metrics.length === 0 ? (
<p>No metrics available</p>
) : (
<div class="metrics-grid">
{metrics.map((metric) => (
<div class="metric-card">
<h3>{metric.data.name}</h3>
<p class="value">{metric.data.value}</p>
</div>
))}
</div>
)
}
</section>
<section class="alerts">
<h2>High Priority Alerts</h2>
{
alerts.length === 0 ? (
<p>No alerts - all systems operational</p>
) : (
<ul class="alerts-list">
{alerts.map((alert) => (
<li class="alert">
<strong>{alert.data.title}</strong>
<p>{alert.data.description}</p>
</li>
))}
</ul>
)
}
</section>
</div>

性能考量和最佳实践

使用实时内容集合时,考虑性能影响非常重要,尤其是在使用较慢或更复杂的API时。以下是一些需要牢记的最佳实践:

使用缓存提示进行缓存

实时内容集合支持缓存提示,允许你为响应提供缓存元数据。这通过启用适当的缓存头和缓存失效策略来帮助优化性能。在未来的Astro版本中,这些缓存提示将用于自动缓存页面,但目前你可以使用它们在页面中设置适当的HTTP头。

src/loaders/cached-store-loader.ts
import type { LiveLoader } from 'astro:content';
interface Product {
id: string;
name: string;
price: number;
lastModified: string;
category: string;
}
export function createStoreLoader(baseUrl: string): LiveLoader<Product> {
return {
loadCollection: async (filter) => {
try {
const response = await fetch(`${baseUrl}/products`);
if (!response.ok) {
return {
error: new Error(
`Failed to fetch products: ${response.statusText}`,
),
};
}
const products = await response.json();
return {
entries: products.map((product: Product) => ({
id: product.id,
data: product,
cacheHint: {
tags: [`product-${product.id}`],
lastModified: new Date(product.lastModified),
},
})),
cacheHint: {
tags: ['products'],
},
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
loadEntry: async (id) => {
try {
const response = await fetch(`${baseUrl}/products/${id}`);
if (response.status === 404) {
return { entry: null };
}
if (!response.ok) {
return {
error: new Error(`Failed to fetch product: ${response.statusText}`),
};
}
const product = await response.json();
return {
entry: {
id: product.id,
data: product,
},
cacheHint: {
tags: [`product-${id}`],
lastModified: new Date(product.lastModified),
},
};
} catch (error) {
return {
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
},
};
}

然后在你的页面中使用缓存提示来设置适当的HTTP头。

src/pages/products/[id].astro
---
export const prerender = false;
import { getLiveEntry } from 'astro:content';
const {
entry: product,
error,
cacheHint,
} = await getLiveEntry('products', Astro.params.id);
if (error || !product) {
return Astro.redirect('/products');
}
// Set cache headers based on the cache hint
if (cacheHint?.lastModified) {
Astro.response.headers.set(
'Last-Modified',
cacheHint.lastModified.toUTCString(),
);
}
if (cacheHint?.tags) {
Astro.response.headers.set('Cache-Tag', cacheHint.tags.join(','));
}
// Set your own cache control headers
Astro.response.headers.set('Cache-Control', 'public, max-age=600'); // 10 minutes
---
<h1>{product.data.name}</h1>
<p>Price: ${product.data.price}</p>

请注意,缓存提示提供了关于你内容的元数据,但你仍然需要设置自己的缓存头来控制实际的缓存行为。像Netlify这样的平台允许你根据这些标签使缓存失效,因此你可以确保你的实时内容保持最新,而无需不必要的API调用。

实际用例

电子商务产品目录

src/pages/products/[...slug].astro
---
export const prerender = false;
import { getLiveCollection, getLiveEntry } from 'astro:content';
const slug = Astro.params.slug;
const { entry: product, error } = await getLiveEntry('products', slug);
if (error || !product) {
return Astro.redirect('/products');
}
// Also fetch related products
const { entries: related } = await getLiveCollection('products', {
category: product.data.category,
exclude: product.id,
limit: 4,
});
// Render product details and related items...
---

新闻和社交媒体聚合

这是一个使用社区加载器创建实时新闻和社交媒体仪表板的示例:

src/pages/dashboard.astro
---
export const prerender = false;
import { getLiveCollection } from 'astro:content';
// Fetch live RSS feed using community loader
const { entries: astroNews, error: newsError } =
await getLiveCollection('astroNews');
// Fetch live Bluesky posts using community loader
const { entries: socialPosts, error: socialError } =
await getLiveCollection('socialPosts');
const hasErrors = newsError || socialError;
---
<div class="dashboard">
{
hasErrors && (
<div class="error-banner">
Some content may be unavailable. Please refresh to try again.
</div>
)
}
<section class="news-section">
<h2>Latest Tech News</h2>
{
newsError ? (
<p>Unable to load news at this time.</p>
) : (
<div class="news-grid">
{astroNews.map((article) => (
<article class="news-card">
<h3>
<a href={article.data.link} target="_blank">
{article.data.title}
</a>
</h3>
<p class="meta">
{article.data.pubDate?.toLocaleDateString()} |{' '}
{article.data.creator}
</p>
{article.data.summary && (
<p class="summary">{article.data.summary}</p>
)}
</article>
))}
</div>
)
}
</section>
<section class="social-section">
<h2>Latest from Bluesky</h2>
{
socialError ? (
<p>Unable to load social posts at this time.</p>
) : (
<div class="posts-feed">
{socialPosts.map((post) => (
<div class="post-card">
<div class="post-header">
<strong>{post.data.author.displayName}</strong>
<span class="handle">@{post.data.author.handle}</span>
<time>{post.data.createdAt.toLocaleString()}</time>
</div>
<div class="post-content">
{post.rendered && <Fragment set:html={post.rendered.html} />}
</div>
</div>
))}
</div>
)
}
</section>
</div>

在页面中使用自定义加载器

创建自定义加载器后,你可以在页面中使用它们。

src/pages/products/[id].astro
---
export const prerender = false;
import { getLiveEntry } from 'astro:content';
// Fetch a single product with error handling
const { entry: product, error } = await getLiveEntry(
'products',
Astro.params.id,
);
if (error) {
console.error('Failed to load product:', error);
return Astro.redirect('/products');
}
if (!product) {
return Astro.redirect('/products');
}
---
<h1>{product.data.name}</h1>
<p class="price">
{
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(product.data.price)
}
</p>
<p class="stock-status">
{product.data.inStock ? 'In Stock' : 'Out of Stock'}
</p>
{
product.data.description && (
<div class="description">
<p>{product.data.description}</p>
</div>
)
}

实时内容集合的未来

实时内容集合目前仍处于实验阶段,但它们代表了Astro发展的重要一步,为在Astro上构建具有动态、实时内容的网站开辟了更多用例,同时保持了你所喜爱的开发者体验。

下一步

实时内容集合在Astro 5.10中仍处于实验阶段,我们需要你的反馈。若要参与:

实时内容集合为构建动态、实时网络体验开辟了令人兴奋的新可能性,同时保持了你所钟爱的开发者体验。无论你是构建电子商务网站、新闻平台还是数据仪表板,实时集合都能提供所需的灵活性和强大功能,以创建真正动态的网络应用程序。