更友好的 Nuxt 路由权限控制

-------------------- 更新 --------------------

发现这种方案搭配 CDN 会存在问题。
CDN 会缓存服务端返回的静态资源,如果返回的页面中具有个人信息,既会浪费 CDN 资源,又会导致个人信息在 CDN 的泄露。
因此就要避免在服务端请求用户信息并渲染到页面中。

改进办法:

删掉服务端的 userInfo 请求,将服务端依据用户信息的路由控制后移到页面程序中。

在所有页面中,mounted 生命周期中,判断是否 store 中没有 userInfo 然而 cookie 中存在 token,如果是,则请求 userInfo 接口填充信息;在需要登录的页面中, mounted 生命周期中,判断 cookie 中是否存在 token,不存在则直接跳转到登录页面。

这样改造之后在需要登陆的页面刷新,会先呈现未登录状态的页面,即服务端返回的无差别页面,随后变成有用户信息的登陆态页面。虽然体验上打了折扣,但个人感觉还是比跳到登录页再跳回来的感受要好一些。

具体改进步骤如下:

  • 删掉 nuxtServerInit
  • middleware/auth 中增加 process.client 的判断,使其只在客户端路由中生效
// middleware/auth.js
export default function ({ route, redirect, store, $cookies }) {
  if (process.client) {
    const token = $cookies.get('token')
    const logout = $cookies.get('logout')
    const loggedIn = store.getters['user/loggedIn']

    // 处理其它选项卡中登陆后的情况
    if (token && !loggedIn) {
      store.commit('user/SET_TOKEN', token)
      store.dispatch('user/getInfo')
    } 
    // 处理其它选项卡中登出后的情况
    else if (!token && logout && loggedIn) { 
      store.dispatch('user/logout')
      redirect('/login')
    } 
    // 正常情况
    else if (!loggedIn) {
      $cookies.set('redirect.login', route.path)
      redirect('/login')
    }
  }
}
  • 添加 mixin,restoreUserInfo.js 添加到 layouts/default.vue 中
// mixin/restoreUserInfo.js
/**
 * 用户登录状态,刷新页面,则重新根据 token 请求用户信息填充 store
 */
const restoreUserInfoMixin = {
  mounted () {
    const token = this.$cookies.get('token')
    const loggedIn = this.$store.getters['user/loggedIn']

    // 有 token 却没用户信息,则获取用户信息
    if (accessToken && !loggedIn) {
      this.$store.commit('user/SET_TOKEN', token)
      this.$store.dispatch('user/getInfo')
    }
  }
}

export default restoreUserInfoMixin

  • 另一个 mixin 以 plugin 的形式添加,在需要登录的页面中加入 auth: true 生效
// plugins/auth.js
/**
 * 需要登录的页面,判断有没有 token
 */
import Vue from 'vue'
Vue.mixin({
  mounted () {
    if (this.$options.auth) {
      const token = this.$cookies.get('token') || this.$cookies.get('udm_access_token')

      // 没 token,则不予展示
      if (!token) {
        this.$router.push('/login')
      }
    }
  }
})

-------------------- 原文 --------------------

在项目开发中,经常会遇到需要进行路由控制的场景,比如只有在登陆的情况下,才可以访问个人中心页面。

原本引入了 @nuxtjs/auth 模块,但是应用中发现它并不完美。比如在个人中心页面,登陆的情况下,刷新页面或者新标签页打开页面时,会先跳到登陆页面再跳回当前页。参考这篇文章

文中作者认为“这是 @nuxtjs/auth 的机制,因为它要加载 vuex 状态树,并没有什么不友好”,但我依然无法忍受,再加之项目需要更加自由的路由控制,于是我决定放弃 @nuxtjs/auth 的封装,自己来实现。

解决上面问题的思路在于 nuxtServerInit,它是一个 store action,只在应用初始化时执行一次(刷新时、新标签页打开时),执行在路由中间件之前。我们可以在这里读取 cookie 中的 token,并发送请求,将请求结果填充进 store 中。

梳理下需要完成的功能:

  • 未登录状态访问需要登录的页面,跳转到登录页面
  • 登录后可以自动跳回到之前的页面
  • 登录后可以正常访问页面
  • 登录后刷新或新标签页打开需要登录的页面,不丢失登录状态
  • 在需要登录的页面点击退出登录,退出后跳回首页
  • 退出登录后其它标签页也为退出状态
  • 登录状态下接口请求自带 Authorization: 'Bearer token' 头信息

为了实现以上功能,需要:

  • 存储之 cookie 持久化
  • 存储之 vuex 状态管理
    • nuxtServerInit 进行初始化的信息填充
    • user 模块存储用户信息
  • 插件之 axios 来添加 Authorization 头
  • 中间件之 auth 进行路由权限控制
  • 插件之 mock 用来开发中接口自测

一、cookie 持久化

引入 cookie-universal-nuxt 进行 cookie 管理

npm install cookie-universal-nuxt -s

然后加入到 nuxt.config.js 的模块中

// nuxt.config.js
export default {
  // 省略其余代码
  modules: ['cookie-universal-nuxt']
}

之后就可以通过 this.$cookies 使用了

这里需要用设置的 cookie 有三个:

  • token: 登录后拿到的 token
  • redirect: 记录被重定向到 login 页面前的地址,以便登录后回跳
  • logout: 用户点击退出后标记为 true, 登录后标记为 false(加这个是为了点击退出后其它标签页也要丢失登录状态,后面会讲到)

二、状态管理

nuxtServerInit 必须写在 store/index.js 中才会被调用。

在这里判断 cookie 中有没有 token,如果有,就存储到 store 中并请求用户信息,然后把用户信息也存储到 store 中。

注意需要 await 接口请求。

// store/index.js
export default {
  actions: {
    async nuxtServerInit ({ commit }, { $cookies }) {
      const token = $cookies.get('token')
      if (token) {
        commit('user/SET_TOKEN', token)
        await this.$axios.get('/user')
          .then((response) => {
            commit('user/SET_INFO', response.data.data)
          })
          .catch((err) => {
            console.error(err)
          })
      }
    }
  }
}

然后添加一个 user 模块。

在 user 模块中存储 token 和用户信息 info。
并提供一个 getters loggedIn。
在 actions 中提供 getInfo、login、register、logout 动作,包括对应的接口请求、状态填充和 cookie 操作。

// store/user.js
/* eslint-disable no-unused-expressions */
export default {
  namespace: true,

  state: () => ({
    token: '',
    info: null
  }),

  getters: {
    loggedIn (state) {
      return !!state.info
    }
  },

  mutations: {
    SET_TOKEN: (state, payload) => {
      state.token = payload
    },
    SET_INFO: (state, payload) => {
      state.info = payload
    }
  },

  actions: {
    getInfo ({ commit }) {
      return new Promise((resolve, reject) => {
        this.$axios.get('/user')
          .then((response) => {
            commit('SET_INFO', response.data.data)
            resolve()
          })
          .catch((err) => {
            reject(err)
          })
      })
    },
    login ({ commit, dispatch }, payload) {
      return new Promise((resolve, reject) => {
        this.$axios.post('/login', payload)
          .then(async (response) => {
            commit('SET_TOKEN', response.data.data.token)
            let options = null
            if (payload.remember) {
              options = {
                maxAge: 60 * 60 * 24 * 7
              }
            }
            this.$cookies.set('token', response.data.data.token, options)
            this.$cookies.set('logout', false)
            await dispatch('getInfo')
            resolve()
          })
          .catch((err) => {
            reject(err)
          })
      })
    },
    register ({ commit, dispatch }, payload) {
      return new Promise((resolve, reject) => {
        this.$axios.post('/register', payload)
          .then(async (response) => {
            commit('SET_TOKEN', response.data.data.token)
            this.$cookies.set('token', response.data.data.token)
            this.$cookies.set('logout', false)
            await dispatch('getInfo')
            resolve()
          })
          .catch((err) => {
            reject(err)
          })
      })
    },
    logout ({ commit }) {
      return new Promise((resolve, reject) => {
        this.$axios.post('/logout', null, {
          baseURL: BASE_URL.default
        })
          .then(() => {
            commit('SET_TOKEN', '')
            commit('SET_INFO', null)
            this.$cookies.remove('token')
            this.$cookies.set('logout', true)
            const current = this.$router.history.current.path
            const pagesNeedLoggedIn = ['/my', '/me', '/market']
            const isNeedLoggedIn = pagesNeedLoggedIn.some(item => current.indexOf(item) === 0)
            if (isNeedLoggedIn) {
              this.$router.push('/')
            }
            resolve()
          })
          .catch((err) => {
            reject(err)
          })
      })
    }
  }
}

三、axios 插件

首先项目中要引入了 axios 模块,如果没有先 npm install @nuxtjs/axios -s,然后添加一个 axios 插件

// nuxt.config.js
export default {
  // 省略其余代码
  modules: ['@nuxtjs/axios'],
  plugins: ['@/plugins/axios']
}

写 plugins/axios.js 插件。
如果 cookie 中或 store 中存在 token(防止用户禁用了浏览器 cookie,所以也要查看下 store),则加入 ajax 请求头 Authorization。

// plugins/axios.js
export default function ({ $axios, $cookies, store }) {
  $axios.setBaseURL('http://api.example.com')

  $axios.onRequest((config) => {
    const cookieToken = $cookies.get('token')
    const storeToken = store.state.user.token
    const token = cookieToken || storeToken
    if (token) {
      config.headers.Authorization = 'Bearer ' + token
    }
    return config
  }}
}

四、路由中间件

然后通过一个路由中间件来进行权限控制的跳转,在需要路由控制的页面中通过middleware: 'auth'引入。

这里除了拦截访问、重定向到登录页的主要逻辑外,还要处理两种情况。

一是其它选项卡中登录后的情况。这时在当前选项卡,cookie 中有了 token,但是 store 中没有,这时应该把 token 填充到 store 并去请求用户信息。

二是其它选项卡登出后的情况。这时在当前选项卡,store 中还有 token 等数据,但是 cookie 中已经没有了 token,并且 logout 为 true,这时候应该 dispatch 下 logout,并将当前页面重定向到登录页。

这两种情况都是在再次点击链接触发路由时生效的,并非和其它选项卡实时同步。并且这两种情况都不考虑 cookie 被禁用,在禁用 cookie 时,也不存在这些联动问题了。

再说一下主要逻辑:拦截访问、重定向到登录页,这时候需要把当前地址记录到 cookie 中,用来登录后的回跳。

// middleware/auth.js
export default function ({ route, redirect, store, $cookies }) {
  const token = $cookies.get('token')
  const logout = $cookies.get('logout')
  const loggedIn = store.getters['user/loggedIn']

  // 处理其它选项卡中登陆后的情况
  if (token && !loggedIn) {
    store.commit('user/SET_TOKEN', token)
    store.dispatch('user/getInfo')
  } 
  // 处理其它选项卡中登出后的情况
  else if (!token && logout && loggedIn) { 
    store.dispatch('user/logout')
    redirect('/login')
  } 
  // 正常情况
  else if (!loggedIn) {
    $cookies.set('redirect.login', route.path)
    redirect('/login')
  }
}

五、页面

一切就绪,可以在页面中写方法进行 login、logout 了。这时基本只需要 dispatch action 就可以了。注意 login 后需要处理一下回跳,如果 cookie 中存在 redirect.login 记录,则回跳过去,并把记录清掉;如果没有,则跳到个人中心页面。

// pages/login.js
// 省略其它代码
async login () {
  this.submitting = true

  const validateResult = await this.validate()
  if (!validateResult) {
    this.submitting = false
    return
  }

  const data = {
    account: this.formData.account,
    password: this.formData.password,
    remember: this.formData.remember
  }

  this.$store.dispatch('user/login', data)
    .then(() => {
      const redirect = this.$cookies.get('redirect.login')
      if (redirect) {
        this.$router.push(redirect)
        this.$cookies.remove('redirect.login')
      } else {
        this.$router.push('/my/account')
      }
    })
    .catch(() => {
      this.$message({
        type: 'error',
        message: '发生错误,请重试'
      })
    })
    .then(() => {
      this.submitting = false
    })
}
// components/Nav.js
// 省略其它代码
logout () {
  this.$store.dispatch('user/logout')
}

六、Mock

Mock 不必多说,用来开发时模拟接口请求返回假数据。
npm install mockjs -D 然后在 plugins 中加入个 mock.js 就可以了。

// plugins/mock.js 
import Mock from 'mockjs'
// 示例
Mock.mock(/\/login/, 'post', () => {
  return {
    code: 200,
    message: 'success',
    data: {
      token: 'thisisatoken123abc'
    }
  }
})

它会自动拦截 axios 请求。但是注意只在浏览器端有效,服务端发出的不会拦截。

总结

至此我们上面所列的功能就都可以实现了。
后面可以进阶一下,看是否可以封装成类似 @nuxtjs/auth 的插件。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,755评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,369评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,799评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,910评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,096评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,159评论 3 411
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,917评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,360评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,673评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,814评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,509评论 4 334
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,156评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,882评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,123评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,641评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,728评论 2 351

推荐阅读更多精彩内容