本文为稀土掘金技术社区首发签约文章,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 是一个库,可实现及时、双向和基于事件的通讯。
它有这些特点:
- 高性能。大多数情况下,将使用 WebSocket 建立连接,在服务器和客户端之间提供低负载通信通道。
- 可靠。如果不可能建立 WebSocket 连接,它将会退回到 HTTP 长轮询。如果连接丢失,客户端将自动尝试重新连接。
- 可扩展。将应用程序部署到多个服务器,并轻松地向所有连接的客户端发送事件。
服务端和客户端使用的示例代码如下(左服务端,右客户端):
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
中的 dev
和 start
命令:
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 的值有:
"polling"
,表示 HTTP 长轮询(long-polling)"websocket"
,表示 WebSocket"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-schedule 或 node-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)
举几个例子就熟悉了:
0/2 * * * * ?
:每 2 秒执行一次任务0 0 2 1 * ?
:每月的 1 日的 凌晨 2 点调整任务0 0 12 * * ?
: 每天中午12点触发0 0 10,14,16 * * ?
: 每天上午 10 点,下午 2 点,4 点
但其实 Cron 表达式的特殊字符更加丰富,比如 0 15 10 ? * MON-FRI
表示周一至周五的上午 10:15 触发。
具体 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. 源码
- 功能实现:Next.js 与 Socket.IO 实现简易聊天室
- 源码地址:github.com/mqyqingfeng...
- 下载代码:
git clone -b next-socket git@github.com:mqyqingfeng/next-app-demo.git