使用 vue3 写一个登录页

前言

对初学 web 前端的人来说,一个登录页面可能会是他们前端打怪生涯中的最初遇到的几个 Boss 之一。几乎任何系统都需要登录功能,因为登录功能是系统实现身份认证和访问控制的第一步。

很久以前,前端大牛们写一个登录页,都是新建一个 html 文件手撸一遍页面模板,新建一个 css 文件手撸一遍页面样式,再新建一个 js 文件手撸一遍登录逻辑。而现在,这种方式显得十分古老。我作为一个初探前端的新手,在经历各种尝试后,给大家介绍如何使用 vite+vue3+ typescriptanyscript 写一个登录页。

介绍登录

一个登录页中应该有什么,这应该是很多初学者需要面对的问题。在我看来,完成一次登录需要以下几个步骤:

  1. 对于未持有令牌的匿名访客,只能允许其访问公共页面,否则跳转登录页。
  2. 在登录页中提交身份认证的信息,例如账号、密码。
  3. 后端通过后,返回一个标识身份的令牌,例如 token。
  4. 使用令牌从后端获取用户信息,例如角色、权限。
  5. 通过角色、权限为用户量身定做一个路由表。
  6. 用户可以自由访问所给路由表中的全部网页

根据以上6步,我总结了以下使用 vue3 编写登录功能所涉及到的知识:

  1. 写一个靓靓的登录页(html+css)
  2. 异步请求相关知识(axios)
  3. 前端路由的相关知识(vue-route)
  4. 模拟后端数据(mock)
  5. 全局状态管理(vuex,例子用的是 pinia)

这里给出 vue3 项目配置的小纸条——是时候学习 vue3 了

一个靓靓的登录页

不要觉得代码很多,这个登录页除了2个输入框和一个提交按钮外,其他都是样式相关的代码。

简单地说,就是通过 vue 的 v-model 指令,我们实现了2个输入框和变量的双向绑定。然后我们编写按钮事件,调用 doLogin 将2个输入框的内容传入作为参数。然后在回调中反馈登录的状态。

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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
<!-- 文件名:@/components/Login.vue -->
<template>
<div class="login10">
<div class="login10-bg"></div>
<div class="login10-container">
<div class="login10-container-head">Login</div>
<div class="login10-container-wrap">
<i class="iconfont icon-yonghu"></i>
<input v-model="form.username" type="text" name="username" placeholder="Username">
</div>
<div class="login10-container-wrap">
<i class="iconfont icon-mima"></i>
<input v-model="form.password" type="password" name="password" placeholder="Password">
</div>
<div class="login10-container-access">
<input type="checkbox">
<span>do you access the services ?</span>
</div>

<input class="login10-container-action btn" type="button" value="Login" @click="onSubmit()">

<div class="login10-container-or">OR</div>
<input class="login10-container-action btn" type="button" value="Login with twitter">

<div class="login10-container-signup">
<span>don't have account ? <a href="#">sign up</a></span>
</div>
</div>
</div>
</template>

<script lang='ts'>
import { defineComponent, ref, reactive, toRefs, onBeforeMount, onMounted, isReactive} from 'vue'
import { useLayoutStore } from '@/store/modules/layout';
import router from '../router'
interface DataProps {}
export default {
name: '',
setup() {
const layoutStore = useLayoutStore()
const form = reactive({
username:'',
password:''
})
const onSubmit = async()=>{
const { username, password } = form
layoutStore.doLogin({username,password}).then(()=>{
console.log('登录成功',layoutStore.getStatus.ACCESS_TOKEN)
router.push({ path: '/' })
}).catch(e=>{
console.log(e)
})
}
return {
form,onSubmit
}
}
};
</script>

<style lang='less' scoped>
.btn{
border:none;
outline: none;
width:100%;
height:40px;
font-size:16px;
border-radius:40px;

}
.login10{
overflow: hidden;
height:100vh;
position:relative;
font-family: sans-serif;

&-bg{
position: absolute;
width:100%;
height:100%;
background:linear-gradient(-45deg,rgba(64, 115, 158,1.0),rgba(39, 60, 117,1.0));
background-size:cover;
z-index:-100;
}

&-container{
background:rgba(255,255,255,.6);
margin:60px auto 0;
width:400px;
border-radius:16px;
padding:40px;
display:flex;
align-items: center;
flex-direction: column;

border-top:2px solid rgba(255,255,255,.3);
border-left:2px solid rgba(255,255,255,.3);
box-shadow:2px 2px 10px rgba(0,0,0,.2);
&-head{
font-size:30px;
margin:40px 0;
}
&-wrap{
width:100%;
height:40px;
background:rgba(245, 246, 250,1.0);
border-radius:40px;
margin-bottom:20px;
display:grid;
grid-template-columns:15% 86%;
input{
outline:none;
border:none;
background:none;
font-size:16px;
&::placeholder{
font-size:16px;
}
}

i{
line-height:40px;
text-align: center;
}
}

&-access{
margin-bottom:20px;
width:100%;
padding:0 .4rem;
display:flex;
justify-content: flex-end;
span{
margin-left:8px;
}
}

&-action{
margin-bottom:20px;
}

&-or{
margin-bottom:20px;
display:flex;
width:100%;
&:before,&:after{
content:'';
border-bottom:1px solid black;
flex:1 1;
margin:auto;
}
}

&-signup{
margin:20px 0;
}
}
}
</style>

全局状态管理

上面的登录页使用了 store。这是一个全局状态管理组件,通过这个组件,我们可以设置整个应用程序的通用状态。

比方说,登录就是一种状态,我们可以用一个布尔变量 isLogin 来标识某个访客是否登录了系统。不过我这里并没有这样做,因为目前比较流行的是使用 token 来标识一个访客的登录状态。只要拿到了 token,就表明该访客登录成功,我们需要的是使用 store 保存这个 token,以便在其他组件中取出并使用。

使用 pinia 来做全局状态管理

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
62
63
64
65
66
67
68
69
70
// 文件名:@/store/modules/layout.ts
import { constantRouterMap, asyncRouterMap } from '@/config/router.config'
import router from '@/router/index'
import { login, getUser, UmsAdminLoginParam } from '@api/login'
import { setLocal, getLocal } from '@/utils/tools'
const { ACCESS_TOKEN } = getLocal<IStatus>('token')

// some other code ...

export const useLayoutStore = defineStore({
id:'layout',
state:():ILayout => ({
menubar: {
menuList: [],
},
// 用户信息
userInfo: {
name: '',
roles: {
permission:[]
}
},
status: {
ACCESS_TOKEN: ACCESS_TOKEN || ''
}
}),
getters: {
getMenubar():IMenubar {
return this.menubar
},
getUserInfo():IUserInfo {
return this.userInfo
},
getStatus():IStatus {
return this.status
}
},
actions: {
setToken(token:string):void {
this.status.ACCESS_TOKEN = token
setLocal('token', this.status, 1000 * 60 * 60)
},
async doLogin(loginParam: UmsAdminLoginParam) {
await login(loginParam).then(response => {
const { token } = response.data
if(token){
const { token } = response.data
this.setToken(token)
}else{
throw '用户名或密码错误'
}
})
},
async doGetUser():Promise<void> {
const res = await getUser()
const userInfo = res.data
this.userInfo = userInfo
},
async generateRoutes() {
await this.doGetUser()
const roles = this.userInfo.roles
const accessedRoutes = filterAsyncRouter(asyncRouterMap,roles)
accessedRoutes.forEach((r:any) => {
router.addRoute(r)
})
constantRouterMap.slice().reverse().forEach(r => {accessedRoutes.unshift(r)})
this.menubar.menuList = accessedRoutes
}
}
})

从 state 中可以看出,我们管理的全局状态有:

menuList:保存当前 user 的路由。
userInfo:保存用户权限。
status:保存 ACCESS_TOKEN。

doLogin 用来改变 state。我们在前面登录页中用到了它。登录页按下按钮,就会调用到 doLogin,从 login api 中取出登录状态并写入 state。

setLocal 用于将 token 存入 localStorage,而 getLocal 用于取出。

使用 localStorage 存储 token,不仅能在用户关闭浏览器后依然保存登录状态,而且还能管理 token 的生命时间。

代码中的 token 保存时间较长,在调试的时候记得安装清除浏览器缓存的插件或工具。

url 的编码/解码相关的 2 个函数。

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
// 文件名:@/utils/tool.ts
/**
* localStorage设置有效期
* @param name localStorage设置名称
* @param data 数据对象
* @param pExpires 有效期(默认100年)
*/
export function setLocal(name:string, data:IObject<any>, pExpires = 1000 * 60 * 60 * 24 * 365 * 100):void {
data.startTime = Date.now()
data.expires = pExpires
localStorage.setItem(name, JSON.stringify(data))
}

/**
* 判断localStorage有效期是否失效
* @param name localStorage设置名称
*/
export async function useLocal(name: string):Promise<ILocalStore> {
return new Promise((resolve, reject) => {
const local = getLocal<ILocalStore>(name)
if(local.startTime + local.expires < Date.now()) reject(`${name}已超过有效期`)
resolve(local)
})
}

/**
* 二次编码url
* @param url
* @returns
*/
export function decode(url: string): string {
return decodeURIComponent(decodeURIComponent(url))
}

/**
* 二次解码url
* @param url
* @returns
*/
export function encode(url: string): string {
return encodeURIComponent(encodeURIComponent(url))
}

登录相关 api 和 mock 模拟数据

编写服务器程序不在本章考虑范畴,因此我们使用 mock 来代替服务器返回的数据。

登录相关的 api。

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
// 文件名:@/api/login.ts
import { request, service } from '@utils/axios'
import { AxiosResponse } from 'axios'
type UmsAdminParam = {
username:string,
password:string,
icon?:string,
email?:string,
nickName?:string,
note?:string
}

export type UmsAdminLoginParam = {
username:string,
password:string
}
export function login(data:UmsAdminLoginParam): Promise<AxiosResponse>{
return service({
url:'/api/admin/login',
method:'post',
data:data
})
}

export function register(data:UmsAdminParam){
return request({
url:'/api/admin/register',
method:'post',
data:data
})
}

export function getUser(): Promise<AxiosResponse>{
return service({
url:'/api/admin/user',
method:'get'
})
}

模拟用户数据的 mock。

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
// 文件名:mock/test.ts
export default [
{
url:'/api/admin/login',
method:'post',
response:(param:any)=>{
const username = 'sugar'
const password = '123'
const { username:u, password:p } = param.body
if(u!==username || p!==password){
return {
code: 0,
data:{
},
msg:'账号或密码错误'
}
}
return{
code:0,
data:{
name:'sugar',
token: '4291d7da9005377ec9aec4a71ea837f'
}
}
}
},
{
url:'/api/admin/user',
method:'get',
response:()=>{
return{
code:0,
data:{
name:'sugar',
roles:{
permission:["login","login2","login3","login4","article"]
}
}
}
}
}
] as MockMethod[]

路由前置守卫 beforeEach

vue-router 的一个钩子。钩子是应用程序执行到某个阶段时调用的回调函数,用来提供拦截并添加额外代码。

如果把将 url 输入到地址栏比作入境,那么 beforeRouteEach 的身份就是海关,在你的 url 到达之前,必须检查你的身份,确定你是否满足条件访问。

这里有个技巧,就是将备用 url 藏在 query 中。因为访客在对当前登录状态不知情的状态下,可能会访问到需要权限的 url,此时我们就可以将此时被拦截的 url 当作备用保存,以便访客进行登录后直接帮他跳转。

整体逻辑是:

 1. 先判断当前是否正在访问登录页,根据当前 ACCESS_TOKEN 和 备用 url 重定向页面。
 2. 判断用户是否已登录,没有登录则将当前 url 设置成备用 url,然后重定向到登录页。
 3. 如果用户已登录,判断 localStorage 中的 ACCESS_TOKEN 是否已失效。
 4. 如果用户已登录且 token 未过期,则判断当前用户是否存在专属路由,没有则构造专属路由,然后重新访问一遍当前地址。
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
// 文件名:@/permission.ts
import router from "./router";
import { decode, encode } from '@/utils/tools'
import { useLayoutStore } from '@/store/modules/layout'
import { useLocal } from '@/utils/tools'

const loginRoutePath = '/login'
const defaultRoutePath = '/'
const whiteList = ['login', 'register']
router.beforeEach(async(to,from)=>{

const layoutStore = useLayoutStore()

/**1、设置文档标题 */
/**2、判断当前是否在登录页面 */
if (to.path.toLocaleLowerCase() === loginRoutePath.toLocaleLowerCase()) {
if(layoutStore.getStatus.ACCESS_TOKEN) return typeof to.query.from === 'string' ? decode(to.query.from) : defaultRoutePath
return
}
/**3、判断是否登录 */

if(!layoutStore.getStatus.ACCESS_TOKEN) {
return loginRoutePath + (to.fullPath ? `?from=${encode(to.fullPath)}` : '')
}

// 前端检查token是否失效
useLocal('token')
.then((d:any) => layoutStore.setToken(d.ACCESS_TOKEN))
.catch(() => layoutStore.logout())

// 判断是否还没添加过路由

if(layoutStore.getMenubar.menuList.length === 0) {
await layoutStore.generateRoutes()
return to.fullPath
}

})

构造用户专属路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async doGetUser():Promise<void> {
const res = await getUser()
const userInfo = res.data
this.userInfo = userInfo
},
async generateRoutes() {
await this.doGetUser()
const roles = this.userInfo.roles
const accessedRoutes = filterAsyncRouter(asyncRouterMap,roles)
accessedRoutes.forEach((r:any) => {
router.addRoute(r)
})
constantRouterMap.slice().reverse().forEach(r => {accessedRoutes.unshift(r)})
this.menubar.menuList = accessedRoutes
}

doGetUser 用于调用 api 获取用户权限信息 permission,然后保存到 state 中。generateRoutes 用于根据权限信息筛选出用户可以访问的路由。

将经过筛选的路由然后添加到 vue-router 中。

最后再把公共路由和生成的路由拼接形成用户路由保存到 state,用于对以后使用标签页时提供支持。

然后是用权限筛选路由的逻辑:

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
// 文件名:@/store/modules/layout.ts
function hasPermission (permission: any, route: any) {
// 没有meta.permission的路由,默认放行
if (route.meta && route.meta.permission) {
// 有meta.permission的路由,根据服务器给出的roles.permissionlist决定是否放行
let flag = false
// 遍历服务器获取到的roles.permissionList
for (let p of permission){
flag = route.meta.permission.includes(p)
if(flag){
return true
}
}
return false
}
return true
}

// routerMap中的route全部遍历,然后通过hasPermission()来将某一个route.meta.permission和roles.permissionList所有元素比较
// 比较相同则留下,不同就会呗filter掉
// 最后留下的便是所属权限对应的路由
function filterAsyncRouter (routerMap: any, roles: any) {
const accessedRoutes = routerMap.filter((route:any) => {
if (hasPermission(roles.permission, route)) {
if (route.children && route.children.length) {
// 将filter后的路由重写回子路由
route.children = filterAsyncRouter(route.children, roles)
}
return true
}
return false
})
return accessedRoutes
}

它们的作用是比对每个路由中 meta.permission 是否在 userInfo.role.permission 数组中对应存在。如果不存在,则踢出路由表,从而实现用权限筛选路由。

公共路由 constantRouterMap 和 动态路由 asyncRouterMap 的定义在配置目录中。

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
//文件名:@/config/router.config.ts
export const constantRouterMap = [
{
path:'/login',
name:'login',
component:() => import('@c/login10.vue')
}
]

export const asyncRouterMap = [
{
path:'/',
name:'MainLayout',
redirect:'/article',
component:() => import('@/layout/MainLayout.vue'),
children:[{
path:'article',
name:'article',
component:()=>import('@view/Article.vue'),
meta:{
permission:['article']
}
}]
}
]

然后是 vue-router 的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//文件名:@/router/index.ts
import { createRouter, createWebHistory, Router, RouteRecordRaw } from 'vue-router'
import { constantRouterMap } from '@/config/router.config'


// 路由模式
const router = createRouter({
history: createWebHistory(''),
routes: constantRouterMap as Array<RouteRecordRaw>,
scrollBehavior(to, from, savedPosition) {
return {
el: '#app',
top: 0,
behavior: 'smooth',
}
},
})

export default router

文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.
`-- myvue3
|-- mock
| `-- test.ts
`-- src
|-- api
| `-- login.ts
|-- components
| `-- Login10.vue
|-- config
| `-- router.config.ts
|-- permission.ts
|-- router
| `-- index.ts
|-- store
| |-- index.ts
| `-- modules
| `-- layout.ts
`-- utils
`-- tools.ts