htmx 逆引きレシピ
WebSocketで双方向にするには?
公開日:
最終更新日:
WebSocketを使うと、サーバとブラウザが“つながりっぱなし”になり、更新を待たずに即反映できます。
このレシピでは チャット(双方向) と 共同編集(共有メモ) を1つの接続で扱い、さらに サーバからの即時通知 も流して、リアルタイム風の画面を作ります。
使用するhtmx属性
hx-ext="ws":WebSocket拡張を有効化し、ws-connect/ws-sendを使えるようにします。ws-connect:WebSocketの接続先を指定し、ページ表示中はサーバと接続を維持します(HTTPがhttpsなら接続先はwssが必要です)。ws-send:フォーム送信をWebSocket経由にし、入力値をサーバへ送ります(サーバ側で受け取って全員へ配信できます)。hx-trigger:送信タイミングを制御します(共同編集はkeyup changed delay:300msなどで“入力しながら送る”形にできます)。hx-swap-oob:サーバからのHTML断片で、指定した要素を“画面の別の場所”でも同時更新します(チャットの先頭追加や、共有メモ枠の差し替えに使えます)。
利用シーン
- 「チャット/共同編集」:誰かの操作を、他の閲覧者にも即反映して“リアルタイム感”を出したいときに使えます。
- 「サーバからの即時反映」:オンライン人数やジョブ完了など、サーバ起点の更新をページにそのまま押し込みたいときに便利です。
- 「ポーリングより即時性が欲しい」:一定間隔で取りに行くのではなく、更新が起きた瞬間に届けたい場合に向きます。
WebSocketで双方向にするには?
チャット(双方向)と共同編集(共有メモ)を同じWebSocket接続で扱い、さらにサーバからの即時反映(オンライン人数・時刻・自動通知)も流します。
※私のレンタルサーバーでは動きません。よって、ローカルで動かす用のコードになってます。
HTML
<div class="DEMO">
<h4>WebSocketで双方向にするには?</h4>
<div
id="DEMO_WS_ROOT"
class="CARD"
hx-ext="ws"
ws-connect="ws://localhost:8080"
>
<div class="ROW" style="justify-content:space-between;">
<div class="ROW">
<span id="DEMO_WS_STATE" class="BADGE is-warn">WS:未接続</span>
<span id="DEMO_WS_ONLINE" class="BADGE">オンライン:-</span>
<span id="DEMO_WS_SERVER_TIME" class="BADGE">サーバ時刻:-</span>
</div>
<div class="ROW">
<button id="DEMO_WS_CLEAR_LOCAL" class="BTN" type="button">ローカル表示クリア</button>
</div>
</div>
<hr style="border:none; border-top:1px dashed rgba(0,0,0,.12); margin:1rem 0;">
<div class="GRID">
<!-- =========================
左:チャット(双方向)
========================= -->
<section>
<h3 style="margin:0;">① チャット(双方向)</h3>
<p class="HTMX-NOTE" style="margin:.35rem 0 0;">
フォームを <code>ws-send</code> で送信すると、サーバが全員にブロードキャストして即反映します。
</p>
<form
id="DEMO_WS_CHAT_FORM"
class="FORM"
ws-send
>
<!-- kind はサーバ側で分岐するための種別 -->
<input type="hidden" name="kind" value="chat">
<label class="HTMX-NOTE">
送信者
<select name="user">
<option value="tanaka">tanaka</option>
<option value="sato">sato</option>
<option value="suzuki">suzuki</option>
</select>
</label>
<label class="HTMX-NOTE">
メッセージ
<input type="text" name="message" value="" placeholder="例:承認お願いします!">
</label>
<button class="BTN is-ok" type="submit">送信</button>
</form>
<ul id="DEMO_WS_CHAT_LIST" class="CHAT_LIST">
<li class="CHAT_ITEM is-placeholder">
<div class="CHAT_HEAD">
<span class="CHAT_USER">info</span>
<span class="CHAT_TIME">--:--:--</span>
</div>
<div class="CHAT_MSG">(ここにチャットが流れます)</div>
</li>
</ul>
</section>
<!-- =========================
右:共同編集(共有メモ)
========================= -->
<section>
<h3 style="margin:0;">② 共同編集(共有メモ)</h3>
<p class="HTMX-NOTE" style="margin:.35rem 0 0;">
入力内容を一定間隔で <code>ws-send</code> し、サーバが保持する「最新」を全員へ即反映します。
</p>
<form
id="DEMO_WS_NOTE_FORM"
class="FORM"
ws-send
hx-trigger="keyup changed delay:300ms from:#DEMO_WS_NOTE_TEXT"
>
<!-- kind はサーバ側で分岐するための種別 -->
<input type="hidden" name="kind" value="note">
<label class="HTMX-NOTE">
あなたの編集(ローカル入力)
<textarea id="DEMO_WS_NOTE_TEXT" name="note" placeholder="例:端末貸与の承認フローを整理する"></textarea>
</label>
<p class="HTMX-NOTE">
下の「共有メモ(サーバ保持)」は、<code>hx-swap-oob</code> でサーバから即時更新されます。
</p>
</form>
<!-- サーバから outerHTML で差し替えられる枠 -->
<div id="DEMO_WS_NOTE_VIEW" class="CARD" style="margin-top:.6rem;">
<strong>共有メモ(サーバ保持)</strong>
<div class="ROW" style="margin-top:.35rem;">
<span id="DEMO_WS_NOTE_VER" class="BADGE">ver:-</span>
<span id="DEMO_WS_NOTE_BY" class="BADGE">更新者:-</span>
</div>
<pre id="DEMO_WS_NOTE_PRE" class="HTMX-NOTE">(まだ共有メモはありません)</pre>
</div>
</section>
</div>
<p class="HTMX-NOTE" style="margin-top:1rem;">
※ サーバは定期的に「オンライン人数」「サーバ時刻」「システム通知」もプッシュします(<strong>サーバからの即時反映</strong>の例)。
</p>
</div>
</div>
<script src="/htmx/ws.min.js" defer></script>
<script>
(() => {
// WS状態バッジ
const state = document.getElementById("DEMO_WS_STATE");
// クリア(ローカル表示のみ)
document.getElementById("DEMO_WS_CLEAR_LOCAL").addEventListener("click", () => {
document.getElementById("DEMO_WS_CHAT_LIST").innerHTML =
`<li class="CHAT_ITEM is-placeholder">
<div class="CHAT_HEAD">
<span class="CHAT_USER">info</span>
<span class="CHAT_TIME">--:--:--</span>
</div>
<div class="CHAT_MSG">(ここにチャットが流れます)</div>
</li>`;
});
// 送信後:チャット入力だけリセットしたい
document.body.addEventListener("htmx:wsAfterSend", (e) => {
if (!e.detail || !e.detail.elt) return;
if (e.detail.elt.id !== "DEMO_WS_CHAT_FORM") return;
const msg = e.detail.elt.querySelector('input[name="message"]');
if (msg) msg.value = "";
});
// WS接続状態表示
document.body.addEventListener("htmx:wsOpen", () => {
state.textContent = "WS:接続中";
state.classList.remove("is-warn");
state.classList.add("is-ok");
});
document.body.addEventListener("htmx:wsClose", () => {
state.textContent = "WS:切断";
state.classList.remove("is-ok");
state.classList.add("is-warn");
});
document.body.addEventListener("htmx:wsError", () => {
state.textContent = "WS:エラー";
state.classList.remove("is-ok");
state.classList.add("is-warn");
});
})();
</script>
js(_ws_server.js)
/**
* WebSocketデモサーバ(htmx ws拡張向け)
* - 受信:htmx ws-send から来る JSON を処理({kind:"chat", ... , HEADERS:{...}})
* - 送信:HTMLフラグメント(hx-swap-oob で即反映)
*
* 起動:
* npm i ws
* node _ws_server.js
*/
const http = require("http");
const WebSocket = require("ws");
// サーバを起動するポート
const PORT = 8080;
// 共有メモ(サーバが持つ最新)
let sharedNote = "";
let sharedVer = 0;
let sharedBy = "system";
// HTTPサーバ(ws用)
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("WS server is running.\n");
});
// WebSocketサーバ
const wss = new WebSocket.Server({ server });
// HTMLエスケープ
const esc = (s) =>
String(s)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
// 全員に送る
const broadcast = (html) => {
wss.clients.forEach((ws) => {
if (ws.readyState === WebSocket.OPEN) ws.send(html);
});
};
// オンライン数・時刻を更新(サーバからの即時反映)
const pushStatus = () => {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
const online =
`<span id="DEMO_WS_ONLINE" class="BADGE is-ok" hx-swap-oob="outerHTML">` +
`オンライン:${wss.clients.size}` +
`</span>`;
const time =
`<span id="DEMO_WS_SERVER_TIME" class="BADGE" hx-swap-oob="outerHTML">` +
`サーバ時刻:${hh}:${mm}:${ss}` +
`</span>`;
broadcast(online + time);
};
// 共有メモを配信(共同編集の即反映)
const pushNote = () => {
const noteView =
`<div id="DEMO_WS_NOTE_VIEW" class="CARD" style="margin-top:.6rem;" hx-swap-oob="outerHTML">` +
`<strong>共有メモ(サーバ保持)</strong>` +
`<div class="ROW" style="margin-top:.35rem;">` +
`<span id="DEMO_WS_NOTE_VER" class="BADGE">ver:${sharedVer}</span>` +
`<span id="DEMO_WS_NOTE_BY" class="BADGE">更新者:${esc(sharedBy)}</span>` +
`</div>` +
`<pre id="DEMO_WS_NOTE_PRE" class="HTMX-NOTE">` +
`${sharedNote ? esc(sharedNote) : "(まだ共有メモはありません)"}` +
`</pre>` +
`</div>`;
broadcast(noteView);
};
// 受信メッセージをJSONとして読み、必要ならフォールバックする
const parseIncoming = (text) => {
// 基本:ws-send は JSON で来る(HEADERS を含む)
try {
const obj = JSON.parse(text);
// 期待する形でなければ空にする
if (obj && typeof obj === "object") return obj;
} catch (e) {
// JSONでない場合はフォールバックへ
}
// 念のため:クエリ形式で来た場合(古い実装など)
try {
return Object.fromEntries(new URLSearchParams(text));
} catch (e) {
return {};
}
};
// 接続したとき
wss.on("connection", (ws) => {
// 接続直後にステータスと共有メモを送る
pushStatus();
pushNote();
// 受信
ws.on("message", (buf) => {
// 受信を文字列化
const text = buf.toString("utf8");
// JSON(基本)としてパース
const data = parseIncoming(text);
// 種別(kind)で分岐
const kind = String(data.kind ?? "").toLowerCase();
// チャット
if (kind === "chat") {
// 送信者
const user = String(data.user ?? "anon").trim();
// メッセージ
const message = String(data.message ?? "").trim();
// 空なら何もしない
if (!message) return;
// 時刻
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
// 先頭に追加(afterbegin)
const isSystem = String(user).toLowerCase() === "system";
const li =
`<li class="CHAT_ITEM${isSystem ? " is-system" : ""}" hx-swap-oob="afterbegin:#DEMO_WS_CHAT_LIST">` +
`<div class="CHAT_HEAD">` +
`<span class="CHAT_USER">${esc(user)}</span>` +
`<span class="CHAT_TIME">${hh}:${mm}:${ss}</span>` +
`</div>` +
`<div class="CHAT_MSG">${esc(message)}</div>` +
`</li>`;
// 全員へ配信
broadcast(li);
// ついでにステータス更新
pushStatus();
return;
}
// 共同編集(共有メモ)
if (kind === "note") {
// メモ本文
const note = String(data.note ?? "");
// 更新者(フォームに user を追加したいならここで受け取れる)
const by = String(data.user ?? "someone").trim() || "someone";
// サーバ側の最新を更新
sharedNote = note;
sharedVer += 1;
sharedBy = by;
// 全員へ最新を即反映
pushNote();
// ついでにステータス更新
pushStatus();
return;
}
// どれにも当てはまらない場合は何もしない(デモなので静かに捨てる)
});
// 切断したとき
ws.on("close", () => {
pushStatus();
});
});
// サーバから定期的にステータスをプッシュ(即時反映の例)
setInterval(() => {
pushStatus();
}, 1500);
// たまに「サーバ発のシステム通知」を流す(即時反映の例)
setInterval(() => {
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
const li =
`<li class="CHAT_ITEM is-system" hx-swap-oob="afterbegin:#DEMO_WS_CHAT_LIST">` +
`<div class="CHAT_HEAD">` +
`<span class="CHAT_USER">system</span>` +
`<span class="CHAT_TIME">${hh}:${mm}:${ss}</span>` +
`</div>` +
`<div class="CHAT_MSG">サーバからの即時通知です</div>` +
`</li>`;
broadcast(li);
}, 12000);
// 起動
server.listen(PORT, "127.0.0.1", () => {
console.log(`WS server listening on ws://127.0.0.1:${PORT}`);
});
CSS
.CARD{border:1px solid rgba(0,0,0,.12); border-radius:.8rem; padding:1rem; background:#fff}
.HTMX-NOTE{opacity:.85; font-size:.92rem; line-height:1.55}
.BADGE{display:inline-flex; align-items:center; padding:.15rem .55rem; border-radius:999px; font-size:.85rem; border:1px solid rgba(0,0,0,.15); background:#fff}
.BADGE.is-ok{background:#e8fff1}
.BADGE.is-warn{background:#fff4e5}
.ROW{display:flex; gap:.6rem; align-items:center; flex-wrap:wrap}
.GRID{display:grid; gap:1rem}
@media (min-width: 900px){ .GRID{grid-template-columns: 1fr 1fr} }
.CHAT_LIST{
margin:.6rem 0 0;
padding:0;
list-style:none;
max-height:14rem;
overflow:auto;
border-top:1px dashed rgba(0,0,0,.12);
padding-top:.6rem;
display:grid;
gap:.5rem;
}
.CHAT_ITEM{
border:1px solid rgba(0,0,0,.12);
border-radius:.75rem;
padding:.55rem .7rem;
display:grid;
gap:.25rem;
}
.CHAT_HEAD{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:.75rem;
}
.CHAT_USER{font-weight:700}
.CHAT_TIME{
opacity:.7;
font-size:.85rem;
white-space:nowrap;
}
.CHAT_MSG{
white-space:pre-wrap;
line-height:1.55;
}
.CHAT_ITEM.is-system{
opacity:.9;
}
.CHAT_ITEM.is-placeholder{
opacity:.75;
border-style:dashed;
}
.CHAT_MSG{white-space:pre-wrap;}
.TIME{opacity:.7; font-size:.85em; margin-left:.35rem}
.FORM{display:grid; gap:.6rem}
textarea{min-height:7rem; resize:vertical}
pre{white-space:pre-wrap; word-break:break-word; margin:.5rem 0 0}
コマンド
cd /mochimochimikan.com/htmx/demo
npm init -y
npm i ws
node _ws_server.js
デモ
WebSocketで双方向にするには?
WS:未接続
オンライン:-
サーバ時刻:-
① チャット(双方向)
フォームを ws-send で送信すると、サーバが全員にブロードキャストして即反映します。
-
info --:--:--(ここにチャットが流れます)
② 共同編集(共有メモ)
入力内容を一定間隔で ws-send し、サーバが保持する「最新」を全員へ即反映します。
共有メモ(サーバ保持)
ver:-
更新者:-
(まだ共有メモはありません)
※ サーバは定期的に「オンライン人数」「サーバ時刻」「システム通知」もプッシュします(サーバからの即時反映の例)。
解説
① チャット(双方向)
- チャットフォームに
ws-sendを付け、送信をWebSocket経由にしています。 - サーバは受け取った内容をHTML(
<li ... hx-swap-oob="afterbegin:#CHAT">)として全員へ配信し、チャット一覧の先頭へ追加します。 - 結果の反映はHTTPレスポンスではなく、サーバからのプッシュで即時に行われます。
② 共同編集(共有メモ)
- テキスト入力のたびに送信したいので、フォームに
hx-trigger="keyup changed delay:300ms"を付けて“入力しながら送る”形にしています。 - サーバ側では「共有メモの最新状態」を保持し、更新が来たら全員へ配信して、共有メモ枠を
hx-swap-oob="outerHTML"で差し替えます。 - これにより、誰かが入力すると他の画面にも同じ内容が即反映されます。
③ サーバからの即時反映(プッシュ通知)
- サーバは一定間隔で「オンライン人数」「サーバ時刻」「システム通知」などを配信し、画面の各バッジを
hx-swap-oobで更新します。 - ユーザー操作がなくても更新されるため、監視ダッシュボードや通知UIの雰囲気を作りやすいです。
※このデモはWebSocketサーバの常駐が必要です。レンタルサーバではWebSocketのプロキシ設定や常駐プロセスが使えない場合があるため、
本ページはローカル環境(HTTP)での実行を前提にしています。ローカルでは ws://localhost:8080 に接続して動作します。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール