基于Vue3+Node Koa+WebSocket实现的“宠物收养交流平台”

4,587 阅读7分钟

一、前言🐕

在这篇文章中,我将会分享如何完整的实现一个 Vue3 + Koa 前后端分离项目,主要介绍token登录验证流程、如何使用Pinia实现Vue状态管理、阿里云OSS对象存储以及项目优化。希望对你有帮助!

项目地址:Pet-home 宠物收养交流平台

演示地址:pet-home

用户名: 张三(或李四)

密码: 123

(或自行创建用户)

二、相关技术栈🐈

  • Vue3、vue-router :搭建前端用户界面,实现SPA单页应用;
  • Koa :编写服务端应用程序、后端接口,调度Mysql数据库对用户数据持久化;
  • axios :基于 promise 的网络请求库,是前端向后端获取数据的请求工具;
  • pinia :更加轻量级的 Vue 存储库;
  • flexible :移动端适配解决方案;
  • vant :轻量、可靠的移动端组件库;
  • json web token (JWT) :token验证登录;
  • ali-oss :阿里云OSS对象存储服务;

三、项目展示🐟

1. 登录、注册页

登录与注册.gif

2. 主页宠物展示

主页展示.gif

3. 宠物详情页

宠物主页.gif

4. 用户个人主页

用户主页.gif

5. 收藏页的查看

收藏页.gif

6. 宠物信息的录入

宠物添加.gif

7. 宠物信息的修改与删除

宠物修改与删除.gif

四、项目实现🐇

1. 项目结构

├─ client              //前端项目
│  └─ src
│     ├─ utils
│     │  └─ baseImg.js // OSS服务文件
│     ├─ store         // pinia配置文件
│     ├─ views         // 路由组件
│     ├─ assets        // 项目资源配置文件
│     ├─ router        // 前端路由文件
│     ├─ axios         // axios数据请求封装
│     ├─ components    // 项目组件
│     ├─ main.js       // 入口文件
│     └─ App.vue       // 根组件
│ 
└─ server              // 后端项目
   ├─ app.js           // 入口文件
   ├─ controllers      // mysql配置文件
   ├─ middleware       // 中间件封装
   ├─ routes           // 后端路由配置
   └─ utils       
      └─ jwt.js        // token验证

2. axios请求封装

本项目使用axios向后端发送数据请求,并对请求的数据进行拦截处理。

在第一次封装中,我们对所有的请求和响应都要执行的任务进行统一处理。请求拦截中,我们主要进行的工作是将浏览器本地存储的token放置在请求头中,之后对请求放行,后端会对请求头中的token进行校验。在响应拦截中,我们主要对于后端返回消息的状态码进行甄别并匹配对应的处理,常见的有200请求成功、401用户未登录、404资源未找到。当然后端可能返回错误,主要原因就是后端代码执行错误,与前端信息接收无关。

在第二次封装中,我们主要对每个不同请求的请求路径、方式还有数据进行封装。数据请求时调用对应封装的函数传入请求数据即可,对项目中的所用的请求可以写在同一文件,方便修改和管理。

config.js 请求体配置文件

export default{
    baseUrl: 'http://localhost:3000',            // 后端服务域名和端口号
    headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
    },
    data: {},
    timeout: 10000,                             // 请求超时时间
    withCredentials: true,                      // 是否携带 token
}

api.js 请求响应拦截配置文件

import axios from "axios";
import config from './config.js';
import { Toast } from 'vant';
import router from '../router/index.js'

export default function $axios(options){

    return new Promise((resolve, reject)=>{

        const instance = axios.create({
            baseURL: config.baseUrl,
            timeout: config.timeout,
            headers: {},
        })

        // 请求拦截
        instance.interceptors.request.use(
            conf =>{
                // 带上 token 给后端
                conf.headers['Authorization'] = `${localStorage.getItem('token') || ''}`;
                return conf
            },
            error => {
                console.log(error);
            }
        )

        // 响应拦截
        instance.interceptors.response.use(
            response =>{
                let data = response.data
                if(data.code !== 200){  // 业务逻辑错误
                    if(data.mess)  Toast.fail(data.mess)
                    return Promise.reject(data)
                } 
                return data
            },
            err =>{  // 后端服务器错误
                if(err && err.response){
                    switch(err.response.status){
                        case 400:
                            err.mess = '请求错误'
                        break
                        case 401:
                            err.mess = '账号已过期,请重新登录'
                            router.push('/login')  // 跳转登录页登录
                        break
                        case 404:
                            err.mess = '请求地址不存在'
                        break
                        default:
                    }
                    Toast.fail(err.mess)           // 打印错误信息
                }
                return Promise.reject(err)
            }
        )

        // 发生请求
        instance(options).then(res=>{
            resolve(res)
        })
        .catch(error=>{
            reject(error)
        })

    })
}

index.js 请求接口文件(部分)

import $axios from './api.js'

export const login = data => {   // 用户登录
    return $axios({
        url: '/user/login',
        method: 'post',
        data
    })
}

export const register = data => {  // 用户注册
    return $axios({
        url: '/user/register',
        method: 'post',
        data
    })
}

export const addUserPet = data => { // 添加宠物信息
    return $axios({
        url: '/pet/add',
        method: 'post',
        data
    })
}

3. token登录验证

在前端请求头中,获取到 authorization 对应的token字段,返回到 Auth 函数进行token验证。验证通过即可在ctx.state.user中获取到当前发送请求的用户id,失败或者token过期则会返回前端401 用户未登录,并跳转前端进行登录,登录成功服务器返回浏览器新的token用于下次身份验证。

jwt.js token生成验证文件

const jwt = require("jsonwebtoken");
const secret = '113Bmongojsdalkfnxcvmas';

/**
 * @param {*} value 用户 id信息
 * @param {*} expires token有效时间
 */
const generate = (value, expires) => {  // 创建 token
    try {
        return jwt.sign(value, secret, { expiresIn: expires });
    } catch (e) {
        return '';
    }
}
const verify = (token) => {             // 查看 token
    try {
        return jwt.verify(token, secret); // 如果过期将返回false
    } catch (e) {
        return false;
    }
}

module.exports = {
    generate,
    verify
}

auth.js token验证中间件

const jwt = require('../utils/jwt.js')

const Auth = async (ctx, next) => {
    const { authorization = '' } = ctx.request.header;
    const token = authorization.replace('Bearer ', '');
    const info = jwt.verify(token);
    if (info) {
        ctx.state.user = info; // 将用户信息存放到 state中
    } else {
        ctx.throw(401, 'token error');
    }
    await next();
};

module.exports = {
    Auth
}

4. OSS对象存储

在用户填写宠物信息时,需要提供宠物照片上传功能,本次项目使用mysql数据库实现信息持久化,一般仅支持存储字符型数据,对于图片格式数据存储在数据库会对数据的查询造成更大的性能开销,因此我的解决方案是将图片存储在第三方服务器,数据库只存储对应图片的url地址。

我选择的是阿里云的OSS对象存储,该存储服务需要读者们自行注册账号开启服务。

图片上传的主要流程为:

  1. 前端要上传图片时,到后端服务器取上传的policy及签名(signature);
  2. 后端服务器返回上传所需的policysignature
  3. 前端拿到了签名后,直接根据OSS服务器地址(Bucket 域名)发起请求上传到OSS服务器;

图片上传组件使用的是Vant文件上传,由Vue3数据代理之后图片格式为Base64编码格式还需将其转为Blob格式,可以把他看做是存放二进制数据的容器,属性有文件大小和文件类型。

此部分参考文章:基于node.js和oss的后端签名直传 - 掘金 (juejin.cn)

Addpet.vue 使用OSS对象存储

// html部分
<van-field name="petImg" label="宠物图片" :rules="[{ required: true, message: '请选择宠物图片' }]" >
    <template #input>
          <van-uploader v-model="petImg" :max-count="1" :after-read="afterReadImg"/>
    </template>
</van-field>

// js部分
const afterReadImg = (file) => {
  uploadInfo().then(res => {                // 获取OSS服务policy及签名(signature)
    let url = dataURItoBlob(file.content);  // 将base64转blob
    uploadfile(res.data, url).then(res => { // 将图片上传并传回url
      state.petImgUrl = res;
    })
  })
};

pet.js (后端路由文件)获取OSS对象配置信息

router.post('/uploadInfo', async (ctx, next) => {
    const config = {
        dirPath: 'pet/img/',  // 文件存放到哪个目录下
        bucket: 'aaaaaaa',  // bucketName
        host: 'http://aaaaaaa.oss-cn-hangzhou.aliyuncs.com',  //自己的地址
        accessKeyId: 'xxxxxxxxxxxxxxxxxxxxx',  //自己的key
        accessKeySecret: 'sssssssssssssssssssssss',  //自己的 KeySecret
        expAfter: 300000,  // 签名失效时间,毫秒
        maxSize: 1048576000  // 文件最大的 size
    }
    const expireTime = new Date().getTime() + config.expAfter;
    const expiration = new Date(expireTime).toISOString();
    const policyString = JSON.stringify({
        expiration,
        conditions: [
            ['content-length-range', 0, config.maxSize],
            ['starts-with', '$key', config.dirPath]
        ]
    })
    const policy = Buffer.from(policyString).toString('base64');

    const signature = crypto.createHmac('sha1', config.accessKeySecret).update(policy).digest("base64");

    ctx.body = {
        code: 200,
        data: {
            signature,
            policy,
            'host': config.host,
            'OSSAccessKeyId': config.accessKeyId,
            'key': expireTime,
            'success_action_status': 200,
            'dirPath': config.dirPath,
        },
        mess: '请求成功'
    }
})

baseImg.js OSS对象存储服务配置

export const uploadfile = (config, file) => { // 使用 OSS签名,通过 FormData 提交实现图片上传
    return new Promise((resolve, reject) => {
        const param = new FormData();
        const { OSSAccessKeyId, dirPath, host, key, policy, signature, success_action_status } = config;

        const objectKey = `${key}.${file.type.split('/').pop()}`;

        param.append('OSSAccessKeyId', OSSAccessKeyId);
        param.append('policy', policy);
        param.append('signature', signature);
        param.append('key', dirPath + objectKey);
        param.append('success_action_status', success_action_status);
        param.append('file', file, objectKey);

        const xhr = new XMLHttpRequest();
        xhr.open('post', host, true);

        xhr.addEventListener('load', (e) => {
            if (e.target.status !== 200) {
                Toast.fail('图片上传失败!');
                reject(res);
                return;
            }
            if (e.target.status === 200) {
                Toast.success('图片上传成功!');
                resolve(host + '/' + dirPath + objectKey);
            }
        }, false);
        xhr.send(param);
    })
}

5. Pinia状态管理

在用户登录账号使用相关服务时,多少都会对数据进行更改操作,相关数据势必是从数据库中获取,而如果频繁的操作数据就相当于频繁操作数据库,也会增加请求的次数。为此我们可将一些数据存储在Pinia中统一管理,等数据操作完毕再一次性对数据库中的数据进行修改,减少数据请求和数据库操作的次数。

index.js store实例创建和pinia缓存

import { createPinia } from "pinia";
import piniaPersist from 'pinia-plugin-persist' // 数据持久化
const store = createPinia();                    // 创建 store 实例
store.use(piniaPersist)
export default store;

user.js user仓库信息(部分)

export const userStore = defineStore({
    id: 'user',  // 命名,唯一
    state: () => {
        return {
            userInfo: {},     //  用户信息
            userPet: null,    //  用户的宠物信息 
            userCollect: [],  // 用户收藏的宠物信息
            exitPetIndex: 0,  // 被修改宠物的index
        }
    },
    actions: {
    // 用户信息操作
        setUserName(name) {
            this.userInfo.name = name
        },
        getUserInfo(){
            return this.userInfo
        },

    // 宠物信息操作
        setPetInfo(petlist){
            this.userPet = [...petlist]
        },
        getPetInfo(){
            return this.userPet
        },
        getPetInfoExit(){
            return JSON.parse(JSON.stringify(this.userPet[this.exitPetIndex]));
        },
        setExitIndex(index){
            this.exitPetIndex = index
        },
        savePetInfo(item){
            this.userPet[this.exitPetIndex] = JSON.parse(JSON.stringify(item));
            updataPet({
                data: {...item}
            }).then(res => {
                Toast(res.mess);
            })
        }
    },
    persist: {  // 开启数据缓存
        enabled: true
    }
})

6. 实现上拉加载

在主页查看宠物信息的时候,我们并不需要将数据库中的所有宠物数据全给请求过来,在用户当前页面显示有一定数量的宠物信息,监听用户的页面的滚动事件,判断页面是否触底,是则再次请求数据并入数组。而后端则采用分页查询的方式返回数据,因此在前端数据请求的时候需要向后端返回当前页数和查询条数。

判断页面触底

const scrollFn = () => {  // 滚动触底事件
    let scrollTop = document.documentElement.scrollTop || document.body.scrollTop
    let clientHeight = document.documentElement.clientHeight
    let scrollHeight = document.documentElement.scrollHeight
    if (scrollTop + clientHeight >= scrollHeight - 50) {
      reqPetData(state.active)
    }
}

宠物信息展示组件

<template>
  <div class="card" @click="toDetails">
      <div class="item_img">
          <img :src="petItem.petimg" alt="" class="petimg">
      </div>
      <div class="item_content">
          <div class="wrap_left">
              <p class="name">{{petItem.petname}}</p>
              <p class="type">{{petItem.petbreed}}</p>
              <p class="age">{{petItem.petage}}岁</p>
          </div>
          <div class="wrap_right">
            <div>
                <i class="iconfont" :class="petItem.petsex == '1' ? 'icon-nan' : 'icon-nv'"></i>
            </div>
            <div @click.stop="cardColl">
                <i class="iconfont icon-xin2 iconColl2" v-if="collect"></i>
                <i class="iconfont icon-xin2 iconColl1" v-else></i>
            </div>
          </div>
      </div>
  </div>
</template>

7. 验证码生成

使用canvas来绘制验证码图案。主要思路为:先将大小写字母以及阿拉伯数字串联写成一个字符串存入数据源,进行四次随机读取字符串上的字符,需要将选出的四个字符形成新的字符串传回给父组件,以便之后验证正确性使用。

随机读取到的字符则进行2d的绘制。 VueImageVerify.vue 图形的绘制

const draw = () => {
  const ctx = verify.value.getContext("2d");
  ctx.fillStyle = randomColor(180, 230);
  ctx.fillRect(0, 0, 120, 40);
  let imgCode = "";

  // 随机产生字符串并进行绘制
  for (let i = 0; i < 4; i++) {
    const text = state.pool[randomNum(0, state.pool.length)];
    imgCode += text;
    const fontSize = randomNum(18, 40);
    const deg = randomNum(-30, 30);
    ctx.font = fontSize + "px Simhei";
    ctx.textBaseline = "top";
    ctx.fillStyle = randomColor(80, 150);
    ctx.save();
    ctx.translate(30 * i + 15, 15);
    ctx.rotate((deg * Math.PI) / 180);
    ctx.fillText(text, -10, -15);
    ctx.restore();
  }
  // 随机产生5条干扰线,干扰线的颜色要浅一点
  for (let i = 0; i < 5; i++) {
    ctx.beginPath();
    ctx.moveTo(randomNum(0, 120), randomNum(0, 40));
    ctx.lineTo(randomNum(0, 120), randomNum(0, 40));
    ctx.strokeStyle = randomColor(180, 230);
    ctx.closePath();
    ctx.stroke();
  }
  // 随机产生40个干扰的小点
  for (let i = 0; i < 40; i++) {
    ctx.beginPath();
    ctx.arc(randomNum(0, 120), randomNum(0, 40), 1, 0, 2 * Math.PI);
    ctx.closePath();
    ctx.fillStyle = randomColor(150, 200);
    ctx.fill();
  }
  return imgCode;
};

8. vue-router的配置

项目中的页面主要分为两种类型:导航页(底部有导航栏)和跳转页(无导航栏),我们可以将页面信息编写在路由元信息(meta)中。

index.js 路由表配置

 const routes = [{
    path: '/',
    redirect: '/home',
  },
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/login'),
    meta: {
      type: 'page',
      keepAlive: false
    }
  },
  {
    path: '/home',
    name: 'home',
    component: () => import('@/views/home'),
    meta: {
      type: 'view',
      keepAlive: true
    }
  },
  {
    path: '/user',
    name: 'user',
    component: () => import('@/views/user'),
    meta: {
      type: 'view',
      keepAlive: false
    }
  },
]

meta中type代表页面类型:导航页(view)、跳转页(page),keepAlive代表该页面是否进行缓存,缓存的页面在进行组件切换时,页面状态会被保留。

App.vue

 <router-view v-slot="{ Component }">
    <transition name="scale" mode="out-in">
      <keep-alive>  // 使用keep-alive标签将需要进行缓存的组件进行包裹
        <component
          :is="Component"
          v-if="$route.meta.keepAlive && $route.meta.type === 'view'"
        />
      </keep-alive>
    </transition>
  </router-view>
  

9. node Koa后端服务的搭建

app.js 入口文件

const Koa = require('koa');
const app = new Koa();
const cors = require('@koa/cors');
const bodyParser = require('koa-bodyparser');
const user = require('./routes/user');
const pet = require('./routes/pet');
const io = require('./socket/index');

app.use(cors())      // 跨域处理
app.use(bodyParser())// 帮助 koa解析 post请求传递的参数

app.use(user.routes(), user.allowedMethods())//启用路由
app.use(pet.routes(), pet.allowedMethods())//启用路由

app.listen(3000, ()=>{ // 后端开启 3000 端口服务
    console.log('项目已启动!');
})

routes/user.js 后端路由 user文件(部分)

const router = require('koa-router')();
const userService = require('../controllers/mySqlConfig');

router.prefix('/user');  //公共前缀

// 登录接口
router.post('/login', async (ctx, next)=>{  // 匹配前端"/user/login"路径的请求
    // 拿到前端的参数
    let _username = ctx.request.body.username;
    let _password = ctx.request.body.password;

    // 去数据库里匹配数据
    await userService.userLogin(_username, _password).then(res=>{
        // 向前端返回内容
        if(res.length){  // 数据库查询有结果
            let result = {
                id: res[0].id,
                username: res[0].username
            }
            let token = jwt.generate(result, '1d')
            ctx.body = {
                code: 200,
                data: {
                    id: res[0].id,
                    name: res[0].username,
                    userImg: res[0].userimg,
                    sign: res[0].usersign,
                    token
                },
                mess: '登录成功'
            }
        }else{   // 数据库查询无结果
            ctx.body = {
                code: 406,
                data: 'error',
                mess: '账号、密码有误或用户不存在'
            }
        }
    }).catch(err=>{ // 数据库查询报错
        ctx.body = {
            code: 407,
            data: err,
            mess: '服务器出错'
        }
    })
});

module.exports = router;

mySqlConfig.js 数据库操作(部分)

const mysql = require('mysql')
const config = require('./defaultConfig')

// 创建线程池(连接数据库的准备工作)
let pool = mysql.createPool({
    host: config.dataBase.HOST,
    user: config.dataBase.USERNAME,
    password: config.dataBase.PASSWORD,
    database: config.dataBase.DATABASE,
    port: config.dataBase.PORT
})


// 连接线程池,做sql查找
let allService = {
    query: function(sql, values){
        return new Promise((resolve, reject)=>{
            pool.getConnection(function(err, connection){  //连接数据库
                if(err){
                    reject(err)
                }else{//连接成功
                    connection.query(sql, values, (err, rows)=>{ //执行 sql
                        if(err){
                            reject(err)
                        }else{
                            resolve(rows)
                        }
                        connection.release() //释放连接
                    })
                }
            })
        })
    }
}

// 用户登录
let userLogin = function(username, password){
    let _sql = `select * from users where username="${username}" and userpwd="${password}";`
    return allService.query(_sql)
}

module.exports = { userLogin }

defaultConfig.js 数据库配置信息

const config = { 
    dataBase: {
        DATABASE: 'pet',
        USERNAME: 'root',
        PASSWORD: '123456',
        PORT: '3306',
        HOST: 'localhost'
    }
}

module.exports = config

五、优化🦜

1. 页面切换的过渡动画

使用 transition 内置组件

官网介绍:Transition | Vue.js (vuejs.org)

在项目中的导航页主要采用 “缩放过渡”

App.js

<transition name="scale" mode="out-in">
  <keep-alive>
    <component
      :is="Component"
      v-if="$route.meta.keepAlive && $route.meta.type === 'view'"
    />
  </keep-alive>
</transition>

// 缩放过渡
.scale-enter-active,
.scale-leave-active {
  position: absolute;
  transition: all .32s ease-out;
}

.scale-enter-from,
.scale-leave-to {
  opacity: 0;
  transform: scale(0.9);
}

在跳转页中主要采用 “平移过渡”,但是当跳转页进入跳转页时,旧页面应该采用 “缩放过渡”。我的做法是在Pinia中维护一个页面栈,进入一个跳转页就将页面信息入栈,退出跳转页则出栈,这样只要确保进入的页面为 “平移过渡”,退出的页面为 “缩放过渡” 就行。

App.js 入栈和出栈的判断

 <!-- html 部分 -->
 <transition v-for="item in pageInfo" :key="item" :name="routeType ? item[0] : item[1]" mode="out-in">
  <component
    :is="Component"
    v-if="!$route.meta.keepAlive && $route.meta.type === item[2]"
  />
</transition>

// js 部分
 router.beforeEach((to, from, next) => {
      // 由 view页面进入 page页面
      if(to.meta.type === "petSpace" && from.meta.type === "view"){
        state.routeType = false;
        state.pushType = true;
      }else if(to.meta.type === "userSpace" && from.meta.type === "view"){
        state.routeType = true;
        state.pushType = true;
      }
      // 由 page页面进入 page页面
      if ((to.meta.type === "userSpace" && from.meta.type === "petSpace") || (to.meta.type === "petSpace" && from.meta.type === "userSpace")) { 
        state.routeType = state.routeType ? false : true;
        if((uStore.getPageStack().length > state.pageStackLength) !== state.pushType){
          state.routeType = state.routeType ? false : true;
        }
        state.pushType = uStore.getPageStack().length > state.pageStackLength;
      }
      // 由 page页面进入 view页面
      if(to.meta.type === "view" && from.meta.type === "petSpace"){
        state.routeType = false;
      }else if(to.meta.type === "view" && from.meta.type === "userSpace"){
        state.routeType = true;
      }
      next();
    });

2. 防抖与节流

在项目中,有时需要监听页面的滚动条高度,但并不需要时刻返回高度,因此进行节流处理,如在页面持续滚动的时候,每隔500毫秒才监听并返回一次滚动条的高度,从而节约性能。

此外还有按钮提交的事件,如宠物信息提交按钮,如果用户在点击提交时进行多次点击,就会进行多余的数据请求,因此使用防抖处理,如在500毫秒内再次点击提交按钮,则会取消之前的请求。

tool.js 节流与防抖函数

export const debounce = (fn, wait) => {  // 防抖函数
    let time = null;
    return function(){
        clearTimeout(time);
        time = setTimeout(()=>{
            fn.apply(this)
        }, wait)
    }
}

export const throttle = (fn, wait) => {  // 节流函数
    let canRun = true;
    return function () {
        if (!canRun) return;
        canRun = false;
        setTimeout(() => {
            fn.apply(this);
            canRun = true;
        }, wait)
    }
}

3. 移动端适配

使用flexible进行移动端适配

  • 安装:npm i lib-flexible --save

  • 在入口文件 main.js 中引入

      import 'lib-flexible/flexible'; // 移动端适配
    
  • 页面中的元素用 rem 单位来设置,rem 则是根据根元素的font-size来计算的,例如font-size=32px, 0.5rem就等于16px。当我们更换设备时,根元素的font-size就会发生改变,从而实现屏幕的适配效果。

六、结尾🐖

这次主要介绍了项目的主体部分:为用户和宠物信息的处理以及后端服务的搭建,下次将介绍WebSocket实时推送,并实现用户在线聊天功能。