在 React 和 Vue 中使用
Web SDK 的核心包不依赖任何前端框架:会变化的数据都以“快照 + 订阅”的形式提供,可以接入任何框架。React 和 Vue 另有官方的绑定包,负责在组件的生命周期中创建、订阅和释放列表,处理 React StrictMode 的重复挂载、热更新和服务端渲染:
| 包 | 内容 | 支持的版本 |
|---|---|---|
@deeprespond/im-web-react | DRProvider 和 hooks | React 18、19 |
@deeprespond/im-web-vue | 插件和组合式函数 | Vue 3.3 以上 |
绑定包不含界面组件,只负责数据。它们与核心包一起发布、版本号相同,请安装相同的版本。Svelte、Angular 等其他框架直接使用核心包,见不用绑定包。
客户端放在哪里
单页应用
在一个模块中创建一次客户端,整个应用共用,不要在组件中创建:
// src/im.ts
import { createDRClient } from '@deeprespond/im-web';
export const im = createDRClient({ appKey: '1575529652#demo', apiUrl: 'https://im.example.com' });
// 热更新会重新执行这个模块:先释放旧的客户端,新的才能创建(webpack 为 import.meta.webpackHot)
if (import.meta.hot) import.meta.hot.dispose(() => void im.destroy());然后把它交给绑定包:React 用 <DRProvider client={im}>,Vue 用 app.use(createDRPlugin({ client: im }))。
服务端渲染
Next.js、Nuxt 等框架会在服务端执行页面代码,而客户端只能在浏览器中创建(在服务端调用 createDRClient 抛出 unsupported)。这时不要在模块顶层创建,改为把选项交给绑定包,由它在浏览器中创建、卸载时释放:React 用 <DRProvider options={...}>,Vue 用 createDRPlugin({ options: ... })。服务端渲染时 hooks 和组合式函数返回空的值(见客户端还没有时的值),浏览器中创建客户端后自动更新。
Next.js 的 App Router 中,DRProvider 要放在你自己的 'use client' 组件中:选项中的函数(ticketProvider、logger.sink)不能从服务端组件传给客户端组件。
在组件中创建
要在组件中创建的(例如登录之后才知道 AppKey),在 effect 中创建、在清理函数中 destroy(),或者用 DRProvider 的 options。React StrictMode 在开发模式下会创建、释放、再创建一次,这是允许的:destroy() 之后可以立即再创建。不要在渲染函数或 useState、useMemo 的初始化函数中创建:StrictMode 会调用它们两次,第二次创建时第一个还没有释放,会抛出 invalid_state(client_exists)。
React
npm install @deeprespond/im-web @deeprespond/im-web-react提供客户端
import { DRProvider } from '@deeprespond/im-web-react';
// im 为上文 src/im.ts 中创建的客户端(import { im } from './im')
// 在入口中渲染它:createRoot(document.getElementById('root')!).render(<Root />)
export function Root() {
return (
<DRProvider client={im}>
<App />
</DRProvider>
);
}服务端渲染的应用改用 options:
'use client';
import type { ReactNode } from 'react';
import { DRProvider } from '@deeprespond/im-web-react';
const options = { appKey: '1575529652#demo', apiUrl: 'https://im.example.com' };
export function IMProvider({ children }: { children: ReactNode }) {
return <DRProvider options={options}>{children}</DRProvider>;
}options 只在挂载时读取一次,之后修改不起作用;要换成另一个应用时,改变 DRProvider 的 key 让它重新挂载。
登录状态
useAuthState() 返回登录状态,据此决定显示登录页还是主界面。客户端还没有创建时(服务端渲染、第一次渲染)为 null,这时还不知道是否已登录,应显示加载中:
import { useAuthState, useConnectionState } from '@deeprespond/im-web-react';
export function Root() {
const auth = useAuthState();
const connection = useConnectionState();
if (auth === null) return <p>加载中…</p>;
if (auth.kind === 'signed_out' || auth.kind === 'ended') return <LoginPage />;
return (
<>
{connection.kind !== 'ready' && <p className="banner">{connection.kind === 'offline' ? '网络不可用' : '连接中…'}</p>}
<MainPage />
</>
);
}登录页中用 useDRClient() 取得客户端调用登录:
import { useDRClient } from '@deeprespond/im-web-react';
export function LoginPage() {
const im = useDRClient();
const login = async () => {
const res = await fetch('/api/im-ticket', { method: 'POST', credentials: 'include' });
const { ticket } = (await res.json()) as { ticket: string };
await im?.auth.loginWithTicket({ ticket });
};
return <button disabled={!im} onClick={() => void login()}>登录</button>;
}会话列表
useLiveList(create, deps) 在 effect 中调用 create(im) 取得列表,卸载时释放。登录前不调用 create,退出、切换用户后自动按新的登录重新创建:
import { memo } from 'react';
import type { ConversationView } from '@deeprespond/im-web';
import { useLiveList, useConversationTitle, useUnreadTotal } from '@deeprespond/im-web-react';
export function ConversationList({ onOpen }: { onOpen: (key: string) => void }) {
const { snapshot, list } = useLiveList<ConversationView>((im) => im.conversations.list(), []);
const unread = useUnreadTotal();
return (
<div>
<h3>消息{unread > 0 && `(${unread})`}</h3>
{snapshot.loading && snapshot.items.length === 0 && <p>加载中…</p>}
{snapshot.items.map((c) => (
<ConversationRow key={c.conversation_key} view={c} onOpen={onOpen} />
))}
{snapshot.hasMore && <button onClick={() => void list?.loadMore()}>加载更多</button>}
</div>
);
}
// 没有变化的会话仍是原来的对象,memo 的行组件不会重新渲染
const ConversationRow = memo(function ConversationRow({ view, onOpen }: { view: ConversationView; onOpen: (key: string) => void }) {
const title = useConversationTitle(view);
return (
<div onClick={() => onOpen(view.conversation_key)}>
{title} {view.unread_count > 0 && <b>{view.unread_count}</b>}
</div>
);
});请为 useLiveList 写明条目的类型(如 useLiveList<ConversationView>),否则快照中条目的类型推断不出来。其他列表同样适用:好友为 useLiveList<Friend>((im) => im.friends.list(), []),群成员为 useLiveList<GroupMember>((im) => im.groups.members(groupId), [groupId]),deps 变化时释放旧的列表、创建新的。
会话标题不在会话对象中:它来自好友备注、用户资料、群名,这些变化时会话对象不变。所以 memo 的行组件要用 useConversationTitle、useDisplayName 显示名称,它们会在名称变化时重新渲染。
消息列表与发送
useMessageList(conversation_key, opts?) 打开一个会话的消息列表,conversation_key 变化时释放旧的、打开新的,卸载时释放:
import { useState } from 'react';
import { DRError } from '@deeprespond/im-web';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
import { useDRClient, useDREvent, useDisplayName, useMessageList, useTyping } from '@deeprespond/im-web-react';
export function Chat({ conversationKey }: { conversationKey: string }) {
const im = useDRClient();
const { snapshot, list } = useMessageList(conversationKey);
const typing = useTyping(conversationKey);
const [text, setText] = useState('');
useDREvent('message.sendFailed', (p) => {
if (p.conversation_key === conversationKey) alert(describeError(p.error));
});
const send = async () => {
if (!im || !text) return;
setText('');
try {
await im.messages.send({ conversation_key: conversationKey }, { type: 'text', body: { text } });
} catch (err) {
if (err instanceof DRError) alert(describeError(err));
}
};
return (
<div>
{snapshot.hasOlder && <button onClick={() => void list?.loadOlder()}>查看更早的消息</button>}
{snapshot.items.map((m) => (
<MessageRow key={m.client_msg_id ?? String(m.seq)} sender={m.sender} text={m.type === 'text' ? String(m.body?.text ?? '') : `[${m.type}]`} status={m._local.status} />
))}
{typing.length > 0 && <p>对方正在输入…</p>}
<input value={text} onChange={(e) => { setText(e.target.value); im?.conversations.typing(conversationKey); }} />
<button onClick={() => void send()}>发送</button>
</div>
);
}
function MessageRow({ sender, text, status }: { sender: string | null; text: string; status: string }) {
const name = useDisplayName(sender);
return <p>{name}:{text}{status !== 'sent' && `(${status})`}</p>;
}- 草稿会话(还没有发过消息的单聊)发出第一条消息后变为正式会话,列表会就地切换,
conversation_key随后改为新的会话 ID 不会引起重新打开。 - 只改变
opts.around_seq时,列表跳转到这条消息附近(list.jumpTo()),不重新打开。 - 列表在界面上被隐藏但没有卸载时(如标签式界面中切走的面板),调用
list.setViewing({ visible: false }),否则开启了autoMarkRead时会把用户没看到的消息标为已读。
事件、在线状态与聊天室
import { useDREvent, usePresence, useChatroom, useHandle, useDRClient, useDRState } from '@deeprespond/im-web-react';
export function Notifier() {
// 总是调用最新一次渲染的函数,不会因为它变化而重新订阅
useDREvent('message.received', ({ message, alert }) => {
if (alert && document.hidden) new Notification('新消息', { body: message.sender ?? '' });
});
return null;
}
export function OnlineDots({ usernames }: { usernames: string[] }) {
const presence = usePresence(usernames); // 名单按内容比较,每次渲染传入新数组不会重新订阅
return <>{usernames.map((u) => <span key={u}>{u}{presence.get(u)?.online ? '(在线)' : ''}</span>)}</>;
}
export function LiveRoom({ roomId }: { roomId: string }) {
const { snapshot, error } = useChatroom(roomId); // 挂载时进入,卸载或 roomId 变化时离开
if (error) return <p>进入失败</p>;
if (!snapshot) return <p>正在进入…</p>;
return <p>{snapshot.info.name}:{snapshot.messages.length} 条消息</p>;
}
export function CurrentCall() {
const current = useDRState((im) => im.calls.current, ['call.incoming', 'call.changed'], null);
const snapshot = useHandle(current); // 通话的快照;没有通话时为 null
return snapshot ? <p>通话状态:{snapshot.call.status}</p> : null;
}useDRState(read, events, fallback?) 用于读取任何同步的值:events 中的事件发出时重新调用 read,结果没有变化(Object.is)时不重新渲染。各个值对应的事件见用事件读取同步状态的值。
React 绑定的全部导出
| 导出 | 说明 |
|---|---|
DRProvider | 提供客户端。属性 client(已创建的客户端)与 options(DRClientOptions,由它在浏览器中创建,卸载时 destroy())二选一 |
useDRClient() | 当前的客户端 DRClient | null;用 options 时,创建之前为 null |
useLiveList(create, deps) | 返回 { snapshot, list }。在 effect 中调用 create(im) 取得列表,deps 变化、登录会话变化或卸载时 dispose();未登录时不调用 create |
useMessageList(conversation_key, opts?) | 返回 { snapshot, list },打开 im.messages.open(conversation_key, opts);conversation_key 为 null 或 undefined 时不打开 |
useHandle(handle) | 聊天室、通话对象的快照;handle 为 null 时返回 null |
useChatroom(room_id, opts?) | 挂载时进入聊天室,卸载或 room_id 变化时离开(StrictMode 的重复挂载不会离开);返回 { handle, snapshot, error } |
useDRState(read, events, fallback?) | 读取同步的值,events 中的事件发出时重新读取;另外总是在 auth.stateChanged、auth.userSwitched、storage.reset 时重新读取。客户端还没有时返回 fallback |
useDREvent(event, listener) | 订阅事件,卸载时取消 |
usePresence(usernames) | 订阅这些人的在线状态,卸载或名单变化时取消;返回 Map<string, PresenceState | undefined>。应用没有开启在线状态时不订阅,值全为 undefined |
useAuthState() | 登录状态 AuthState | null |
useConnectionState() | 连接状态 ConnectionState |
useConfig() | 运行配置 ClientConfig | null |
useMe() | 本人资料 SelfProfile | null |
useUnreadTotal(opts?) | 未读总数 number,opts.excludeMuted 为 true 时不计免打扰的会话 |
useUser(username) | 其他用户的资料 UserProfile | undefined,本地没有时在后台获取 |
useFriendRequestUnread() | 未读的好友申请数 number |
useTyping(conversation_key) | 会话中正在输入的人 readonly string[] |
useDisplayName(username, opts?) | 用户的显示名 string | undefined(好友备注、群昵称、昵称、用户名中第一个非空的;opts.group_id 给出时考虑群昵称),名称相关的数据变化时更新 |
useConversationTitle(view) | 会话的标题 string | undefined,名称相关的数据变化时更新 |
hooks 内部用 useSyncExternalStore,并发渲染时同一次渲染中读到的数据前后一致。
Vue
npm install @deeprespond/im-web @deeprespond/im-web-vue安装插件
// main.ts。App 为根组件(import App from './App.vue'),im 为上文 src/im.ts 中创建的客户端
import { createApp } from 'vue';
import { createDRPlugin } from '@deeprespond/im-web-vue';
createApp(App).use(createDRPlugin({ client: im })).mount('#app');Nuxt 等服务端渲染的应用改用 options,由插件在浏览器中创建,应用卸载时 destroy():
import { createDRPlugin } from '@deeprespond/im-web-vue';
app.use(createDRPlugin({ options: { appKey: '1575529652#demo', apiUrl: 'https://im.example.com' } }));在组件中使用
组合式函数与 React 的 hooks 一一对应、名字相同。返回的快照和状态是只读的 ShallowRef,在模板中直接使用:
<script setup lang="ts">
import { ref } from 'vue';
import type { ConversationView } from '@deeprespond/im-web';
import { useAuthState, useConnectionState, useDRClient, useDREvent, useLiveList, useMessageList, useUnreadTotal } from '@deeprespond/im-web-vue';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
const im = useDRClient();
const auth = useAuthState();
const connection = useConnectionState();
const unread = useUnreadTotal();
const { snapshot: conversations, list } = useLiveList<ConversationView>((c) => c.conversations.list());
const current = ref<string | null>(null);
const { snapshot: messages } = useMessageList(current); // current 变化时释放旧的列表、打开新的
const text = ref('');
async function send() {
if (!im.value || !current.value || !text.value) return;
const body = { text: text.value };
text.value = '';
await im.value.messages.send({ conversation_key: current.value }, { type: 'text', body });
}
useDREvent('message.sendFailed', (p) => alert(describeError(p.error)));
</script>
<template>
<p v-if="auth === null">加载中…</p>
<p v-else-if="auth.kind !== 'signed_in'">请登录</p>
<div v-else>
<p v-if="connection.kind !== 'ready'">连接中…</p>
<h3>消息({{ unread }})</h3>
<div v-for="c in conversations.items" :key="c.conversation_key" @click="current = c.conversation_key">
{{ c.peer ?? c.group_id }} {{ c.unread_count || '' }}
</div>
<button v-if="conversations.hasMore" @click="list?.loadMore()">加载更多</button>
<div v-if="current">
<p v-for="m in messages.items" :key="m.client_msg_id ?? m.seq ?? ''">{{ m.sender }}:{{ m.body?.text }}</p>
<input v-model="text" @keyup.enter="send"> <button @click="send">发送</button>
</div>
</div>
</template>- 参数可以是值、
ref或 getter(如useMessageList(() => props.conversationKey)),变化时释放旧的、重新创建。 useLiveList(create)没有deps参数:create在追踪依赖的作用域中执行,其中读取的响应式值(如props.groupId)变化时自动重新创建,如useLiveList<GroupMember>((c) => c.groups.members(props.groupId))。同样请写明条目的类型。- 列表在
onMounted中创建、在作用域销毁时释放,所以在服务端渲染中不会创建;list在挂载之后才有值。 - 被
<KeepAlive>缓存的组件不会销毁,列表仍然打开:useMessageList在停用时自动报告列表不在显示,重新激活时恢复,这个会话不会因为页面被缓存而一直算作“正在被看”。 - 会话标题、显示名用
useConversationTitle(view)、useDisplayName(username),返回computed,名称相关的数据变化时自动更新。
Vue 绑定的全部导出
| 导出 | 说明 |
|---|---|
createDRPlugin({ client?, options? }) | 插件。client(已创建的客户端)与 options(在浏览器中创建,应用卸载时 destroy())二选一 |
useDRClient() | 当前的客户端,ShallowRef<DRClient | null> |
useLiveList(create) | 返回 { snapshot, list },都是只读的 ShallowRef;create 中读取的响应式值变化、登录会话变化或作用域销毁时释放 |
useMessageList(conversation_key, opts?) | 返回 { snapshot, list };参数可以是值、ref 或 getter |
useHandle(handle) | 聊天室、通话对象的快照;handle 可以是值、ref 或 getter |
useChatroom(room_id, opts?) | 挂载时进入聊天室,作用域销毁或 room_id 变化时离开;返回 { handle, snapshot, error } |
useDRState(read, events, fallback?) | 读取同步的值,返回只读的 ShallowRef,规则同 React |
useDREvent(event, listener) | 订阅事件,作用域销毁时取消 |
usePresence(usernames) | 订阅在线状态,返回 ShallowRef<Map<string, PresenceState | undefined>>;usernames 可以是值、ref 或 getter |
useAuthState()、useConnectionState()、useConfig()、useMe()、useUnreadTotal(opts?)、useUser(username)、useFriendRequestUnread()、useTyping(conversation_key) | 同 React,返回只读的 ShallowRef |
useDisplayName(username, opts?)、useConversationTitle(view) | 同 React,返回 computed;参数可以是值、ref 或 getter |
组合式函数也可以在组件之外的 effectScope 中使用,这时立即开始,在作用域停止时释放。
客户端还没有时的值
服务端渲染时、用 options 创建客户端之前,hooks 和组合式函数返回以下的值,客户端创建后自动更新:
| 函数 | 值 |
|---|---|
useLiveList | 快照为 { items: [], hasMore: false, loading: true },list 为 null |
useMessageList | 快照为 { items: [], gaps: [], hasOlder: false, hasNewer: false, loading: 'initial', pinned: [] },list 为 null |
useHandle、useChatroom | 快照为 null |
useAuthState() | null:还不知道是否已登录,应显示加载中,而不是登录页 |
useConnectionState() | { kind: 'idle' } |
useConfig()、useMe() | null |
useUnreadTotal()、useFriendRequestUnread() | 0 |
useTyping() | 空数组 |
useUser()、useDisplayName()、useConversationTitle() | undefined |
usePresence() | 值全为 undefined |
有客户端但未登录时,状态类的函数读取客户端的实际值(useAuthState() 为 { kind: 'signed_out' },据此显示登录页);列表类的函数不创建列表,快照为不在加载中的空快照:useLiveList 为 { items: [], hasMore: false, loading: false },useMessageList 的 loading 为 'none'。这些空快照每次都是同一个对象。
不用绑定包
可订阅的对象(LiveList、MessageList、聊天室和通话对象)都提供 getSnapshot() 和 subscribe(listener),规则见可订阅的列表,可以接入任何框架。要点是:在副作用中创建列表、在清理时 dispose();按登录会话重新创建(退出、切换用户后列表已被释放)。
React:
import { useEffect, useState, useSyncExternalStore } from 'react';
import type { ConversationView, LiveList, LiveListSnapshot } from '@deeprespond/im-web';
const EMPTY: LiveListSnapshot<ConversationView> = Object.freeze({ items: Object.freeze([]), hasMore: false, loading: true });
const noSubscribe = () => () => {};
// 订阅函数定义在组件之外,否则每次渲染都会重新订阅
const subscribeAuth = (cb: () => void) => im.on('auth.stateChanged', cb);
export function ConversationList() {
// 同步读取的值:订阅对应的事件、读取属性;服务端渲染时为 null
const sessionId = useSyncExternalStore(subscribeAuth, () => im.auth.currentUser?.session_id ?? null, () => null);
const [list, setList] = useState<LiveList<ConversationView> | null>(null);
useEffect(() => {
if (!sessionId) return; // 未登录时 list() 抛出 not_signed_in
const l = im.conversations.list(); // 在 effect 中创建,不在渲染中创建
setList(l);
return () => {
l.dispose();
setList(null);
};
}, [sessionId]);
const snapshot = useSyncExternalStore(list?.subscribe ?? noSubscribe, list?.getSnapshot ?? (() => EMPTY), () => EMPTY);
return <ul>{snapshot.items.map((c) => <li key={c.conversation_key}>{c.unread_count}</li>)}</ul>;
}Vue(单页应用;服务端渲染时改在 onMounted 中创建):
import { onScopeDispose, shallowRef } from 'vue';
const list = im.conversations.list();
const snapshot = shallowRef(list.getSnapshot());
const off = list.subscribe(() => {
snapshot.value = list.getSnapshot();
});
onScopeDispose(() => {
off();
list.dispose();
});Svelte:
import { readable } from 'svelte/store';
const list = im.conversations.list();
export const conversations = readable(list.getSnapshot(), (set) => {
set(list.getSnapshot());
return list.subscribe(() => set(list.getSnapshot()));
});
// 组件销毁时另外调用 list.dispose()Angular:用 signal() 保存快照,在 subscribe 的回调中更新它;在 DestroyRef.onDestroy 中取消订阅并调用 dispose()。
注意事项
- 不在渲染中创建列表:
im.conversations.list()、im.messages.open()每次调用都创建新的列表,要在副作用中创建(或用绑定包),清理时dispose()。没有释放的消息列表会让 SDK 继续为它补齐消息,还会让这个会话一直算作“正在被看”。 - StrictMode:开发模式下组件会挂载、卸载、再挂载一次。绑定包和“副作用中创建、清理中释放”的写法都能正确处理;
useChatroom不会因此离开聊天室。 - Vue 的响应式:SDK 的对象(客户端、列表、聊天室和通话对象)不可扩展,快照和数据对象已冻结,放进
ref、reactive不会被代理,也不会出错;但它们也不会因此变成响应式的,数据仍要经subscribe或组合式函数取得。 - 长列表:会话列表最多有
conversationWindow(默认 1000)项,消息列表随翻页增长,请使用虚拟滚动。没有变化的条目仍是原来的对象,行组件可以按引用缓存(React.memo、Vue 的v-memo),但行中的名称要用useDisplayName、useConversationTitle显示,否则名称变化时缓存的行不会更新。 - 隐藏的列表:界面上看不到、但没有释放的消息列表(标签式界面中切走的面板),调用
list.setViewing({ visible: false }),显示时再设为true。Vue 的<KeepAlive>由useMessageList自动处理;React 19.2 起的<Activity>隐藏时会执行 effect 的清理,列表已经释放,不需要另外处理。 - 多个标签页:每个标签页有自己的客户端和列表,数据由 SDK 在标签页之间同步,绑定包不需要另外处理。
频道状态
React 的 useChannel(handle?: ChannelHandle | null) 返回 ChannelSnapshot | null,Vue 的 useChannel(handle?) 返回只读 Ref;参数支持 ref / getter。省略句柄时订阅当前客户端频道。
import { useChannel } from '@deeprespond/im-web-react';
function ChannelStatus() {
const snapshot = useChannel();
return <span>{snapshot?.session.status ?? '未加入频道'}</span>;
}import { useChannel } from '@deeprespond/im-web-vue';
const snapshot = useChannel();
// 模板读取 snapshot;脚本读取 snapshot.value。与其他绑定一样,组件应位于已配置客户端的 Provider / 注入上下文中。useChannel 只订阅状态,不自动加入或开启采集;join 放在用户操作中,页面卸载时主动 leave 并清理轨道播放器,见Web 频道。
