Next.js通过Socket.io和redis实现消息推送模块(五)仿掘金用户消息页面最终代码实现

667 阅读8分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言:

最近在完成大三的一个课程期末设计,独立完成做了一个博客社区,主要技术栈是:

前端:next.js + mobx + ts + antd;

后台管理系统:vue3.0 + pinia + ts + elementUI

后端:next.js + ts + 阿里云oss存储 + redis

开发的时候遇到了很多坑,后面会通过文章的方式总结自己在开发过程中踩到的坑以及一些小经验。

由于博客社区嘛,像咱们大掘金平台,是有系统消息、点赞消息啥的等信息推送,然后我也想要做一个横幅(就是系统维护中,或者是出了什么问题,想要用户感知到,所以就想做一个系统消息推送),一开始通过websocket来实现,但是next.js这个框架似乎不太支持,而是支持了一个封装websocket的socket.io这个第三方依赖。所以就使用了socket.io来进行消息推送和在线人数统计的操作,希望大家喜欢~

本系列其他文章:

第一节:juejin.cn/post/709946…

第二节:juejin.cn/post/709966…

第三节:juejin.cn/post/709985…

第四节:juejin.cn/post/710034…

一、需求

我们要实现下面这种,B同学评论了A同学,A同学可以实时收到B的评论消息 image.png 并且点赞了A同学的文章、评论的话,也会实时的告诉A同学,同理关注消息和系统消息也是 模仿掘金平台,做了这个消息页面,包括评论消息、点赞消息、关注消息、系统消息,都是实时的

image.png

image.png

image.png

image.png

二、后端最终实现

先理一下逻辑,如果我们想要达到“B用户给A用户的文章点赞了,A用户右上角可以实时的出现小红点,并且可以看到B用户点赞A用户这条消息”

引出问题: 1、小红点什么时候触发:这个简单,我们之前做的【通过socket.io给指定用户推送消息】就讲过了,还不太清楚的同学可以参考我之前的这篇文章:TODO 待补充 思路就是:B用户点赞/评论/关注了A用户,那么就通过socket.emit一个消息给服务器,服务器获取到A同学的userId之后,就通过redis的哈希表获取到A同学和服务器连接的socket对象,然后判断当前A用户是在线还是离线并进行不同的操作。

image.png

2、右上角的小红点是一回事,那么tab的小红点怎么实现

image.png

由于有【评论】【点赞】【关注】【系统】不同的类型,所以我们需要通过一个数据结构了存储每一个用户当前 【未读的消息类型有哪些】

并且由于消息是一种临时存储的,我们可以这样想,我们点赞了一个用户的文章,这个点赞的要保存在数据库里面,证明你点赞了这个用户的文章,并且这个点赞的动作用户是可以接受到的,那么就需要一个数据结构来存储这个存入数据库中点赞信息的id,所以我们可以使用一个list结构,用了存储不同消息类型的 写入数据库的id字段,这样的话,我们切换tab的时候,比如切换到评论信息这个tab,我们就调用接口,后端就查看评论的这个list存储的id,然后通过这些id从评论表里面找到这个评论相关信息并渲染出来

redis、数据库表结构的设计

我们需要两个数据结构,一个用来存储用户当前未读的消息类型,一个是存储某个消息类型的字段id

以点赞消息为例: 当用户B点赞了用户A的一个文章时候:

  const thumb = new Thumb()
  thumb.create_time = new Date()
  thumb.user = user
  thumb.article = article
  const resThumb = await thumbRepo.save(thumb)
  // 给set结构添加comment这个type
  await redis.sadd('s_user_messageType:' + article.user.id, 'thumb')
  // 给list结构添加comment.id
  await redis.lpush('l_user_thumbMessage:' + article.user.id, resThumb.id)

我定义了一个点赞数据库表:

image.png

next.js的数据库定义是这样的:

import { Entity, BaseEntity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
import { Article } from './article'
import { User } from './user'
import { Comment } from './comment'

@Entity({name: 'thumbs'})
export class Thumb extends BaseEntity {
  @PrimaryGeneratedColumn()
  readonly id!: number;

  @Column()
  create_time!: Date;

  @Column()
  update_time!: Date;

  @ManyToOne(() => User)
  @JoinColumn({name: 'user_id'})
  user!: User;

  @ManyToOne(() => Article)
  @JoinColumn({name: 'article_id'})
  article!: Article;

  @ManyToOne(() => Comment)
  @JoinColumn({name: 'comment_id'})
  comment!: Article;
}

因为可能点赞A同学的评论,也可能点赞A同学的文章

说明:

 // 给set结构添加comment这个type
  await redis.sadd('s_user_messageType:' + article.user.id, 'thumb')
  // 给list结构添加comment.id
  await redis.lpush('l_user_thumbMessage:' + article.user.id, resThumb.id)

这就是我们定义的redis结构了 通过s_user_messageType来存储这个用户未读的消息类型,通过set的不可重复功能 并且我们设置了一个l_user_thumbMessage的list用来存储这个用户被点赞这个数据库记录所对应的id

前端中我们右上角小红点的展示,就是通过一个全局的变量来进行控制,关于怎么在next.js中使用全局状态管理(我用到是Mobx)可以参考我之前写的这篇文章juejin.cn/post/709713…

我定义了一个hasMessage全局共享状态变量 我们在layout.ts组件里,监听message事件,当用户点赞成功回调的时候执行emit('message')事件

 socket.on('message', message => {
    console.log('收到独播信息', message)
    store.common.setCommonInfo({hasMessage: true})
 })

然后小红点就通过hasMessage的判断来决定是否展示

进入消息页面执行的操作

我们在进入到消息页面的时候:

// 根据不同的消息类型,给不同的tab设置小红点
  const changeTabStatus = (type) => {
    if (type === 'thumb') setHasThumb(true)
    else if (type === 'follow') setHasFollow(true)
    else if (type === 'system') setHasSystem(true)
  }
  
const getNewMessage = () => {
    request.post('/api/user/message/getNewMessageType', {
      user_id: store.user.userInfo.userId
    })
      .then((res) => {
        if (res?.code === 0) {
          res.data.map(item => {
            changeTabStatus(item)
          })
        }
      })
  }

useEffect(() => {
    // 调用获取用户未读消息的类型
    getNewMessage()
  }, [])

/api/user/message/getNewMessageType接口是这样子的

import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';

import redis from 'lib/redis'

export default withIronSessionApiRoute(getNewMessageType, ironOptions);

// 文章点赞排序(前k名)
async function getNewMessageType(req: NextApiRequest, res: NextApiResponse) {
  const { user_id } = req.body
  const result = await redis.smembers('s_user_messageType:' + user_id)
  // 获取之后就清空
  await redis.del('s_user_messageType:' + user_id)
  console.log('result', result)
  if (result) {
    res?.status(200).json({
      code: 0,
      msg: `获取用户最新通知类型`,
      data: result
    });
  }
}

说明:获取这个用户的未读消息类型,并进行清空,因为用户已读了,所以就要删除这些未读的消息类型了

然后通过changeTabStatus函数给不同的tab设置小红点

点击不同的消息tab执行的操作

我们点击tab肯定要获取到最新的消息列表 ,以点击了“点赞消息”为例 ,调用接口的接口是这样定义的:

import { NextApiRequest, NextApiResponse } from 'next';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from 'config/index';
import { prepareConnection } from 'db/index';
import { Thumb } from 'db/entity/index';
import redis from 'lib/redis'
import { In } from 'typeorm'
export default withIronSessionApiRoute(getCommentMessage, ironOptions);

async function getCommentMessage(req: NextApiRequest, res: NextApiResponse) {
  const db = await prepareConnection();
  const thumbRepo = db.getRepository(Thumb);
  const { user_id } = req.body
  let thumbIdList = await redis.lrange('l_user_thumbMessage:' + user_id, 0, -1)
  // 转成int类型,不然下面查询不了,以为id是number类型的
  thumbIdList = thumbIdList.map((item : string) => {
    return parseInt(item)
  })

  const thumbList = await thumbRepo.find({
    where: {
      id : In( [...thumbIdList])
    },
    relations: ['user', 'article', 'comment']
  })
  if (thumbList) {
    res?.status(200).json({
      code: 0,
      msg: `获取用户点赞消息`,
      data: thumbList.reverse()
    });
  }
}

说明:

  • await redis.lrange('l_user_thumbMessage:' + user_id, 0, -1) 我们通过这种方式就可以获取到当前用户未读的点赞信息数据库记录所对应的id列表了,然后拿到这个id的数组之后,我们要变成number类型的

  • 之后我们通过数据库查询把所有id都找到对应的数据库记录,并把对应的user、article、comment联表查询出来并返回给用户

  • 前端

const getThumbMessage = () => {
    request.post('/api/user/message/getThumbMessage', {
      user_id: store.user.userInfo.userId
    }).then((res) => {
      if (res?.code === 0) {
        setShowSkeleton(false)
        setThumbMessages(res.data)
      }
    })
  }

前端设置取消骨架屏,并设置点赞消息列表进行展示

三、(前端)前端页面

仿掘金用户信息模块(评论信息、点赞信息、关注信息、系统消息)

效果:

image.png

直接上代码

import styles from './index.module.scss'
import { Row, Col, Tabs } from 'antd';
import RightBar from "components/RightBar"
import { useEffect, useState } from 'react'
import MyMessage from 'components/MyMessage'
import request from 'service/fetch';
import { useStore } from 'store/index'
import { observer } from "mobx-react-lite"
import { Skeleton, Empty } from 'antd'
import io from 'socket.io-client'
var socket : any
const Message = () => {
  const { TabPane } = Tabs;
  // 设置每一个tab的消息列表
  const [commentMessages, setCommentMessages] = useState([])
  const [thumbMessages, setThumbMessages] = useState([])
  const [followMessages, setFollowMessages] = useState([])
  const [systemMessages, setSystemMessages] = useState([])
  
  // 设置是否有对应的消息并对小红点进行展示
  const [hasComment, setHasComment] = useState(false)
  const [hasThumb, setHasThumb] = useState(false)
  const [hasFollow, setHasFollow] = useState(false)
  const [hasSystem, setHasSystem] = useState(false)
  const [showSkeleton, setShowSkeleton] = useState(true)
	const store = useStore()

  // tab切换的时候触发
  const handleTabChange = (key: any) => {
    setShowSkeleton(true)
    if (key == 1) {
      setHasComment(false)
      getCommentMessage()
    } else if (key == 2) {
      setHasThumb(false)
      getThumbMessage()
    } else if (key == 3) {
      setHasFollow(false)
      getFollowMessage()
    } else if (key == 4) {
      setHasSystem(false)
      getNotificationMessage()
    }
  }

  // 根据不同的消息类型,给不同的tab设置小红点
  const changeTabStatus = (type) => {
    if (type === 'thumb') setHasThumb(true)
    else if (type === 'follow') setHasFollow(true)
    else if (type === 'system') setHasSystem(true)
  }
  // 获取最新的消息类型,用于设置小红点
  const getNewMessage = () => {
    request.post('/api/user/message/getNewMessageType', {
      user_id: store.user.userInfo.userId
    })
      .then((res) => {
        if (res?.code === 0) {
          res.data.map(item => {
            changeTabStatus(item)
          })
        }
      })
  }
  // 获取当前评论消息列表
  const getCommentMessage = () => {
    request.post('/api/user/message/getCommentMessage', {
      user_id: store.user.userInfo.userId
    }).then((res) => {
      if (res?.code === 0) {
        setShowSkeleton(false)
        setCommentMessages(res.data)
      }
    })
  }
  // 获取当前关注消息列表
  const getFollowMessage = () => {
    request.post('/api/user/message/getFollowMessage', {
      user_id: store.user.userInfo.userId
    }).then((res) => {
      if (res?.code === 0) {
        setShowSkeleton(false)
        setFollowMessages(res.data)
      }
    })
  }
  const getThumbMessage = () => {
    request.post('/api/user/message/getThumbMessage', {
      user_id: store.user.userInfo.userId
    }).then((res) => {
      if (res?.code === 0) {
        setShowSkeleton(false)
        setThumbMessages(res.data)
      }
    })
  }
  // 获取当前系统消息列表
  const getNotificationMessage = () => {
    request.post('/api/common/notification/getSystemNotification', {
      is_start: 1
    }).then((res) => {
      if (res?.code === 0) {
        setShowSkeleton(false)
        setSystemMessages(res.data)
      }
    })
  }

  useEffect(() => {
    getNewMessage()
    getCommentMessage()
    if (!socket) {
      socket = io('http://localhost:3000')
    }
  }, [store.common.commonInfo.hasComment])

  return (
    <Row className={styles.container} typeof='flex' justify='center' style={{paddingTop:'3.2rem'}}>
      <Col className={styles.containerLeft} xs={24} sm={24} md={14} lg={14} xl={14} style={{backgroundColor:'rgba(255,255,255,.4)'}}>
        <div className={styles.messageContainer}>
          <Tabs tabBarStyle={{background: 'white', width: '100%', paddingLeft: '10px' }} defaultActiveKey="1" onChange={handleTabChange}>
            <TabPane className={styles.tabItem} tab={
              <span>
                评论消息
                { hasComment && <div className={styles.hasTips}></div>} 
              </span>
            } key="1">
              {
                commentMessages.length ? (
                  commentMessages.map(item => (
                    <MyMessage key={item} type='comment' contentItem={item} />
                  ))
                ) : (
                  showSkeleton 
                  ? (
                      <div className={styles.skeleton}>
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 1 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 1 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 1 }} />
                      </div>
                    )
                  : (
                    <div className={styles.emptyContainer}>
                      <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>
                    </div>
                  )
                )
              }
            </TabPane>
            <TabPane className={styles.tabItem} tab={
              <span>
                点赞消息
                { hasThumb && <div className={styles.hasTips}></div>} 
              </span>
            } key="2">
              {
                thumbMessages.length ? (
                  thumbMessages.map(item => (
                    <MyMessage key={item} type='thumb' contentItem={item} />
                  ))
                ) : (
                  showSkeleton 
                  ? (
                      <div className={styles.skeleton}>
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                      </div>
                    )
                  : (
                    <div className={styles.emptyContainer}>
                      <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>
                    </div>
                  )
                )
              }
            </TabPane>
            <TabPane className={styles.tabItem} tab={
              <span>
               关注消息
                { hasFollow && <div className={styles.hasTips}></div>} 
              </span>
            } key="3">
              {
                followMessages.length ? (
                  followMessages.map(item => (
                    <MyMessage key={item} type='follow' contentItem={item} />
                  ))
                ) : (
                  showSkeleton 
                  ? (
                      <div className={styles.skeleton}>
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                      </div>
                    )
                  : (
                    <div className={styles.emptyContainer}>
                      <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>
                    </div>
                  )
                )
              }
            </TabPane>
            <TabPane className={styles.tabItem} tab={
              <span>
                系统消息
                { hasSystem && <div className={styles.hasTips}></div>} 
              </span>
            } key="4">
              {
                systemMessages.length ? (
                  systemMessages.map(item => (
                    <MyMessage key={item} type='system' contentItem={item} />
                  ))
                ) : (
                  showSkeleton 
                  ? (
                      <div className={styles.skeleton}>
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                        <Skeleton className={styles.skeletonItem} avatar paragraph={{ rows: 2 }} />
                      </div>
                    )
                  : (
                    <div className={styles.emptyContainer}>
                      <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}/>
                    </div>
                  )
                )
              }
            </TabPane>
          </Tabs>
        </div>
      </Col>
      <Col className={styles.containerRight} xs={0} sm={0} md={5} lg={5} xl={5}>
        <RightBar ifCanChangeAvatar={true}>
          <></>
        </RightBar>
      </Col>
    </Row>
  )
}

export default observer(Message)