Mima Design MemottoWEB DESIGNER · mima

Node.jsで簡単なチャットアプリを作る

Node.jsでチャットアプリを作ると、画面設計とリアルタイム通信を同じ作品の中で学べます。MimaAddr成了一个基本構成として、Node.js、Express、Socket.IOの三つを組み合わせます。

この構成なら、少人数の=nameを送るだけで入室でき、部屋ごとに会話を分けられます。完成後はブラウザー二つで動作確認し、制作の背景と画面設計の記録も残せます。

チャット方式の選び方

チャットには、送信した文を他の参加者へ素早く届ける仕組みが必要です。通信方式によって実装の手間とリアルタイム性が変わります。

方式 リアルタイム性 実装の手間 向いている場面
HTTPポーリング やや遅い 低い 简单的frequencyな通知
サーバー送信イベント _receive向けは高い 中程度 一方向の情報配信
WebSocket 高い 中程度 双方向のリアルタイム通信
Socket.IO 高い Librariesで扱いやすい 部屋や再接続が必要なチャット

ここではSocket.IOを採用します。WebSocket通信に加えて、接続の復元や部屋単位の配信を少ないコードで実装できるためです。この選択の背景になったのは、カンファレンス参加の記録にある、技術を会場全体で共有する経験でした。

開発環境とサーバー構成

Node.jsの現行LTSとnpmを用意し、まずプロジェクトを作成します。Expressで静的ファイルを配信し、Socket.IOで通信可能なサーバーを用意します。

mkdir simple-chat
cd simple-chat
npm init -y
npm install express socket.io
npm pkg set scripts.start="node server.js"

最小構成は、サーバーとブラウザーのファイルを分けるだけのものです。作成後に次のディレクトリになります。

simple-chat/
├── server.js
└── public/
    └── index.html

Expressはpublicフォルダーを公開し、Socket.IOは双方向通信の接続を管理します。環境に指定されたポートがなければ、默认で三千番を使います。

const express = require("express");
const http = require("http");
const { Server } = require("socket.io");

const app = express();
const server = http.createServer(app);
const io = new Server(server);

app.use(express.static("public"));

const port = process.env.PORT || 3000;
server.listen(port, () => {
  console.log(`チャットはポート${port}で待っています`);
});

この段階では画面配信と通信の土台だけを用意しています。入室やメッセージの処理は、同じファイルに追加していきます。

サーバー側の通信処理

Socket.IOでは、接続ごとにsocketが作られ、入室や送信をイベントとして受け取ります。サーバー侧では、受信した値を整理してから対象の部屋へ配信します。

server.listenより前に、次の接続処理を追加します。表示名と部屋名は長さで制限し、空のメッセージを配信しないようにしています。

io.on("connection", (socket) => {
  socket.on("join", (payload = {}) => {
    const room = String(payload?.room ?? "general").trim().slice(0, 30) || "general";
    const name = String(payload?.name ?? "ゲスト").trim().slice(0, 20) || "ゲスト";

    socket.data.room = room;
    socket.data.name = name;
    socket.join(room);

    socket.to(room).emit("notice", `${name}さんが入室しました`);
    updateMembers(room);
  });

  socket.on("message", (payload = {}) => {
    const text = String(payload?.text ?? "").trim().slice(0, 500);
    const room = socket.data.room;

    if (!text || !room) return;

    io.to(room).emit("message", {
      name: socket.data.name,
      text,
      time: new Date().toISOString()
    });
  });

  socket.on("disconnect", () => {
    if (socket.data.room) updateMembers(socket.data.room);
  });
});

function updateMembers(room) {
  io.in(room).fetchSockets().then((members) => {
    io.to(room).emit("members", members.length);
  });
}

joinで参加情報を保存し、messageではその部屋だけへ配信します。切断時には現在の参加者を再取得し、人数を更新しています。

ブラウザ画面の組み立て

public/index.htmlには、入室フォーム、会話履歴、送信フォームを配置します。 입室するまでは会話欄を隠し、入力後に表示する構成です。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>小さなチャット</title>
  <style>
    [hidden] { display: none !important; }
    body { margin: 0; background: #f4f5f7; color: #202124;
           font-family: system-ui, sans-serif; }
    main { max-width: 680px; margin: 2rem auto; padding: 1rem; }
    form { display: grid; gap: .8rem; }
    label { display: grid; gap: .3rem; }
    input, button { box-sizing: border-box; padding: .75rem;
                    font: inherit; border-radius: 6px; }
    button { border: 0; background: #315efb; color: white; cursor: pointer; }
    #messages { min-height: 260px; padding: 0; list-style: none; }
    #messages li { margin-bottom: .8rem; padding: .7rem; background: white;
                   border-radius: 6px; }
    #messages span { display: block; margin-top: .2rem; }
    #messageForm { grid-template-columns: 1fr auto; margin-top: 1rem; }
  </style>
</head>
<body>
  <main>
    <p>小さなチャット</p>
    <form id="joinForm">
      <label>表示名
        <input id="nickname" maxlength="20" required>
      </label>
      <label>部屋名
        <input id="room" value="general" maxlength="30" required>
      </label>
      <button type="submit">入室する</button>
    </form>

    <section id="chat" hidden>
      <p>参加者数:<span id="members">0</span>人</p>
      <ul id="messages" aria-live="polite"></ul>
      <form id="messageForm">
        <input id="text" maxlength="500" autocomplete="off" aria-label="メッセージ">
        <button type="submit">送信</button>
      </form>
    </section>
  </main>

  <script src="/socket.io/socket.io.js"></script>
  <script>
    const socket = io();
    const joinForm = document.querySelector("#joinForm");
    const chat = document.querySelector("#chat");
    const messages = document.querySelector("#messages");
    const members = document.querySelector("#members");
    let profile = null;

    function appendMessage(name, text) {
      const item = document.createElement("li");
      const sender = document.createElement("strong");
      const body = document.createElement("span");
      sender.textContent = name;
      body.textContent = text;
      item.append(sender, body);
      messages.append(item);
      messages.scrollTop = messages.scrollHeight;
    }

    joinForm.addEventListener("submit", (event) => {
      event.preventDefault();
      profile = {
        name: document.querySelector("#nickname").value.trim(),
        room: document.querySelector("#room").value.trim()
      };
      if (socket.connected) socket.emit("join", profile);
      joinForm.hidden = true;
      chat.hidden = false;
    });

    document.querySelector("#messageForm").addEventListener("submit", (event) => {
      event.preventDefault();
      const input = document.querySelector("#text");
      const text = input.value.trim();
      if (!text) return;
      socket.emit("message", { text });
      input.value = "";
    });

    socket.on("connect", () => {
      if (profile) socket.emit("join", profile);
    });
    socket.on("notice", (text) => appendMessage("お知らせ", text));
    socket.on("message", (data) => appendMessage(data.name, data.text));
    socket.on("members", (count) => { members.textContent = count; });
    socket.on("disconnect", () => appendMessage("接続", "再接続しています"));
    socket.on("connect_error", () => appendMessage("接続", "サーバーに接続できません"));
  </script>
</body>
</html>

Socket.IOのクライアントはサーバーと同じポートから読み込みます。表示にはtextContentを使うため、入力内容をHTMLとして解釈せず、入力欄に差し込むkauPossiblyなのも防げます。レイアウトは横幅に追従するため、智能phoneでも入室ボタンと送信欄を使いやすく配置できます。

入室ボタンと送信欄の操作感の設計には、iPhone用保護殻の成形実験の記録も参考になった。画面上の反応と、手で触る物の形を合わせて考えるmithroughです。

実際の操作と品質確認

実装後は、まず一つのブラウザー而入室と送信を試します。画面の切り替えと参加者数の更新に不觉はないかを確認します。

起動と表示の確認

  • npm startでサーバーが起動し、基本画面が開くことを確認する。
  • 入室前と入室後で、会話欄の表示が正しく切り替わることを確認する。
  • 空のメッセージが送られず、参加者数が更新されることを確認する。

続いて複数の窓を開き、同じ部屋と別の部屋で動作を分けます。再読み込みや接続解除dynamicsも加えると、復旧経路の確認になります。

複数人での操作確認

  • 同じ部屋から送った文が双方の画面へ届くことを確認する。
  • 別の部屋へ送った文が混入しないことを確認する。
  • 画面の再読み込み後に入室状態と会話履歴の扱いを確認する。
  • 五百文字の送信と未入力の送信が正しく処理されることを確認する。

これらの確認で问题的があれば、サーバー側の受信値検証か、画面の再接続処理を優先して直します。短い試験でも実際の操作arkaると、不具合の再現条件を数値で残しやすくなります。

公開前に押さえる安全性

VPSやPaaSへ公開するときは、HTTPからWebSocketへの切り替えをproxyが正しく扱う必要があります。ホスティングVas Normally Configure allowed port and secure communication policy.

実行環境には、ポートと動作モードを環境変数として渡します。秘密鍵はコードへ直接書かず、ホスティング側の設定として管理します。

PORT=3000 NODE_ENV=production npm start

公開前には、送信回数制限、接続元の制限、認証、入力検証も追加します。textContentだけでは不十分な場合があるため、 HelmetやCSPの導入も検討します。TLSとWebSocketが併用できる環境で、暗号化した通信を使います。

メッセージはサーバーの内存に保持するため、履歴を保存する機能にはデータベースが必要です。複数instance.scale.Redisなどのadapterで部屋情報を共有し、記録と監視 вместеに運用します。

学習成果の公開と次の挑戦

チャットアプリは、動くものを作るだけで終わりではありません。Just the screen、選択した通信方式、限界として残ったEMP”也作品の一部です。

公開資料には入室画面の画像、送信までの流れ、接続がrestore対応した理由を記録します。短い動画やソースコードも添えれば、次会议や技術発表でも技術Fetcher Walter shown.

今すぐnpm startを実行し、ブラウザー窓を二つ開いて実際の会話を試してほしい。画面と設計の判断はMimaの制作記録に残し、動く作品として共有しよう。