Fuwari 里接 usememos 图文流 我是怎么做的 fuwari memos moments Fuwari Astro React usememos API 前端
3312 字
17 分钟
Fuwari 里接 usememos 图文流 我是怎么做的

结论先说,我这套“图文”功能本质上不是给 Fuwari 塞了一个复杂插件,而是做了一页单独的 Astro 页面 + React 客户端组件,再让它直接去请求 usememos 的 API,把 memo 数据转成适合博客浏览的卡片流。

真正关键的不是“把 API 调通”,而是中间那层数据清洗和展示重组:图片要从正文和附件里同时提取,标签行要去掉,作者信息要补齐,外链图片还要做一次 CDN 归一化。做完这些之后,图文页才像产品,不像接口调试页。

这篇我就按我博客现在的真实实现,拆一遍完整链路。

先看整体结构#

我这套实现可以粗暴拆成 4 层:

  1. Fuwari 导航入口:把 /moments/ 暴露成一个独立页面
  2. Astro 页面壳:只负责布局和挂载 React 组件
  3. React 图文组件:负责请求 usememos API、清洗数据、做分页和瀑布流
  4. Memos 服务端:提供 /api/v1/memos/api/v1/users/:id 数据

也就是说,Fuwari 在这里更像是博客外壳,真正的图文逻辑主要都在 MemosFeed.tsx 里。

第一步:先在 Fuwari 里开一个“图文”入口#

我博客导航里直接加了一项 图文

export const navBarConfig: NavBarConfig = {
links: [
LinkPreset.Home,
LinkPreset.Archive,
{
name: "图文",
url: "/moments/",
},
LinkPreset.About,
],
};

这一步很普通,但很重要。

因为这说明我不是把图文内容硬塞进文章页,也不是通过 Markdown 去渲染它,而是把它当成博客里的一个独立内容频道

这样做的好处有两个:

  • 不会污染 Fuwari 原本的文章系统
  • 后续想单独优化图文页体验,改动范围比较小

第二步:Astro 页面只负责挂组件#

src/pages/moments/index.astro 其实很薄:

---
import MemosFeed from "@components/moments/MemosFeed.tsx";
import MainGridLayout from "@layouts/MainGridLayout.astro";
---
<MainGridLayout title='图文' description='图文'>
<MemosFeed baseUrl='https://s.bangwu.top/' client:only='react' />
</MainGridLayout>

这里有两个关键信息:

  • 它复用了 Fuwari 原本的 MainGridLayout,所以页面外观和博客整体保持一致
  • MemosFeed 用的是 client:only='react'

这意味着这部分不是 Astro 在服务端预渲染完再丢给浏览器,而是浏览器端直接挂 React 组件

我现在默认这样做,因为图文流本身就很偏交互型:

  • 要滚动加载
  • 要处理瀑布流高度
  • 要做图片轮播
  • 要补充作者缓存

这种场景用客户端组件更顺手。

第三步:组件入口只传一个 baseUrl#

MemosFeed 组件目前只吃一个核心参数:

type Props = {
baseUrl?: string;
};

默认值也是你的 memos 站点:

export default function MemosFeed({
baseUrl = "https://s.bangwu.top/",
}: Props) {

这样设计的好处是简单。

图文页本身并不关心部署细节,它只知道:

  • memo 列表从 ${baseUrl}/api/v1/memos
  • 用户信息从 ${origin}/api/v1/users/:id
  • 详情页跳转到 ${origin}/memos/:id

换句话说,MemosFeed 把 usememos 当成一个内容后端,而不是当成 UI 系统来复用。

第四步:先拉 memo 列表,再做分页#

核心请求函数在这里:

const fetchMemosPage = useCallback(
async (pageToken: string | null) => {
const endpoint = new URL("/api/v1/memos", baseUrl);
endpoint.searchParams.set("pageSize", String(PAGE_SIZE));
if (pageToken) {
endpoint.searchParams.set("pageToken", pageToken);
}
const response = await fetch(endpoint.toString());
if (!response.ok) {
throw new Error(`Failed to load memos: ${response.status}`);
}
const data = await response.json();
const pageMemos = Array.isArray(data)
? data
: data.memos || data.data || data.items || [];
const nextToken =
data.nextPageToken ||
data.next_page_token ||
data.nextToken ||
data.pageToken ||
"";
return { pageMemos, nextToken };
},
[baseUrl],
);

这里我觉得有 3 个实现点比较值得写:

1. 直接用 new URL() 拼 endpoint#

这个比字符串拼接稳,少踩斜杠坑。

2. 兼容多种返回结构#

我这里没有把返回值写死成某一种 shape,而是做了兜底:

  • data.memos
  • data.data
  • data.items
  • 或者接口直接返回数组

这不是炫技,而是现实里接口字段经常会小改。图文页作为消费层,适当宽容一点,后面维护成本更低。

3. 使用 pageToken 做增量加载#

这说明我不是一次性把所有 memo 全拉下来,而是按页读。

对图文流来说,这种策略比一上来全量请求靠谱很多,尤其是图片多的时候。

第五步:原始 memo 不能直接渲染,要先清洗#

真正让这套东西可用的,不是 fetch,而是 buildViews() 这一层。

我把从 usememos 拿到的原始数据,转换成页面最终消费的 MemoView

type MemoView = {
body: string;
images: string[];
timeLabel: string;
id: string;
creatorId: string;
creatorName: string;
raw: Memo;
};

这一步主要做了几件事。

1. 只保留公开内容#

const publicMemos = memos.filter(
(memo) => !memo.visibility || memo.visibility === "PUBLIC",
);

这一层必须有。

不然一旦你把私有 memo 也拉出来,博客就直接变事故现场了。

2. 提取 memo id 和 creator id#

const getMemoId = (memo: Memo): string => {
if (memo.name) {
const parts = memo.name.split("/");
return parts[parts.length - 1] || memo.name;
}
if (memo.uid) return String(memo.uid);
if (memo.id !== undefined) return String(memo.id);
return "";
};
const getCreatorId = (memo: Memo): string => {
if (!memo.creator) return "";
const parts = memo.creator.split("/");
return parts[parts.length - 1] || memo.creator;
};

因为 usememos 的字段可能有不同表达方式,所以这里做了解析兼容。

3. 图片要同时从正文和附件里抽#

正文里的图片:

const extractImages = (origin: string, content: string): string[] => {
const images: string[] = [];
const mdRegex = /!\[[^\]]*]\(([^)]+)\)/g;
const htmlRegex = /<img[^>]*\s+src=["']?([^"'\s>]+)["']?[^>]*>/gi;
// ...
};

附件里的图片:

const extractAttachmentImages = (origin: string, memo: Memo): string[] => {
if (!memo.attachments?.length) return [];
return memo.attachments
.filter((resource) => !resource.type || resource.type.startsWith("image/"))
.map((resource) => resource.externalLink || resource.url || resource.uri || "")
.filter(Boolean)
.map((value) => normalizeImageUrl(origin, value));
};

然后合并去重:

const images = Array.from(
new Set([
...extractImages(origin, content),
...extractAttachmentImages(origin, memo),
]),
);

这一步是整个图文页最像“产品处理”的地方。

因为用户发 memo 时,图片来源可能有两种:

  • Markdown / HTML 正文内嵌图
  • Memos attachment 附件

如果你只处理其中一种,展示就会不完整。

第六步:把不适合卡片展示的内容删掉#

原始 memo 文本直接拿来渲染,效果通常很差,所以我又做了两层清洗。

1. 去掉图片语法#

const stripImageMarkdown = (content: string): string => {
return content
.replace(/!\[[^\]]*]\(([^)]+)\)\s*/g, "")
.replace(/<img[^>]*>\s*/g, "");
};

因为图片已经单独做轮播了,正文里再保留一遍,只会显得重复。

2. 去掉整行 tag#

const stripTagLines = (content: string): string => {
return content
.split("\n")
.filter((line) => !line.trim().match(/^(#[^\s#]+\s*)+$/))
.join("\n");
};

这也是我很认同的一步。

博客里的“图文卡片”不是 memo 后台管理页,不需要把纯标签行原样抬上来,不然信息密度会很乱。

第七步:补作者信息,但不要重复请求#

memo 数据里不一定直接带完整作者展示名,所以我额外查了一次 /api/v1/users/:id

const fetchUser = useCallback(
async (creatorId: string) => {
if (
!creatorId ||
userCache.current.has(creatorId) ||
userFetches.current.has(creatorId)
) {
return;
}
userFetches.current.add(creatorId);
try {
const response = await fetch(`${origin}/api/v1/users/${creatorId}`);
if (!response.ok) {
userCache.current.set(creatorId, { id: creatorId });
return;
}
const data = await response.json();
const user = data.user || data;
userCache.current.set(creatorId, user);
} catch {
userCache.current.set(creatorId, { id: creatorId });
}
},
[origin],
);

这里我用了两层缓存控制:

  • userCache:已经拿到的用户资料
  • userFetches:正在请求中的用户 id

作用很直接:

  • 避免重复 fetch 同一个作者
  • 避免并发滚动时多次打同一个接口

如果图文页里很多 memo 都是同一个人发的,这层优化很值。

第八步:把图片地址统一成自己的 CDN#

我这里还做了一层 URL 归一化:

const normalizeImageUrl = (origin: string, value: string): string => {
if (!value) return "";
try {
const parsed = new URL(value, origin);
const host = parsed.hostname.toLowerCase();
if (host.includes("cloudflarestorage.com")) {
return `https://cdn.bangwu.top${parsed.pathname}`;
}
if (host === "cdn.bangwu.top") {
return `https://cdn.bangwu.top${parsed.pathname}`;
}
return parsed.toString();
} catch {
return value;
}
};

这一步的意义是:

  • 不把底层存储地址直接暴露给前端
  • 统一图片出口域名
  • 后续迁移存储时,上层展示代码不用跟着大改

如果你也准备做类似功能,我很建议中间加这样一层,而不是让页面直接依赖对象存储原始地址。

第九步:展示层用瀑布流,不用普通列表#

展示这块我用的是 masonic

import {
MasonryScroller,
useContainerPosition,
useInfiniteLoader,
usePositioner,
useResizeObserver,
} from "masonic";

以及:

<MasonryScroller
containerRef={containerRef}
positioner={positioner}
resizeObserver={resizeObserver}
items={items}
render={MasonryCard}
itemKey={(item: MemoView) => item.id}
itemHeightEstimate={360}
height={height}
offset={offset}
overscanBy={2}
onRender={maybeLoadMore}
className="memos-grid"
/>

原因也很简单:图文内容高度不固定。

如果你用普通 grid:

  • 文本长短不一
  • 有的卡片有图,有的没图
  • 有的 1 张图,有的多张图

最后视觉上会很散。

瀑布流虽然不新鲜,但放在这种“轻图文 + 不定长卡片”场景里是合适的。

第十步:图片不是静态展示,而是做成了轮播#

卡片里如果有多张图,我没有全堆出来,而是做了一个很轻的轮播:

{data.images.length ? (
<div className="mt-4">
<MemosCarousel images={data.images} title="图文" />
</div>
) : null}

轮播组件本身不复杂:

  • 一个横向 scroll 容器
  • 前后切换按钮
  • 当前页 dots
  • 图片加载完成后再标记状态

重点不在炫,而在控制卡片高度。

因为图文页最怕一条 memo 带 6 张图把页面直接撑烂。轮播后,一条卡片只占一个稳定图片区,观感会统一很多。

第十一步:滚动触发下一页加载#

无限加载的入口是 useInfiniteLoader + loadMore()

const maybeLoadMore = useInfiniteLoader(() => loadMore(), {
isItemLoaded: (index, list) => Boolean(list[index]),
minimumBatchSize: PAGE_SIZE,
threshold: PAGE_SIZE,
totalItems: hasMore ? items.length + PAGE_SIZE : items.length,
});

这套逻辑的关键控制有 3 个:

  • inFlightRef:防并发重复请求
  • nextPageTokenRef:记住下一页 token
  • loadedIds:避免重复 memo 混入列表

其中我最看重的是 loadedIds

.filter((item) => item.id && !loadedIds.current.has(item.id));

很多滚动列表看似能跑,其实翻页重复数据一来就废了。先把去重做掉,后面才不至于一边修 UI 一边怀疑接口。

第十二步:卡片交互保持“像博客,不像后台”#

单个卡片其实很克制:

<div className="card-base memos-card overflow-hidden px-5 py-5">
<div className="text-xs text-black/50 dark:text-white/50">
{data.timeLabel}
{data.creatorName ? ` · ${data.creatorName}` : ""}
</div>
{data.body ? (
<a href={memoUrl(data.id)} className="memos-body ...">
{data.body}
</a>
) : null}
{data.images.length ? <MemosCarousel ... /> : null}
</div>

这里我比较认同现在这个取舍:

  • 顶部只放时间和作者
  • 正文可点击跳转原 memo
  • 图片单独展示
  • 不把 reaction、标签、复杂操作一起搬过来

因为博客里的图文页不需要 1:1 复刻 Memos 原站,而是要做一层适配阅读场景的二次排版

如果你要复刻这套实现,我建议按这个顺序来#

不要一把梭把瀑布流、轮播、作者缓存、图片清洗全写上。建议分阶段推进:

第一阶段:先跑通最小链路#

<MainGridLayout title="图文" description="图文">
<MemosFeed baseUrl="https://your-memos-site.com/" client:only="react" />
</MainGridLayout>

先做到:

  • 能请求 /api/v1/memos
  • 能把公开 memo 列出来
  • 能点击跳回 memo 原文

第二阶段:补“图文感”#

补这几层:

  • 提取正文图片
  • 提取附件图片
  • 去掉正文里的图片语法
  • 去掉纯标签行
  • 格式化时间

第三阶段:再补体验优化#

最后再加:

  • 作者信息缓存
  • 瀑布流
  • 无限滚动
  • 轮播
  • CDN 图片归一化

这样比较稳。

这套实现的优点和坑点#

优点#

  • 和 Fuwari 主体解耦:博客升级时不容易牵连图文页
  • 内容来源统一:Memos 继续当内容后台,博客只负责消费
  • 展示自由度高:你可以把 memo 改造成更像博客卡片,而不是照搬后台样式

坑点#

WARNING

最大的坑不是 React,也不是瀑布流,而是接口返回结构和资源地址不稳定。

我实际看下来,至少要注意这几件事:

  • memos 列表返回结构可能会变,消费层要做兜底
  • 图片可能在 markdown、html、attachment 三个入口里出现
  • baseUrl 最好统一归一,不要上层到处手搓 /api/v1
  • 私有 memo 过滤一定要前置
  • 无限滚动如果不做去重,后面很容易出现重复卡片

如果以后我重构,我会优先改哪两点#

如果我后面再继续打磨,我优先会做两件事:

1. 把数据请求抽成单独的数据层#

现在 MemosFeed.tsx 同时管:

  • 请求
  • 清洗
  • UI
  • 交互

它已经有点胖了。

后面更稳的做法是把下面这些抽出去:

  • fetchMemosPage
  • fetchUser
  • buildViews
  • 图片和文本清洗工具函数

2. 评估部分改成服务端预取#

现在图文页完全 client:only='react',开发顺手,但首屏数据要靠浏览器自己拉。

如果哪天这页流量更高,我会考虑:

  • 首屏先由服务端预取一页数据
  • 后续分页继续在客户端做

这样首屏体感会更稳,也更利于 SEO。

最后给一个我自己的建议#

如果你也在用 Fuwari,想接 usememos,不要一上来纠结“有没有现成插件”。

大多数时候,单独做一个内容频道页 比“强行塞回文章系统”更干净。

我的做法本质上就是一句话:

让 Fuwari 负责壳,让 usememos 负责内容,让中间那层组件负责把原始 memo 变成可读的图文卡片。

这样后面你想继续加:

  • 多图轮播
  • 按标签筛选
  • 按日期归档
  • 只显示某个作者
  • 和文章页做联动推荐

都会比直接魔改主题舒服很多。

如果你现在也准备做类似功能,我建议就先照这个最小链路起:导航入口 → Astro 页面 → React 客户端组件 → usememos API。先跑通,再一点点把产品感补出来。

Fuwari 里接 usememos 图文流 我是怎么做的
https://bangwu.top/posts/fuwari-memos-moments/
作者
棒无
发布于
2026-03-19
许可协议
CC BY-NC-SA 4.0