#frontend#react#ssr

React Tweet:在博客里把推文获取、快照与展示分开

Mizuki_Miru
Mizuki_Miru
2026年10月11日8 次阅读

在博客里引用一条推文,真正需要的通常不是把另一个网站搬进来,而是让这条引用成为文章的一部分:文字跟随正文排版,照片接入已有灯箱,深色模式保持一致,首屏 HTML 也能包含引用内容。

但只把 iframe 换成 React,还没有回答最重要的问题:页面展示的内容从哪里来,何时取得,又在什么条件下不该继续展示?

@violetjs/react-tweet 把这几个问题拆开处理。服务端适配器负责取得数据,TweetData 描述一份快照及其可用性,EmbeddedTweet 将它渲染成原生 React 卡片;需要浏览器挂载后加载时,再使用 Tweet。这不是一套自动存档服务,而是一条允许博客自己决定获取与保存时机的引用链路。

先有快照,再谈卡片

最直接的用法不是传推文 ID,而是传一份数据。下面的作者、账号、地址和正文均为虚构示例,不代表真实推文:

tsx
import { EmbeddedTweet, type TweetData } from "@violetjs/react-tweet";

const saved: TweetData = {
  url: "https://x.com/sketchbook_demo/status/1834000000000000002",
  availability: "available",
  snapshot: {
    author: { name: "Mira Chen", handle: "sketchbook_demo" },
    text: "每一页速写,都从一小滴墨开始。",
    publishedAt: "2026-09-14T09:30:00.000Z",
  },
};

export function ArticleQuote() {
  return (
    <EmbeddedTweet
      tweet={saved}
      locale="zh-CN"
      timeZone="Asia/Shanghai"
      maxTextLines={0}
    />
  );
}

EmbeddedTweet 不会拿着地址重新抓正文。它只消费传入的数据,所以同一个组件既能展示刚获取的结果,也能展示由博客数据库读出的快照。数据库写入、更新频率、缓存过期和响应校验都不是包的职责;这里的 saved 只是示例对象,并没有触发任何持久化。

这层分离的意义在于,读者打开文章的时间,不必等于向外部服务取数的时间。博客可以在编辑插入引用时获取并保存,也可以在服务端生成页面时读取已经保存的数据。后者不需要为每次阅读重新请求推文数据,但新鲜度和失效处理就必须由宿主管理。

快照也不应伪装成实时状态。互动数字只表示来源提供的那次统计,缺失的点赞数不会被补成零;点赞和回复操作打开 X 的真实入口,而不是在博客里模拟一次成功互动。数据形状与展示约定见公开 API 文档。

可用性不是一句提示文案

如果一条引用变成私密内容,仅把卡片底部改成“不可用”,同时继续留下原文、图片或被引用推文,就没有真正处理状态变化。

因此,TweetData 用 availability 区分两种数据形状:available 必须携带 snapshot;unavailable、deleted、private 则不允许携带 snapshot 或 quotedTweet。加载中并不属于这四种内容状态,它是请求过程的状态,不能用缺少快照来冒充。

类型约束还不够,因为数据库旧记录和外部 JSON 不会自动遵守 TypeScript。渲染器在读取快照之前,再检查一次可用性:只要不是 available,就直接输出状态提示和来源链接,不进入作者、正文、媒体和引用的渲染分支。即使不合规数据混入了残留快照,界面也不会显示它。这个检查位于快照渲染器的入口。

这里有一个容易混淆的边界:组件只认识宿主传入的状态,不会持续监测原推文。保存时可用,不代表此后永远可用。若博客需要在来源转为私密或删除后停止展示,就要制定重新确认和失效策略,更新持久化记录,并清除不应继续保留的内容。渲染器拒绝显示残留数据,不等于已经从数据库、接口响应或页面序列化数据中删除它。

所以,“支持快照”不意味着能够突破私密访问限制,也不是继续公开已删除内容的许可。它解决的是取数与展示的时间耦合,不是内容授权。

获取失败,不等于内容被删除

包提供的 getTweet 是服务端数据适配器。它先从 FxTwitter 获取正文和媒体;对于带有来源作者 ID 的可用推文,再通过 X syndication 补充认证与组织关联等作者元数据,包括引用作者。官方元数据中的正文不会覆盖 FxTwitter 正文,避免把可能截断的文本当作更完整的版本。

这一过程也保留了“不知道”的空间:

  • FxTwitter 的 HTTP 状态或响应 envelope 为 401、403、404 时,返回 unavailable。单凭 404,不能确认作者已经删除推文。
  • 来源给出明确的删除墓碑或私密标记时,才使用 deleted 或 private。
  • 网络失败、限流、非 JSON 响应和协议错误会拒绝 Promise;请求取消也继续作为异常传播,而不是被改写成内容不可用。

syndication 的处理还有一处重要区别:它的 401、403、404 只跳过元数据补充,不丢弃已经取得的快照;但其他网络或协议错误仍会使整个调用失败,不能把这一步理解成无条件忽略失败的装饰性请求。若返回了明确私密信息或墓碑,适配器则会阻止相应内容继续作为可用快照返回。具体分支可查阅取数实现与元数据实现。

这些区别影响博客怎样呈现失败。网络暂时不通,可以让用户重试;来源明确私密,就应展示私密状态。把两者统一写成“已删除”,会让界面替来源作出没有证据的判断。

首屏需要正文,就在渲染前准备数据

若希望服务端输出的 HTML 已经包含推文正文,应先取得 TweetData,再交给 EmbeddedTweet。下面展示普通 Node SSR 中这两个步骤的关系;调用方传入真实 ID 或原文地址,并负责捕获获取异常:

tsx
import { getTweet } from "@violetjs/react-tweet/api";
import { EmbeddedTweet } from "@violetjs/react-tweet/unstyled";
import { renderToStaticMarkup } from "react-dom/server";

export async function renderQuote(id: string, signal?: AbortSignal) {
  const tweet = await getTweet(id, { signal });
  return renderToStaticMarkup(
    <EmbeddedTweet tweet={tweet} locale="zh-CN" timeZone="UTC" />,
  );
}

这里使用 /unstyled,避免普通 Node 直接导入 CSS。浏览器样式入口另行导入 @violetjs/react-tweet/styles.css;支持 CSS 导入的前端构建环境则可使用根入口。当前包面向 React 19 和 ESM,示例中的静态渲染还需要 react-dom。这个函数只返回 HTML 片段,不包含页面响应、样式注入或持久化流程。

SSR 下正文仍完整存在,默认六行折叠是展示样式,不会把数据截断成六行;挂载测量后,实际溢出才出现展开按钮。若页面还要水合,服务端与客户端应使用相同数据、locale、timeZone 和消息配置。默认值固定为 en-US 与 UTC,不从运行机器的语言、时区猜测。

另一种需求是先展示文章,再由浏览器加载引用。此时使用 Tweet,并把宿主已有的加载器传进去:

tsx
import { Tweet, type TweetFetcher } from "@violetjs/react-tweet";

export function RemoteQuote({
  id,
  fetcher,
}: {
  id: string;
  fetcher: TweetFetcher;
}) {
  return <Tweet id={id} fetcher={fetcher} locale="zh-CN" />;
}

TweetFetcher 的签名是 (id, { signal }) => Promise<TweetData>。它可以连接博客自己的 API client 或缓存层;包不提供一个预设的 HTTP 端点。不要把服务端 getTweet 直接塞进浏览器:syndication 不向普通站点开放所需的浏览器 CORS,取数应留在服务端。

Tweet 的请求在挂载后的 effect 中执行,因此 SSR 只输出加载骨架。ID 或加载器改变时,它先隐藏旧内容,再开始新请求;清理时既取消 signal,也用活动标记丢弃迟到结果,即使自定义加载器忽略取消,也不会让旧结果覆盖新卡片。异常有手动重试按钮,明确的不可用结果则交给 EmbeddedTweet,不会自动反复抓取。加载器应保持引用稳定,否则每次创建新函数也会触发重新加载。这些行为来自请求生命周期实现。

原生 React,并没有消除网络边界

不加载 iframe 和第三方脚本,意味着博客掌握卡片的 DOM、样式和交互,不意味着浏览器从此不访问外部资源。头像、照片、视频及其封面仍按快照里的资源地址加载;保存了一份 JSON,也不等于把媒体文件一起保存了下来。资源失效时,文字快照可能仍在,图片和视频却未必可用。

默认媒体使用原生图片与视频展示;只有封面的视频不会伪造播放地址,播放失败保留来源入口。若已有灯箱,可用 onOpenPhoto 接管普通照片点击并保留默认布局;需要完全替换媒体时,才使用 renderPhotos 或 renderVideo,同时承担自定义内容的无障碍与交互责任。

正文按文本和结构化链接渲染,不作为 HTML 执行;链接与媒体地址会经过净化。但 URL 净化不等于内容审核,也不等于媒体已经转存。外部服务可达性、限流、超时、缓存、更新策略及内容使用权限,仍需要博客自行处理。getTweet 没有内置缓存或自动重试,也不提供第三方服务持续可用的承诺。

对博客而言,选择因此很具体:正文必须进入首屏 HTML,就在服务端准备快照;允许延后加载,就用 Tweet 接宿主加载器;需要减少阅读时取数,就保存快照,同时明确它何时失效。@violetjs/react-tweet 提供的不是“永不失效的推文”,而是让获取、内容状态与页面展示各自拥有清楚的责任。

评论将在接近时加载。