socket.io公式サイトのGet Startedでは、簡単なチャットアプリを通してsocket.ioの使い方の概要を勉強することができます。
http://socket.io/get-started/chat/
ここの最後に「Homework」と題して6つほど応用編が書いてあるのですが、その回答が公式には載っていないので、勉強のために作ってみた内容をまとめてみました。
記事執筆時点でのsocket.ioのバージョンは1.4.5です。(npmのpackageは1.7.2ですが。。)
Homework
- Broadcast a message to connected users when someone connects or disconnects (誰かが入室・退室したときに他の人に通知する)
- Add support for nicknames (ニックネームをつけられるようにする)
- Don’t send the same message to the user that sent it himself. Instead, append the message directly as soon as he presses enter. (自分自身が直前に送信したのと同じメッセージは送信できないようにする)
- Add “{user} is typing” functionality (「◯◯さんが入力中です...」表示をする)
- Show who’s online (入室中のユーザー一覧を表示する)
- Add private messaging (ダイレクトメッセージ機能をつける。これまだできてません。。)
- Share your improvements! (つくったものをシェアしよう。この記事)
英語がダメすぎてそもそも間違えてたらどうしよう。。
できあがり
動きはこんな感じ。見た目はごめんなさい。
コードはこちらです。
宿題の解き方(案)
Broadcast a message to connected users when someone connects or disconnects (誰かが入室・退室したときに他の人に通知する)
- 入室・退室はそれぞれ
connection
、disconnect
というイベントで取れる - 自分以外の人に送信するときは
socket.broadcast.emit("event", data)
なので、それぞれのイベント発火時にサーバーからクライアントに入室・退室した人の名前を送信してあげる。
let login_users = {}; // 参加者管理
io.on('connection', (socket) => {
// 入室処理
socket.on('enter room', (nickname) => {
login_users[socket.id] = nickname;
socket.broadcast.emit('newcomer joined', login_users[socket.id]);
});
// 退室処理
socket.on('disconnect', () => {
socket.broadcast.emit('user disconnect', login_users[socket.id]);
});
});
'enter room'
イベントについては後述。
ちなみにlogin_users
は入室しているユーザーの一覧を管理するオブジェクトです。
let login_users = {
socket.id: username
}
で、クライアント側ではそれを受けて適当にメッセージを出してあげる。
socket.on('newcomer joined', (nickname) => { // 誰かが入室した時
$messages.innerHTML += '<li class="sysmessage">' + nickname + ' joined</li>';
});
socket.on('user disconnect', (exituser) => { // 参加者が退出した時
$messages.innerHTML += '<li class="sysmessage">' + exituser + ' exited</li>';
});
あと、これはなぜだかわからないのですが、誰かが退出した時にそれを通知するuser disconnect
イベントを、はじめはサーバーが受け取るdisconnect
と同名のイベントにしていたのですが、そうするとなぜか誰かが退出したときにスタックオーバーフローが起きてしまうことが多々ありました。イベント名をuser disconnect
に変えると出なくなり、一応解決しています。
Add support for nicknames (ニックネームをつけられるようにする)
- ページにアクセスしたときにニックネームの入力欄を表示する
- 入力して「入室(Enter)」ボタンを押すと
enter room
イベントでニックネームが送信され、サーバーに保存されるようにする
<div id="modalWindow">
<div>
<form id="registNickname" action="" onsubmit="return false;">
<p>Enter your name</p>
<input id="nickname" autocomplete="off" placeholder="your name"/>
<button>Enter</button>
</form>
</div>
</div>
document.addEventListener('DOMContentLoaded', (e) => {
const $modalWindow = document.querySelector('#modalWindow')
const $nickname = document.querySelector('#nickname');
const $modalBtn = document.querySelector('#registNickname button');
$modalBtn.addEventListener('click', (e) => {
socket.emit('enter room', $nickname.value); // ニックネーム送信
$modalWindow.parentNode.removeChild($modalWindow); // モーダル削除
});
});
let login_users = {};
io.on('connection', (socket) => {
// 入室処理
socket.on('enter room', (nickname) => {
login_users[socket.id] = nickname; // ニックネームをサーバー側に保存
});
});
こんな感じで、ボタンが押されたら入力された名前を送信してモーダル画面を消します。本当は「名前が空欄ならエラー出す」とかもやったほうがいいですね。
Don’t send the same message to the user that sent it himself. Instead, append the message directly as soon as he presses enter. (自分自身が直前に送信したのと同じメッセージは送信できないようにする)
公式のGet Startedのとおりにすると、テキストを入力してボタンを押すとchat message
イベントでテキストをサーバーに送信し、それを全クライアントに発信されますが、
- 最新の投稿内容を各クライアントごとに保存しておく
- 次にクライアントからサーバーにテキストが送信されたら、保存してある値と比較して、同じ文字列だった場合は送信者だけにエラーメッセージを出す
- 元々の送信者だけにイベントを返したいときは
io.to(socket.id).emit('event', data)
で返せる
io.on('connection', (socket) => {
let latestmsg = ""; // 最新投稿の一時保存
socket.on('chat message', (msg) => {
// 直前の投稿と同じ時は送信者エラー文を出す
if (msg === latestmsg) {
io.to(socket.id).emit('reject message', msg);
return;
}
latestmsg = msg; // 最新投稿を更新
io.emit('chat message', { // テキスト投稿
nickname: login_users[socket.id],
msg: msg
});
});
});
socket.on('chat message', (data) => { // 誰かがテキストを送信した時
$messages.innerHTML += '<li><span>' + data.nickname + ':</span>' + data.msg + '</li>';
$systemText.innerHTML = '';
});
socket.on('reject message', (msg) => { // 自分が直前と同じテキストを送信した時
$systemText.innerHTML = 'same text already posted "' + msg + '"';
});
なお、先述のlogin_users
はio.on(connection, (socket) => {})
のコールバック関数の外側で宣言しており、今回のlatestmsg
は内側で宣言している。
let login_users = {}; // コールバック関数外で宣言
io.on('connection', (socket) => {
let latestmsg = ""; // コールバック関数内で宣言
});
外で宣言すると、その変数はそのソケットに接続しているクライアントすべてで共有され、内で宣言すると、そのクライアントだけが参照できるようになるようです。
そのため、「参加中のユーザー一覧」のような全体に関わるものは外で宣言し、「あるユーザーの最新の投稿」のようなユーザー個別のものは内で宣言する。
Add “{user} is typing” functionality (「◯◯さんが入力中です...」表示をする)
いわゆるSlackやFacebook Messengerみたいな機能。良い感じのライブラリもあるかもしれないけど、以下の仕様で簡易的に実装してみました。
- ユーザーが何かのキーを押すと
start typing
イベントをサーバーに送信。連打するとその回数分送信される。 - サーバー側で
start typing
イベントを受信した回数をカウントする変数(nowTyping)を持っておく。受信するたびに++;されるが、その3秒後に--;されるようにする。 - サーバーが
start typing
イベントを受信した時、nowTyping <= 0
だった場合は送信者以外のクライアントにstart typing
イベントを送信し、「◯◯さんが入力中です...」と表示させる。 - 3秒後に--;されていった結果、再度
nowTyping <= 0
となった場合に、送信者以外のクライアントにstop typing
イベントを送信し、「◯◯さんが入力中です...」の表示を消す。 - 実際にテキストが投稿されたときも「◯◯さんが入力中です...」の表示を消す。
io.on('connection', (socket) => {
let nowTyping = 0; // `start typing`イベントを受信した回数
socket.on('start typing', () => {
// はじめに入力開始を他の人に通知
if (nowTyping <= 0) {
socket.broadcast.emit('start typing', login_users[socket.id]);
}
// 一文字打つごとにカウントアップ。
// カウントアップしてから3秒後にカウントダウンし、
// カウントが0になると入力停止したとみなす
nowTyping++;
setTimeout(() => {
nowTyping--;
if (nowTyping <= 0) {
socket.broadcast.emit('stop typing'); // 入力停止を他の人に通知
}
}, 3000);
});
});
socket.on('start typing', (typinguser) => { // 誰かが文字入力を初めた時
$systemText.innerHTML += typinguser + ' is now typing...';
});
socket.on('stop typing', () => { // 誰かが文字入力を止めた時
$systemText.innerHTML = '';
});
socket.on('chat message', (data) => { // 実際にテキストを送信した時も止めたときと同じ処理を行う
$messages.innerHTML += '<li><span>' + data.nickname + ':</span>' + data.msg + '</li>';
$systemText.innerHTML = '';
});
nowTyping
はクライアントごとにカウントするため、io.on(connection, (socket) => {})
のコールバック関数内で宣言します。
Show who’s online (入室中のユーザー一覧を表示する)
先述のユーザー管理オブジェクトlogin_users
を使います。
- 誰かが入室した際に
login_users
に、そのユーザーのsocket.idとニックネームが保存されるので、そのlogin_users
を全クライアントにthe number of users
イベントで送信。 - 誰かが退室した際は
login_users
からそのユーザーの情報を削除し、再度login_users
を全クライアントにthe number of users
イベントで送信。
let login_users = {}; // 参加者管理
io.on('connection', (socket) => {
// 入室処理
socket.on('enter room', (nickname) => {
login_users[socket.id] = nickname; // 入室したユーザーの情報を追加
socket.broadcast.emit('newcomer joined', login_users[socket.id]);
io.emit('the number of users', login_users); // 参加者一覧を更新
});
// 退室処理
socket.on('disconnect', () => {
socket.broadcast.emit('user disconnect', login_users[socket.id]);
delete login_users[socket.id]; // 退室したユーザー情報を削除
io.emit('the number of users', login_users); // 参加者一覧を更新
});
});
socket.on('the number of users', (login_users) => { // 参加者に変動があったとき
$members.innerHTML = Object.values(login_users).join(", ");
});
サーバーからは{socket.id: username}
形式のオブジェクトが送られるので、Object.values(login_users)
で名前だけの配列にして、それを結合して文字列にしています。
Add private messaging (ダイレクトメッセージ機能をつける)
これだけちょっと手間がかかりそうで、まだできてません。。
やり方としてはクライアント側で入室中のユーザーの名前を選んで、login_users
からソケットIDを取得して、io.to(target_socket_id).emit('event', data)
という形でメッセージを送信すれば、特定の相手にメッセージを送ることは可能のはず。ただそれだとpublic messageとprivate messageが混在してしまうので、そもそも画面を分けないといけないのが億劫です。socket.ioのroom機能を使うかもですが、できたらアップしたいと思います。
Share your improvements! (つくったものをシェアしよう)
というわけで、この記事を書いてみました。WebSocket楽しいですね。
もっとスマートな書き方あれば教えていただけますとうれしいです。