快速整合
本頁從零開始做一個能收發訊息的網頁:登入 IM、顯示會話列表、開啟會話檢視訊息、傳送文本訊息、收到新訊息時提醒、退出登入。完成後你會得到一個可以直接執行的示例,可以在它的基礎上開發自己的介面。
準備工作
- 在控制台使用註冊時自動建立的
demo應用,或另外建立應用,在概覽的“接入憑證”中複製 AppKey(如1575529652#demo)和 IM 服務的地址,即“REST 接入地址”(如https://im.example.com)。 - 業務服務端接入服務端 REST API,提供一個“獲取 IM 登入憑證”的介面:使用者登入你的業務系統後,網頁呼叫這個介面,業務服務端為當前使用者簽發登入憑證並返回。
- 準備一個前端工程。本頁使用 Vite 建立的原生 TypeScript 工程,其他構建工具同樣適用:
npm create vite@latest im-demo -- --template vanilla-ts
cd im-demo不想搭工程也可以直接下載Web Demo的演示頁程式碼包,解壓後用任意靜態伺服器託管,填入 AppKey 和服務地址即可體驗全部能力;本頁的步驟用於理解接入過程。
業務服務端簽發憑證的介面大致如下(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 中使用:用官方的繫結包管理列表和狀態;
- 事件與錯誤處理:全部事件和錯誤碼。
接入獨立頻道
完成客戶端初始化與登入後,可以按頻道接入加入業務服務端建立的頻道。加入需要應用頻道開通和目標使用者授權;預設不採集。
