近期在期末考,所以更新会比较慢,这篇文章主要讲解如何实现消息发送功能。
客户端的私聊界面
之前的文章中我都没有去讲解客户端的代码。在讲解消息发送之前,我先介绍一下客户端的代码。
我的客户端中有一个PrivateMessageChatPage
组件,用来显示和某个用户的私聊界面。这是最终的效果:
而PrivateMessageChatPage
的布局是这样的:
目前只有PrivateMessageMessagesWrapper
和PrivateMessageTextBox
组件是有内容的,其他的组件都是空的。不过这不影响我们的消息显示。
首先导入这些组件和一些类型:
1 2 3 4 5 6 7 8 import React , { useState } from 'react' import PrivateMessageTabBar from './Private_Message_Tab_Bar' import PrivateMessageMessagesWrapper from './Private_Message_Messages_Wrapper' import PrivateMessageProfilePanel from './Private_Message_Profile_Panel' import PrivateMessageTextBox from './Private_Message_Text_Box' import FriendsListSideBar from '../private_message_common/Friends_List_Sidebar' import { Message } from '../../types/interfaces' import { MessageContext } from '../context/Message_Context'
Message
类型:
1 2 3 4 5 6 7 export interface Message { id?: string senderId : string receiverId : string text : string timestamp : string }
每条消息都有一个senderId
、receiverId
、text
和timestamp
。id
是消息的唯一标识符。
我们之后会根据senderId
和receiverId
来从服务端获取消息。
PrivateMessageChatPage
组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 const PrivateMessageChatPage = ( ) => { const [receiverName, setReceiverName] = useState<string >('Dummy' ) const [newMessage, setNewMessage] = useState<Message | null >(null ) const addMessage = (message : Message ) => { setNewMessage (message) } return ( <MessageContext.Provider value ={{ newMessage , addMessage }}> <FriendsListSideBar /> <div className ="d-flex flex-column mx-0 h-100 w-100" > <div className ="d-flex flex-row" style ={{ height: '48px ', padding: '8px ', fontSize: '16px ', borderBottom: 'solid 3px rgba (45 , 47 , 52 )' }}> <PrivateMessageTabBar receiverUsername ={receiverName} setReceiverUsername ={setReceiverName} /> </div > <div className ="d-flex flex-row flex-fill align-items-stretch p-0" > <div className ="d-flex flex-column h-100 position-relative" style ={{ minWidth: 0 , minHeight: 0 , flex: '1 1 auto ' }}> <div className ="position-relative" style ={{ flex: '1 1 auto ', minHeight: 0 , minWidth: 0 , zIndex: 0 }}> <PrivateMessageMessagesWrapper receiverUsername ={receiverName} /> </div > <div className ="position-sticky bottom-0 w-100" style ={{ backgroundColor: 'rgba (49 , 51 , 56 )' }}> <PrivateMessageTextBox receiverUsername ={receiverName} addMessage ={addMessage} /> </div > </div > <PrivateMessageProfilePanel /> </div > </div > </MessageContext.Provider > ) } export default PrivateMessageChatPage
MessageContext
是一个React上下文,用来传递消息。
React的Context API是一种在组件之间共享数据的方法,而不必通过组件树的逐层传递props
。
通过创建一个Context对象,然后使用<MyContext.Provider>
组件将值传递给后代组件,可以在组件树中传递数据。
1 2 3 4 5 6 7 8 9 import React from 'react' import { Message } from '../../types/interfaces' interface MessageContextType { newMessage : Message | null addMessage : (message : Message ) => void } export const MessageContext = React .createContext <MessageContextType | undefined >(undefined )
在这个例子中,MessageContext
被用来在组件树中共享newMessage
和addMessage
。
newMessage
是最新的消息。
addMessage
是一个函数,用来添加消息。
我们的PrivateMessageTextBox
组件是用来发送消息的,当用户在输入框中输入新的消息并发送时,组件会调用addMessage
方法,将新消息添加到MessageContext
中。而PrivateMessageMessagesWrapper
组件会监听newMessage
的改变,一旦newMessage
出现了变化,新消息就会被添加到该组件的状态中用于显示。
这意味着,用户只要发送了消息,消息就会立即显示在界面上。
PrivateMessageTextBox
组件的handleSendMessage
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 const PrivateMessageTextBox : React .FC <PrivateMessageTextBoxProps > = ({ receiverUsername } ) => { const [message, setMessage] = useState<string >('' ) const context = useContext (MessageContext ) if (!context) { throw new Error ('MessageContext is undefined' ) } const {addMessage} = context const handleChange = (e : ChangeEvent <HTMLTextAreaElement > ) => { setMessage (e.target .value ) } const handleSendMessage = async (e?: FormEvent ) => { e && e.preventDefault () const jwtToken = localStorage .getItem ('jwtToken' ) const senderId = localStorage .getItem ('userId' ) if (!senderId) { throw new Error ('User ID not found in local storage' ) } const response = await UserService .getUserByUsername (jwtToken, receiverUsername) const receiver = response.data const privateMessage = { id : `${socket.id} ${Math .random()} ` , senderId : senderId, receiverId : receiver._id , text : message, timestamp : new Date ().toISOString (), } socket.emit ('privateMessageSent' , privateMessage) addMessage (privateMessage) setMessage ('' ) } }
这里的UserService.getUserByUsername
方法进行了更改,需要多传入一个jwtToken
参数。
1 2 3 4 5 6 7 8 9 export const UserService = { getUserByUsername : (token : string | null , username : string ) => { return api.get (`/users/username/${username} ` , { headers : { Authorization : `Bearer ${token} ` } }) }, getUserByUserId : (token : string | null , userId : string ) => { return api.get (`/users/userid/${userId} ` , { headers : { Authorization : `Bearer ${token} ` } }) }, }
这是因为在上个文章中我实现了@Public
装饰器,用来标记哪些路由是公开的。
根据用户的用户名或者ID来获取用户信息,都应当是私有的,所以需要传入jwtToken
。
OAuth 2.0授权框架规范中定义了Bearer
令牌类型,它是一种用于OAuth 2.0的访问令牌,用于对资源进行身份验证。任何持有Bearer
令牌的人都可以访问与该令牌相关联的资源。
向服务端发送privateMessageSent
事件后,立即调用addMessage
方法,将消息添加到MessageContext
中。
消息传递
如果只是写了客户端的代码,那么消息只是在客户端显示,而不会真正的发送到服务端。要实现消息发送,我们需要在服务端中接收消息。
在上个文章 的最后一个分段中,我实现了Socket模块。
Socket用白话来说就是一个通道,客户端和服务端可以通过这个通道进行双向通信。双向通信和传统的HTTP请求不同,HTTP请求是单向的,客户端向服务端发送请求、服务端返回响应。而Socket是双向的,客户端和服务端可以随时向对方发送消息。
上个文章中SocketGateway
(网关)订阅了privateMessageSent
事件,但我只是简单的打印了一下消息,并没有去更进一步的处理。
1 2 3 4 @SubscribeMessage ('privateMessageSent' )handlePrivateMessage (@MessageBody () data : any , client : Socket ) { console .log ('Received private message:' , data, 'from' , client) }
这次我要详细地讲解如何实现消息传递。首先消息传递在技术上的流程是这样的:
客户端使用Socket向服务端发送privateMessageSent
事件。
服务端在SocketGateway
中接收到privateMessageSent
事件后,依靠MessagesService
将消息存储到数据库中。
为什么要细分出两个模块呢?因为我认为这两个模块的职责不同,Socket
模块负责处理Socket相关的逻辑,Messages
模块则负责处理消息的存储与获取。
上个文章中并没有细写Messages
模块,我现在来写一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 import { Module } from '@nestjs/common' import { MongooseModule } from '@nestjs/mongoose' import { Message , MessageSchema } from './message.schema' import { MessagesService } from './messages.service' import { MessagesController } from './messages.controller' @Module ({ imports : [MongooseModule .forFeature ([{ name : Message .name , schema : MessageSchema }])], providers : [MessagesService ], controllers : [MessagesController ], exports : [MessagesService ], }) export class MessagesModule {}
exports
是用来导出MessagesService
的,这样其他模块就可以使用MessagesService
了。刚才也说过,SocketGateway
需要使用MessagesService
来存储消息。
我在这里做了一些修改,将MessagesSchema
更改为了MessageSchema
。因为这个模型实际上是用来存储单一的消息,所以我认为它的名字应该是单数形式。同时,我将原先的sender
和receiver
更改为了senderId
和receiverId
,因为我想通过用户的ID来查找用户,而不是直接使用用户对象。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 import { Prop , Schema , SchemaFactory } from '@nestjs/mongoose' import { Document } from 'mongoose' @Schema ()export class Message extends Document { @Prop ({ required : true }) senderId : string @Prop ({ required : true }) receiverId : string @Prop ({ required : true }) text : string @Prop ({ default : Date .now }) timestamp : Date } export const MessageSchema = SchemaFactory .createForClass (Message )
在这里,我添加了一个timestamp
字段,用来记录每条消息的发送时间。
在客户端里,我将发送给服务端的消息数据结构设计为以下形式:
1 2 3 4 5 6 7 { id : `${socket.id} ${Math .random()} ` , senderId : senderId, receiverId : receiver._id , text : message, timestamp : new Date ().toISOString (), }
这里,我也添加了一个timestamp
字段。这是因为我希望客户端在发送消息后,能立即在界面上显示这条消息,而不需要等待服务端的响应。
值得注意的是,我选择让客户端直接显示消息,而没有等待服务端存储消息并返回。这样做的结果是,客户端显示的时间戳实际上是客户端发送消息的时间,而不是服务端存储消息的时间,除非用户刷新了页面,让客户端向服务端请求实际的数据。
接着我们要在MessagesService
中添加一个方法,用来存储消息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import { Injectable } from '@nestjs/common' import { InjectModel } from '@nestjs/mongoose' import { Model } from 'mongoose' import { Message } from './message.schema' import { CreateMessageDto } from './dto/create-message.dto' @Injectable ()export class MessagesService { constructor (@InjectModel (Message.name) private messageModel : Model <Message > ) {} async create (createMessageDto : CreateMessageDto ): Promise <Message > { const createdMessage = new this .messageModel (createMessageDto) return await createdMessage .save () .then (async (message) => { console .log ('Message saved:' , message) return message }) .catch ((error : any ) => { console .log ('Error saving message:' , error) throw error }) } }
CreateMessageDto
是一个数据传输对象,用来传输消息的数据。
1 2 3 4 5 export class CreateMessageDto { readonly text : string readonly senderId : string readonly receiverId : string }
在这个方法中,我只需要text
、senderId
和receiverId
这三个字段。
接着使用save
方法来保存消息,如果保存成功则返回消息,否则抛出错误。
最后就是在SocketGateway
中调用MessagesService
的create
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { MessagesService } from '../messages/messages.service' export class SocketGateway implements OnGatewayInit { constructor ( private messagesService : MessagesService , ) {} @SubscribeMessage ('privateMessageSent' ) async handlePrivateMessage (@MessageBody () data : any ): Promise <void > { await this .messagesService .create ({ senderId : data.senderId , receiverId : data.receiverId , text : data.text }) } }
别忘了SocketModule
中要导入MessagesModule
,才能让SocketGateway
使用MessagesService
。
1 2 3 4 5 6 7 8 9 10 import { Module } from '@nestjs/common' import { SocketService } from './socket.service' import { SocketGateway } from './socket.gateway' import { MessagesModule } from '../messages/messages.module' @Module ({ imports : [MessagesModule ], providers : [SocketGateway , SocketService ], }) export class SocketModule {}
这样服务端就可以接收到客户端发送的消息,并将消息存储到数据库中。
消息显示
用户点击他们和其他用户的私聊界面时,我们需要从服务端获取他们之间的所有消息。
在我的应用中,因为是仿照Discord的,所有的私聊路由都是/channel/@me/:id
这种形式的。id
是接收者的ID。
也就是说我们可以让服务端有一个GET路由,当客户端访问这个路由时,服务端会返回当前用户和id
用户之间的所有消息。
在MessagesController
中添加GET路由:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import { Controller , Get , Param , Request } from '@nestjs/common' import { Request as ExpressRequest } from 'express' import { MessagesService } from './messages.service' @Controller ('messages' )export class MessagesController { constructor (private readonly messagesService : MessagesService ) {} @Get (':senderId/:receiverId' ) async getMessages ( @Request () req : ExpressRequest , @Param ('senderId' ) senderId : string , @Param ('receiverId' ) receiverId : string , ) { return await this .messagesService .getMessages (senderId, receiverId) } }
当客户端访问/api/messages/:senderId/:receiverId
时,服务端会返回当前用户(senderId
)和receiverId
用户之间的所有消息。
MessagesService
中添加getMessages
方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 async getMessages (senderId : string , receiverId : string ) { return this .messageModel .find ({ senderId : senderId, receiverId : receiverId, }) .exec () .then ((messages ) => { console .log ('Messages found:' , messages) return messages }) .catch ((error : any ) => { console .log ('Error finding messages:' , error) throw error }) }
客户端里也添加一个方法,专门访问/api/messages/:receiverId
:
1 2 3 4 5 export const MessageService = { getMessagesByUserId : (token : string | null , senderId : string | null , receiverId : string ) => { return api.get (`/messages/${senderId} /${receiverId} ` , { headers : { Authorization : `Bearer ${token} ` } }) }, }
现在回到客户端的PrivateMessageMessagesWrapper
组件。我们需要明白这个组件的职责是什么:
当用户点击某个用户的私聊界面时,组件会向服务端请求senderId
用户和receiverId
用户之间的所有消息。
当用户发送消息时,组件会将新消息添加到消息列表中来立即显示。
首先以最基础的形式来写这个组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 const PrivateMessageMessagesWrapper : React .FC <PrivateMessageMessagesWrapperProps > = ({ receiverUsername } ) => { const [messages, setMessages] = useState<Message []>([]) const currentUser = useSelector ((state : { auth: { user: UserProfile } } ) => state.auth .user ) return ( <div className ="d-flex flex-column position-absolute top-0 bottom-0 overflow-y-scroll overflow-x-hidden" style ={{ left: 0 , right: 0 , overflowAnchor: 'none ' }}> <ol className ="p-0 m-0" style ={{ flex: 1 , minHeight: '0 ', listStyle: 'none ' }}> {messages.map((message, index) => ( <li key ={message.id || index } className ="position-relative mx-2" style ={{ outline: 'none ' }}> <div className ="position-relative" style ={{ marginTop: '1.0625rem ', minHeight: '2.75rem ', paddingTop: '0.125rem ', paddingBottom: '0.125rem ', paddingLeft: '72px ', paddingRight: '48px !important ', wordWrap: 'break-word ', userSelect: 'text ', }}> <div className ="position-static ms-0 ps-0" style ={{ textIndent: 'none ' }}> {/* User's avatar */} <img src ={imgURL} className ="position-absolute overflow-hidden" style ={{ pointerEvents: 'auto ', textIndent: '-9999px ', left: '16px ', marginTop: 'calc (4px-0.125rem )', width: '40px ', height: '40px ', borderRadius: '50 %', cursor: 'pointer ', userSelect: 'none ', }} alt ="" /> {/* Username and message time */} <h3 className ="overflow-hidden position-relative p-0 m-0 d-flex flex-row align-items-center" style ={{ display: 'block ', lineHeight: '1.375rem ', minHeight: '1.375rem ', whiteSpace: 'break-spaces ', }}> <span className ="me-1 fs-6 position-relative overflow-hidden text-white" style ={{ fontWeight: '500 ', display: 'inline ', verticalAlign: 'baseline ', outline: 'none ', }}> {message.senderId === currentUser._id ? currentUser.username : receiverUsername} </span > <span className ="ms-1" style ={{ fontSize: '0.75rem ', height: '1.25rem ', verticalAlign: 'baseline ', display: 'inline-block ', cursor: 'default ', pointerEvents: 'none ', outline: 'none ', fontWeight: '500 ', color: 'rgba (148 , 154 , 158 )', }}> <time dateTime ={message.timestamp.toString()} > {formatDate(message.timestamp)}</time > </span > </h3 > </div > {/* Message Content */} <div className ="overflow-hidden position-relative fs-6 p-0 m-0" style ={{ userSelect: 'text ', whiteSpace: 'break-spaces ', wordWrap: 'break-word ', marginLeft: 'calc (-1 * 72px )', paddingLeft: '72px ', textIndent: '0 ', lineHeight: '1.375rem ', color: 'rgba (219 , 222 , 225 )', }}> <span > {message.text}</span > </div > </div > </li > ))} </ol > </div > ) }
messages
是该组件的状态,用来存储所有的需要被显示的消息。消息的数据结构是Message
,上面已经定义过了。
currentUser
是从Redux中提取出的当前用户的信息,包括了用户的ID和用户名。
这个组件的底层逻辑是这样的:
map
遍历messages
数组,对每一条消息都生成一个li
元素。
每一条消息都包含了用户的头像(这里写死了)、用户名(如果消息的senderId
和当前用户的ID相同,那么消息的用户名就是当前用户的用户名,否则就是接收者的用户名)、消息发送时间和消息内容。
消息发送时间是通过formatDate
函数格式化的:
1 2 3 4 5 6 7 8 9 10 export const formatDate = (timestamp : string ) => { const date = new Date (timestamp) const year = date.getFullYear () const month = String (date.getMonth () + 1 ).padStart (2 , '0' ) const day = String (date.getDate ()).padStart (2 , '0' ) const hours = String (date.getHours ()).padStart (2 , '0' ) const minutes = String (date.getMinutes ()).padStart (2 , '0' ) return `${year} /${month} /${day} ${hours} :${minutes} ` }
那么我们该如何获取消息、并将消息添加到messages
中呢?
当PrivateMessageMessagesWrapper
组件被挂载时,以及receiverUsername
发生变化时,我们都需要向服务端请求消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 useEffect (() => { const fetchMessages = async ( ) => { const jwtToken = localStorage .getItem ('jwtToken' ) const senderId = localStorage .getItem ('userId' ) const response = await UserService .getUserByUsername (jwtToken, receiverUsername) const receiver = response.data try { const res = await MessageService .getMessagesByUserId (jwtToken, senderId, receiver._id ) setMessages (res.data ) return res.data } catch (err) { console .error (err) } } fetchMessages ().then ((r ) => console .log ('Messages fetched:' , r)) }, [receiverUsername])
这里我使用了useEffect
钩子,当receiverUsername
发生变化时,就会调用fetchMessages
方法。
fetchMessages
方法会向服务端请求senderId
用户和receiverId
用户之间的所有消息,并将消息存储到messages
中。
不只是如此,先前我们在PrivateMessageTextBox
组件中发送消息时,会将用户自身发送的消息添加到MessageContext
中。PrivateMessageMessagesWrapper
组件同样也需要去监听用户自身发送的消息,并将这些消息添加到messages
中:
1 2 3 4 5 6 7 8 9 10 11 const context = useContext (MessageContext )if (!context) { throw new Error ('MessageContext is undefined' ) } const { newMessage } = contextuseEffect (() => { if (newMessage) { setMessages ((prevMessages ) => [...prevMessages, newMessage]) } }, [newMessage])
滚动到底部
当该被显示的消息超过了可视区域时,用户需要手动滚动到底部才能看到最新的消息。这是不友好的,我们应当让用户看到最新的消息。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 const endOfMessagesRef = useRef<null | HTMLSpanElement >(null )const prevMessagesLength = useRef<number >(0 )useEffect (() => { if (endOfMessagesRef.current && messages.length > prevMessagesLength.current ) { endOfMessagesRef.current .scrollIntoView ({ behavior : 'smooth' , block : 'nearest' , inline : 'start' }) } prevMessagesLength.current = messages.length }, [messages]) return ( <div > <ol > {messages.map((message, index) => ( // ... ))} <span ref ={endOfMessagesRef} /> </ol > </div > )
我使用了useRef
来创建一个endOfMessagesRef
引用,用来指向消息列表的最底部。当messages
数组的长度发生变化时,我就将endOfMessagesRef
滚动到可视区域。
prevMessagesLength
也是一个useRef
,用来存储上一次messages
的长度。这个长度在回调函数的最后会被更新为当前的messages
的长度。
useEffect
的回调函数会先检查endOfMessagesRef.current
是否存在,以及messages
的长度是否大于prevMessagesLength.current
。如果两个条件都满足,就将endOfMessagesRef.current
滚动到可视区域。
scrollIntoView
方法是一个DOM方法,用来将元素滚动到可视区域。
behavior
决定了滚动的动画效果,smooth
表示平滑滚动。
block
决定了元素在垂直方向上的对齐方式,nearest
表示将元素对齐到最接近的边缘。
inline
决定了元素在水平方向上的对齐方式,start
表示将元素对齐到起始边缘。
span
标签是一个空元素,用来占位,需要放在ol
标签的最后一个子元素后面。
浏览器刷新后状态丢失
在用户刷新浏览器后,Redux的状态会丢失。这是因为Redux的状态是存储在内存中的,刷新浏览器后内存被清空,状态也就丢失了。
由于我们已经在localStorage
中存储了用户的jwtToken
和userId
,我们可以从localStorage
中获取这些信息,并重新设置Redux的状态。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import { useAppDispatch } from './redux/store' import { setUserDetails } from './redux/actions/authActions' function App ( ) { const dispatch = useAppDispatch () useEffect (() => { const token = localStorage .getItem ('jwtToken' ) const userId = localStorage .getItem ('userId' ) if (token && userId) { dispatch (setUserDetails (userId)) } }, [dispatch]) }
setUserDetails
是一个Redux的action,用来设置用户的ID。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 export const setUserDetails = (userId : string ) => { return async (dispatch : Dispatch ) => { try { const token = localStorage .getItem ('jwtToken' ) const response = await UserService .getUserByUserId (token, userId) if (response.status === 200 ) { dispatch (setCurrentUser (response.data )) } else { console .log (response.data .message ) } } catch (error) { console .error (error) } } }
此处使用了localStorage
中的userId
来向服务端请求了用户的信息,并将用户信息存储到Redux的状态中。
令牌过期
服务端向客户端返回的jwtToken
是有过期时间的。当jwtToken
过期后,客户端再向服务端发送请求时,服务端会返回401 Unauthorized
错误。
在这种情况下,我们应当让用户重新登录。我在axios
的拦截器中添加了一个拦截器,用来处理401
错误。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 api.interceptors .response .use ( (response ) => { return response }, (error ) => { if (error.response ) { if (error.response .status === 401 && error.response .statusText === 'Unauthorized' ) { localStorage .removeItem ('jwtToken' ) window .location .reload () } } return Promise .reject (error) }, )
window.location.reload()
会重新加载页面,没有jwtToken
的情况下,用户会因为路由守卫而被重定向到登录页面。
其他的小改动
User
接口
User
接口的id
字段改为_id
:
1 2 3 4 5 6 7 export interface User { _id?: string emailAddress?: string username : string password?: string access_token?: string }
这是因为在MongoDB中,每个文档都有一个_id
字段,用来唯一标识文档。为了可以直接将MongoDB中的文档映射到User
接口,我将id
改为了_id
。
自定义滚动条
我使用的是Edge浏览器,它的滚动条不能说难看,只能说和好看不搭边。所以我在index.css
中自定义了滚动条的样式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 ::-webkit-scrollbar { width : 16px ; height : 16px ; } ::-webkit-scrollbar-track { background : hsl ( 220 calc ( 1 * 6.5% ) 18% / 1 ); margin-bottom : 8px ; border : 4px solid transparent; background-clip : padding-box; border-radius : 8px ; } ::-webkit-scrollbar-thumb { background : hsl ( 225 calc ( 1 * 7.1% ) 11% / 1 ); background-clip : padding-box; border : 4px solid transparent; border-radius : 8px ; min-height : 40px ; } ::-webkit-scrollbar-corner { background : transparent; }
这些样式是根据Discord的滚动条样式来写的。