#frontend#react#ssr

@violetjs/react-tweet:把推文当数据渲染,而不是嵌一段脚本

Mizuki_Miru
Mizuki_Miru
2026年10月12日48 次阅读

同一条虚构推文的明暗主题

在博客里引用一条推文,官方给的做法是贴一段 <blockquote> 加 widgets.js:加载第三方脚本,脚本再把引用替换成 iframe。@violetjs/react-tweet 走另一条路:把推文当成一份数据,用原生 React 组件渲染成卡片。没有 iframe、没有第三方脚本,也不依赖路由、状态库或 Tailwind(图片和视频仍会请求各自的资源地址)。

这篇文章讲它是什么、为什么要把「取数据」和「画卡片」拆开、与 Vercel 的 react-tweet 有什么不同,以及怎么用、什么时候不该用。

嵌入推文通常怎么做,痛在哪

做法痛点
官方 widgets.js / oEmbed第三方脚本与 iframe;样式不归你管;首屏时先是一段引用文字,脚本加载后才变卡片
Vercel 的 react-tweet不用 iframe;Tweet 是异步服务端组件,直接请求 X 的 syndication 接口。它也导出 EmbeddedTweet 与 react-tweet/api,可以分开用
自己截图不可检索、不可访问、不能换主题,也不是推文本身

在我这边装上 react-tweet@3.3.1 观察到两点:它的样式是 CSS Modules,在纯 Node 里 import "react-tweet" 会直接报 ERR_UNKNOWN_FILE_EXTENSION: .css,需要打包器处理;默认主题的正文文本片段用 dangerouslySetInnerHTML 渲染(源码注释说明是为了保留文本编码,见其 issue #29),可见它的前提是数据来自 syndication 接口。我用一段含 <b> 的假数据直接喂给它的 TweetBody,输出里 <b>bold</b> 原样成了标签。这不是它正常用法下的漏洞;只是说明数据换成别的来源时,转义要你自己负责。

@violetjs/react-tweet 的差别不在「能不能分开用」(两者都能),而在数据假设:它自有一份 TweetData 模型,展示层不信任数据,把正文当纯文本、把不可用状态拆成三态。

它是什么

四个入口,各管一件事:

导入用途
@violetjs/react-tweet组件与类型,自带默认 CSS
@violetjs/react-tweet/unstyled同一组组件,不导入 CSS,适合 Node SSR 或自带样式
@violetjs/react-tweet/apigetTweet、parseTweetId,不依赖 React 与 CSS
@violetjs/react-tweet/styles.css为 unstyled 入口手动加载默认样式

组件三个:

  • EmbeddedTweet:展示一份已保存的快照,不发请求;
  • Tweet:客户端加载组件,你提供 fetcher,它负责骨架、取消、重试;
  • TweetCard:只有布局和插槽,不带 X 品牌,也不含任何来源逻辑,可以拿来排自己的「卡片」。

先跑起来:已保存的快照

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

export const saved: TweetData = {
  url: "https://x.com/sketchbook_demo/status/1834000000000000002",
  availability: "available",
  snapshot: {
    author: { name: "Mira Chen", handle: "sketchbook_demo", verification: "individual" },
    text: "Finished our sketchbook zine. Every page starts with a little ink.",
    publishedAt: "2026-09-14T09:30:00.000Z",
    metrics: { likes: 1284, replies: 46, reposts: 212 },
  },
};

export const Snapshot = () => <EmbeddedTweet tweet={saved} />;

只填来源确实提供的信息;上图里的作者、账号和内容均为虚构。明暗主题默认跟随系统,也支持祖先的 .light / .dark 或 data-theme,并可用 --tweet-background、--tweet-accent 等 CSS 变量改色。

三个设计,各用实际输出检验

我在临时项目里装了 npm 上的 @violetjs/react-tweet@0.1.0 与 React 19,用 renderToStaticMarkup 渲染,下面是实际结果。

1. 正文不当 HTML 解析

快照文本里放 Finished our sketchbook zine. https://example.com <img src=x onerror=alert(1)>:

  • 输出不含 <iframe>、<script>;
  • <img onerror> 被转义为 &lt;img src=x onerror=alert(1)&gt; 的纯文本,没有变成标签。

链接要可点击,就用有序的 segments(text / link / mention / hashtag)。我用 javascript:alert(1) 作链接 URL 实测:输出的 HTML 里没有出现 href="javascript,退回为不可点击的文本。

2. 不可用状态不泄露残留内容

推文被删除、设为私密或不可访问时,应该只显示一句提示。数据层面这类状态是类型约束出来的:availability 为 deleted / private / unavailable 时,类型上不允许携带 snapshot。但数据来自网络,类型不等于运行时。把一份 availability: "deleted" 却仍带着 snapshot 的数据传进去:

text
data-state="deleted"
This post has been deleted. View original on X

渲染结果里没有原文(includes("Finished our sketchbook") 为 false)。引用卡片同理:把一条 deleted 却带着快照的推文作为 quotedTweet,输出里同样没有它的正文。「删除」只在来源明确确认时才会出现;getTweet 对 FxTwitter 的 404 返回的是 unavailable,文档明确写了 404 不能证明删除。

3. 渲染结果是确定的

locale 默认 en-US、timeZone 默认 UTC,不读服务器本地时区或浏览器语言,所以只要两端传入相同的数据与参数,渲染出的字符串就相同(我只做了服务端渲染,没有做浏览器水合的比对)。把 locale="zh-CN"、timeZone="Asia/Shanghai" 传进去,发布时间 2026-09-14T09:30:00Z 渲染为 2026年9月14日 17:30。本地化只改界面文案和日期数字,不翻译作者原文。

Tweet(客户端加载组件)在 SSR 时只输出加载骨架(data-state="loading",没有正文),取回的正文不会进静态 HTML。想要 SSR 带正文,在服务端自己 getTweet 再 renderToStaticMarkup。

在服务端取数据

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

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

getTweet 先向 FxTwitter 取完整正文与媒体,再用 X 的 syndication 接口补作者的认证与关联信息(据文档,还会按作者 ID 一致性核对,避免账号改名后展示私密快照;这一条我没有实测)。它只在服务端用:文档说明 X 的 syndication 元数据服务不开放浏览器 CORS。包不内置缓存与重试,也不允许替换 fetch、代理或认证头——这些由宿主决定。参数 id 可以是 ID,也可以是 x.com / twitter.com 的原文地址,parseTweetId 会校验,危险 URL、其他主机、非法路径返回 null,不发请求。

我在写这篇文章的环境里访问不到 FxTwitter 与 syndication,getTweet 的实时取数我没能跑通,因此上面「取数据」一节只给出接口说明,没有实际输出。用之前请在你的服务端环境实测一次。

宿主要自己加载(例如走自己的 API)时,改用 Tweet + fetcher:

tsx
<Tweet id="1834000000000000002" fetcher={(id, { signal }) => myApi.getTweet(id, { signal })} />

挂载、id 或 fetcher 变化、手动重试都会发请求;请求被替换或卸载时取消 signal,并且(据文档)即使你的 fetcher 忽略取消,迟到的结果也会被丢弃。出错时显示来源链接与重试按钮;明确不可用的结果不自动重试。

其他展示能力

正文默认折叠六行、引用只展开一层、多图可用横向照片条并接灯箱(onOpenPhoto)、点赞回复跳转 X 真实入口而不伪造互动——细节见 API 文档,这里不重复。

和 Vercel react-tweet 比

@violetjs/react-tweetreact-tweet(Vercel)
数据模型自有 TweetData,展示层与来源解耦数据结构即 syndication 响应;EmbeddedTweet 与 api 可单独使用
取数getTweet(FxTwitter + syndication 补充),只服务端syndication 接口,服务端组件或 SWR 客户端
正文文本纯文本,不解析 HTML(已实测)文本片段走 dangerouslySetInnerHTML,数据来源须已转义(已实测其内部组件)
不可用推文删除/私密/不可用三态(已实测)TweetNotFound(据源码,一个组件)
样式独立 CSS;unstyled 入口无 CSS,可在 Node 直接导入CSS Modules,纯 Node 导入需打包器
React 版本1918 或 19
成熟度0.1.03.x,使用广泛

后一行很重要:Vercel 的方案久经验证,生态里问题与答案都多;这个包刚发布首个版本。

边界

  • 只有 React 19,不提供 CommonJS 入口。
  • 默认样式用了现代 CSS(README 写明 light-dark、color-mix、CSS mask),老浏览器需要自己写样式。
  • 取数依赖第三方服务。 FxTwitter 与 X 的 syndication 都不是你能控制的稳定契约,也要留意各自的使用条款与限流;生产环境请自己加缓存、超时和重试,并准备好 unavailable 的降级展示。
  • 不隶属于 X 或 FxTwitter;认证徽章几何包含 Luis Alvarez 的 MIT 授权作品,已保留署名。

上手

sh
pnpm add @violetjs/react-tweet react
  1. 先用「已保存的快照」跑通 EmbeddedTweet,确认样式与主题;
  2. 需要实时数据时,在服务端 getTweet,缓存结果,页面只拿 TweetData(这一步我没能实测,上线前自己验证);
  3. 自己有 API 时用 Tweet + fetcher,不要在浏览器里直接调 getTweet。

链接

评论将在接近时加载。