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...
相关推荐
zwjapple15 小时前
Node.js 集成百度语音
node.js·语音识别
q***05615 小时前
使用Node.js搭配express框架快速构建后端业务接口模块Demo
node.js·express
码途进化论15 小时前
从Chrome跳转到IE浏览器的完整解决方案
前端·javascript
笙年15 小时前
Vue 基础配置新手总结
前端·javascript·vue.js
哆啦A梦158815 小时前
40 token
前端·vue.js·node.js
摇滚侠16 小时前
Vue 项目实战《尚医通》,获取挂号医生的信息展示,笔记43
前端·javascript·vue.js·笔记·html5
k093316 小时前
vue3中基于AntDesign的Form嵌套表单的校验
前端·javascript·vue.js
茶憶16 小时前
UniApp RenderJS中集成 Leaflet地图,突破APP跨端开发限制
javascript·vue.js·uni-app
喜欢踢足球的老罗16 小时前
Sequelize vs Prisma:现代 Node.js ORM 深度技术解析与实战指南
node.js·prisma·sequelize
没头脑和不高兴y16 小时前
Element-Plus-X:基于Vue 3的AI交互组件库
前端·javascript