所需工具

  • Vue Cli 3.*
  • Node

安装所需工具

安装 Node 环境

前往 Node 官网下载

安装 Vue Cli

npm install -g @vue/cli
# OR
yarn global add @vue/cli

安装完成后,需要到

创建项目

vue create YOUR_PROJECT_NAME
# OR
vue ui
George-2:Node George$ vue create spa


Vue CLI v3.8.2
┌───────────────────────────┐
│  Update available: 3.8.4  │
└───────────────────────────┘
? Please pick a preset: default (babel, eslint)


Vue CLI v3.8.2
✨  Creating project in /Users/George/Develop/Node/spa.
🗃  Initializing git repository...
⚙  Installing CLI plugins. This might take a while...

yarn install v1.13.0
info No lockfile found.
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...
success Saved lockfile.
✨  Done in 207.89s.
🚀  Invoking generators...
📦  Installing additional dependencies...

yarn install v1.13.0
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 🔨  Building fresh packages...

success Saved lockfile.
✨  Done in 53.93s.
⚓  Running completion hooks...

📄  Generating README.md...

🎉  Successfully created project spa.
👉  Get started with the following commands:

 $ cd spa
 $ yarn serve

George-2:Node George$ 

安装依赖

vue add router
vue add vuex
vue add axios

上面分别为项目添加了 vue-router、vuex、axios 依赖。

项目目录结构

项目实现

用户状态管理

因为用户可能有两种状态,一种是已登陆一种是未登陆,那么前端如何来保存用户登陆状态呢?这里可以使用 Vuex 来进行存储。但是只有 Vuex 是不够的,因为当用户手动刷新浏览器后,Vue 整个生命周期就重新加载了,所以还要配合浏览器提供的 Local StorageSession Storage 来保证用户刷新而不丢失用户状态。

import Vue from 'vue'
import Vuex from 'vuex'


Vue.use(Vuex);

export default new Vuex.Store({
  state: {
    access_token: localStorage.getItem('access_token'),
  },
  mutations: {
    SET_ACCESS_TOKEN: (state, data) => {
      if(data === false) {
        Vue.set(state, 'access_token', '');
        localStorage.removeItem('access_token');
      } else {
        Vue.set(state, 'access_token', data.access_token);
        localStorage.setItem('access_token', data.access_token);
      }
    }
  },
  actions: {
    signIn({commit}, params) {
      return new Promise((resolve,reject) => {
        Vue.axios.post('/signin', params).then(res => {
          commit('SET_ACCESS_TOKEN', res.data);
          resolve(res);
        }).catch(err => {
          reject(err);
        })
      });
    }
  }
});

系统状态管理

在全局保存 Layout 的信息

import Vue from 'vue'
import api from '../../apis'
import * as types from '../types'

export default {
  state: {
    layout: {
      current: "guest"
    },
    menu: {
      active: "/"
    },
    installed: false,
    version: ""
  },
  mutations: {
    SET_SYSTEM: (state, data) => {
      Vue.set(state, 'installed', data.installed);
      Vue.set(state, 'version', data.version);
    },
    SET_MENU_ACTIVE: (state, data) => {
      Vue.set(state.menu, 'active', data)
    },
    SET_LAYOUT_CURRENT: (state, data) => {
      Vue.set(state.layout, 'current', data)
    }
  },
  actions: {
    fetchSystem({ commit }) {
      api.system.fetch().then(response => {
        commit(types.SET_SYSTEM, response.data)
      });
    }
  }
}

请求401拦截

"use strict";

import Vue from 'vue';
import axios from 'axios'
import store from '../store'
import router from '../router'

// Full config:  https://github.com/axios/axios#request-config
// axios.defaults.baseURL = process.env.baseURL || process.env.apiUrl || '';
// axios.defaults.headers.common['Authorization'] = AUTH_TOKEN;
// axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded';

let config = {
  baseURL: process.env.baseURL || process.env.apiUrl || "",
  timeout: 60 * 1000, // Timeout
  withCredentials: true, // Check cross-site Access-Control
};

const request = axios.create(config);

// 定义请求拦截器
request.interceptors.request.use(config => {
  // 遍历请求参数
  for(let key in config.params) {
    // 如果参数的值为空,则删除这个 Key
    if (config.params.hasOwnProperty(key) && (config.params[key] === "" || config.params[key] === null)) {
      delete config.params[key];
    }
  }
  // 如果有用户的 access token 则在请求头中加上
  if (store.state.account.access_token) {
    config.headers.Authorization = `Bearer ${store.state.account.access_token}`;
  }
  return config;
}, error => {
  return Promise.reject(error);
});

// 定义响应拦截器
request.interceptors.response.use(
  response => {
    return response.data;
  },
  error => {
    // 如果状态码为 401 则清空 Vuex 中的状态,并跳转到登陆页面
    if (error.response.status === 401) {
      store.commit('SET_PROFILE', false);
      store.commit('SET_ACCESS_TOKEN', false);
      router.push("/signin");
    }
    return Promise.reject(error.response);
  }
);

Plugin.install = function(Vue) {
  Vue.axios = request;
  window.axios = request;
  Object.defineProperties(Vue.prototype, {
    axios: {
      get() {
        return request;
      }
    },
    $axios: {
      get() {
        return request;
      }
    },
  });
};

Vue.use(Plugin);

export default Plugin;

前端路由拦截

仅仅实现了API 401状态码拦截还是不够的,还需要对前端路由做响应的鉴权。

import Vue from 'vue'
import store from '../store'
import Router from 'vue-router'

Vue.use(Router);

const router = new Router({
  mode: 'history',
  base: process.env.BASE_URL,
  routes: [
    {
      path: '/',
      name: 'dashboard',
      meta: {
        requiresAuth: true
      },
      component: () => import('../views/Dashboard.vue'),
    },
    {
      path: '/signin',
      name: 'signin',
      meta: {
        requiresAuth: false
      },
      component: () => import('../views/SignIn.vue')
    },
    {
      // 会匹配所有路径
      path: '*',
      name: "notfound",
      meta: {
        requiresAuth: false
      },
      component: () => import('../views/NotFound.vue')
    }
  ]
});

/**
 * Routing to intercept
 */
router.beforeEach((to, from, next) => {
  // 如果跳转到NotFound页面则提前设置视图的Layout 为 guest
  if (to.name === 'notfound') {
    store.commit('SET_LAYOUT_CURRENT', 'guest');
  }

  // 如果从NotFound 页面返回,并且需要认证的话,则设置视图的Layout 为 backend
  if (from.name === 'notfound' && to.meta.requiresAuth === true) {
    store.commit('SET_LAYOUT_CURRENT', 'backend')
  }

  // 当用户的 access token 存在且有效,前往的路由是 登陆页面时,直接跳转到首页
  if (store.state.account.access_token && to.path === '/signin') {
    next({
      path: "/"
    })
  }

  /**
   * 路由鉴权主要逻辑
   */
  if (to.matched.some(record => record.meta.requiresAuth)) {
    const access_token = store.state.account.access_token;
    //通过access_token判断用户是否已经登录
    if (!access_token) {
      next({
        path: '/signin'
      })
    } else {
      next()
    }
  } else {
    next()
  }
});

export default router;

页面布局

由于登陆页面后台页面的布局不同所以没法服用,为了在登陆后跳转路由不重新渲染公共模块,如菜单、顶部导航条等组件,所以需要在 App.vue 中实现根据用户登陆状态动态切换页面布局的逻辑。

<template>
  <div id="app">
    <component :is="layout.current"></component>
  </div>
</template>

<script>
  import {mapState} from 'vuex'
  import Guest from './layouts/Guest'
  import Backend from './layouts/Backend'

  export default {
    name: 'app',
    data() {
      return {
      }
    },
    methods: {},
    components: {
      guest: Guest,
      backend: Backend
    },
    computed: {
      ...mapState({
        layout: state => state.system.layout,
        profile: state => state.account.profile,
        access_token: state => state.account.access_token
      })
    },
    watch: {
      // Watch 用户信息变更,切换页面布局
      profile(value) {
        if (value.id === '') {
          this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
        } else {
          this.$store.commit('SET_LAYOUT_CURRENT', 'backend');
        }
      },
      /**
       * 当access_token的state发生变化时
       * 说明用户登录信息验证成功,并且拿到了access_token
       * 此时调用fetchProfile 这个action 获取用户信息
       */
      access_token(value) {
        if (value == null) {
          window.console.log(value);
        }
      },
      '$route' (to, from) {
        if (from.path === '/' && to.name === 'notfound') {
          this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
        }

        if (from.path === '/') {
          if (to.name !== 'notfound') {
            this.$store.commit('SET_LAYOUT_CURRENT', 'backend');
          }
        }

        if (to.name === 'signin') {
          this.$store.commit('SET_LAYOUT_CURRENT', 'guest');
        }

        if (from.name === 'notfound') {
          this.$store.commit('SET_MENU_ACTIVE', to.path);
        }

        this.$store.commit('SET_MENU_ACTIVE', to.path);
      }
    }
  }
</script>

<style lang="scss">

</style>

到此基本上前端有关用户鉴权的逻辑都已经实现,剩下的只要专心的去写业务逻辑即可!

I hope this is helpful, Happy hacking…