Nuxt 集成 RSS 服务完全指南:从模块到手写的优雅之路
为什么需要 RSS?
在算法推荐泛滥的今天,RSS 依然是最纯粹的内容订阅方式。它让用户真正掌控自己获取信息的渠道,不受平台算法的干扰。为你的 Nuxt 博客添加 RSS 服务,不仅能提升用户体验,更是对开放互联网精神的致敬。
本文将带你从零开始,在 Nuxt 4 中实现一个完全可控、生产可用的 RSS 服务。我们将绕过第三方模块的潜在陷阱,亲手构建属于自己的 RSS 生成器。
💡 前置要求:本文假设你已经熟悉 Nuxt 4 和 @nuxt/content v3 的基本使用,能够独立创建项目并配置 content 模块。如果你还不熟悉这些,建议先查阅官方文档。
两种方案的对比
方案一:使用第三方模块(如 nuxt-feedme)
看似简单:
export default defineNuxtConfig({
modules: ["nuxt-feedme"],
feedme: {
feeds: {
"/feed.xml": { type: "rss2" },
},
},
});
实际可能遇到的坑:
- ❌ 生产环境 API 404(模块试图调用不存在的开发接口)
- ❌ 文档老旧,与实际版本脱节
- ❌ 配置复杂,黑盒调试困难
- ❌ 钩子机制学习成本高
- ❌ 依赖更新可能导致兼容性问题
方案二:手写 RSS(本文推荐)
核心优势:
- ✅ 完全可控,每一行代码都了然于心
- ✅ 无依赖,零兼容问题
- ✅ 调试简单,哪里错看哪里
- ✅ 性能极致,可按需优化
- ✅ 代码量少,维护成本极低
RSS 的本质:一句话说透
RSS = 获取数据 + 拼接 XML(或 JSON)
仅此而已。理解了这一点,你就掌握了 RSS 的全部奥秘。
完整实现步骤
1. 环境配置
在 nuxt.config.ts 中配置好运行时变量:
export default defineNuxtConfig({
modules: ["@nuxt/content"],
runtimeConfig: {
public: {
siteUrl: process.env.SITE_URL || "https://yourdomain.com",
siteName: "你的博客名称",
siteDescription: "博客描述",
},
},
});
2. 创建 MinimarkTree 转 HTML 工具函数
Nuxt Content v3 返回的 doc.body.value 是结构化的 MinimarkTree,需要转换为 HTML。函数会完整保留标签属性,确保图片、链接等元素正常显示。
// utils/minimarkToHtml.ts
/**
* 将 Nuxt Content v3 的 MinimarkTree 转换为 HTML 字符串
* @param node - 文档 body 的 value 节点 (doc.body.value)
* @returns HTML 字符串
*/
export function minimarkToHtml(node: any): string {
if (!node) return "";
// 处理根节点
if (node.type === "minimark" && Array.isArray(node.value)) {
return node.value.map(minimarkToHtml).join("");
}
// 文本节点
if (typeof node === "string") return node;
// 数组节点
if (Array.isArray(node)) {
return node.map(minimarkToHtml).join("");
}
if (node && typeof node === "object") {
// 元素节点(带标签)
if (node.tag) {
// 生成属性字符串
const attrs = node.props
? " " +
Object.entries(node.props)
.map(
([key, val]) => `${key}="${String(val).replace(/"/g, """)}"`,
)
.join(" ")
: "";
const children = (node.children || []).map(minimarkToHtml).join("");
// 自闭合标签
if (["img", "br", "hr", "input"].includes(node.tag)) {
return `<${node.tag}${attrs} />`;
}
return `<${node.tag}${attrs}>${children}</${node.tag}>`;
}
}
return "";
}
3. 创建 RSS 2.0 生成器
// server/routes/feed.xml.ts
import { minimarkToHtml } from "~/utils/minimarkToHtml";
export default defineEventHandler(async (event) => {
const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public;
const docs = await queryCollection(event, "docs")
.order("date", "DESC")
.all();
let rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>${siteName}</title>
<link>${siteUrl}</link>
<description>${siteDescription}</description>
<language>zh-CN</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
`;
for (const doc of docs) {
let fullContent = "";
if (doc.body?.value) {
try {
fullContent = minimarkToHtml(doc.body.value);
} catch (e) {
console.error("转换失败:", e);
fullContent = doc.description || "";
}
}
const link = `${siteUrl}${doc.path}`;
const date = new Date(doc.date).toUTCString();
rss += `
<item>
<title><![CDATA[${doc.title}]]></title>
<link>${link}</link>
<guid isPermaLink="true">${link}</guid>
<pubDate>${date}</pubDate>
<description><![CDATA[${doc.description || ""}]]></description>
<content:encoded><![CDATA[${fullContent}]]></content:encoded>
</item>
`;
}
rss += `
</channel>
</rss>`;
setResponseHeader(event, "content-type", "application/xml; charset=utf-8");
return rss;
});
4. 创建 Atom 1.0 生成器
Atom 是另一种 XML 格式的订阅标准,结构更规范:
// server/routes/feed.atom.ts
import { minimarkToHtml } from "~/utils/minimarkToHtml";
export default defineEventHandler(async (event) => {
const { siteName, siteDescription, siteUrl } = useRuntimeConfig().public;
const docs = await queryCollection(event, "docs")
.order("date", "DESC")
.all();
const updated = docs[0]?.date
? new Date(docs[0].date).toISOString()
: new Date().toISOString();
let atom = `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>${siteName}</title>
<subtitle>${siteDescription}</subtitle>
<link href="${siteUrl}/feed.atom" rel="self"/>
<link href="${siteUrl}" rel="alternate"/>
<id>${siteUrl}</id>
<updated>${updated}</updated>
<author>
<name>MoonGate</name>
</author>
`;
for (const doc of docs) {
let content = "";
if (doc.body?.value) {
try {
content = minimarkToHtml(doc.body.value);
} catch (e) {
console.error("转换失败:", e);
content = doc.description || "";
}
}
const link = `${siteUrl}${doc.path}`;
const published = new Date(doc.date).toISOString();
atom += `
<entry>
<title>${doc.title}</title>
<link href="${link}"/>
<id>${link}</id>
<published>${published}</published>
<updated>${published}</updated>
<summary>${doc.description || ""}</summary>
<content type="html"><![CDATA[${content}]]></content>
</entry>
`;
}
atom += `\n</feed>`;
setResponseHeader(
event,
"content-type",
"application/atom+xml; charset=utf-8",
);
return atom;
});
5. 创建 JSON Feed 1.1 生成器
JSON Feed 是现代化的订阅格式,结构清晰:
// server/routes/feed.json.ts
import { minimarkToHtml } from "~/utils/minimarkToHtml";
export default defineEventHandler(async (event) => {
const { siteUrl } = useRuntimeConfig().public;
const docs = await queryCollection(event, "docs")
.order("date", "DESC")
.all();
const feed = {
version: "https://jsonfeed.org/version/1.1",
title: "MoonGate",
home_page_url: siteUrl,
feed_url: `${siteUrl}/feed.json`,
description: "Where Moon Meets Code",
language: "zh-CN",
authors: [
{
name: "MoonGate",
url: siteUrl,
},
],
items: await Promise.all(
docs.map(async (doc) => {
let contentHtml = "";
if (doc.body?.value) {
try {
contentHtml = minimarkToHtml(doc.body.value);
} catch (e) {
console.error("转换失败:", e);
contentHtml = doc.description || "";
}
}
return {
id: `${siteUrl}${doc.path}`,
url: `${siteUrl}${doc.path}`,
title: doc.title,
content_html: contentHtml,
summary: doc.description || "",
date_published: new Date(doc.date).toISOString(),
language: "zh-CN",
tags: doc.tags || [],
};
}),
),
};
setResponseHeader(
event,
"content-type",
"application/feed+json; charset=utf-8",
);
return feed;
});
6. 添加缓存优化(可选)
使用 Nuxt 内置的缓存处理器,减轻服务器压力:
export default defineCachedEventHandler(
async (event) => {
// ... 上面的代码
},
{
maxAge: 60 * 60, // 缓存 1 小时
name: "feed-cache",
getKey: () => "static", // 所有用户共享缓存
},
);
7. 在网页中引入 RSS 订阅
在 app.vue 或布局文件中添加自动发现链接:
// app.vue
<script setup>
const { siteName } = useRuntimeConfig().public;
useHead({
link: [
{
rel: "alternate",
type: "application/rss+xml",
title: siteName,
href: "/feed.xml",
},
{
rel: "alternate",
type: "application/atom+xml",
title: siteName,
href: "/feed.atom",
},
{
rel: "alternate",
type: "application/json",
title: siteName,
href: "/feed.json",
},
],
});
</script>
验证与测试
本地测试
# 启动服务
pnpm dev
# 测试三种格式
curl http://localhost:3000/feed.xml
curl http://localhost:3000/feed.atom
curl http://localhost:3000/feed.json
# 检查响应头
curl -I http://localhost:3000/feed.xml
常见问题排查
Q1: RSS 显示 [object Object]
原因:没有将 doc.body 正确转换为 HTML。
解决:使用本文提供的 minimarkToHtml 函数。
Q2: 链接是相对路径,没有域名
原因:拼接 URL 时遗漏了 siteUrl。
解决:确保使用 ${siteUrl}${doc.path}。
Q3: 日期格式错误
原因:直接使用了 ISO 字符串。
解决:RSS 2.0 用 new Date(date).toUTCString(),Atom 和 JSON 用 .toISOString()。
Q4: 生产环境 404
原因:server/routes/ 下的文件未正确部署。
解决:检查构建输出是否包含 .output/server/ 目录。
Q5: JSON Feed 在浏览器中显示不全
原因:浏览器插件或开发者工具为了性能做了预览截断。
解决:直接用 RSS 阅读器测试,或使用 curl 查看完整内容。
为什么手写比用模块更好?
| 维度 | 第三方模块 | 手写方案 |
|---|---|---|
| 代码量 | 配置复杂,还要写钩子 | 每个文件约 40 行,简单明了 |
| 依赖 | 多个间接依赖 | 零依赖 |
| 学习成本 | 高(要懂黑盒逻辑) | 低(懂 RSS 格式即可) |
| 调试难度 | 高(报错看不懂) | 极低(哪里错看哪里) |
| 生产稳定性 | 容易踩坑 | 稳定可靠 |
| 维护成本 | 依赖作者更新 | 自己掌控 |
记住:RSS 的本质就是“查数据 + 拼 XML/JSON”,没有任何复杂逻辑需要模块来封装。
进阶优化
1. 分页限制
const docs = await queryCollection(event, "docs")
.order("date", "DESC")
.limit(20) // 只取最近 20 篇
.all();
2. 自定义命名空间(RSS 2.0)
xmlns: media = "http://search.yahoo.com/mrss/"; // 支持媒体内容
xmlns: dc = "http://purl.org/dc/elements/1.1/"; // 支持 Dublin Core
3. 添加更多元数据
// 在 JSON Feed 中添加
"authors": [{ "name": "作者名", "url": "个人主页" }],
"language": "zh-CN",
"tags": ["技术", "前端"]
结语
技术世界总是充满各种“开箱即用”的解决方案,但有时候,亲手构建一个简单功能所获得的理解和掌控感,远胜于使用复杂的第三方模块。
RSS 作为一个诞生了 20 多年的简单协议,其魅力就在于透明和可控。通过本文的手写方案,你不仅为博客添加了实用的功能,更深入理解了 Web 的本质。
现在,去享受自己动手的成果吧!🎉
如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️


评论区
登录后参与评论