这节来写下聊天的前端页面:
左侧是聊天室列表,右边是聊天界面。
点击左侧切换聊天室,就可以在不同聊天室聊天。
先写下样式:
return <div id="chat-container">
<div className="chat-room-list">
<div className="chat-room-item">技术交流群</div>
<div className="chat-room-item selected">技术交流群</div>
<div className="chat-room-item">技术交流群</div>
<div className="chat-room-item">技术交流群</div>
</div>
<div className="message-list">
<div className="message-item">
<div className="message-sender">
<img src="http://localhost:9000/chat-room/dong.png" />
<span className="sender-nickname">神说要有光</span>
</div>
<div className="message-content">
你好
</div>
</div>
<div className="message-item">
<div className="message-sender">
<img src="http://localhost:9000/chat-room/dong.png" />
<span className="sender-nickname">神说要有光</span>
</div>
<div className="message-content">
你好
</div>
</div>
<div className="message-item from-me">
<div className="message-sender">
<img src="http://localhost:9000/chat-room/dong.png" />
<span className="sender-nickname">神说要有光</span>
</div>
<div className="message-content">
你好
</div>
</div>
</div>
</div>
样式 index.scss
#chat-container {
margin: 20px;
display: flex;
flex-direction: row;
width: 800px;
height: 600px;
.chat-room-list {
width: 150px;
border: 1px solid #000;
overflow-y: auto;
}
.chat-room-item {
line-height: 50px;
padding-left: 20px;
border: 1px solid #000;
cursor: pointer;
&:hover, &.selected{
background: lightgreen;
}
}
.message-list {
border: 1px solid #000;
flex: 1;
overflow-y: auto;
.message-item {
padding: 20px;
display: flex;
flex-wrap: wrap;
.message-sender {
width:100%;
img{
width: 20px;
height: 20px;
padding-right: 10px;
}
}
.message-content{
border: 1px solid #000;
width:max-content;
padding: 10px 20px;
border-radius: 4px;
background: skyblue;
}
&.from-me{
text-align: right;
justify-content: right;
.message-content{
text-align: right;
justify-content: right;
background: #fff;
}
}
}
}
}
因为样式比较复杂,我们用到了 sass
安装下:
npm install --save-dev sass
看下效果:
布局比较简单,整体宽度 800px,左侧宽度固定,右侧 flex:1
然后右边的布局也是 flex 布局,有 .from-me 的设置 justify-content:right;
然后我们请求下聊天室列表:
const [roomList, setRoomList] = useState<Array<Chatroom>>();
async function queryChatroomList() {
try{
const res = await chatroomList();
if(res.status === 201 || res.status === 200) {
setRoomList(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
queryChatroomList();
}, []);
{
roomList?.map(item => {
return <div className="chat-room-item" data-id={item.id} key={item.id} >{item.name}</div>
})
}
然后 interfaces 加一下请求聊天记录的接口:
export async function chatHistoryList(id: number) {
return axiosInstance.get(`/chat-history/list?chatroomId=${id}`);
}
组件里当点击聊天室的时候,就查询对应的聊天记录显示:
useEffect(() => {
queryChatroomList();
}, []);
interface ChatHistory {
id: number
content: string
type: number
chatroomId: number
senderId: number
createTime: Date,
sender: UserInfo
}
const [chatHistory, setChatHistory] = useState<Array<ChatHistory>>();
async function queryChatHistoryList(chatroomId: number) {
try{
const res = await chatHistoryList(chatroomId);
if(res.status === 201 || res.status === 200) {
setChatHistory(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
return <div id="chat-container">
<div className="chat-room-list">
{
roomList?.map(item => {
return <div className="chat-room-item" data-id={item.id} key={item.id} onClick={() => {
queryChatHistoryList(item.id);
}}>{item.name}</div>
})
}
</div>
<div className="message-list">
{chatHistory?.map(item => {
return <div className="message-item" data-id={item.id} key={item.id} >
<div className="message-sender">
<img src={item.sender.headPic} />
<span className="sender-nickname">{item.sender.nickName}</span>
</div>
<div className="message-content">
{item.content}
</div>
</div>
})}
</div>
</div>
然后我们加上聊天的功能:
用绝对定位把这个 div 定位在右下角:
position: relative;
.message-input {
width: 648px;
border: 1px solid #000;
height: 100px;
position: absolute;
bottom: 0;
right: 0;
.message-type {
display: flex;
.message-type-item {
width: 100px;
&:hover {
font-weight: bold;
cursor: pointer;
}
}
}
.message-input-area {
width: 650px;
display: flex;
.message-send-btn {
width: 50px;
height: 50px;
}
}
}
但这样会和 .message-list 重合:
我们改下 .message-list 的高度
height: calc(100% - 100px);
然后把它的 border-bottom 去掉:
border-bottom: 0;
这样,布局就完成了。
我们来加一下发消息的功能:
用 inputText 的 state 保存输入的内容,点击发送的时候调用 sendMessage 方法。
sendMessage 方法从 localStorage 拿 userId,然后单独一个 state 保存 chatroomId。
点击切换聊天室的时候 setChatroomId
当收到新的消息的时候重新查询聊天记录
roomId 改变的时候重新链接一下:
import { Button, Input, message } from "antd";
import { useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import './index.scss';
import { chatHistoryList, chatroomList } from "../../interfaces";
import { UserInfo } from "../UpdateInfo";
import TextArea from "antd/es/input/TextArea";
interface JoinRoomPayload {
chatroomId: number
userId: number
}
interface SendMessagePayload {
sendUserId: number;
chatroomId: number;
message: Message
}
interface Message {
type: 'text' | 'image'
content: string
}
type Reply = {
type: 'sendMessage'
userId: number
message: Message
} | {
type: 'joinRoom'
userId: number
}
interface Chatroom {
id: number;
name: string;
createTime: Date;
}
interface ChatHistory {
id: number
content: string
type: number
chatroomId: number
senderId: number
createTime: Date,
sender: UserInfo
}
interface User {
id: number;
email: string;
headPic: string;
nickName: string;
username: string;
createTime: Date;
}
export function getUserInfo(): User {
return JSON.parse(localStorage.getItem('userInfo')!);
}
export function Chat() {
const socketRef = useRef<Socket>();
const [roomId, setChatroomId] = useState<number>();
useEffect(() => {
if(!roomId) {
return;
}
const socket = socketRef.current = io('http://localhost:3005');
socket.on('connect', function() {
const payload: JoinRoomPayload = {
chatroomId: roomId,
userId: getUserInfo().id
}
socket.emit('joinRoom', payload);
socket.on('message', (reply: Reply) => {
queryChatHistoryList(roomId);
});
});
return () => {
socket.disconnect();
}
}, [roomId]);
function sendMessage(value: string) {
if(!value) {
return;
}
if(!roomId) {
return;
}
const payload: SendMessagePayload = {
sendUserId: getUserInfo().id,
chatroomId: roomId,
message: {
type: 'text',
content: value
}
}
socketRef.current?.emit('sendMessage', payload);
}
const [roomList, setRoomList] = useState<Array<Chatroom>>();
async function queryChatroomList() {
try{
const res = await chatroomList();
if(res.status === 201 || res.status === 200) {
setRoomList(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
useEffect(() => {
queryChatroomList();
}, []);
const [chatHistory, setChatHistory] = useState<Array<ChatHistory>>();
async function queryChatHistoryList(chatroomId: number) {
try{
const res = await chatHistoryList(chatroomId);
if(res.status === 201 || res.status === 200) {
setChatHistory(res.data.map((item: Chatroom) => {
return {
...item,
key: item.id
}
}));
}
} catch(e: any){
message.error(e.response?.data?.message || '系统繁忙,请稍后再试');
}
}
const [inputText, setInputText] = useState('');
return <div id="chat-container">
<div className="chat-room-list">
{
roomList?.map(item => {
return <div className="chat-room-item" key={item.id} data-id={item.id} onClick={() => {
queryChatHistoryList(item.id);
setChatroomId(item.id);
}}>{item.name}</div>
})
}
</div>
<div className="message-list">
{chatHistory?.map(item => {
return <div className="message-item" data-id={item.id}>
<div className="message-sender">
<img src={item.sender.headPic} />
<span className="sender-nickname">{item.sender.nickName}</span>
</div>
<div className="message-content">
{item.content}
</div>
</div>
})}
</div>
<div className="message-input">
<div className="message-type">
<div className="message-type-item" key={1}>表情</div>
<div className="message-type-item" key={2}>图片</div>
<div className="message-type-item" key={3}>文件</div>
</div>
<div className="message-input-area">
<TextArea className="message-input-box" value={inputText} onChange={(e) => {
setInputText(e.target.value)
}}/>
<Button className="message-send-btn" type="primary" onClick={() => {
sendMessage(inputText)
setInputText('');
}}>发送</Button>
</div>
</div>
</div>
}
测试下:
聊天没问题,就是样式不大对。
我们加一下判断:
className={`message-item ${item.senderId === userInfo.id ? 'from-me' : ''}`}
现在样式就对了,只不过自己聊没意思。
在数据库里查一下上面那个聊天室的另一个用户:
换个浏览器登录小强的账号:
然后在光的账号这边看下:
收到了小强的消息。
我们聊一会天:
能聊天了。
就是每次需要手动滚动到底部才能看到新消息。
我们加一下自动滚动:
<div id="bottom-bar" key='bottom-bar'></div>
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
这样,基本聊天功能就完成了。
但这样其实性能并不好,没必要每发一条消息就查一下聊天记录。
我们在服务端把消息存到聊天记录表之后,把这条消息返回:
此外,sender 的信息也要查出来:
把 UserService 导出,然后在 chat 模块引入:
返回 history 的时候把 sender 也查出来:
@Inject(UserService)
private userService: UserService
@SubscribeMessage('sendMessage')
async sendMessage(@MessageBody() payload: SendMessagePayload) {
const roomName = payload.chatroomId.toString();
const history = await this.chatHistoryService.add(payload.chatroomId, {
content: payload.message.content,
type: payload.message.type === 'image' ? 1 : 0,
chatroomId: payload.chatroomId,
senderId: payload.sendUserId
});
const sender = await this.userService.findUserDetailById(history.senderId);
this.server.to(roomName).emit('message', {
type: 'sendMessage',
userId: payload.sendUserId,
message: {
...history,
sender
}
});
}
然后前端就可以直接在后面添加了:
socket.on('message', (reply: Reply) => {
if(reply.type === 'sendMessage') {
setChatHistory((chatHistory) => {
return chatHistory ? [...chatHistory, reply.message] : [reply.message]
});
setTimeout(() => {
document.getElementById('bottom-bar')?.scrollIntoView({block: 'end'});
}, 300);
}
});
这样,全程只需要查询一次聊天记录,性能好很多。
总结
这节我们实现了聊天页面。
首先,我们写了布局,在左侧展示聊天室列表。
点击聊天室的时候,在右侧展示查询出的聊天记录。
点击发送消息的时候,通过 socket 链接来 emit 消息。
监听服务端的 message 消息,有新消息的时候添加到聊天记录里,并通过 scrollIntoView 滚动到底部。
这样,多个用户在不同房间聊天的功能就完成了。