React 绑定

自 v4 起,Hocuspocus 通过 @hocuspocus/provider-react 包提供了专门的 React 绑定。它提供了两个用于管理 WebSocket 连接和房间生命周期的组件,以及用于订阅 provider 状态(连接、同步、awareness、事件)的 hooks——全部基于 useSyncExternalStore 构建,并且对 React StrictMode 安全。

安装

npm install @hocuspocus/provider @hocuspocus/provider-react yjs

Peer dependencies:React 18 或 19,Yjs ^13.6.8,以及与之匹配版本的 @hocuspocus/provider

快速开始

使用 HocuspocusProviderWebsocketComponent(它管理共享的 WebSocket)和一个或多个 HocuspocusRoom 组件(每个文档一个)包裹你的协作子树。在房间内部,像 useHocuspocusProvideruseHocuspocusAwarenessuseHocuspocusConnectionStatus 这样的 hooks 可以让你访问 provider。

import {
  HocuspocusProviderWebsocketComponent,
  HocuspocusRoom,
} from '@hocuspocus/provider-react'

export function App() {
  return (
    <HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
      <HocuspocusRoom name="example-document">
        <Editor />
      </HocuspocusRoom>
    </HocuspocusProviderWebsocketComponent>
  )
}

websocket 组件会为其子树创建一个单独的 HocuspocusProviderWebsocket。多个 HocuspocusRoom 可以共享它,这样在文档之间切换时就能避免连接开销(也就是多路复用)。

组件

HocuspocusProviderWebsocketComponent

管理共享的 HocuspocusProviderWebsocket 实例。在应用顶部附近创建一次即可。

<HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
  {children}
</HocuspocusProviderWebsocketComponent>

Props

PropTypeDescription
urlstringHocuspocus 服务器 URL。除非提供了 websocketProvider,否则为必需项。
websocketProviderHocuspocusProviderWebsocket提供你自己的 socket 实例以获得完全控制。与 url 互斥。
childrenReactNode通常是一个或多个 HocuspocusRoom

该组件会优雅地处理 React StrictMode 的双重挂载——WebSocket 每个挂载周期只创建一次,并且只有在不是外部提供时才会销毁。

HocuspocusRoom

创建一个文档专属的 HocuspocusProvider,并将其连接到共享的 WebSocket。必须渲染在 HocuspocusProviderWebsocketComponent 内部。

<HocuspocusRoom
  name="example-document"
  token={async () => fetchJwt()}
  onAuthenticationFailed={(data) => console.error(data.reason)}
  onSynced={() => console.log('已同步')}
>
  <Editor />
</HocuspocusRoom>

Props

PropTypeDescription
namestring文档名称。必需。
documentY.Doc可选——传入你自己的 Y.Doc。如果省略,将为你创建一个。
tokenstring | (() => string) | (() => Promise<string>)发送到服务器用于身份验证的 JWT(或异步解析函数)。
onOpen, onConnect, onClose, onDisconnect, onStatus, onSynced, onUnsyncedChanges, onMessage, onOutgoingMessage, onStateless, onAuthenticated, onAuthenticationFailed, onAwarenessUpdate, onAwarenessChange, onDestroyFunction每个 provider 事件 的可选处理器。

更改 namedocumenttoken 或上游 websocket provider 会重新创建底层 provider。其余内容都会保持稳定。

处理身份验证失败

当服务器的 onAuthenticate 钩子抛出异常时,provider 会发出 authenticationFailed 事件,并调用 HocuspocusRoom 上的 onAuthenticationFailed 处理器,参数为:

{ reason: string }  // 服务器抛出的错误消息

典型的用户体验:清除过期 token,显示登录界面,然后用新的 token 重新渲染。

<HocuspocusRoom
  name="example-document"
  token={token}
  onAuthenticationFailed={({ reason }) => {
    console.warn('认证失败:', reason)
    setToken(null)  // 进入你的登录流程
  }}
>
  <Editor />
</HocuspocusRoom>

如果你更喜欢在嵌套组件中响应它,同样可以通过 useHocuspocusEvent('authenticationFailed', handler) 使用该事件。

Hooks

下面所有 hooks 都必须在 HocuspocusRoom 内部使用。如果没有 room 上下文,它们会抛出错误。

useHocuspocusProvider

返回当前房间的 HocuspocusProvider 实例。当你需要直接访问 provider 时使用它——例如将 Tiptap 的 Collaboration / CollaborationCaret 扩展连接到 provider 的 Y.Doc 和 awareness。

import { useHocuspocusProvider } from '@hocuspocus/provider-react'
import { useEditor, EditorContent } from '@tiptap/react'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import { StarterKit } from '@tiptap/starter-kit'

function Editor() {
  const provider = useHocuspocusProvider()

  const editor = useEditor({
    extensions: [
      StarterKit.configure({ undoRedo: false }),
      Collaboration.configure({ document: provider.document }),
      CollaborationCaret.configure({
        provider,
        user: { name: 'John Doe', color: '#ffcc00' },
      }),
    ],
  })

  return <EditorContent editor={editor} />
}

useHocuspocusConnectionStatus

订阅 WebSocket 连接状态。返回 'connecting' | 'connected' | 'disconnected'

import { useHocuspocusConnectionStatus } from '@hocuspocus/provider-react'

function ConnectionIndicator() {
  const status = useHocuspocusConnectionStatus()

  return (
    <div className={`status-${status}`}>
      {status === 'connected'
        ? '在线'
        : status === 'connecting'
          ? '正在连接…'
          : '离线'}
    </div>
  )
}

useHocuspocusSyncStatus

订阅本地更改是否已与服务器同步。返回 'synced' | 'syncing'

import { useHocuspocusSyncStatus } from '@hocuspocus/provider-react'

function SaveIndicator() {
  const syncStatus = useHocuspocusSyncStatus()

  return <div>{syncStatus === 'syncing' ? '正在保存…' : '所有更改已保存'}</div>
}

useHocuspocusAwareness

订阅已连接用户列表(awareness 状态)。返回一个对象数组,每个对象包含 clientId,以及你在 awareness 上设置的任何状态(name、color、cursor 等)。

import { useHocuspocusAwareness } from '@hocuspocus/provider-react'

function UserList() {
  const users = useHocuspocusAwareness()

  return (
    <div className="avatars">
      {users.map((user) => (
        <div
          key={user.clientId}
          style={{ backgroundColor: user.color as string }}
          title={user.name as string}
        >
          {(user.name as string | undefined)?.[0]}
        </div>
      ))}
    </div>
  )
}

useHocuspocusEvent

通过稳定订阅监听任意 provider 事件(处理器引用会在内部保持最新,因此你不需要做 memoize)。

import { useHocuspocusEvent } from '@hocuspocus/provider-react'

function AuthGuard() {
  useHocuspocusEvent('authenticationFailed', (data) => {
    console.error('认证失败:', data.reason)
    redirectToLogin()
  })

  useHocuspocusEvent('close', (data) => {
    console.log('连接已关闭', data.event.code, data.event.reason)
  })

  return null
}

完整示例 — Tiptap + awareness

将这些全部组合起来:一个 Tiptap 编辑器、一个连接指示器和一个用户列表:

import {
  HocuspocusProviderWebsocketComponent,
  HocuspocusRoom,
  useHocuspocusAwareness,
  useHocuspocusConnectionStatus,
  useHocuspocusProvider,
} from '@hocuspocus/provider-react'
import { EditorContent, useEditor } from '@tiptap/react'
import Collaboration from '@tiptap/extension-collaboration'
import CollaborationCaret from '@tiptap/extension-collaboration-caret'
import { StarterKit } from '@tiptap/starter-kit'

function Editor() {
  const provider = useHocuspocusProvider()
  const status = useHocuspocusConnectionStatus()
  const users = useHocuspocusAwareness()

  const editor = useEditor({
    extensions: [
      StarterKit.configure({ undoRedo: false }),
      Collaboration.configure({ document: provider.document }),
      CollaborationCaret.configure({
        provider,
        user: { name: 'John Doe', color: '#ffcc00' },
      }),
    ],
  })

  return (
    <>
      <header>
        <span>状态:{status}</span>
        <span>{users.length} 在线</span>
      </header>
      <EditorContent editor={editor} />
    </>
  )
}

export function App() {
  return (
    <HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
      <HocuspocusRoom name="example-document">
        <Editor />
      </HocuspocusRoom>
    </HocuspocusProviderWebsocketComponent>
  )
}

切换文档(多路复用)

由于 WebSocket 位于外层组件,切换 HocuspocusRoom 上的 name 属性会销毁旧的文档提供器,并在不重新连接套接字的情况下创建一个新的:

function Workspace({ docId }: { docId: string }) {
  return (
    <HocuspocusProviderWebsocketComponent url="ws://127.0.0.1:1234">
      <HocuspocusRoom name={docId}>
        <Editor />
      </HocuspocusRoom>
    </HocuspocusProviderWebsocketComponent>
  )
}

如果你希望在同一个套接字上同时打开多个文档,请将多个 HocuspocusRoom 作为同级元素渲染。当连接到 v4 服务器并对许多可能存在文档名冲突的提供器进行多路复用时,可以考虑在底层的 HocuspocusProviderWebsocket 上启用 sessionAwareness