Next.js App Router + Socket.IO 实现简易聊天室

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

前言

如果要用 Next.js 实现消息推送、实时日志、聊天室等功能,你可能第一时间想到使用 WebSocket,但一找资料你会发现,Next.js 官方文档(App Router)中竟然没有相关内容的介绍......

这就让很多同学犯了难......难道 Next.js 不支持 WebSocket?如果要实现这些功能,该怎么实现呢?本篇我们就以实时聊天功能为例,和大家讲讲 Next.js 下的实现方式。

聊天室效果如下:

PS:本篇已收录到掘金专栏《Next.js 开发指北》

PPS:系统学习 Next.js,欢迎入手小册《Next.js 开发指南》。基础篇、实战篇、源码篇、面试篇四大篇章带你系统掌握 Next.js!

WebSocket

我们先从 WebSocket 开始说起。

WebSocket 是一个基于 TCP 协议的网络通信协议。传统的 HTTP 协议只能由客户端发起,而使用 WebSocket,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话。

WebSocket 对应的协议标识符是 ws,一个示例地址为:

bash 复制代码
ws://example.com:80/some/path

浏览器提供了 WebSocket API 用于建立 WebSocket 连接,示例代码如下:

javascript 复制代码
var ws = new WebSocket("wss://echo.websocket.org");

ws.onopen = function(evt) { 
  console.log("Connection open ..."); 
  ws.send("Hello WebSockets!");
};

ws.onmessage = function(evt) {
  console.log( "Received Message: " + evt.data);
  ws.close();
};

ws.onclose = function(evt) {
  console.log("Connection closed.");
};

这是客户端 API,用于建立连接并监听消息,使用起来相对简单。

麻烦的地方在于服务端怎么实现?

通常我们会借助一些现成的实现,就比如 Socket.IO

Socket.IO

Socket.IO 是一个库,可实现及时、双向和基于事件的通讯。

它有这些特点:

  1. 高性能。大多数情况下,将使用 WebSocket 建立连接,在服务器和客户端之间提供低负载通信通道。
  2. 可靠。如果不可能建立 WebSocket 连接,它将会退回到 HTTP 长轮询。如果连接丢失,客户端将自动尝试重新连接。
  3. 可扩展。将应用程序部署到多个服务器,并轻松地向所有连接的客户端发送事件。

服务端和客户端使用的示例代码如下(左服务端,右客户端):

Next.js + Socket.IO

那就让我们使用 Next.js 和 Socket.IO 从头实现一个简易的聊天室功能吧。

1. 创建项目

运行 npx create-next-app@latest 创建 Next.js 项目。

2. 安装依赖项

javascript 复制代码
npm install socket.io socket.io-client express

3. 创建 Server.js

在项目根目录创建 server.mjs,代码如下:

javascript 复制代码
import { createServer } from "node:http";
import express from "express";
import next from "next";
import { Server } from "socket.io";
import schedule from 'node-schedule'

const dev = process.env.NODE_ENV !== "production";
const hostname = "localhost";
const port = 3000;
// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });
const handler = app.getRequestHandler();

app.prepare().then(() => {

  const server = express();
  const httpServer = createServer(server);
  const io = new Server(httpServer);
  const roomId = 'room';

  io.on("connection", (socket) => {
    console.log("A user connected:", socket.id);
    socket.join(roomId);
    
    socket.on("send_msg", (data) => {
      console.log(data, "DATA");
      io.in(roomId).emit("receive_msg", data);
    });
  });

  io.on("disconnect", () => {
    console.log("A user disconnected:", socket.id);
  });

  server.all('*', (req, res) => {
    return handler(req, res);
  });

  httpServer.listen(port, () => {
    console.log(`Server is running`);
  });

});

这种写法来自于 Next.js 的 Custom Server 章节,用于开发者自定义 Server。

我们借助 Custom Server 的方式使 Socket.IO 与 Next.js 共享底层的 HTTP Server。这样做会有一些弊端,比如无法在 Vercel 上进行部署,无法进行一些静态优化。日常开发中尽可能不要使用这种方式。

修改 package.json 中的 devstart 命令:

javascript 复制代码
{
  "scripts": {
    "dev": "node server.mjs",
    "start": "NODE_ENV=production node server.mjs"
  }
}

4. 示例:检测连接状态和连接方式

新建 app/socket.js,代码如下:

javascript 复制代码
"use client";

import { io } from "socket.io-client";

export const socket = io();

新建 app/page.js,代码如下:

jsx 复制代码
"use client";

import { useEffect, useState } from "react";
import { socket } from "./socket";

export default function Home() {
  const [isConnected, setIsConnected] = useState(false);
  const [transport, setTransport] = useState("N/A");

  useEffect(() => {
    if (socket.connected) {
      onConnect();
    }

    function onConnect() {
      setIsConnected(true);
      setTransport(socket.io.engine.transport.name);
      
      // https://socket.io/docs/v4/client-options/#upgrade
      socket.io.engine.on("upgrade", (transport) => {
        setTransport(transport.name);
      });
    }

    function onDisconnect() {
      setIsConnected(false);
      setTransport("N/A");
    }

    socket.on("connect", onConnect);
    socket.on("disconnect", onDisconnect);

    return () => {
      socket.off("connect", onConnect);
      socket.off("disconnect", onDisconnect);
    };
  }, []);

  return (
    <div>
      <p>Status: { isConnected ? "connected" : "disconnected" }</p>
      <p>Transport: { transport }</p>
    </div>
  );
}

在这段代码中,socket.connected用于判断连接状态,transport 变量表示建立连接的方式,正如之前讲到,为了保证建立连接,Socket.IO 会在不可能建立 WebSocket 连接的时候回退到 HTTP 长轮询方式。transport 的值有:

  1. "polling",表示 HTTP 长轮询(long-polling)
  2. "websocket",表示 WebSocket
  3. "webtransport",表示 WebTransport

此外,注意我们是在 useEffect 中判断的 socket.connected,如果我们直接使用 const [isConnected, setIsConnected] = useState(socket.connected); 的方式,会导致服务端和客户端渲染不一致产生水合错误。

运行 npm run dev,此时浏览器效果如下:

如果要发送消息:

javascript 复制代码
socket.emit("hello", "world");

注:更多方法可以参考官方文档的 《Emit cheatsheet》章节。

如果要接收消息:

javascript 复制代码
socket.on("hello", (value) => {
  // ...
});

5. 实战:聊天室

新建 app/chat/page.js,代码如下:

jsx 复制代码
"use client"

import React, { useEffect, useState } from "react";
import { io } from "socket.io-client";

const socket = io();
const username = Math.random().toString(36).slice(-6);

const ChatPage = () => {
  const [currentMsg, setCurrentMsg] = useState("");
  const [chat, setChat] = useState([]);

  const sendData = async (e) => {
    e.preventDefault();
    if (currentMsg !== "") {
      const msgData = {
        user: username,
        msg: currentMsg
      };
      await socket.emit("send_msg", msgData);
      setCurrentMsg("");
    }
  };


  useEffect(() => {
    socket.on("receive_msg", (data) => {
      setChat((pre) => [...pre, data]);
    });
  }, [socket]);


  return (
    <div className="flex flex-col justify-center items-center">
      <ul role="list" className="divide-y divide-gray-100 w-9/12">
        {chat.map(({ user, msg }, key) => {
      const isUser = (user == username);
      return (
        <li key={key} className={`flex justify-between gap-x-6 py-2 ${isUser ? 'flex-row-reverse' : 'flex-row'}`}>
          <div className={`h-8 w-8 flex flex-col justify-center items-center rounded-full ${isUser ? 'bg-green-300' : 'bg-gray-200'}`}>{user.charAt(0)}</div>
          <div className="min-w-0 flex-auto">
            <p className={`text-sm font-semibold leading-6 text-gray-900 ${isUser ? 'text-right' : ''}`}>{msg}</p>
          </div>
        </li>
      )
    })
        }
      </ul>
      <form onSubmit={(e) => sendData(e)} className="mt-6 flex max-w-md gap-x-4">
        <input
          id="chat"
          name="chat"
          type="text"
          required
          value={currentMsg}
          className="min-w-0 flex-auto rounded-md border-0 bg-white/5 px-3.5 py-2 shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-indigo-500 sm:text-sm sm:leading-6 text-black"
          placeholder="Chat with your friends"
          onChange={(e) => setCurrentMsg(e.target.value)}
          />
        <button
          type="submit"
          className="flex-none rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
          >
          Send
        </button>
      </form>
    </div>
  );
};

export default ChatPage;

为了帮助大家理解实现方式,我们仔细讲讲这段代码的实现:

socket.io-client 是 socket.io 的客户端,用于客户端建立连接。它提供了 io 这个函数。通常应该是 io(url) 的形式,比如:

javascript 复制代码
import { io } from "socket.io-client";

const socket = io("ws://example.com/my-namespace", options);

当 url 参数没有传入的时候,默认值是 window.location.host,正好我们就实现在了 localhost:3000,所以直接调用了 io 函数,返回了 Socket 实例。

Socket 实例用于与服务端交互,你可以把它理解成一个 EventEmitter,它通过网络向服务器发送事件并从服务器接收事件:

javascript 复制代码
socket.emit("hello", { a: "b", c: [] });

socket.on("hello", (...args) => {
  // ...
});

当我们提交表单的时候,会调用 socket.emit("send_msg", msgData)向服务端发送事件,而 send_msg 事件的处理逻辑在项目根目录的 server.mjs 中:

javascript 复制代码
  io.on("connection", (socket) => {
    console.log("A user connected:", socket.id);
    socket.join(roomId);
    
    socket.on("send_msg", (data) => {
      console.log(data, "DATA");
      io.in(roomId).emit("receive_msg", data);
    });
  });

当服务端收到 send_msg 事件的时候,又会发送 receive_msg 事件通知客户端。

这里我们用了"房间","房间"你可以理解为分组,通过 join 方法加入一个组,然后再通过 in(roomId).emit 通知该组内的所有连接。

真实的聊天室中肯定会有聊天室 ID 的概念,输入聊天室的 ID 才能进入对应的聊天室。我们这里直接使用了一个固定的房间 ID,所以谁打开这个链接都能进行聊天。

客户端在 useEffect 中监听了 receive_msg 事件,并通过 state 进行更新:

javascript 复制代码
  useEffect(() => {
    socket.on("receive_msg", (data) => {
      setChat((pre) => [...pre, data]);
    });
  }, [socket]);

就这样实现了一个简易的聊天室功能。

我们打开两个浏览器 tab,访问 http://localhost:3000/chat,此时就可以进行聊天了:

6. 总结:浏览器客户端中使用

从上面的实战代码中总结一下。当你想在浏览器客户端建立 WebSocket 连接、监听事件时,基本示例代码如下:

javascript 复制代码
'use client';

import React, { useEffect, useState } from 'react';

import io from 'socket.io-client';
const socket = io('http://localhost:3000');

const App = () => {
    useEffect(() => {
        socket.on('message2', (data) => {
            // Execute any command
        })
    }, [socket]);

    return (
       // ...
    );
};

export default App;

7. 总结:路由处理程序中使用

那我能将 Next.js App Router 下的路由处理程序改成 WebSocket 连接吗?

答案是不能,但你可以发送事件给 server.js,示例代码如下:

javascript 复制代码
import { NextResponse } from "next/server";

import io from 'socket.io-client';
const socket = io('http://localhost:3000');

export async function POST(req, res) {
    try {
        // do something you need to do in the backend 
        // (like database operations, etc.)
        socket.emit('message1', 'Sync Process Completed');
        return NextResponse.json({ data: 'Success' }, { status: 200 });

    } catch (error) {
        console.error('Error:', error);
        return NextResponse.json({ error: error }, { status: 200 })
    }

}

8. 实现定时任务

如果要实现定时任务,该怎么实现呢?

通常我们会借助 node-schedulenode-cron 来实现,两者用起来差不多,node-schedule 下载量稍微高一点。我们介绍下 node-schedule。

node-schedule 的基本用法如下:

javascript 复制代码
const schedule = require('node-schedule');

const job = schedule.scheduleJob('42 * * * *', function(){
  console.log('The answer to life, the universe, and everything!');
});

其中 scheduleJob 方法的第一个参数是 Cron 表达式。

Cron 表达式是一种用于定义在特定时间点或时间段执行任务的字符串。它由七个字段组成,分别表示秒、分钟、小时、日期、月份、星期和年份。每个字段都可以使用星号 (*) 表示任意值,或使用逗号 (,) 分隔多个值,或使用连字符 (-) 表示范围。

javascript 复制代码
*    *    *    *    *    *
┬    ┬    ┬    ┬    ┬    ┬
│    │    │    │    │    │
│    │    │    │    │    └ day of week (0 - 7) (0 or 7 is Sun)
│    │    │    │    └───── month (1 - 12)
│    │    │    └────────── day of month (1 - 31)
│    │    └─────────────── hour (0 - 23)
│    └──────────────────── minute (0 - 59)
└───────────────────────── second (0 - 59, OPTIONAL)

举几个例子就熟悉了:

  1. 0/2 * * * * ?:每 2 秒执行一次任务
  2. 0 0 2 1 * ?:每月的 1 日的 凌晨 2 点调整任务
  3. 0 0 12 * * ? : 每天中午12点触发
  4. 0 0 10,14,16 * * ?: 每天上午 10 点,下午 2 点,4 点

但其实 Cron 表达式的特殊字符更加丰富,比如 0 15 10 ? * MON-FRI表示周一至周五的上午 10:15 触发。

具体 Cron 表达式可以参考:

  1. CRON 表达式详解
  2. Cron表达式生成器

这里我们使用 node-schedule 实现一个每隔 10s 在聊天室里说 Hello 的机器人。

其实很简单,server.mjs 修改代码如下:

javascript 复制代码
// ...

app.prepare().then(() => {

  const server = express();
  const httpServer = createServer(server);
  const io = new Server(httpServer);
  const roomId = 'room';

  const job = schedule.scheduleJob("*/10 * * * * *", () => {
      io.in(roomId).emit("receive_msg", {
        user: 'bot',
        msg: 'Hello!'
      });
  });

  io.on("connection", (socket) => {
    // ...
  });

  // ...

});

重新运行 npm run dev,效果如下:

9. 源码

  1. 功能实现:Next.js 与 Socket.IO 实现简易聊天室
  2. 源码地址:github.com/mqyqingfeng...
  3. 下载代码:git clone -b next-socket git@github.com:mqyqingfeng/next-app-demo.git

参考链接

  1. www.ruanyifeng.com/blog/2017/0...
  2. socket.io/
  3. blog.stackademic.com/building-a-...
  4. medium.com/@farmaan303...
相关推荐
一颗烂土豆20 分钟前
Meshopt 压缩深度解析,为什么它比 Draco 更快
前端·javascript·webgl
YFF菲菲兔1 小时前
调度系统和调和系统的桥梁
react.js
kyriewen3 小时前
同事每天催我 Code Review,我写了个脚本让 AI 替我 review PR——现在他反过来催 AI 了
前端·javascript·ai编程
米丘5 小时前
vite8 vite preview 命令做了什么?
node.js·vite
weedsfly5 小时前
迭代器、生成器与异步迭代——让数据“按需流动”的艺术
前端·javascript
YFF菲菲兔5 小时前
commitRoot 源码解析
react.js
假如让我当三天老蒯6 小时前
前端跨域解决方案(学习用)
前端·javascript·面试
铁皮饭盒7 小时前
Bun 哪比 Node.js 快?
javascript·后端
JieE21215 小时前
LeetCode 56. 合并区间|超清晰 JS 图解思路,面试高频区间题
javascript·算法·面试
candyTong18 小时前
RTK 技术原理:一次典型会话里,80% 上下文是怎么省下来的
javascript·后端·架构