🔥开源了一个可能是最适合后端开发者的Vue3后台框架

7,196 阅读8分钟

1. 写在前面

先聊聊我们为什么做了这么个项目吧。

在2019年夏天的某个夜晚,我还在拿 Vue2/JavaScript/Element UI 肝一个 B/S 架构的 Web B端后台管理项目。当时正在做的是一个 WMS系统 的出入库单据管理页面,出库单提交的时候,需要先查询其他 N 个接口的数据进行组合之后提交到后端。然而此时问题出现了:

  • 某些接口返回的主键是 Long 的ID,有些接口返回的是类似 userId 类的主键 key
  • 某些接口布尔值返回了 1/0,有些又返回了 true/false
  • 某些接口的 createTime 返回的是毫秒时间戳,有的返回的是 yyyy-MM-dd HH:mm:ss 格式的字符串
  • 某些接口的 空数组数据 返回的不是 [] 而是 null
  • 某些接口的字符串数据返回的不是 "" 而是 null
  • 某些 PUT 接口表示了新建一条数据,POST 又用来做了查询
  • 某些接口直接 {"code":500, "message":"未知异常"}
  • 某些接口。。。
  • 某些。。。

在我情绪即将崩溃之际,凭借我三十年的工作经验(梗),我决定做点什么。于是有了这篇文章:分享一些我们正在使用的API接口开发规范 (当然,实际我们约定的规范这只是冰山一角)

然而约定规范后发现,后端改起来倒是挺快的(按照规范,我后来个人出了一套 Java 框架: AirPower4J),前端改起来可麻烦大了:

  • 项目已经进行了 30% - 40% 了,直接改掉之前的也不太恰当了。
  • 新的代码使用这套规范的话,基于 JavaScript 的灵活,也不太好约束后来的代码了。
  • 推翻重来的话,成本又太高了。

于是这个项目本着 瞎** 完成的原则进行了开发和交付,但团队成员都沉下心来思考了一下之前碰到的问题(团队成员水平参差不齐,而且团队是新成立的,也没有默契)。

最终我们决定 先写规范,再写代码,先做好技术选型后再开发业务代码。

2. 技术选型

在团队小伙伴激烈的讨论和评审之后,最终我们决定使用 TypeScript + Vue3 + Element Plus + Vite + Axios + Vue Router + Sass + ESLint + Prettier 等技术栈的方式来进行开发:

其中 TypeScript 严格使用 数据建模 来规范代码, Vue3 采用 Composition API 模式, ESLint 采用 Airbnb 规范 等等。

于是乎有了这么一些清晰的概念(这里后端的小伙伴应该很熟悉了,特别是 Java SpringBoot JPA 的后端程序员):

因为起名的问题(抄了 Apple 难产产品的名字 AirPower),很多类都携带了前缀 Air

AirModel

一切数据结构(除配置类的JSON数据结构)之外,都继承来自 AirModel, 表示该类数据结构都至少包含 copy() toJson() recoverBy() 等很多常用的方法。

AirEntity(继承自 AirModel)

一切从数据库里出来的数据,都使用 Entity 声明为 实体,且继承来自 AirEntity,表示至少有 ID 创建时间 修改时间 这些前后端约定的公共属性

AirService

表示前端发送网络请求的 超类,其中完成了 Axios 的封装、Loading态管理、请求头管理、请求异常处理、响应数据处理、超时管理等等网络请求的通用功能。

AirEntityService(继承自 AirService)

表示是 实体数据 的网络请求服务,其中至少包含了 add() update() delete() get() list() page() 等网络请求功能。

AirEnum 枚举类

表示标准枚举类,其中至少包含 key label 等属性,且提供了 AirEnum 的助手类来完成一些枚举的其他功能,如通过 key 获取 label、创建枚举、枚举转数据等等常用的功能。

Decorator 装饰器

Java 注解类似,在前端的 TypeScript 里,我们也声明了大量的装饰器来完成一些通用的功能,实现一处配置处处生效。比如:

  • @Form 表示该属性是表格字段,且可以配置 输入类型 默认值 数据校验 输入长度 表单说明 等常用的表单选项

  • @Table 表示该属性是表格字段,且可以配置表格的 宽度 是否显示 排序 是否枚举渲染 是否可复制 是否是时间列 等等表格选项

  • @Search 表示该字段是搜索字段,会显示到表格上方的搜索区域里,且可配置搜索的一些表单样式等等

  • @Model @Field 表示类或字段的一些翻译信息,比如 UserEntity 是用户, nickName 是昵称等等

  • @Type 表示强制转换类型,如后端的 1/0 转到前端的 true/false,数组的 null 自动转为 [] 等,同时还提供了 类到JSON 和 JSON 到类的自定义转换等装饰器

  • @Alias 表示类似 fastjson 的一些属性别名配置,可处理后端改名之后前端需要跟着修改的情况

还有很多很多很多很多。

看到这里,相信有一些 Java 开发者已经很熟悉了。

3. 部分实现

接下来我们将用代码来演示我们一些和后端非常类似的 前端、Vue3、TypeScript 的一些开发示例

3.1 实体类的声明

我们将通过继承的方式来声明用户实体,除了用户本身的属性之外,还从 BaseEntity 上继承了一些基础属性,如 ID 创建时间 修改时间 等等。

同时使用了大量的装饰器来对用户的一些行为进行了描述

@Model("用户")
export class UserEntity extends BaseEntity {
  @Form({
    email: true,
    requiredString: true,
  })
  @Table()
  @Search()
  @Field('邮箱') email!: string

  @Form({
    password: true,
  })
  @Field('密码') password!: string

  @Form({
    requiredString: true,
    minLength:3,
    maxLength: 12
  })
  @Table()
  @Search()
  @Field('昵称') nickname!: string

  @Form({
    mobilePhone: true,
    requiredString: true,
  })
  @Table({
    phone: true,
  })
  @Search()
  @Field('手机') phone!: string

  @Field('角色')
  @Table({
    payloadArray: true,
    payloadField: 'name',
  })
  @Type(RoleEntity, true) roleList!: RoleEntity[]
}

3.2 Service(API)的声明

通过类的声明,完成了后端API路径的配置,同时从服务基类中继承了所有的增删改查方法

export class UserService extends AbstractBaseService<UserEntity> {
  baseUrl = 'user'

  entityClass = UserEntity
  
  async login(user: UserEntity): Promise<string> {
    const result = await this.api('login').post(user)
    return result as unknown as string
  }

  async register(user: UserEntity): Promise<void> {
    await this.api('register').post(user)
  }

  async resetMyPassword(user: UserEntity): Promise<void> {
    await this.api('resetMyPassword').post(user)
  }
  
  async getMyInfo(): Promise<UserEntity> {
    const json = await this.api('getMyInfo').post()
    return UserEntity.fromJson(json)
  }
}

3.3 枚举的声明

声明了枚举之后,可以将这个枚举类标记到 @Form @Table @Search 等装饰器上,前端页面将会按照枚举进行下拉框的渲染等操作

import { AirEnum } from '@/airpower/base/AirEnum'
import { AirColor } from '@/airpower/enum/AirColor'

export class MaterialTypeEnum extends AirEnum {
  static readonly PUBLIC = new MaterialTypeEnum(1, '公共物料', AirColor.SUCCESS)
  static readonly PRIVATE = new MaterialTypeEnum(2, '私有物料', AirColor.NORMAL)
}

3.4 简单表格页面

通过我们内置的一些组件封装,实现了统一风格的标准工具栏、表格、分页等,且提供了方便的 useAirTable 的 Hooks,直接使用一些增删改查、禁用启用的基础功能。

<template>
  <APanel>
    <AToolBar
      :loading="isLoading"
      :entity="UserEntity"
      :service="UserService"
      @on-add="onAdd"
      @on-search="onSearch"
    />
    <ATable
      v-loading="isLoading"
      :data-list="response.list"
      :entity="UserEntity"
      :ctrl-width="150"
      show-enable-and-disable
      @on-edit="onEdit"
      @on-delete="onDelete"
      @on-enable="onEnable"
      @on-disable="onDisable"
    />
    <template #footerLeft>
      <APage
        :response="response"
        @on-change="onPageChanged"
      />
    </template>
  </APanel>
</template>

<script lang="ts" setup>
import {
  APage, APanel, ATable, AToolBar,
} from '@/airpower/component'
import { UserEntity } from '@/model/user/UserEntity'
import { UserService } from '@/model/user/UserService'
import { useAirTable } from '@/airpower/hook/useAirTable'
import { UserEditor } from './component'
import { AirDialog } from '@/airpower/helper/AirDialog'
import { UserSelector } from '@/view/console/user/component'

const {
  isLoading, response,
  onPageChanged, onDelete, onEdit, onAdd, onSearch, onEnable, onDisable,
} = useAirTable(UserEntity, UserService, {
  editView: UserEditor,
})
</script>

3.5 编辑用户

通过封装的弹窗、表格字段等组件,通过 useAirEditor Hooks快速取出需要的一些方法和属性,快速实现用户的编辑功能。

<template>
  <ADialog
    :title="title"
    :form-ref="formRef"
    :loading="isLoading"
    confirm-text="保存"
    :fullable="false"
    @on-confirm="onSubmit()"
    @on-cancel="onCancel()"
  >
    <el-form
      ref="formRef"
      :model="formData"
      label-width="120px"
      :rules="rules"
      @submit.prevent
    >
      <AFormField field="email" />
      <AFormField field="nickname" />
    </el-form>
  </ADialog>
</template>

<script lang="ts" setup>
import {
  AButton, ADialog, AFormField, AGroup,
} from '@/airpower/component'
import { airPropsParam } from '@/airpower/config/AirProps'
import { UserService } from '@/model/user/UserService'
import { UserEntity } from '@/model/user/UserEntity'
import { AirDialog } from '@/airpower/helper/AirDialog'
import { useAirEditor } from '@/airpower/hook/useAirEditor'

const props = defineProps(airPropsParam(new UserEntity()))

const {
  isLoading, formData, formRef, title, rules,
  onSubmit,
} = useAirEditor(props, UserEntity, UserService)
</script>

3.6 验证器相关

我们对 ElementPlus 的验证器进行了封装,使用起来更简单,更语义化(当然,@Form 上本身已经支持了很多验证):

const rules = {
    nickname: [
      AirValidator.show('不允许包含管理员等字符').ifContain('管', '理', '员'),
      AirValidator.show('昵称只允许中文').ifNotChinese(),
      AirValidator.show('昵称不能为空').ifEmpty(),
    ],
    idcard: [
      AirValidator.show('不是有效的二代身份证').ifNotChineseIdCard(),
    ],
    account: [
      AirValidator.show('账号只允许字母和数字').ifNot(AirInputType.NUMBER, AirInputType.CHAR),
    ],
  }

3.7 更多的例子

太多了太多了。。。

4. 框架设计原则

AirPower4T 的灵感来自于 Java SpringBoot JPA 等后端开发思想,使用了大量的 类、枚举、接口、装饰器 等, 提供了很多基于 Element Plus 的常用后台管理组件,帮助开发者快速开发 Web 应用。

AirPower4T 的设计理念是 面向对象编程,将一切能抽象的功能、数据结构、服务封装为 类, 使用类的继承来实现一些代码的复用,减少重复代码,使代码更加清晰易读。 使用装饰器来实现一些 常用组件、数据转换规则、前端显示文案 等信息的配置,使组件的配置更集中、灵活、直观。

与目前主流的编码方式不太一致,但都是通过一些设计模式、架构思想、封装思维来完成一些重复代码的设计和封装、快速二次开发的接入等。

5. 开源仓库

我们目前是以核心包子目录的方式和宿主项目进行集成,如果你有兴趣,可以看看我们这套前端框架 AirPower4T 的开源仓库:

具体如何使用,可以阅读开源仓库的 README

当然,我们也提供了与 AirPower4T 这套设计完美融合的后端框架 AirPower4J,如果你想做全栈,也可以参考我们后端的开源仓库:

6. 最后

感谢你的阅读,如果能看到这里,也感谢你浓浓的兴趣。

今天的文章完毕。