结论先说,我这套“图文”功能本质上不是给 Fuwari 塞了一个复杂插件,而是做了一页单独的 Astro 页面 + React 客户端组件,再让它直接去请求 usememos 的 API,把 memo 数据转成适合博客浏览的卡片流。
真正关键的不是“把 API 调通”,而是中间那层数据清洗和展示重组:图片要从正文和附件里同时提取,标签行要去掉,作者信息要补齐,外链图片还要做一次 CDN 归一化。做完这些之后,图文页才像产品,不像接口调试页。
这篇我就按我博客现在的真实实现,拆一遍完整链路。
先看整体结构
我这套实现可以粗暴拆成 4 层:
- Fuwari 导航入口:把
/moments/暴露成一个独立页面 - Astro 页面壳:只负责布局和挂载 React 组件
- React 图文组件:负责请求 usememos API、清洗数据、做分页和瀑布流
- 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.memosdata.datadata.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:记住下一页 tokenloadedIds:避免重复 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
- 交互
它已经有点胖了。
后面更稳的做法是把下面这些抽出去:
fetchMemosPagefetchUserbuildViews- 图片和文本清洗工具函数
2. 评估部分改成服务端预取
现在图文页完全 client:only='react',开发顺手,但首屏数据要靠浏览器自己拉。
如果哪天这页流量更高,我会考虑:
- 首屏先由服务端预取一页数据
- 后续分页继续在客户端做
这样首屏体感会更稳,也更利于 SEO。
最后给一个我自己的建议
如果你也在用 Fuwari,想接 usememos,不要一上来纠结“有没有现成插件”。
大多数时候,单独做一个内容频道页 比“强行塞回文章系统”更干净。
我的做法本质上就是一句话:
让 Fuwari 负责壳,让 usememos 负责内容,让中间那层组件负责把原始 memo 变成可读的图文卡片。
这样后面你想继续加:
- 多图轮播
- 按标签筛选
- 按日期归档
- 只显示某个作者
- 和文章页做联动推荐
都会比直接魔改主题舒服很多。
如果你现在也准备做类似功能,我建议就先照这个最小链路起:导航入口 → Astro 页面 → React 客户端组件 → usememos API。先跑通,再一点点把产品感补出来。