MVVM 现在很火,前后端分离模式也越来越普及。正好公司最近要使用前后端分离的协作模式来构建后台管理系统,前端基于 Vue.js 来构建,项目基于 Vue CLI 创建,在构建一些后台通用功能时、参考了知名的 iView Admin 项目,例如根据路由生成的导航菜单、面包屑导航等。下面就介绍一下本次实践过程中遇到的一些问题和解决方法。

这里先说明一下笔者在项目中与后端的通信方式:用户登录成功后获取 token ,并将该 token 保存在 sessionStorage 中(刷新时不会失效),随后的每次 HTTP 请求都将该 token 放在 HTTP Request Headers 中供后端校验,用户退出或者 token 失效时从 sessionStorage 中清除它。

动态路由(权限控制)

对于一个常见的 CRUD 后台管理系统,最主要的非业务功能需求,就是对于用户或者角色的菜单权限动态控制(在依赖 Vue Router 的项目中即菜单对应的动态路由,注意与路由动态 import 组件的区别)。 iView Admin 其实已经帮我们解决了在构建一般后台管理系统时的大部分问题,但是对于后台管理系统最常见的权限控制功能,只是做了一个简单的基于角色的权限控制,并没有对路由做动态处理,对于功能不多、权限要求不太严格的系统,初始化时注册好全部菜单路由然后在 router.beforeEach 导航守卫中根据角色权限来放行的这种权限控制方式是合适的,但是对于权限要求严格、业务复杂的系统,这样做就降低了灵活性。

理想的做法是,用户的菜单权限数据在后台持久化,用户登录后,从后台获取的用户信息中拿到菜单权限树并保存到 Vuex 中,然后根据该结构数据来动态创建用户的导航菜单路由,最后通过 router.addRoutes(routes) 方法添加到实际的路由中去。不过在实现上述过程中有几个问题要注意:

  • 打开应用时,默认打开的是根路径 / ,但由于在初始状态的路由中并未添加该路由(初始路由只添加了 errors (401/404/500) 和 login 等页面的路由,根路径 / 通常的做法是在登录成功后 redirect 到第一个菜单功能或 dashboard 页),因此会跳转到 404 页面(因为 404 路由的 path 是 * ),所以 404 的路由也需要在添加完菜单权限路由后动态添加。

  • 用户登录后,若手动刷新页面,对于 SPA 来说,应用的 Vue 根实例(在 main.js 中定义)会被重新初始化,显然 Vuex 中的菜单权限树也会被重置,因此需要在用户刷新页面时,重新向后台获取菜单权限树生成菜单路由。

  • 用户在退出系统后(调用 logout 接口,页面未整体刷新)重新登录,这时会重复添加动态路由,Vue 在控制台会有 warning 提示: [vue-router] Duplicate named routes definition: xxx 可以使用 Vue Router 的 matcher 属性来重置重复动态路由,参考这里

  • 页面刷新时,因为要重新获取菜单权限树生成菜单路由,为保证刷新后仍然停留在当前路由页面,可根据 router.beforeEach 导航守卫中的 to.path 来处理,以保证刷新后仍然停留在当前页面。

BaseAPI

管理后台主要就是做 CRUD 操作,符合规范的 RESTful API 基本都是基于不同资源 namespace 的 CRUD 操作,可以利用 ES6 的 Class API 很方便的实现一个 BaseAPI Client,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import client from '@/lib/axios'
/**
* Base api client
*/
class BaseAPI {
constructor(prefix) {
this.prefix = prefix
}

view(id) {
return client.request({
url: `/${this.prefix}/${id}`
})
}

viewByObj(data) {
return client.request({
url: `/${this.prefix}/info`,
data
})
}

viewList(params) {
return client.request({
url: `/${this.prefix}/list`,
params
})
}

countList(params) {
return client.request({
url: `/${this.prefix}/count`,
params
})
}

create(data) {
return client.request({
url: `/${this.prefix}/add`,
data,
method: 'post'
})
}

update(data) {
return client.request({
url: `/${this.prefix}/edit`,
data,
method: 'put'
})
}

remove(id) {
return client.request({
url: `/${this.prefix}/delete/${id}`,
method: 'delete'
})
}
}

export default BaseAPI

示例中定义的这 7 个方法类似常见的后端持久化框架定义的数据库 CRUD 方法,要为一个功能模块定义 API 时,继承该 BaseAPI 即可实现复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import BaseAPI from './base'
import client from '@/lib/axios'

class TestAPI extends BaseAPI {
constructor(prefix = 'test') {
super(prefix)
}

test() {
return client.request({
url: `/test/req`
})
}
}

export default new TestAPI()

每个模块的 API 在构造时提供参数 prefix ,即 namespace, 在组件中可以这样使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import TestAPI from '@api/test'

// http://api.example.com/v1/test/list
TestAPI.viewList({ foo })
.then(r => {
console.log('resp', r)
})
.catch(err => {
console.error('err', err)
})

// http://api.example.com/v1/test/req
TestAPI.test({ foo })
.then(r => {
console.log('resp', r)
})
.catch(err => {
console.error('err', err)
})

功能权限指令 v-permission

另一项重要的权限控制就是功能按钮的权限控制,如列表中的操作按钮(查看,编辑,删除等),在 Vue 中我们可以使用自定义指令来实现,权限指令的设计目标是应当尽可能简化权限植入代码和保持直观。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import store from '@/store'
import { oneOf } from '@lib/assist'

export default {
inserted(el, { arg, value }, { context }) {
let pageName = context.$options.name
if (value) {
pageName = value
}
if (!oneOf(`${pageName}-${arg}`, store.state.user.permission)) {
if (Element.prototype.remove) {
el.remove()
} else {
el.parentNode.removeChild(el)
}
}
}
}

同样地,我们将按钮的权限列表也放在了 Vuex 中,可以约定 {功能模块名}-{功能按钮名} 这样的结构存储。要注意在这里是使用权限指令时, 功能模块名需要和 Vue 单文件组件的 name 保持一致。以 test-module 中的 delete 按钮为例:

Vuex 中的 permission 列表中可能包含了该按钮的权限:

1
2
3
4
5
6
7
permission: [
...
'test-module-edit',
'test-module-delete',
'test-module-add',
...
]

自定义指令 v-permission 在 Vue Template 中的使用方法:

1
2
3
4
5
6
7
8
9
10
11
<template>
<div>
...
<button @click="testDelete" type="primary" v-permission:delete>
删除
</button>
...
</div>
</template>

export default { name: 'test-module' }

render 函数里的使用方法:

1
2
3
4
5
6
7
directives: [
{
name: 'permission',
arg: 'delete',
value: 'test-module'
}
]

项目地址:https://github.com/zkerhcy/base-admin

参考