导读:本期聚焦于星河创作的《在 Vue 3 项目中如何工程化对接 ActivityPub 联邦社交协议?》,敬请观看详情。ActivityPub 并不是一个只能靠服务端完成的协议,前端同样要承担 Actor 解析、活动构建和状态同步的职责。如果把接口调用散落在组件中,签名、跨域与类型不一致的问题会迅速放大。本文从 Vue 3 工程化视角拆解联邦社交协议的接入方式,先厘清 ActivityPub 的核心对象模型和前后端边界,再用 TypeScript 类型系统映射 Actor、Activity 与 Note,并结合 Pinia 管理收件箱和发件箱状态。之后封装 useActivityPub 组合式函数,把拉取远端 Actor、发送 Create 活动、处理 Follow 请求等操作收敛为可复用逻辑。最后讨论 HTTP 签名在纯前端无法安全保存私钥的问题,给出通过 BFF 进行签名的推荐结构,以及跨域、错误重试和 JSON-LD 上下文处理的实践要点。读完可以搭建一套能对接 Mastodon 等实例的联邦社交前端基础层。

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

在 Vue 3 项目中如何工程化对接 ActivityPub 联邦社交协议?

一、先厘清协议模型: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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/1002/64593.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。