前言
对初学 web 前端的人来说,一个登录页面可能会是他们前端打怪生涯中的最初遇到的几个 Boss 之一。几乎任何系统都需要登录功能,因为登录功能是系统实现身份认证和访问控制的第一步。
很久以前,前端大牛们写一个登录页,都是新建一个 html 文件手撸一遍页面模板,新建一个 css 文件手撸一遍页面样式,再新建一个 js 文件手撸一遍登录逻辑。而现在,这种方式显得十分古老。我作为一个初探前端的新手,在经历各种尝试后,给大家介绍如何使用 vite+vue3+ typescript 写一个登录页。
介绍登录
一个登录页中应该有什么,这应该是很多初学者需要面对的问题。在我看来,完成一次登录需要以下几个步骤:
- 对于未持有令牌的匿名访客,只能允许其访问公共页面,否则跳转登录页。
- 在登录页中提交身份认证的信息,例如账号、密码。
- 后端通过后,返回一个标识身份的令牌,例如 token。
- 使用令牌从后端获取用户信息,例如角色、权限。
- 通过角色、权限为用户量身定做一个路由表。
- 用户可以自由访问所给路由表中的全部网页
根据以上6步,我总结了以下使用 vue3 编写登录功能所涉及到的知识:
- 写一个靓靓的登录页(html+css)
- 异步请求相关知识(axios)
- 前端路由的相关知识(vue-route)
- 模拟后端数据(mock)
- 全局状态管理(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
| <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,以便在其他组件中取出并使用。
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
| 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')
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
|
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)) }
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) }) }
export function decode(url: string): string { return decodeURIComponent(decodeURIComponent(url)) }
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
| 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
| 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
| 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() if (to.path.toLocaleLowerCase() === loginRoutePath.toLocaleLowerCase()) { if(layoutStore.getStatus.ACCESS_TOKEN) return typeof to.query.from === 'string' ? decode(to.query.from) : defaultRoutePath return } if(!layoutStore.getStatus.ACCESS_TOKEN) { return loginRoutePath + (to.fullPath ? `?from=${encode(to.fullPath)}` : '') }
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
| function hasPermission (permission: any, route: any) { if (route.meta && route.meta.permission) { let flag = false for (let p of permission){ flag = route.meta.permission.includes(p) if(flag){ return true } } return false } return true }
function filterAsyncRouter (routerMap: any, roles: any) { const accessedRoutes = routerMap.filter((route:any) => { if (hasPermission(roles.permission, route)) { if (route.children && route.children.length) { 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
| 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
| 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
|