快速集成
本页从零开始做一个能收发消息的网页:登录 IM、显示会话列表、打开会话查看消息、发送文本消息、收到新消息时提醒、退出登录。完成后你会得到一个可以直接运行的示例,可以在它的基础上开发自己的界面。
准备工作
- 在控制台创建应用,在应用详情中记下 AppKey(如
1575529652#demo)和 IM 服务的地址(如https://im.example.com)。 - 业务服务端接入服务端 REST API,提供一个“获取 IM 登录凭证”的接口:用户登录你的业务系统后,网页调用这个接口,业务服务端为当前用户签发登录凭证并返回。
- 准备一个前端工程。本页使用 Vite 创建的原生 TypeScript 工程,其他构建工具同样适用:
npm create vite@latest im-demo -- --template vanilla-ts
cd im-demo业务服务端签发凭证的接口大致如下(Node.js,/api/im-ticket 是你自己定义的地址,用户的身份取自你的业务系统的登录状态):
// 业务服务端:为已登录你的业务系统的用户签发 IM 登录凭证
app.post('/api/im-ticket', async (req, res) => {
const username = req.session.userId; // 你的业务系统中的当前用户
const r = await fetch(`${IM_API}/${ORG}/${APP}/users/${encodeURIComponent(username)}/login-tickets`, {
method: 'POST',
headers: { Authorization: `Bearer ${appToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_create: true }), // 用户不存在时自动创建
});
const { ticket } = await r.json();
res.json({ ticket });
});凭证 5 分钟内有效、只能使用一次,相当于用户的临时密码,只通过 HTTPS 返回给这个用户的网页。App Token 只能保存在服务端,不能放在网页中。
开发和测试时,也可以用 curl 签发一个凭证,复制到网页中使用。
安装
npm install @deeprespond/im-web创建客户端
在一个单独的模块中创建客户端,整个页面共用这一个:
// src/im.ts
import { createDRClient } from '@deeprespond/im-web';
export const im = createDRClient({
appKey: '1575529652#demo',
apiUrl: 'https://im.example.com',
});创建客户端不发任何请求。这个浏览器中已经登录过的,SDK 立即恢复登录(im.auth.currentUser 不为 null),不需要再登录。
登录
向业务服务端取得登录凭证,交给 SDK 登录:
import { createDRClient } from '@deeprespond/im-web';
async function fetchTicket(): Promise<string> {
const res = await fetch('/api/im-ticket', { method: 'POST', credentials: 'include' });
const { ticket } = (await res.json()) as { ticket: string };
return ticket;
}
const im = createDRClient({
appKey: '1575529652#demo',
apiUrl: 'https://im.example.com',
// 凭证失效、或登录状态过期时,SDK 用它重新取一次凭证
ticketProvider: () => fetchTicket(),
});
if (!im.auth.currentUser) {
const { user } = await im.auth.loginWithTicket({ ticket: await fetchTicket() });
console.log('已登录', user.username);
}登录成功后,SDK 在后台连接长连接并同步数据。用户关闭页面再打开时会自动恢复登录,所以只在 im.auth.currentUser 为 null 时才需要登录。登录失败的处理见初始化、登录与连接。
显示会话列表
im.conversations.list() 返回一个可订阅的会话列表,按置顶和最近消息的时间排序。数据先从本地读取,同步到新数据时自动更新并通知你:
import { conversationTitle } from '@deeprespond/im-web/render';
const list = im.conversations.list();
list.subscribe(() => {
const { items, loading } = list.getSnapshot();
for (const c of items) {
console.log(conversationTitle(im, c), c.unread_count, c.last_message?.type);
}
if (loading) console.log('加载中');
});conversationTitle 给出会话的标题:单聊为对方的备注或昵称,群聊为群名。列表必须在登录之后创建,未登录时调用会抛出 not_signed_in。
打开会话并显示消息
用户点击会话时,用它的 conversation_key 打开消息列表;要和某个人开始聊天,先用 openSingle 取得与他的单聊:
// 从会话列表中打开
const key = list.getSnapshot().items[0]!.conversation_key;
// 或者与某个人开始聊天(还没有会话时得到一个本地的草稿会话)
const view = await im.conversations.openSingle('lisi');
const messages = im.messages.open(view.conversation_key);
messages.subscribe(() => {
for (const m of messages.getSnapshot().items) {
const text = m.type === 'text' ? String(m.body?.text ?? '') : `[${m.type}]`;
console.log(m.sender, text, m._local.status);
}
});
messages.setViewing({ visible: true, atBottom: true }); // 用户正在看这个会话的最新消息
messages.markRead(); // 把这个会话标记为已读- 消息按顺序排列,最新的在最后;还在发送中的消息排在最后,
_local.status为sending、failed等。 - 打开时显示本地最新的 50 条,向上翻页时调用
messages.loadOlder()。 - 离开这个会话时调用
messages.dispose()释放列表。
发送文本消息
const sent = await im.messages.send(
{ conversation_key: messages.conversation_key },
{ type: 'text', body: { text: '你好' } },
);
console.log(sent.client_msg_id, sent._local.status); // queued:已进入发送队列send 在消息进入发送队列后立即返回,消息同时出现在打开着的消息列表中。之后 SDK 负责发送和失败重试,状态变化通过消息列表通知你;网络断开时消息留在队列中,恢复后自动发出。也可以不打开会话,直接发给某个人或某个群:{ to_user: 'lisi' }、{ to_group: '群 ID' }。
监听新消息和连接状态
im.on('message.received', ({ message, alert }) => {
// alert 为 true 时由本标签页负责提醒,避免多个标签页重复弹出通知
if (alert && document.hidden) showToast(`${message.sender} 发来一条新消息`);
});
im.on('connection.stateChanged', (state) => {
// ready:已连接并同步完成;waiting:等待重连;offline:网络不可用
render(state.kind === 'ready' ? '' : state.kind === 'offline' ? '网络不可用' : '连接中…');
});
im.on('auth.stateChanged', (state) => {
if (state.kind === 'ended') render('登录已失效,请重新登录', state.reason);
});新消息到达时,打开着的会话列表和消息列表会自动更新,不需要你在事件中处理;message.received 只用于弹出通知、播放提示音等提醒。全部事件见事件与错误处理。
退出
await im.auth.logout();退出后 SDK 清除本地的登录状态、断开长连接,打开着的列表被清空并释放,同一浏览器的其他标签页也一起退出。多人共用的电脑上,可以用 logout({ clearLocalData: true }) 同时删除这个用户在本地的聊天记录。
完整示例
把以下两个文件放进上面创建的 Vite 工程,替换其中的 AppKey、服务地址和取凭证的接口,运行 npm run dev 后打开页面。用两个浏览器(或一个普通窗口加一个隐私窗口)以两个用户登录,就可以互相发消息。
<!-- index.html -->
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>IM 示例</title>
</head>
<body>
<p id="status">未登录</p>
<button id="logout">退出</button>
<ul id="conversations"></ul>
<form id="open"><input id="peer" placeholder="对方用户名"> <button>开始聊天</button></form>
<div id="messages"></div>
<form id="send"><input id="text" placeholder="消息"> <button>发送</button></form>
<script type="module" src="/src/main.ts"></script>
</body>
</html>// src/main.ts
import { createDRClient, DRError, type LiveList, type ConversationView, type MessageList } from '@deeprespond/im-web';
import { conversationTitle, displayName, summarize } from '@deeprespond/im-web/render';
import { describeError } from '@deeprespond/im-web/locale/zh-CN';
const $ = (id: string) => document.getElementById(id) as HTMLElement;
const value = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
async function fetchTicket(): Promise<string> {
const res = await fetch('/api/im-ticket', { method: 'POST', credentials: 'include' });
return ((await res.json()) as { ticket: string }).ticket;
}
const im = createDRClient({
appKey: '1575529652#demo',
apiUrl: 'https://im.example.com',
ticketProvider: () => fetchTicket(),
});
// ---- 状态 ----
function showStatus() {
const user = im.auth.currentUser?.username ?? '未登录';
$('status').textContent = `${user} · ${im.connection.state.kind}`;
}
im.on('connection.stateChanged', showStatus);
im.on('auth.stateChanged', (state) => {
showStatus();
if (state.kind === 'signed_in') startConversationList();
if (state.kind === 'ended') alert(describeError({ code: state.code, reason: state.reason }));
});
// 另一个标签页登录了别的用户:列表已被清空释放,按新用户重新创建
im.on('auth.userSwitched', () => startConversationList());
// ---- 会话列表 ----
let conversations: LiveList<ConversationView> | null = null;
function startConversationList() {
conversations?.dispose();
conversations = im.conversations.list();
conversations.subscribe(renderConversations);
renderConversations();
}
function renderConversations() {
const me = im.auth.currentUser?.username;
const items = conversations?.getSnapshot().items ?? [];
$('conversations').replaceChildren(...items.map((c) => {
const li = document.createElement('li');
const last = c.last_message ? summarize(c.last_message, { nameOf: (u) => displayName(im, u), self: me }) : '';
li.textContent = `${conversationTitle(im, c)}${c.unread_count ? ` (${c.unread_count})` : ''}:${last}`;
li.onclick = () => openConversation(c.conversation_key);
return li;
}));
}
// ---- 消息列表 ----
let current: MessageList | null = null;
function openConversation(key: string) {
current?.dispose();
const list = im.messages.open(key);
current = list;
const renderMessages = () => {
$('messages').replaceChildren(...list.getSnapshot().items.map((m) => {
const p = document.createElement('p');
const text = m.type === 'text' ? String(m.body?.text ?? '') : summarize(m, { nameOf: (u) => displayName(im, u) });
p.textContent = `${displayName(im, m.sender)}:${text}${m._local.status === 'sent' ? '' : `(${m._local.status})`}`;
return p;
}));
};
list.subscribe(renderMessages);
renderMessages();
list.setViewing({ visible: true, atBottom: true });
list.markRead();
}
$('open').onsubmit = async (e) => {
e.preventDefault();
const view = await im.conversations.openSingle(value('peer'));
openConversation(view.conversation_key);
};
$('send').onsubmit = async (e) => {
e.preventDefault();
const text = value('text');
if (!current || !text) return;
(document.getElementById('text') as HTMLInputElement).value = '';
try {
await im.messages.send({ conversation_key: current.conversation_key }, { type: 'text', body: { text } });
} catch (err) {
alert(err instanceof DRError ? describeError(err) : String(err));
}
};
// ---- 新消息提醒 ----
im.on('message.received', ({ message, alert: isAlertTab }) => {
if (isAlertTab && document.hidden) document.title = `新消息 - ${displayName(im, message.sender)}`;
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) document.title = 'IM 示例';
});
// ---- 退出 ----
$('logout').onclick = async () => {
current?.dispose();
current = null;
$('messages').replaceChildren();
await im.auth.logout();
};
// ---- 启动:已登录的自动恢复,否则用凭证登录 ----
showStatus();
if (im.auth.currentUser) {
startConversationList();
} else {
try {
await im.auth.loginWithTicket({ ticket: await fetchTicket() });
} catch (err) {
$('status').textContent = err instanceof DRError ? describeError(err) : String(err);
}
}这个示例中:
- 登录成功时 SDK 发出
auth.stateChanged(signed_in),示例据此创建会话列表;已登录的页面直接创建; - 会话列表、消息列表的显示都只依赖
getSnapshot(),收到新消息、发送状态变化、其他设备上的已读等都会自动反映出来; - 打开同一网站的第二个标签页,它会自动处于登录状态,与第一个标签页共用一条连接。
下一步
- 初始化、登录与连接:全部初始化选项、密码登录、会话结束的原因、连接状态与多个标签页;
- 会话、消息:未读数、草稿、图片和文件消息、撤回、已读回执等;
- 在 React 和 Vue 中使用:用官方的绑定包管理列表和状态;
- 事件与错误处理:全部事件和错误码。
接入独立频道
完成客户端初始化与登录后,可以按频道接入加入业务服务端创建的频道。加入需要应用频道开通和目标用户授权;默认不采集。
