本文已参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。
身份认证
- 虽然完成了登录功能,但实际上现在用户没登录也能访问(对应的url),这样的话显得登录功能毫无意义。
- 为了让登录变得有意义:
- 应当在⽤户登录成功后给⽤户⽣成⼀个标记(令牌),并将这个令牌保存起来。
- 在⽤户访问任意需要登录的⻚⾯(组件)时都去验证令牌;
- 从⽽识别⽤户是否登录或是否有权访问对应功能。
- 如何能够让 login 组件中的数据可以被任意其他组件访问呢?这时可以使⽤ Vue 官⽅的状态管理⼯具Vuex。
Vuex
- Vuex ⽂档:vuex.vuejs.org/zh/
简介
- Vuex是专门为Vue.js设计的状态管理库
- 采用集中式的方式存储需要共享的数据
- 本质就是一个JavaScript库
- 用来解决复杂组件通信,数据共享的问题
[官方文档]Vuex 可以帮助我们管理共享状态,并附带了更多的概念和框架。这需要对短期和⻓期效益进⾏权衡。如果您不打算开发⼤型单⻚应⽤,使⽤ Vuex 可能是繁琐冗余的。确实是如此——如果您的应⽤够简单,您最好不要使⽤ Vuex。⼀个简单的 store 模式 (opens new window)就⾜够您所需了。但是,如果您需要构建⼀个中⼤型单⻚应⽤,您很可能会考虑如何更好地在组件外部管理状态,Vuex 将会成为⾃然⽽然的选择。
如何判断是否需要Vuex?
- 多个视图依赖于同一状态
- 来自不同视图的行为需要变更为同一状态
Vuex使用
安装Vuex
npm install vuex -S
- 也可以使用Vue CLI创建项目时可以在项目选项中选择Vuex,进行安装
使用
- 因为Vuex和VueRouter一样,是Vue的插件,因此需要使用
vue.use(Vuex)
State
- 存储在组件中需要共享的数据
Mutation
- 更改Vuex的store中的状态的唯一方法是提交mutation
- 原因:方便维护
- 提交mutation使用
store.commit('方法名')
- 含义:触发某一个mutation
- 有历史记录
- 通过详细的列表记录了每一次操作
- 必须为同步函数
提交载荷(Payload)
- 向store.commit传入额外的参数,即mutation的载荷
- 大多数情况下,载荷应该是一个对象,这样可以包含多个字段并且记录mutation会更易读
Action
- Action类似于mutation,不同在于:
- Action提交的是mutation,而不是直接变更状态
- Action可以包含任意异步操作,而mutation中不能存在异步操作。
- 举例:mutation中只能存在同步操作,若在mutation中写异步操作在devtools中不能达到记录真实操作顺序的效果。
总结
- 异步操作写在actions里,同步操作写在mutations里面,最终调用的是具有异步功能的actions
Module
- 由于使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得比较复杂时,store对象就有可能变得相当臃肿。
- 为了解决以上问题,Vuex允许我们将store分割成为模块(module)。每个模块拥有自己的state、mutation、action、getter、甚至是嵌套子模块。
- 代码演示
const moduleA = {
state: () => ({...}),
mutations: {...},
actions: {...},
getters: {...}
}
复制代码
getters
- Vuex允许在store中定义“getter”(可以认为是store的计算属性)。就像计算属性一样,getter的返回值会根据它的依赖被缓存起来,且只有当它的依赖值发生了改变才会被重新计算。
- 代码演示
const store = new Vuex.store({
state:{
todos: [
{id: 1,text: '...',done: true},
{id: 2,text: '...',done: false}
]
},
getters: {
doneTodos: state => {
return state.todos.filter(todo => todo.done)
}
}
})
复制代码
身份认证:登录状态存储
- 为了能在任意组件中访问用户的登录信息,因此将状态存储在Vuex中。
校验页面访问权限
- 路由跳转时,需要校验登陆状态,并根据结果进行后续处理。
- 使用Vue Router的导航守卫beforeEach,在任务导航被触发时进行登陆状态检测。
实现方法
- 直接给某个路由设置,这时内部的子路由都需要认证(包括自己)
- 验证Vuex的store中的登录用户信息是否存储
- 当前后台⻚⾯均需要登录状态,但如果需求中只有部分⻚⾯需要登录状态的话,该如何判断处理呢?可以通过 Vue Router 中的路由元信息功能来设置。
路由元信息
- 设置部分页面需要验证登录状态
- 此项目中将"/"的子路由设置为需要身份认证
- meta用于保存与路由相关的自定义数据
- requiresAuth表示是否需要认证,true为需要认证
登录后跳转到上次访问的页面
- 例如我当前访问⼴告管理
/advert
,这时我想访问⽤户管理/user
,但因为⻓时间没操作,登录状态过期了(⼿动请求 store 模拟),就会⾃动跳回/login
,当我再次登录后,默认会跳转到/
⾸⻚。 - 如果希望登录后能跳转到上次正在访问的⻚⾯,该如何操作呢?
- 这时应当在每次跳转到/login 时记录当前 to ⽬标路由信息,通过跳转路由的 query 属性进⾏设置。
- 在
login/index.vue
中这样书写:this.$router.push(this.$route.query.redirect || '/')
注意事项
- fullpath与path的区别
-
route的区别
- 前者:跳转操作、调用方法
- 后者:当前这条路由的路由信息
- 校验页面访问权限是否成功时,需要将
- Vuex中的user对象设为:null
- 控制台Application中的Local Storage中的user对象删除
用户信息与接口鉴权
- 先使用postman进行接口测试
- 测试过程中可以将token字符串直接写在全局。这样就不用每次测试不同接口都填写一遍了。
注意
- 401:未授权(4开头的客户端错误)
实现用户信息展示
- 封装请求
// 用户基本信息 因为不需要传递参数,因此传递为空
export const getUserInfo = () => {
return request({
method: 'GET',
url: '/front/user/getInfo'
headers: {
// 需要从Vuex的store中取出token信息
Authorization: store.state.user.access_token
}
})
}
复制代码
- 数据绑定给视图
methods: {
async loadUserInfo () {
// 将返回的数据保存到data中
const { data } = await getUserInfo()
this.userInfo = data.content
}
}
复制代码
通过请求拦截器设置Token
- 注意:需要先判断是否有user对象才能user.access_token,要严谨
// 创建axios请求拦截器,回调函数
request.interceptors.request.use(function (config) {
const { user } = store.state
// 设置请求头中的token字符串
if (user && user.access_token) {
config.headers.Authorization = user.access_token
}
return config
})
复制代码
用户退出
- “退出”设置点击事件无效
- 分析:此处的“退出”是组件而不是按钮,给组件绑定的事件都是自定义事件,并不是原生的dom事件。
- 但是现在想要用原生的dom事件,就需要给事件设置“事件修饰符”
提升用户体验:弹出框
- 提醒用户是否需要退出MessageBox弹窗
handleLogout () {
// 消息弹框的内容 消息弹框的标题
this.$confirm('确定退出吗?', '退出提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// messageBox与message消息提示相结合,提示信息
this.$message({
type: 'success',
message: '退出成功!'
})
// 1.消除Vuex中存储的user对象为null
this.$store.commit('setUser', null)
// 2.路由跳转 至 /login
this.$router.push('/login')
}).catch(() => {
this.$message({
type: 'info',
message: '已取消退出'
})
})
}
复制代码
Token过期处理
- 之所以出现“用户信息消失”,但是页面仍停留在“首页”,因为后台页面不需要去服务端鉴权,只是在客户端本地验证Vuex中的数据,但是在本地Vuex是无法验证token是否过期
- 后端的接口鉴权并不是一直可以使用的,会过期,过期后是可以进行处理的
Token过期分析
- Token用于进行接口鉴权,但Token具有后端设置的过期时间,当Token过期后,就无法再请求接口数据了。
- 项目中后端设置的过期时间为24h,测试时可以手动修改token值让Token失效
- 处理方式
- 方式1:用户重新登陆,获取新的Token。但当过期时间较短时,用户每次都要重新登录,体验不好。
- 为了提高用户信息安全性,Token的过期时间都比较短。(就算万一泄露了,过一会儿就过期无效了)
- 方式2:根据用户信息,自动给用户生成新的Token,减少重新登录次数。
- 方式1:用户重新登陆,获取新的Token。但当过期时间较短时,用户每次都要重新登录,体验不好。
- 观察前面的功能,接口的响应信息中具有三个与Token相关的信息。
- access_token:当前使用的Token,用于访问需要授权的接口
- expires_in:access_token的过期时间
- refresh_token:刷新获取新的access_token
刷新Token(重点)
- 方法1:在每个请求发起前进行拦截(请求拦截器),根据expires_in判断token是否过期,如果过期则刷新后再继续请求接口。
- 优点:请求前拦截处理,能节省请求次数。
- 缺点:后端需要提供Token过期时间字段(例如expires_in),并且需要结合计算机本地时间判断,如果计算机时间被篡改(特别是比服务器时间慢)时,拦截会失败
- 方法2:在每个请求响应后进行拦截(响应拦截器),如果发现请求失败(Token过期导致的)时,刷新你Token再重新请求接口。
- 优点:无需Token过期时间字段,无需判断时间
- 缺点:多消耗一次请求
- 推荐使用方法2,相比方法1,方法2更加稳定,不会出现意外的问题。
使用Axios响应拦截器
- 拦截器
- 响应拦截器会在响应接收完毕,在对应请求处理前被拦截器拦截。
- 响应拦截器参数response中保存了响应的信息。
刷新Token操作
- HTTP状态码401表示未授权,可以导致401的情况有很多:
- 没有Token
- Token无效
- Token过期
- 判断方式如下:
- 检测是否存在refresh_token(后端通常会限制每个refresh_token只能获一次新Token)
- 获取成功,重启发送请求,请求接口数据即可
- 获取失败,跳转登录页
- 如果没有,跳转登录页
- 检测是否存在refresh_token(后端通常会限制每个refresh_token只能获一次新Token)
Axios错误处理
- 查看官网,书写方式
处理Token过期
- 数组存储因为Token更新,而挂起的请求
多个请求重复刷新Token处理
- 如果页面中存在多个请求(大多数页面中都不会只有一个请求),如果Token过期,每个请求都会刷新Token,这时重复刷新多次没有意义,又增加了请求个数,还会出现额外的问题
- 每个refresh_token只能使用一次,多次请求会出现错误
- 为了避免多次请求重复刷新Token,可以通过一个(let)变量isRefreshing标记Token的刷新状态
- 默认状态为false,并在发送刷新Token请求前检测,当状态为false才能传送
- 发送刷新请求时,设置标记为true
- 请求完毕,设置标记为false(应该在finally内部书写,不论请求失败还是成功)
- **出现问题:**虽然刷新Token的问题解决了,但之前发送的两个请求只有一个成功执行,其余的请求被阻止了。
- **解决方式:**声明一个数组存储所有被挂起的请求,当Token刷新完毕再将这些请求重新发送。
// 存储应等待挂起的请求
let requests = []
request.interceptors.response.use(function (response) {
.....
if (status === 400) {
errorMessage = '请求参数错误'
} else if (status === 401) {
......
if (isRefreshing) {
// 将当前请求添加到数组中,并将数组返回
return requests.push(() => {
request(error.config)
})
}
// 如果是没有在更新的,设置标记true
isRefreshing = true
// refresh_token进行token刷新
request({
method: 'POST',
url: '',
data: qs.stringify({
refreshtoken: store.state.user.refresh_token
})
}).then({
......
// 将其他的请求进行调用执行
requests.forEach(callback => callback())
// 清空所有的请求
requests = []
return request(error.config)
}).catch({
......
}).finally({
isRefreshing = false
})
......
}
.....
}
复制代码
「欢迎在评论区讨论,掘金官方将在掘力星计划活动结束后,在评论区抽送100份掘金周边,抽奖详情见活动文章」。