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

在博客里引用一条推文,官方给的做法是贴一段 <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/api | getTweet、parseTweetId,不依赖 React 与 CSS |
@violetjs/react-tweet/styles.css | 为 unstyled 入口手动加载默认样式 |
组件三个:
EmbeddedTweet:展示一份已保存的快照,不发请求;Tweet:客户端加载组件,你提供fetcher,它负责骨架、取消、重试;TweetCard:只有布局和插槽,不带 X 品牌,也不含任何来源逻辑,可以拿来排自己的「卡片」。
先跑起来:已保存的快照
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>被转义为<img src=x onerror=alert(1)>的纯文本,没有变成标签。
链接要可点击,就用有序的 segments(text / link / mention / hashtag)。我用 javascript:alert(1) 作链接 URL 实测:输出的 HTML 里没有出现 href="javascript,退回为不可点击的文本。
2. 不可用状态不泄露残留内容
推文被删除、设为私密或不可访问时,应该只显示一句提示。数据层面这类状态是类型约束出来的:availability 为 deleted / private / unavailable 时,类型上不允许携带 snapshot。但数据来自网络,类型不等于运行时。把一份 availability: "deleted" 却仍带着 snapshot 的数据传进去:
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。
在服务端取数据
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:
<Tweet id="1834000000000000002" fetcher={(id, { signal }) => myApi.getTweet(id, { signal })} />挂载、id 或 fetcher 变化、手动重试都会发请求;请求被替换或卸载时取消 signal,并且(据文档)即使你的 fetcher 忽略取消,迟到的结果也会被丢弃。出错时显示来源链接与重试按钮;明确不可用的结果不自动重试。
其他展示能力
正文默认折叠六行、引用只展开一层、多图可用横向照片条并接灯箱(onOpenPhoto)、点赞回复跳转 X 真实入口而不伪造互动——细节见 API 文档,这里不重复。
和 Vercel react-tweet 比
@violetjs/react-tweet | react-tweet(Vercel) | |
|---|---|---|
| 数据模型 | 自有 TweetData,展示层与来源解耦 | 数据结构即 syndication 响应;EmbeddedTweet 与 api 可单独使用 |
| 取数 | getTweet(FxTwitter + syndication 补充),只服务端 | syndication 接口,服务端组件或 SWR 客户端 |
| 正文文本 | 纯文本,不解析 HTML(已实测) | 文本片段走 dangerouslySetInnerHTML,数据来源须已转义(已实测其内部组件) |
| 不可用推文 | 删除/私密/不可用三态(已实测) | TweetNotFound(据源码,一个组件) |
| 样式 | 独立 CSS;unstyled 入口无 CSS,可在 Node 直接导入 | CSS Modules,纯 Node 导入需打包器 |
| React 版本 | 19 | 18 或 19 |
| 成熟度 | 0.1.0 | 3.x,使用广泛 |
后一行很重要:Vercel 的方案久经验证,生态里问题与答案都多;这个包刚发布首个版本。
边界
- 只有 React 19,不提供 CommonJS 入口。
- 默认样式用了现代 CSS(README 写明
light-dark、color-mix、CSS mask),老浏览器需要自己写样式。 - 取数依赖第三方服务。 FxTwitter 与 X 的 syndication 都不是你能控制的稳定契约,也要留意各自的使用条款与限流;生产环境请自己加缓存、超时和重试,并准备好
unavailable的降级展示。 - 不隶属于 X 或 FxTwitter;认证徽章几何包含 Luis Alvarez 的 MIT 授权作品,已保留署名。
上手
pnpm add @violetjs/react-tweet react- 先用「已保存的快照」跑通
EmbeddedTweet,确认样式与主题; - 需要实时数据时,在服务端
getTweet,缓存结果,页面只拿TweetData(这一步我没能实测,上线前自己验证); - 自己有 API 时用
Tweet+fetcher,不要在浏览器里直接调getTweet。
链接
- npm · 仓库 · API 文档 · MIT
- 对比对象:vercel/react-tweet、FxEmbed