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(或异步解析器)。
sessionAwarenessboolean启用会话感知的多路复用,使具有相同文档名称的多个 room 可以共享一个 WebSocket。仅在连接到 v4 服务器时设为 true。默认 false。请参见 sessionAwareness
flushDelayfalse | number在短时间窗口内批量发送文档和 awareness 更新(毫秒),以在高强度编辑时减少 websocket 流量。默认 false。请参见批量发送外发更新
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

All hooks below must be used inside HocuspocusRoom. If there is no room context, they will throw an error.

useHocuspocusProvider

Returns the current room's HocuspocusProvider instance. Use it when you need direct access to the provider—for example, to connect Tiptap's Collaboration / CollaborationCaret extensions to the provider's Y.Doc and 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

Subscribes to the WebSocket connection status. Returns '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

Subscribes to whether local changes have been synced with the server. Returns 'synced' | 'syncing'.

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

function SaveIndicator() {
  const syncStatus = useHocuspocusSyncStatus()

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

useHocuspocusAwareness

Subscribes to the list of connected users (awareness state). Returns an array of objects, each containing clientId, plus any state you set on awareness (name, color, cursor, etc.).

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

Listen to any provider event via a stable subscription (the handler reference is kept up to date internally, so you don't need to memoize it).

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