在 Vue 3 里做 ActivityPub 集成,最容易犯的错误是把协议当成一组普通 REST 接口来处理。ActivityPub 的核心交互对象是 Actor、Activity 和各类 Object,数据格式使用 JSON-LD,而且大多数实例要求对 GET 请求携带 HTTP Signature 签名。前端如果只是用 axios 拼 URL,很快会因为签名失效、字段结构不一致或跨域策略被卡住。工程化的思路是先建立稳定的协议模型层,再让组件通过组合式函数和状态管理访问数据,而不是在视图里直接操作网络请求。

一、先厘清协议模型:Actor、Activity 与 JSON-LD
ActivityPub 使用 JSON-LD 描述联邦网络中的实体。Actor 是核心可寻址对象,既可以是个人账户,也可以是群组或机器人服务。每个 Actor 通常会暴露 inbox、outbox、followers 和 following 四个端点,其中 inbox 用来接收其他实例投递的活动,outbox 用来发布自己的活动。前端要做的第一件事不是急着调接口,而是理解这些端点和活动之间的关系。
下面是一个典型 Actor 文档的结构。可以看到除了 id 和 type 外,publicKey 字段会包含 PEM 格式的公钥,服务端用它来验证请求签名。前端的类型定义需要把这些字段固定下来,但又不能完全锁死,因为不同实现会附加扩展属性。
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/users/alice",
"type": "Person",
"preferredUsername": "alice",
"inbox": "https://social.example/users/alice/inbox",
"outbox": "https://social.example/users/alice/outbox",
"followers": "https://social.example/users/alice/followers",
"publicKey": {
"id": "https://social.example/users/alice#main-key",
"owner": "https://social.example/users/alice",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
}
}
需要注意的是,前端的职责通常不包含验签,因为验签依赖私钥或公钥计算,放在浏览器里既不安全也浪费性能。工程上更合理的做法是让后端代理负责签名和验证,前端只处理 JSON 数据的展示和交互逻辑。类型系统的作用是让这些远端 JSON 在进入组件前就被规范化,避免出现运行到一半才发现字段缺失的尴尬。
二、用 TypeScript 类型和 Pinia 管理联邦数据
既然 ActivityPub 的数据结构相对固定,就值得用 TypeScript 接口将其描述清楚。定义类型时,除了协议要求的字段,还应该加上索引签名来接收扩展字段。这样当 Mastodon 返回 alsoKnownAs 或移动端标识时,代码不会因为类型不完整而报错。
export interface APActor {
id: string
type: 'Person' | 'Service' | 'Application' | string
preferredUsername?: string
name?: string
summary?: string
inbox: string
outbox?: string
followers?: string
following?: string
publicKey?: {
id: string
owner: string
publicKeyPem: string
}
[key: string]: unknown
}
export interface APObject {
id: string
type: string
published?: string
attributedTo?: string
content?: string
to?: string[]
cc?: string[]
[key: string]: unknown
}
export interface APActivity {
id?: string
type: 'Create' | 'Follow' | 'Like' | 'Announce' | 'Accept' | 'Reject' | string
actor: string
object: APObject | string
to?: string[]
cc?: string[]
published?: string
[key: string]: unknown
}
类型定义完成之后,接下来要考虑数据放在哪里。Pinia 作为 Vue 3 官方推荐的状态管理库,很适合保存 Actor 缓存、时间线活动和待发送队列。把远端 Actor 缓存在本地 Map 中,可以避免重复请求同一个实例;把发布操作先放入本地队列再异步提交,能提升界面响应速度。下面的 store 只做数据存取,不处理业务规则,这样后续增加点赞或关注功能时不会互相干扰。
import { defineStore } from 'pinia'
import type { APActor, APActivity } from '@/types/activitypub'
interface ActivityPubState {
actors: Map<string, APActor>
timeline: APActivity[]
outboxQueue: APActivity[]
loading: boolean
error: string | null
}
export const useActivityPubStore = defineStore('activityPub', {
state: function () {
return {
actors: new Map(),
timeline: [],
outboxQueue: [],
loading: false,
error: null
} as ActivityPubState
},
getters: {
getActorById: function (state) {
return function (id: string) {
return state.actors.get(id)
}
}
},
actions: {
cacheActor(actor: APActor) {
this.actors.set(actor.id, actor)
},
appendTimeline(items: APActivity[]) {
this.timeline.push(...items)
},
pushOutbox(activity: APActivity) {
this.outboxQueue.push(activity)
}
}
})
这层状态管理让组件保持轻量。比如用户时间线组件只从 store 中读取 timeline,而不用知道数据来自哪个实例、是否需要签名。后续如果加入多账户切换,也只需要在 store 中维护多个 Actor 缓存。这样协议相关的复杂度被限制在数据层,不会扩散到 UI 层。
三、封装 useActivityPub 组合式函数
Vue 3 的组合式 API 非常适合处理具备生命周期的协议操作。我们可以把获取远端 Actor、发送 Create 活动、处理关注请求等逻辑封装进 useActivityPub,让视图组件只调用函数并绑定状态。这样做的好处是逻辑可以复用,也方便编写单元测试。
下面这个 composable 通过后端 BFF 的 /api/ap/proxy 和 /api/ap/outbox 接口来与 ActivityPub 网络交互。前端不直接请求远端实例,而是把目标 URL 交给服务端,由服务端完成签名和转发。fetchActor 会把获取到的 Actor 写入 Pinia 缓存,sendCreateNote 则构造一个 Note 对象并包装成 Create 活动。
import { ref } from 'vue'
import { useActivityPubStore } from '@/stores/activityPub'
import type { APActor, APActivity, APObject } from '@/types/activitypub'
const API_BASE = '/api/ap'
export function useActivityPub() {
const store = useActivityPubStore()
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchActor(actorUrl: string) {
loading.value = true
error.value = null
try {
const response = await fetch(API_BASE + '/proxy?url=' + encodeURIComponent(actorUrl))
if (!response.ok) throw new Error('无法获取 Actor')
const actor = await response.json() as APActor
store.cacheActor(actor)
return actor
} catch (err) {
error.value = err instanceof Error ? err.message : '未知错误'
} finally {
loading.value = false
}
}
async function sendCreateNote(content: string, to: string[]) {
const note: APObject = {
id: '',
type: 'Note',
content,
to,
attributedTo: store.actors.size > 0 ? Array.from(store.actors.keys())[0] : ''
}
const activity: APActivity = {
type: 'Create',
actor: note.attributedTo,
object: note,
to,
published: new Date().toISOString()
}
store.pushOutbox(activity)
const response = await fetch(API_BASE + '/outbox', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activity)
})
if (!response.ok) throw new Error('活动发送失败')
}
return {
loading,
error,
fetchActor,
sendCreateNote
}
}
这样封装还有一个好处:组件里不再出现任何 URL 拼接和请求头设置。对于关注、点赞、转发等互动操作,可以继续在 useActivityPub 中增加对应方法,例如 sendFollow 会构造 type 为 Follow 的活动并提交到目标 Actor 的 inbox。由于所有方法共享 loading 和 error 状态,界面可以统一显示加载和错误提示。
发布操作采用乐观更新策略,先把活动放入 outboxQueue,再等待服务端确认。如果服务端返回失败,可以在 catch 中把活动从队列移除或标记为失败状态。这种模式对联邦网络的不稳定性特别重要,因为远端实例可能因为网络抖动暂时不可达,本地队列可以保证用户体验不中断。
四、HTTP 签名、跨域与错误处理
ActivityPub 的安全性依赖 HTTP Signature。请求方需要用私钥对请求目标、Host 和 Date 等头字段进行签名,接收方则根据 Actor 文档中的 publicKey 来验证签名。私钥显然不能放在浏览器环境里,因此 Vue 3 前端不应该直接对远端实例发起签名请求。推荐的做法是使用 BFF 模式,由服务端保管私钥并对所有出站请求统一签名。
下面是一段 Node.js 中生成签名头的示例。它把 method 和小写后的请求路径组合成签名基础字符串,再用 RSA-SHA256 算法生成签名。实际项目中还需要处理 body 摘要、Digest 头以及更完整的头字段列表。
import crypto from 'crypto'
function signRequest(privateKeyPem, keyId, method, url, headers) {
const signingString = [
'(request-target): ' + method.toLowerCase() + ' ' + new URL(url).pathname,
'host: ' + new URL(url).host,
'date: ' + headers.date
].join('\n')
const signer = crypto.createSign('sha256')
signer.update(signingString)
const signature = signer.sign(privateKeyPem, 'base64')
return 'keyId="' + keyId + '",algorithm="rsa-sha256",headers="(request-target) host date",signature="' + signature + '"'
}
跨域是另一个现实问题。浏览器直接向 Mastodon 实例发起请求时,很多实例的 CORS 策略并不会对任意来源开放。通过 BFF 代理后,同源请求不再受跨域限制,服务端还可以统一添加超时、日志和错误上报。对于 SPA 架构,可以让 Nginx 或 Node 服务转发 /api/ap 路径;对于 Nuxt 或 Next 全栈项目,可以直接在服务端路由中实现签名。
联邦网络中的实例可用性参差不齐,错误处理不能只依赖 HTTP 状态码。除了 4xx 客户端错误可以直接提示外,5xx 和网络超时应该采用重试机制。下面的 fetchWithRetry 使用指数退避,在三次尝试之间延迟递增,避免因为瞬时故障丢失用户操作。
async function fetchWithRetry(url: string, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url)
if (response.ok) return response
if (response.status < 500) throw new Error('客户端错误,不重试')
} catch (err) {
if (i === retries - 1) throw err
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)))
}
}
throw new Error('重试次数已用完')
}
把重试逻辑放在 composable 的请求层,可以让组件保持简单。对于创建活动这种写操作,重试前要检查活动是否已经成功写入但响应丢失,避免产生重复帖子。可以在每次活动里带上客户端生成的唯一 id,服务端凭此做幂等处理。这样即使请求超时后用户手动重试,远端实例也能识别出同一条活动。
Vue 3ActivityPub联邦社交协议修改时间:2026-10-02 08:47:44