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...
相关推荐
一路向前的月光2 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   2 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web2 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
Jiaberrr3 小时前
Element UI教程:如何将Radio单选框的圆框改为方框
前端·javascript·vue.js·ui·elementui
安冬的码畜日常5 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
太阳花ˉ5 小时前
html+css+js实现step进度条效果
javascript·css·html
john_hjy6 小时前
11. 异步编程
运维·服务器·javascript
风清扬_jd6 小时前
Chromium 中JavaScript Fetch API接口c++代码实现(二)
javascript·c++·chrome
丁总学Java6 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js