vue3 + express + sequelize实现mini-QQ移动端(包含套壳成uni-app)

312 阅读9分钟

整体效果

源码在最下面

登录页

image.png

注册页

image.png

主页

image.png

image.png

image.png

image.png

聊天室

image.png

联系人页面

image.png

image.png

image.png

用户详情页

image.png

image.png

项目启动

前端

前端使用vue3 + vite

  • 安装依赖
npm install
  • 启动项目
npm run dev

后端

后端使用express + routing-controller + sequelize

  • 安装依赖
npm install
  • 配置数据库

在lib/QQ_DB.sql里有数据库表结构,本地数据库导入创建即可(要先创建一个QQ_DB数据库再导入sql文件,如何导入sql文件可上网搜)。创建好后最好先自己连接查询看看有没有问题(最好使用mysql8.0以上版本)

在这里推荐一个vscode插件,可以很方便的查看数据库,操作数据库和创建数据库

image.png

连接服务

image.png

连接好后就可以看到这样的UI(数据库部分信息已打码)

image.png 当然这是我初始化好之后的

创建好数据库后,在lib/config/index.ts里配置数据库连接参数

/**后端 - 数据库相关配置 */
export const databaseConfig = {
  /**数据库host */
  host: "localhost",
  /**数据库用户 */
  user: "xxxxxx",
  /**数据库密码 */
  pass: "xxxxx",
};
  • 配置文件上传路径 在lib/config/index.ts里,将该路径修改为自己想要存储在本机的路径
/**在本机上存文件的路径的基础路径(绝对路径) */
export const uploadPath = "/Users/john/images/";
  • 最后启动项目
npm start

注意事项

需要注意的是,本地运行时,如果是在pc端启动后台,同时在手机登录前端进行测试,需要电脑和手机在同一局域网内,可以使用手机开启热点,电脑连接手机热点,同时需要配置:

前端配置

需要先获取当前主机IP (如何查看当前主机IP可以上网搜一下,不同系统的查询方式不一样) .env.development文件需要配置VITE_IP

VITE_ENV=development
VITE_URL=/api

#当前主机IP,默认是localhost,但是如果通过使用电脑连手机热点,在手机访问电脑开启的前端项目,则需要配置为具体当前电脑#IP。如何获取电脑IP可自行上网搜索。例如:192.1.1.1
VITE_IP=localhost

前端在vite.config.ts更改启动IP与代理IP

export default defineConfig({
  server: {
    host: 'yourIp',//你的主机ip
    port: 8888,
    // open: true
    proxy: {
      '/api': {
        target: 'http://yourIp:3000',//后台默认在3000端口启动,需要配置代理
        changeOrigin: true, // 是否更改请求头中的 Origin
      }
    }
  }
});

后端配置

后端配置app.ts(找到app.listen函数并配置)

  app.listen(3000, "yourIp");

h5套壳

新建模板

打开HBuilder-X(没有的需要下载www.dcloud.io/hbuilderx.h… ),项目 -> 新建5+App项目 -> Hello H5+模板

image.png

配置manifest.json文件

基础配置

AppId直接获取即可,应用入口地址填项目部署机器的地址,本地启动的项目可以填自己本机IP image.png

模块配置:所有权限都不需要

image.png

权限配置:记得把android.permission.READ_CONTACTS权限去掉,不然打包时会报错

image.png

源码视图:去掉默认样式

image.png

开始打包

右键项目 -> 发行 -> 云打包

image.png

image.png

总体路由架构

image.png

技术细节

由于涉及到技术较多,这里不好全部一一分析,所以我便找了些关键的技术点解释一下,详细的可以看源码。希望各位看官能给点建议~

实时聊天实现

说到实时聊天,大家肯定第一想到的就是WebSocket。但其实里边还有很多细节,比如:对方不在线时,发送的信息该怎么处理,该什么时候初始化WebSocket等。下面来谈谈我的实现(包括前端后端)(以下仅为个人的愚见,各位大佬有啥更好的方法可以指出

image.png 聊天页面里的聊天记录由两大部分组成。一部分是历史聊天记录,在组件初始化时从数据库获取;一部分是实时聊天记录,在接收到消息时,或发出消息时,在前端临时存储,并在组件卸载前一并发送到后端存储到数据库里。这样可以避免在聊天过程中需要频繁的进行数据库的读取操作。

WebSocket通信的封装(包括前端后端)

这里是基于 juejin.cn/post/737136… 这位大佬的文章进行封装的

前端实现

封装WebSocket操作

以下代码均基于 juejin.cn/post/737136… 这位大佬的文章,侵权联系我删

//以下代码均基于 https://juejin.cn/post/7371365854012276747 这位大佬的文章,侵权联系我删
//src/util/WebSocket/index.ts
class Log {
    private static console = true;
    log(title: string, text: string) {
        if (!Log.console) return;
        if (import.meta.env.MODE === 'production') return;
        const color = '#ff4d4f';
        console.log(
            `%c ${title} %c ${text} %c`,
            `background:${color};border:1px solid ${color}; padding: 1px; border-radius: 2px 0 0 2px; color: #fff;`,
            `border:1px solid ${color}; padding: 1px; border-radius: 0 2px 2px 0; color: ${color};`,
            'background:transparent'
        );
    }
    closeConsole() {
        Log.console = false;
    }
}
class EventDispatcher extends Log {
    private listeners: { [type: string]: Function[] } = {};

    protected addEventListener(type: string, listener: Function) {
        if (!this.listeners[type]) {
            this.listeners[type] = [];
        }
        if (this.listeners[type].indexOf(listener) === -1) {
            this.listeners[type].push(listener);
        }
    }

    protected removeEventListener(type: string) {
        this.listeners[type] = [];
    }

    protected dispatchEvent(type: string, data: any) {
        const listenerArray = this.listeners[type] || [];
        if (listenerArray.length === 0) return;
        listenerArray.forEach(listener => {
            listener.call(this, data);
        });
    }
}

import type { ChatHistoryType } from "@/views/Dialog/api";

export class WebSocketClient extends EventDispatcher {
  /**socket链接 */
  private url = "";
  /**socket实例 */
  private socket: WebSocket | null = null;
  /**重连次数 */
  private reconnectAttempts = 0;
  /**最大重连数 */
  private maxReconnectAttempts = 5;
  /**重连间隔 */
  private reconnectInterval = 10000; // 10 seconds
  /**发送心跳数据间隔 */
  private heartbeatInterval = 1000 * 30;
  /**计时器id */
  private heartbeatTimer?: number;
  /**彻底终止ws */
  private stopWs = false;
  // *构造函数
  constructor(url: string) {
    super();
    this.url = url;
  }
  // >生命周期钩子
  onopen(callBack: Function) {
    this.addEventListener("open", callBack);
  }
  onmessage(callBack: Function) {
    this.addEventListener("message", callBack);
  }
  onclose(callBack: Function) {
    this.addEventListener("close", callBack);
  }
  onerror(callBack: Function) {
    this.addEventListener("error", callBack);
  }
  // >消息发送
  public send(message: string): void {
    if (this.socket && this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(message);
    } else {
      console.error("[WebSocket] 未连接");
    }
  }

  // !初始化连接
  public connect(): void {
    if (this.reconnectAttempts === 0) {
      this.log("WebSocket", `初始化连接中...          ${this.url}`);
    }
    if (this.socket && this.socket.readyState === WebSocket.OPEN) {
      return;
    }
    this.socket = new WebSocket(this.url);

    // !websocket连接成功
    this.socket.onopen = (event) => {
      this.stopWs = false;
      // 重置重连尝试成功连接
      this.reconnectAttempts = 0;
      // 在连接成功时停止当前的心跳检测并重新启动
      this.startHeartbeat();
      this.log(
        "WebSocket",
        `连接成功,等待服务端数据推送[onopen]...     ${this.url}`
      );
      this.dispatchEvent("open", event);
    };

    this.socket.onmessage = (event) => {
      this.dispatchEvent("message", event);
      this.startHeartbeat();
    };

    this.socket.onclose = (event) => {
      if (this.reconnectAttempts === 0) {
        this.log("WebSocket", `连接断开[onclose]...    ${this.url}`);
      }
      if (!this.stopWs) {
        this.handleReconnect();
      }
      this.dispatchEvent("close", event);
    };

    this.socket.onerror = (event) => {
      if (this.reconnectAttempts === 0) {
        this.log("WebSocket", `连接异常[onerror]...    ${this.url}`);
      }
      this.closeHeartbeat();
      this.dispatchEvent("error", event);
    };
  }

  // > 断网重连逻辑
  private handleReconnect(): void {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      this.log(
        "WebSocket",
        `尝试重连... (${this.reconnectAttempts}/${this.maxReconnectAttempts})       ${this.url}`
      );
      setTimeout(() => {
        this.connect();
      }, this.reconnectInterval);
    } else {
      this.closeHeartbeat();
      this.log("WebSocket", `最大重连失败,终止重连: ${this.url}`);
    }
  }

  // >关闭连接
  public close(): void {
    if (this.socket) {
      this.stopWs = true;
      this.socket.close();
      this.socket = null;
      this.removeEventListener("open");
      this.removeEventListener("message");
      this.removeEventListener("close");
      this.removeEventListener("error");
    }
    this.closeHeartbeat();
  }

  // >开始心跳检测 -> 定时发送心跳消息
  private startHeartbeat(): void {
    if (this.stopWs) return;
    if (this.heartbeatTimer) {
      this.closeHeartbeat();
    }
    this.heartbeatTimer = setInterval(() => {
      if (this.socket) {
        this.socket.send(JSON.stringify({ type: "heartBeat", data: {} }));
        this.log("WebSocket", "送心跳数据...");
      } else {
        console.error("[WebSocket] 未连接");
      }
    }, this.heartbeatInterval);
  }

  // >关闭心跳
  private closeHeartbeat(): void {
    clearInterval(this.heartbeatTimer);
    this.heartbeatTimer = undefined;
  }
}

export interface ChatMsgType {
  type: "chat";
  data: ChatHistoryType;
}

export interface MsgType {
  type: "chat" | "error";
  data: any;
}

封装WebSocket全局初始化
//src/util/initWs/index.ts
import { StateKey } from "@/store/useChatStore";
import { jsonParse, showTip } from "..";
import { WebSocketClient, type ChatMsgType, type MsgType } from "../WebSocket";
import { useChatStore } from "@/store/useChatStore";
import pinia from "@/store/store";
import { logout } from "../logout";
const chatStore = useChatStore(pinia);
const { setChatState, setWsInstance } = chatStore;
const getInitWsFn = () => {
  /**加锁,保证全局只能初始化一次 */
  let isInit = false;
  /**websocket实例 */
  let ws: WebSocketClient;
  return {
    /**
     * 初始化ws实例和回调
     * @param user_name 当前登录的用户名
     * @returns 
     */
    initWs(user_name: string) {
      if (isInit) {
        return;
      }
      // 创建websocket实例,同时将当前用户名当作参数传递,方便后端存储各个用户的websocket实例
      ws = new WebSocketClient(
        `ws://192.168.121.176:3000/mySocketUrl?user_name=${user_name}`
      );
      /**websocket连接 */
      ws.connect();
      /**处理接收消息逻辑(以下仅为本项目中处理接收消息的逻辑,各位可以根据自身项目需要进行编写) */
      ws.onmessage((e: MessageEvent<string>) => {
        const res = jsonParse<MsgType>(e.data);
        if (res.type === "chat") {
          /**将接收到的聊天消息存储到pinia中,方便全局使用 */
          setChatState(res.data.from, res.data, StateKey.CURRENT_CHAT_STATE);
        }
      });
      
      //将websocket实例存到pinia中,方便在项目的任何地方都可以调用ws.send来发送消息
      setWsInstance(ws);
      //初始化完成后加锁,避免再次初始化
      isInit = true;
    },
    /** 销毁ws实例*/
    destoryWs() {
      if (!ws) {
        return;
      }
      ws.close();
      isInit = false;
    },
  };
};
export const { initWs, destoryWs } = getInitWsFn();

使用

在需要初始化的地方直接引入initWs函数调用即可

// 在App.vue中调用初始化
import { initWs } from "./util/initWs";
onMounted(async () => {
  
  if (localStore.getItem("token")) {
    console.log("app.vue - 已登录");
    initWs(userState.value!.user_name);
  }

发送消息,需要从pinia中取出我们存储的ws实例(在initWs函数中会把ws实例存储到pinia库中,当然这个pinia库需要你自己创建并引入)

import { useChatStore } from "@/store/useChatStore";
const chatStore = useChatStore();
const { ws_instance } = chatStore;
ws_instance.send(JSON.stringify(msg));

后端代码

封装后台websocket

import QQ_DB from "../../database/all/qq_db";
import { isHaveChat } from "../../src/controllers/chatHistory.controller";
import { ChatMsgType, MsgType } from "../../types/others";
import { logSpecial } from "../io/log";
import { jsonParse } from "../other";
import WebSocket from "../../node_modules/@types/ws/index";
enum TARGET_USER {
  ALL = "all",
}
interface ClientObjType {
  /**客户端当前登录用户的qq号(用于区分各个客户端) */
  user_name: string;
  ws_instance: WebSocket.WebSocket;
}
// 存储所有连接的客户端
export const clients = new Set<ClientObjType>();

/**
 * 初始化websocket实例,并记录
 * @param ws websocket实例
 * @param user_name 客户端当前登录用户的qq号(用于区分各个客户端)
 */
export const initAndAddWs = (ws: WebSocket.WebSocket, user_name: string) => {
  const obj = {
    user_name: user_name,
    ws_instance: ws,
  };
  clients.add(obj);
  // 这里是本项目特定的处理消息逻辑,可以根据自己项目进行修改
  ws.on("message", function (msg: any) {
    const res = jsonParse<ChatMsgType>(msg);
    if (res.type === "chat") {
      /**自己发给自己的信息就不发了 */
      if (res.data.to === user_name) {
        return;
      }
      //发送消息
      sendMsgToTargetUser(res, res.data.to);
    }
  });
  ws.on("close", function (e: any) {
    logSpecial("连接关闭", user_name);
    clients.delete(obj);
  });
};

/**
 * 发送消息给指定客户端
 * @param message 消息
 * @param target_user 目标客户端的用户名
 */
function sendMsgToTargetUser(
  message: MsgType,
  target_user: string | TARGET_USER
) {
  clients.forEach(async (client) => {
    if (target_user === TARGET_USER.ALL) {
      client.ws_instance.send(JSON.stringify(message));
      return;
    }
    if (
      client.user_name === target_user &&
      client.ws_instance.readyState === 1
    ) {
      logSpecial(message);
      logSpecial("找到你啦");
      client.ws_instance.send(JSON.stringify(message));
    } else {
      // 用户不在线,先把信息存到数据库 且当前信息为聊天信息
      if (message.type === "chat") {
         //先判断该消息是不是存储过了,存储过了就不存了
        if (!(await isHaveChat(message.data))) {
          await QQ_DB.add("chat_history", message.data);
        }
      }
    }
  });
}
/**
 * 判断该用户是否已经连接了
 * @param clientObj 当前请求连接的客户端
 * @returns
 */
export const judgeClientIsHave = (user_name: string) => {
  let flag = false;
  logSpecial(clients);
  clients.forEach((item) => {
    if (item.user_name === user_name) {
      flag = true;
    }
  });
  return flag;
};

/**
 * 查询该用户是否已经连接,并清除掉已连接的客户端
 */
export const judgeClientIsHaveAndClearClient = (user_name: string) => {
  clients.forEach((item) => {
    if (item.user_name === user_name) {
      sendMsgToTargetUser(
        { type: "error", data: { msg: "你的账号已在别处登录" } },
        item.user_name
      );
      disconnect(item.user_name)
    }
  });
};

/**
 * 断开某个用户的连接
 * @param user_name 用户qq号
 */
export const disconnect = (user_name: string) => {
  clients.forEach((item) => {
    if (item.user_name === user_name) {
      item.ws_instance.close();
      clients.delete(item);
    }
  });
};

使用websocket

// 这里使用expressWs库
import expressWs from "express-ws";
import { initAndAddWs } from "./util/WebSocket/initAndAddWs";
const route = express.Router();
const app = express();
expressWs(app);
/**
* route.ws('/url',(ws, req)=>{  })
* 建立WebSocket服务,并指定对应接口url,及相应回调
* ws为实例化的对象,req即为请求
*
* ws.send方法用来向客户端发送信息
* ws.on方法用于监听事件(如监听message事件,或监听close事件)
* */
route.ws("/mySocketUrl", (ws, req) => {
    const params = req.query;
    logSpecial('连接', params["user_name"])
    initAndAddWs(ws, params["user_name"] as string);
});
app.use(route);
app.listen(3000, () => {
    console.log(`  App is running at http://localhost:3000\n`);
    console.log("  Press CTRL-C to stop\n");
  });

前端源码(gitee链接):gitee.com/yangqinhang…

前端源码(GitHub链接):github.com/yangqinhang…

后端源码(gitee链接):gitee.com/yangqinhang…

后端源码(GitHub链接):github.com/yangqinhang…

miniQQ移动端:http://8.138.95.105/ (注:此为移动端界面,PC端打开样式会比较奇怪)

miniQQ App端apk包:pan.baidu.com/s/1RUJdwlfW… 提取码:zqhw

特别注意⚠️:移动端和app端仅供体验,请勿用于其他用途。且我会定期清理服务器,若某个文件或图片无法加载了可能就是被我删了