使用Vue3和NodeJS搭建h5移动端单页和后端RESTful项目模版

1,581 阅读9分钟

一、项目介绍

本项目使用Vue3搭建h5移动端单页,使用NodeJS基于MVC搭建RESTful框架模版。

前端基于Vue3 + webpack4 + sass + vant uirem适配方案,搭建手机端模板。

后端基于koa + sequelize + mysql搭建restful api的模版。

Node版本要求:Vue CLI 4.x 需要 Node.js v8.9 或更高版本 (推荐 v10 以上)。本示例使用的Node.js v10.17.0

源码已经提交到github了,麻烦大家动动小手Star一下。

二、统一开发规范

代码开发之前首先要做的就是使用VsCodeESLint + stylelint统一格式化JSCSS代码。

ESLint

VScode里需要安装ESLint插件,安装完成后,想要让扩展进行工作,我们还需要先进行ESLint的安装配置。EsLint可以局部安装和全局安装,这里使用当前项目构建系统的一部分进行局部安装:

npm install eslint --save-dev

安装完成后在项目根目录下配置.eslintrc.js文件:

module.exports = {
  // 指定要启用的环境
  env: {
    browser: true,
    es6: true,
    node: true
  },
  // 设置语言选项
  parserOptions: {
    ecmaVersion: 6 
  },
  // 启用推荐规则
  extends: 'eslint:recommended',
  rules: {
    // 除了与 null 字面量进行比较时,总是强制使用绝对相等
    eqeqeq: ['error', 'always', { null: 'ignore' }], 
  }
}

当使用rules新增自定义规则的时候,每个规则的第一个值都是代表该规则检测后显示的错误级别:

  1. off0将关闭规则
  2. warn1将规则视为一个警告
  3. error2将规则视为一个错误

更完整的规则可以访问:

eslint.cn/docs/rules/

eslint.vuejs.org/user-guide/…

最后在Vscodesetting.json里启用ESLint

// VSCode 会根据你当前项目下的.eslintrc.js配置文件的规则来验证自动格式化代码
"editor.codeActionsOnSave": {
  "source.fixAll": true
}

如果在Vscode安装了vetur插件,在使用Vue3的时候报vue/no-multiple-template-root的错误,vetur会提示说不能有多个根元素。解决办法是打开setting配置,取消检查:

// F1>Preferences:Open Settings (JSON)
"vetur.validation.template": false

还有一个问题就是Vue项目是放在子目录下的,VsCode会去顶层目录下找eslint-plugin-vue,会报Cannot find module 'eslint-plugin-vue'错误。解决方案是在setting配置里自定义ESLint工作目录:

// F1>Preferences:Open Settings (JSON)
"eslint.workingDirectories": [
    "./vue3-template"
]

stylelint

VScode里需要stylelint插件,然后在项目里局部安装:

npm install --save-dev stylelint stylelint-config-standard stylelint-order

stylelint是运行工具,stylelint-config-standardstylelint的推荐配置。stylelint-orderCSS属性排序插件(先写定位,再写盒模型,再写内容区样式,最后写CSS3相关属性)。

安装完成后在项目下配置.stylelintrc.json的配置文件:

{
  // 启用默认推荐规则
  "extends": "stylelint-config-standard"
  // rules优先级大于extends,如果想修改插件的默认规则,可以增加rules
  "rules": {
    // 指定字符串使用单引号还是双引号
    "string-quotes": "double"
  }
}

三、前端

前端使用Vue-cli4来初始化,使用的是@vue/cli 4.5.13版本:

vue -V
@vue/cli 4.5.13

npm get registry
// 这里我没有使用淘宝源
https://registry.npmjs.org/

下面是我创建项目的初始设置:

image.png

Sass建议使用dart-sass,不要使用node-sass

image.png

具体可以参考:panjiachen.github.io/vue-element…

多环境配置

一般一个项目都会有以下3种环境:

  • 开发环境 development
  • 测试环境 stage
  • 生产环境 production

package.json里的scripts配置三种环境,如下:

"scripts": {
  "serve": "vue-cli-service serve",
  "build": "vue-cli-service build",
  "stage": "vue-cli-service build --mode staging",
  "preview": "vue-cli-service build --report",
  "lint": "vue-cli-service lint"
}

在项目根目录新建不同环境的配置文件:

  • .env.development
NODE_ENV = 'development'

VUE_APP_BASE_API =  '/development-api'
  • .env.staging
NODE_ENV = 'staging'

VUE_APP_BASE_API =  '/staging-api'
  • .env.production
NODE_ENV = 'production'

VUE_APP_BASE_API =  '/production-api'

vue-cli官方已经集成了webpack-bundle-analyzer插件分析webpack构建产物。只需要执行npm run preview 就可以构建打包并生成report.html帮助分析包内容了。打开文件后,如下:

image.png

rem适配

因为Vant默认使用px作为样式单位,如果需要使用rem单位,官方推荐使用以下两个工具:

  • postcss-pxtorem 是一款PostCSS插件,用于将px单位转化为rem单位
  • lib-flexible 用于设置rem基准值
// 安装
npm install vant@next -S
npm install amfe-flexible -S
npm install postcss -D
npm install postcss-pxtorem@5.1.1 -D

安装最新版本的postcss 8.3.0postcss-pxtorem 6.0.0不兼容,需要使用postcss-pxtorem@5.1.1版本。

main.js文件中引入lib-flexible

import 'amfe-flexible'

然后在根目录下新建postcss.config.js文件:

module.exports = {
  plugins: {
    // postcss-pxtorem 插件的版本需要 >= 5.0.0
    'postcss-pxtorem': {
      rootValue({ file }) {
        // vant组件rootValue使用37.5
        return file.indexOf('vant') !== -1 ? 37.5 : 75
      },
      propList: ['*']
    }
  }
}

目前postcss-pxtorem默认版本是6.0.0,与vue-cli4不兼容。所以postcss-pxtorem版本最好降到5.1.1。

Vant UI设置rootValue: 37.5,我们使用了lib-flexible适配,所以iPhone61rem等于37.5px。

因为Vant默认使用px作为样式单位,假如说Vant组件有一个元素宽度是375px。 通过插件设置rootValue37.5,转换rem10rem。如果插件rootValue改为75,转换rem5rem。但是实际的font-size还是37.5px。所以Vant实际css像素是187.5px,整体缩小了一倍。

所以就可以加一个判断是否是vant文件,如果是vant文件就已rootValue: 37.5为基准。假如UI设计稿是750px,我们就不用拿到设计稿计算了,直接1:1来写css样式了。

vant 按需加载

推荐使用官方文档的按需来引入组件,安装babel-plugin-import插件:

npm install babel-plugin-import -D

然后在根目录配置babel.config.js

module.exports = {
  presets: [
    '@vue/cli-plugin-babel/preset'
  ],
  plugins: [
    [
      'import',
      {
        libraryName: 'vant',
        libraryDirectory: 'es',
        style: true
      }
    ]
  ]
}

可以在src/plugins/vant.js下统一管理组件,把引入组件的工作单独写入到一个js文件中,如下:

import { Button, Tabbar, TabbarItem } from 'vant'

export default function(app) {
  app.use(Button).use(Tabbar).use(TabbarItem)
}

然后在main.js文件中引入就可以了:

import { createApp } from 'vue'
import useVant from '@/plugins/vant'
const app = createApp(App)
// 注册vant组件
useVant(app)

Vuex 状态管理

Vuex使用模块化方式管理来编写一个计数器demo。如下目录结构:

├── store
│   ├── modules
│   │   └── counter.js
│   ├── index.js

index.js如下:

import { createStore } from 'vuex'

const modulesFiles = require.context('./modules', true, /\.js$/)

// 自动装配模块
const modules = modulesFiles.keys().reduce((modules, modulePath) => {
  const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
  const value = modulesFiles(modulePath)
  modules[moduleName] = value.default
  return modules
}, {})

export default createStore({
  modules
})

counter.js如下:

const state = function() {
  return {
    count: 0
  }
}
const mutations = {
  increment(state, count) {
    state.count++
  }
}
const actions = {
  incrementAsync({ commit }) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        commit('increment')
        resolve()
      }, 1000)
    })
  }
}
const getters = {
  evenOrOdd(state) {
    return state.count % 2 === 0 ? 'even' : 'odd'
  }
}
export default {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
}

main.js文件中引入:

import { createApp } from 'vue'
import store from './store'
app.use(store)

使用方式:

<template>
  <div>{{ count }} is {{ evenOrOdd }}.</div>
  <button @click="incrementAsync">Increment async</button>
</template>
<script>
import { computed } from '@vue/runtime-core'
import { useStore } from 'vuex'
export default {
  name: 'App',
  setup() {
    const store = useStore()
    return {
      count: computed(() => store.state.counter.count),
      evenOrOdd: computed(() => store.getters['counter/evenOrOdd']),
      incrementAsync: () => store.dispatch('counter/incrementAsync')
    }
  }
}
</script>

Vue-Router 路由

本模版采用hash模式来进行开发,也使用模块化进行管理。

├── router
│   ├── modules
│   │   └── tabbar.js
│   ├── index.js

index.js如下:

import { createRouter, createWebHashHistory } from 'vue-router'
import tabBar from './modules/tabBar'
// 静态路由
export const constantRoutes = [tabBar]

// 动态路由
export const asyncRoutes = []

const router = createRouter({
  // 新的 history 配置取代 mode
  history: createWebHashHistory(),
  routes: constantRoutes
  // Vue3 中scrollBehavior返回的对象 x 改名为 left,y 改名为 top
  // scrollBehavior: () => ({ top: 0 })
})

export default router

定义底部导航modules/tabBar.js,然后在相应的目录新建layoutHomemy组件:

import Layout from '@/layout'
export default {
  path: '/',
  component: Layout,
  meta: { title: '首页', keepAlive: true },
  redirect: '/home',
  children: [
    {
      path: 'home',
      name: 'home',
      component: () => import('@/views/home'),
      meta: { title: '首页', keepAlive: true }
    },
    {
      path: 'my',
      name: 'my',
      component: () => import('@/views/my'),
      meta: { title: '我的', keepAlive: true }
    }
  ]
}

main.js文件中引入:

import { createApp } from 'vue'
import router from './router'

app.use(router)

axios 封装

安装axios

npm install axios -S

src/utils/request.js封装axios

import axios from 'axios'
import store from '@/store'
const servie = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

// 添加请求拦截器
servie.interceptors.request.use(config => {
  if (store.getters.accessToken) {
    config.headers.accessToken = ''
  }
  return config
}, error => {
  console.log('err:' + error)
  return Promise.reject(error)
})

// 添加响应拦截器
servie.interceptors.response.use(response => {
  const res = response.data
  if (res.code !== 200) {
    return Promise.reject(res.msg || 'error')
  } else {
    return res
  }
}, error => {
  console.log('err:' + error)
  return Promise.reject(error)
})

export default servie

然后在src/api文件夹下统一管理接口就好了,如下:

// common.js

export function getInfo(data) {
  return request({
    url: '/info',
    method: 'get',
    data
  })
}

vue.config.js 配置

const path = require('path')
const resolve = dir => path.join(__dirname, dir)

module.exports = {

  publicPath: '/',
  outputDir: 'dist', // 生产环境构建文件的目录
  assetsDir: 'static', //  outputDir的静态资源(js、css、img、fonts)目录
  lintOnSave: false,
  productionSourceMap: false, // 如果不需要生产环境的 source map,可以将其设置为 false 以加速生产环境构建。

  // 跨域配置
  devServer: {
    disableHostCheck: true,
    proxy: {
      // /api如果网络请求中有这个字符串,那么就开始匹配代理
      '/api': {
        target: 'http://xx.xxx.com', // 指向开发环境 API 服务器
        changeOrigin: true, //  如果设置成true,发送请求头中host会设置成target
        ws: true, // 开启webSocket
        // 重写路径,替换成target中已/api开头的地址为空字符串,根据业务进行修改
        pathRewrite: {
          '^/api': ''
        }
      }
    },
    hot: true, // 设置为true的时候,如果编译报错,会抛出错误。重新改成正确的,又会触发重新编译,整个浏览器会重新刷新!
    port: 8999, // 端口号
    open: true, // 启动后打开浏览器
    // 当出现编译器错误或警告时,在浏览器中显示全屏覆盖层
    overlay: {
      warnings: true,
      errors: true
    }
  },
  chainWebpack: config => {
    // 别名 alias
    config.resolve.alias
      .set('@', resolve('src'))

    config.plugin('preload').tap(() => [
      {
        rel: 'preload',
        fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
        include: 'initial'
      }
    ])
    config.plugins.delete('prefetch')

    config.when(process.env.NODE_ENV !== 'development', config => {
      config
        .plugin('ScriptExtHtmlWebpackPlugin')
        .after('html')
        .use('script-ext-html-webpack-plugin', [
          {
            // 将 runtime 作为内联引入不单独存在
            inline: /runtime\..*\.js$/
          }
        ])
        .end()
      config.optimization.splitChunks({
        chunks: 'all',
        cacheGroups: {
          // cacheGroups 下可以可以配置多个组,每个组根据test设置条件,符合test条件的模块
          commons: {
            name: 'chunk-commons',
            test: resolve('src/components'),
            minChunks: 3, //  被至少用三次以上打包分离
            priority: 5, // 优先级
            reuseExistingChunk: true // 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的。
          },
          node_vendors: {
            name: 'chunk-libs',
            chunks: 'initial', // 只打包初始时依赖的第三方
            test: /[\\/]node_modules[\\/]/,
            priority: 10
          },
          vantUI: {
            name: 'chunk-vantUI', // 单独将 vantUI 拆包
            priority: 20,
            test: /[\\/]node_modules[\\/]_?vant(.*)/
          }
        }
      })
      config.optimization.runtimeChunk('single')
    })
  }
}

四、Node后端

一开始想用egg.js来开发,但是对node.js对于我来说对自己理解整个项目不太友好。所以自己就看了很多文章在加上动手写了一个基于koaMVC的后端模板,自己在这个基础上编写代码目前感觉也挺好用的。

需要解决的问题

自己手动搭建一个NodeJS后端模板,需要考虑的问题还挺多的,这里我列举一下我想到的:

  • 快速调试
  • 错误处理
  • 日志记录
  • 文件目录分层
  • 数据合法性校验
  • 区分开发环境和生产环境 ....

然后接下来就一步一步解决这些问题。

项目目录结构

首先先把项目的基础的目录结构列出来,有个大致的了解:

├── bin                        // 项目启动目录
    ├── www                    // 配置项
├── logs                       // 日志文件
├── src                        // 源代码
    ├── config                 // 公共配置文件
        └── index.js
        └── config-development.js  // 开发环境
        └── config-production.js   // 生产环境
        └── config-staging.js      // 测试环境
    ├── controllers            // 路由层
        └── userController.js   
    ├── db                     // 数据库连接配置
        └── index.js  
        └── init-db.js         // 自动生成数据库表
    ├── lib                    // 第三方依赖库
        └── WXBizDataCrypt.js  // 如微信小程序的加解密
    ├── middlewares            // 中间件
        └── errorHandle.js     // 错误处理中间件
        └── getTokenData.js    // 获取token中间件
        └── myLogger.js        // 写入日志中间件
    ├── models                 // 数据库模型
        └── index.js           // 自动加载models
        └── user.js            // user用户模型
    ├── services               // 服务层
        └── index.js           // 自动加载services
        └── userService.js     // 用户服务层
    ├── utils                  // 工具库
        └── response.js        // 自定义response
        └── exception.js       // 自定义异常
├── .eslintrc.js               // eslint配置               
├── app.js                     // 应用入口
├── ecosystem.config.js        // pm2配置文件
├── logs.config.js             // 日志配置
├── package-lock.json          
├── package.json               // 项目依赖

nodemon、cross-env介绍

nodemon用来监视node.js应用程序中的任何更改并自动重启服务。

cross-env用来处理windows和其他unix系统在设置环境变量的写法上不一致的问题。

需要安装下面依赖:

npm install cross-env -D
npm install nodemon -D

下文会在package.json中使用nodemoncross-env

Node 开发调试

--inspect命令可以启动调试模式。

然后在Chrome浏览器的地址栏,输入 chrome://inspect 回车后就可以看到下面的界面。

image.png

Target部分,点击inspect链接,就可以进入调试了。

第二种方法就是打开"开发者工具",顶部左上角有一个Node的绿色标志,点击就可以进入调试了。

image.png

具体更多使用细节可以看这篇文章

pm2和开发环境配置

因为本项目是基于koa2的,所以环境配置首先需要安装一下:

npm install koa -S

1. 新建www文件

bin文件夹下面新建www可执行文件,用来运行、关闭、重启服务。

#!/usr/bin/env node

/**
 * Module dependencies.
 */
var app = require('../app')
var http = require('http')

var port = normalizePort(process.env.PORT || '3000')

/**
 * Create HTTP server.
 */
var server = http.createServer(app.callback())

/**
 * Listen on provided port, on all network interfaces.
 */
server.listen(port)
server.on('error', onError)
server.on('listening', onListening)

/**
 * Normalize a port into a number, string, or false.
 */
function normalizePort(val) {
  var port = parseInt(val, 10)
  if (isNaN(port)) {
    // named pipe
    return val
  }
  if (port >= 0) {
    // port number
    return port
  }
  return false
}

/**
 * Event listener for HTTP server "error" event.
 */
function onError(error) {
  if (error.syscall !== 'listen') {
    throw error
  }
  var bind = typeof port === 'string'
    ? 'Pipe ' + port
    : 'Port ' + port

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case 'EACCES':
      console.error(bind + ' requires elevated privileges')
      process.exit(1)
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */
function onListening() {
  var addr = server.address()
  var bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port
  console.log('Listening on ' + bind)
}

这里是koa-generator程序启动的代码,根据我们的具体情况简单的修改直接使用就可以了。然后新建app.js,导入koa2

const Koa = require('koa')
const app = new Koa()

app.use(async(ctx, next) => {
  ctx.body = 'Hello World'
})

module.exports = app

2. 安装全局PM2

$ npm install pm2@latest -g
# or
$ yarn global add pm2

全局安装完成后,在package.jsonscript配置和pm2默认的读取的配置文件ecosystem.config.js进行多环境运行的配置。

// ecosystem.config.js
module.exports = {
  // 应用程序列表,pm2可以管理多个程序
  apps: [
    {
      name: 'www', // 应用进程名称
      script: './bin/www', // 启动脚本路径
      ignore_watch: ['node_modules', 'logs'], // 忽视监听的文件
      args: '', // 传递给脚本的参数
      instances: 1, // 应用启动实例个数,仅在cluster模式有效,默认为fork
      autorestart: true,
      watch: true, // 监听重启,启用情况下,文件夹或子文件夹下变化应用自动重启
      max_memory_restart: '1G', // 最大内存限制数,超出自动重启
      // 测试环境
      env_staging: {
        'PORT': 8002,
        'NODE_ENV': 'staging'
      },
      // 生产环境
      env_production: {
        'PORT': 80,
        'NODE_ENV': 'production'
      }
    }
  ]
}

3. 在package.json添加环境


"scripts": {
  "dev": "cross-env NODE_ENV=development PORT=8001 nodemon --inspect bin/www",
  "stage": "cross-env pm2 start ecosystem.config.js --env staging",
  "prod": "cross-env pm2 start ecosystem.config.js --env production",
  "logs": "pm2 logs",
  "stop": "pm2 stop ecosystem.config.js"
}

下面对相关环境进行解释:

npm run dev    // 开发环境
npm run stage  // 测试环境
npm run prod   // 生产环境
npm run logs   // 查看pm2日志
npm run stop   // 停止pm2服务

自定义异常

当编写代码的时候,我们自己可能会遇到一些未知的错误要抛出异常。比如说http错误,参数错误等。我们自己可以写一个工具类来进行处理。

// exception.js

/**
 * http 异常处理
 */
class HttpException extends Error {
  constructor(customError = {}) {
    super()
    const defaultError = { message: '参数错误', state: 500, errorCode: 10000 }
    const { message, status, errorCode } = Object.assign(defaultError, customError)
    this.message = message
    this.status = status
    this.errorCode = errorCode
  }
}

/**
 * request params 异常处理
 * example: throw new ParamsException({ message: '参数错误', status: 400,errorCode: 10001 })
 */
class ParamsException extends Error {
  constructor(customError = {}) {
    super()
    const defaultError = { message: '参数错误', status: 400, errorCode: 10001 }
    const { message, status, errorCode } = Object.assign(defaultError, customError)
    this.message = message // 返回的错误信息
    this.status = status // http status code 2xx 4xx 5xx
    this.errorCode = errorCode // 自定义的错误码,例如:10001
  }
}

module.exports = {
  HttpException,
  ParamsException
}

自定义response

自定义response来对返回前端的响应进行处理,把响应提取出来进行封装,返回的通用的格式:

/**
 * 响应成功格式
 * @param {*} data 数据
 * @param {*} code 响应码
 * @param {*} msg 消息
 * @returns {}
 */
const responseFormat = (data = {}, msg = 'success',code = 200) => {
  return {
    code,
    msg,
    data
  }
}

module.exports = {
  responseFormat
}

koa中间件的使用

中间件使用了常用的开源中间件和一些自定义中间件。

1. koa-bodyparser、koa-static、koa2-cors

安装后直接使用就可以了:

npm install koa-bodyparser -S
npm install koa-static -S
npm install koa2-cors -S
const static = require('koa-static')
const bodyparser = require('koa-bodyparser')
const path = require('path')
const cors = require('koa2-cors')

// post请求参数解析
app.use(bodyparser({
  enableTypes: ['json', 'form', 'text', 'xml']
}))
// 处理静态文件
app.use(static(path.join(__dirname, './src/public')))
// 跨域处理
app.use(cors())

2. 自定义logger中间件

为了线上更好地监控或排查问题,要记录请求的参数或者报错等信息。没有日志的话就无法定位问题。一个好的日志系统,在项目开发中是十分重要的。项目中使用log4js来进行项目日志的配置。首先进行安装:

npm install log4js -S

在根目录下新建logs.config.js进行测试环境和生产环境的日志输出配置:

/**
 * 日志配置文件
 */
const log4js = require('log4js')
const path = require('path')

let developmentLogConfig = {}
// 测试环境增加标准输出流
if (process.env.NODE_ENV !== 'production') {
  developmentLogConfig = {
    STDOUT: {
      type: 'stdout'
    }
  }
}

// 保存日志的文件名
const fileAllName = path.join(__dirname, './logs/all.log')
const fileErrorName = path.join(__dirname, './logs/error.log')

log4js.configure({
  /**
   * 如果生产环境在cluster模式下,pm2需要设置为true, 否则日志不生效
   * pm2: process.env.NODE_ENV === 'production'
   */
  appenders: {
    ...developmentLogConfig,
    FILE_ALL: {
      type: 'datefile', // log4js 会按照日期分日志,一天一个文件,每过一天都会把前一天的 all.log 重命名为 all.2021-06-03.log
      filename: fileAllName,
      backups: 10, // 日志最多保留10个
      maxLogSize: 10485760, // 文件最大值10M
      daysToKeep: 10, // 最多保留10天的日志,如果为0则永久保存
      keepFileExt: true // 是否保持日志文件后缀名
    },
    FILE_ERROR: {
      type: 'datefile',
      filename: fileErrorName,
      daysToKeep: 30,
      keepFileExt: true
    }
  },
  categories: {
    default: {
      appenders: process.env.NODE_ENV !== 'production' ? ['STDOUT', 'FILE_ALL'] : ['FILE_ALL'],
      level: 'debug'
    },
    error: {
      appenders: ['FILE_ERROR'],
      level: 'error'
    }
  }
})

const defaultLogger = log4js.getLogger()
const errorLogger = log4js.getLogger('error')

// 导出对应的log
module.exports = {
  defaultLogger,
  errorLogger
}

middlewares文件夹下新建myLogger.js中间件,并在app.js里注册中间件记录接口请求时间,写入日志:

// 文件目录:src/middlewares/myLogger.js
const { defaultLogger } = require('../../logs.config')

module.exports = function() {
  return async(ctx, next) => {
    const start = new Date()
    await next()
    const ms = new Date() - start
    const logText = `${ctx.method} ${ctx.status} ${ctx.url} 响应时间:${ms}ms`
    defaultLogger.info(logText)
  }
}

最后在app.js使用中间件

const myLogger = require('./src/middlewares/myLogger')

// 需要放在所有中间件的最顶部
app.use(myLogger())

3. jwt验证中间件

项目使用jsonwebtoken生成tokenkoa-jwt来进行jwt验证。

npm install jsonwebtoken -S
npm install koa-jwt -S
const jwt = require('jsonwebtoken')
// 可以写入配置文件中
const config = {
  // 自定义jwt加密的私钥
  PRIVATE_KEY: 'private_key',
  // 过期时间秒为单位
  JWT_EXPIRED: 60 * 60
}

// 生成token
const token = jwt.sign({ data: 'testToken', exp: config.JWT_EXPIRED }, config.PRIVATE_KEY)

ctx.body = {
  token
}

然后通过koa-jwt配置中间件实现对token的验证:

// app.js
const koaJwt = require('koa-jwt')
app.use(koaJwt({ secret: 'private_key' }).unless({
  // 设置login、register接口,可以不需要认证访问
  path: [
    /^\/user\/login/,
    /^\/user\/register/
  ]
}))

4. 统一进行异常处理中间件

良好的编码习惯必然离不开异常处理,通过对异常处理,不仅仅要返回给客户端进行友好的提示,还要把错误的堆栈信息写入日志。

// errorHandles.js

const { errorLogger } = require('../../logs.config')

module.exports = function() {
  return async(ctx, next) => {
    try {
      await next()
    } catch (err) {
      errorLogger.error(err.stack)
      // 异常处理
      ctx.status = err.statusCode || err.status || 500
      ctx.body = {
        msg: err.message || '服务器错误',
        errorCode: err.errorCode || err.statusCode || err.status || 500
      }
    }
  }
}

// 文件目录:app.js
const Koa = require('koa')
const app = new Koa()
const errorHandler = require('./src/middlewares/errorHandler.js')
// 在jwt中间件验证之前使用
app.use(errorHandler())

数据校验

在定义接口的时候我们不能信任用户提交过来的数据,需要对数据进行最基本的校验。验证是一件麻烦的事情,很有可能你需要验证数据类型,长度,特定规则等等。joihapijs自带的数据校验模块,他已经高度封装常用的校验功能,这里将使用joi来进行数据校验和错误的处理:

// 安装
npm install joi -S

下面是一个demo,具体API可以看joi的官方文档

const Joi = require('joi')
const { ParamsException } = require('../utils/exception')
router.get('/vali', async ctx => {
  const schema = Joi.object({
    username: Joi.string().min(1).max(30).required(),
    password: Joi.string().trim().pattern(new RegExp('^[a-zA-Z0-9]{3,30}$')),
  })

  // allowUnknown: true 代表对多余传进来的变量不要理会
  const { error } = schema.validate({ username: 'bob', age: 10 }, { allowUnknown: true })
  if (error) {
    // 使用自定义异常抛出错误
    throw new ParamsException({ message: error.details[0].message, state: 400 })
  }
  ctx.body = {msg:'success'}
})

当抛出ParamsException异常后,自定义全局异常处理捕获到异常,返回给用户提示并写入日志。

controllers路由层

这里使用koa-router来搭建模板的路由部分。首先需要安装koa-router:

npm install koa-router -S

controllers目录新建userController.js,如下:

const Router = require('koa-router')
const router = new Router()

router.prefix('/user')

router.get('/getInfo', ctx => {
  ctx.body = { data: 'getInfo' }
})

module.exports = router

然后就可以在app.js里通过程序读取controllers目录下的所有路由文件,来统一注册路由,这样就不用每次新建一个文件就要重新导入了。

// app.js
const path = require('path')
const fs = require('fs')
const Koa = require('koa')
const app = new Koa()

/* loader router */
const jsFiles = fs.readdirSync(path.join(__dirname, './src/controllers')).filter(file => file.indexOf('.') !== 0 && file.endsWith('.js') !== -1)

jsFiles.forEach(file => {
  const mapping = require(path.join(__dirname, './src/controllers', file))
  app.use(mapping.routes(), mapping.allowedMethods())
})

services层和models层也同理。

models层

定义services层之前,需要定义models层模型。该项目中是使用mysql对数据库进行操作。需要初始化数据库配置和定义。项目使用的是NodeORM框架Sequelize来操作数据库。首先需要安装:

npm install mysql2 -S
npm install sequelize -S

定义数据库连接的基础信息配置:

// config-development.js

const config = {
  myEnv: 'development',
  mysql: {
    dialect: 'mysql',
    host: 'xxx.xx.xx.x',
    port: 3306,
    database: 'template_db',
    username: 'root', // 数据库用户名
    password: '123456', // 数据库密码
    // 连接池配置
    max: 20,
    min: 10,
    // 当前连接多久没有操作就断开
    idle: 10000,
    // 多久没有获取到连接就断开
    acquire: 30000
  }
}
module.exports = config

sequelize新建model的语法有好几种,这里使用sequelize.define来定义model。因为我们要自定义一些自己的内容,不要直接使用SequelizeAPI,通过新建db文件夹下面的index.js文件加入我们自定义内容来间接的定义Model。如下:

const { v4: uuidv4 } = require('uuid')
const { mysql } = require('../config')
const { Sequelize, DataTypes } = require('sequelize')
const { dialect, host, port, database, username, password, max, min, idle, acquire } = mysql


// 初始化对象
const sequelize = new Sequelize({
  dialect,
  host,
  port,
  database,
  username,
  password,
  pool: {
    max,
    min,
    idle,
    acquire
  },
  define: {
    freezeTableName: true, // 取消表名复数形式
    underscored: true // 驼峰下划线转换
  },
  query: {
    raw: true
  }
})

/*
  * uuid
  * @returns uuid
  */
function generateId() {
  return uuidv4().replace(/-/g, '')
}

/*
  * 统一定义model
  * 每个model必须有createAt,updateAt。
  * 主键使用uuid生成,名称必须是id。
  */
function defineModel(name, attributes, options = {}) {
  const attrs = {
    ...attributes,
    id: {
      type: DataTypes.STRING(50),
      primaryKey: true
    },
    // createAt和updateAt以BIGINT存储时间戳,无需处理时区问题。
    createAt: {
      type: DataTypes.BIGINT
    },
    updateAt: {
      type: DataTypes.BIGINT
    }
  }
  // console.log(`model->${name} is create`)
  return sequelize.define(name, attrs, {
    ...options,
    timestamps: false,
    hooks: {
      // create前的hook,插入id,createAt,updateAt
      beforeCreate(instance, options) {
        if (!instance.id) {
          instance.id = generateId()
        }
        const now = Date.now()
        instance.createAt = now
        instance.updateAt = now
      },
      // update前的hook,更新updateAt
      beforeUpdate(instance) {
        instance.updateAt = Date.now()
      }
    }
  })
}

const mysqlDB = {
  defineModel,
  // 测试环境下生成表结构功能
  sync() {
    return new Promise((resolve, reject) => {
      if (process.env.NODE_ENV !== 'production') {
        sequelize.sync({ force: true }).then(() => {
          console.log('创建成功!')
          resolve()
        }).catch(err => {
          reject(err)
        })
      } else {
        reject('不能在生产环境下使用sync()')
      }
    })
  }
}
module.exports = {
  DataTypes,
  mysqlDB,
  sequelize
}

可以在开发模式下,通过node init-db.js自动生成数据库表:

const { sync } = require('../models')
/**
 * 初始化数据库
 */
sync().then(() => {
  console.log('init db ok!')
  process.exit(0)
}).catch((e) => {
  console.log('failed with: ' + e)
  process.exit(0)
})

这里通过自定义defineModel统一主键生成方式,统一createAtupdateAt为时间戳。然后所有的Model存放在models文件夹内,并且以model的实际名称命名。如下面的user.js

const { mysqlDB, DataTypes } = require('../db')

module.exports = mysqlDB.defineModel('user',
  {
    username: {
      type: DataTypes.STRING(255),
      allowNull: false,
      comment: '用户名'
    },
    password: {
      type: DataTypes.STRING(255),
      allowNull: false,
      comment: '密码'
    }
  },
  {
    tableName: 'user' // 自定义表名
  }
)

最后在models文件夹下面新建index.js来自动扫描所有model

// models/index.js
const fs = require('fs')
const path = require('path')
const { sequelize, mysqlDB } = require('../db')

const jsFiles = fs.readdirSync(__dirname).filter((file) => {
  return file.endsWith('.js') && (file !== 'index.js') && (file.indexOf('.') !== 0)
})

const models = {}

jsFiles.forEach((file) => {
  const name = file.substring(0, file.length - 3)
  models[name] = require(path.join(__dirname, file))
})

models.sequelize = sequelize

// 生成数据库表
models.sync = async() => {
  return await mysqlDB.sync()
}

module.exports = models

这样就可以通过const { user } = require('../models/index')来引入不同的model了。

services层

services主要负责操作数据库逻辑。和上面models文件一样,首先新建index.js文件进行自动注册每个service

// services/index.js
const fs = require('fs')
const path = require('path')

const jsFiles = fs.readdirSync(__dirname).filter(file => {
  return file.endsWith('.js') && (file !== 'index.js') && (file.indexOf('.') !== 0)
})

const service = {}

jsFiles.forEach((file) => {
  const name = file.substring(0, file.length - 3)
  service[name] = require(path.join(__dirname, file))
})

module.exports = service

这样就可以通过const { userService } = require('../services')来动态导入所有的service了。然后新建userService.js。进行数据库的操作:

const { user, sequelize } = require('../models')

module.exports = {
  // 登录
  async login({ username, password }) {
    return await user.findOne({ where: { username, password }})
  },
  // 编辑用户
  async editUser(user) {
    // 更新数据的时候要开启`individualHooks:true`选项,否则不会触发`beforeUpdate`的`hooks`。
    const result = await sequelize.transaction(async(t) => {
      const res = await user.update(user, { where: { id: user.id }, transaction: t, individualHooks: true })
      return res
    })
    return result
  },
  // 增加用户
  async addUser({ username, password }) {
    // 开启事务
    const result = await sequelize.transaction(async(t) => {
      const result = await user.create({ username, password }, { transaction: t })
      return result
    })
    return result
  }
}

遇到多条SQL语句执行的时候,一定要开启事务。sequelize.transaction是框架对事务的操作,默认使用数据库的隔离级别。

sequelize日志

sequelize输出日志默认是在控制台输出,需要修改默认配置,开发环境下在控制台输出,生产环境下输出到日志文件里,如下:

const sequelize = new Sequelize({

// 省略其他一些配置...


logging: process.env.NODE_ENV === 'production' ? sqlLogger : console.log
})

function sqlLogger(msg) {
  defaultLogger.info(msg)
}

五、总结

使用常用的技术和框架,通过一步一步的搭建,完成了Vue3Node前后端项目的结合。

项目github源码,源码有些细节没有写在文章里,喜欢的可以再去了解下。