阅读 1119

【以模块化的思想开发中后台项目】第一章

前言

继上一篇【以模块化的思想来搭建中后台项目】,时隔一月再开始写该系列的第二篇,没办法最近确实有点忙,不像以前划水摸鱼,到点就溜😭(y1s1这段时间确实挺忙的),不过这个系列的文章我肯定会好好花时间打磨,尽我所能的写出质量较高且比较容易上手和理解的文章。

项目构建后的配置

在项目初始化好了之后,我们再来对当前项目进行开发前的前期准备

alias

当项目逐渐变大之后,文件与文件直接的引用关系会很复杂,这时候就需要使用alias了。 有的人喜欢alias 指向src目录下,再使用相对路径找文件,我还是比较喜欢将常用的目录都进行设置

配置vue.config.js

'@': resolve('src'),
'styles': resolve('styles'),
'components': resolve('src/components'),
'api': resolve('src/api'),
'utils': resolve('src/utils'),
'store': resolve('src/store'),
'router': resolve('src/router'),
复制代码

上面这些可以根据团队和项目大小程度来进行配置,但我觉得管理系统应该都不会太小,良好的一些规范对于日常的维护就显得的特别重要了

store/index.js

我们通过解析store文件中的index.js来看看webpack所体现出来的前端自动化

这里引用一下vue官方的例子,就是说当我们状态比较多的时候就需要分模块来管理我们的应用状态,该项目也就用到了modules来划分模块

export user = {
  namespaced: true, // 使用命名空间
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... },
  getters: { ... }
}

export app = {
  namespaced: true, // 使用命名空间
  state: () => ({ ... }),
  mutations: { ... },
  actions: { ... }
}

import user from './modules/user.js'
import app from './modules/app.js'
const store = new Vuex.Store({
  modules: {
    user,
    app,
  }
})

this.$store.dispatch('user/login') 
this.$store.dispatch('app/toggleSideBar') 
复制代码

解析module/index.js

store文件夹

在开始解析之前这里需要讲一个webpack提供的方法require.context: 获取一个特定的上下文,主要用来实现自动化导入模块,它会遍历文件夹中的指定文件,然后返回匹配的文件路径, 我们可以使用这个方法实现自动导入,使得不需要每次显式的调用import导入模块

require.context函数接受三个参数:

  • directory {String} -读取文件的路径
  • useSubdirectories {Boolean} -是否遍历文件的子目录
  • regExp {RegExp} -匹配文件的正则

require.context函数执行后返回的是一个函数,并且这个函数接受3个参数

  • resolve {Function} -接受一个参数request,为文件夹下面符合路径对应的文件名
  • keys {Function} -返回匹配成功模块的名字组成的数组
  • id {String} -执行环境的id,返回的是一个字符串,相对于工程的相对路径&&匹配正则组成的字符串

其实stroe/index.js中的代码并不多,就这二十来行代码就将modules文件中分割的六个模块都给引用了进来,

import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters' // getter文件是单独手动引入进来的

Vue.use(Vuex)

// ==========================> 关键部分提取出来解析

const store = new Vuex.Store({
  modules,
  getters
})

export default store
复制代码

关键部分提取出来解析

// 调用方法获取所有当前文件下modules文件夹下遍历所有(.js)文件
const modulesFiles = require.context('./modules', true, /\.js$/)

const modules = modulesFiles.keys().reduce((modules, modulePath) => {
  // set './app.js' => 'app' 这里已经给了例子就是获取文件名称,不过正则写的稍微有点毛病
  /^\.\/(.*)\.\w+$/.test('./.js'); // => true 源码的
  /^\.\/(.+)\.\w+$/.test('./.js') // => false 修改后的
  const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
  const value = modulesFiles(modulePath)
  modules[moduleName] = value.default
  return modules
}, {})

// 最后返回的modules
{
  app: {
  	state: () => ({ ... }),
  	mutations: { ... },
  	actions: { ... }                 
  },
  user: {
  	state: () => ({ ... }),
  	mutations: { ... },
  	actions: { ... }                 
  },
	...                      
}
复制代码

注意⚠:require函数中参数无法使用变量的问题

Bad:以下写法都会报错

let imgUrl = '图片路径'
<img src={require(imgUrl)}></img>
复制代码
function importAll (targetPath, isDeepDirectory, execPathReg) {
  require.context(targetPath, isDeepDirectory, execPathReg)
}
importAll('./components/common', true, /\.vue$/)

复制代码

Good: 下面这种写法不会报错

let imgUrl = require('图片路径')
<img src={imgUrl}></img>
复制代码
export function importAll(module)  {
  module.keys().forEach( path => {
    ![](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/fb7bfaa74a5249b891ff6c9b414c2a70~tplv-k3u1fbpfcp-watermark.image)console.log(path)
  })
}

import { importAll } from './importAll.js'
importAll(require.context('./components/common', true, /\.vue$/))
复制代码

Register Global Components

借用上面的思路我们也可以通过require.context这个方法来全局注册我们之前封装的通用全局组件

项目中常用的组件

main.js

// global components
const r = require.context('./components/common', true, /\.vue$/)
r.keys().forEach(path => {
  const filePath = path.substr(2)
  const module = require('./components/common/' + filePath)
  Vue.component(module.default.name, module.default)
})

复制代码

我们consooe.log(module)看下

组件实例

效果图💗

先找个页面直接使用全局注册好的组件

<div class="plan">
  <DynamicForm
    ref="pageForm"
    v-model="formModel"
    label-width="130px"
    :form-config="baseFormConfig"
    :show-btn="false"
  />
</div>
复制代码

直接使用全局注册的组件

这里需要注意⚠:全局注册的组件必须是在root Vue 前面注册:Vue文档:组件注册

router/index.js

一个庞大的管理系统一定会有很多页面,单靠router/index.js引入肯定是不行的,我们需要将每个模块进行划分成独立文件,在日后维护的时候可以看到页面就能知道当前路由在哪个文件下,如果当前模块需要添加页面也不需要去index.js文件中找。

庞大的项目

我们的模块

router/index.js

/* 页面框架 */
import Layout from '@/layout'

/* errorPage模块 */
import dashboard from './dashboard'

/* login模块 */
import login from './login'

/* A模块 */
import testA from './testA'
/* B模块 */
import testB from './testB'
/* C模块 */
import testC from './testC'
/* D模块 */
import testD from './testD'
/* E模块 */
import testE from './testE'
/* F模块 */
import testF from './testF'
/* G模块 */
import testG from './testG'

export const asyncRoutes = [
  ...testA,
  ...testB,
  ...testC,
  ...testD,
  ...testE,
  ...testF,
  ...testG,
  // 404 page must be placed at the end !!!
  { path: '*', redirect: '/404', hidden: true }
]

复制代码

像上面这样模块化规范,不管项目怎么庞大维护起来还是很方便的。

styles/variable.scss

对于全局样式我们可以做可配置化的,像侧边栏配色这种。

variable.scss

/ sidebar
$menuText:#bfcbd9;
$menuActiveText:#fff;
$subMenuActiveText:#f4f4f5; 

$menuBg:#008B8D;
$menuHover:rgba(102, 197, 199, .68);

$subMenuBg:#00787A;
$subMenuHover: rgba(102, 197, 199, .68);

:export {
  menuText: $menuText;
  menuActiveText: $menuActiveText;
  subMenuActiveText: $subMenuActiveText;
  menuBg: $menuBg;
  menuHover: $menuHover;
  subMenuBg: $subMenuBg;
  subMenuHover: $subMenuHover;
  sideBarWidth: $sideBarWidth;
}


复制代码

使用

<template>
  <div :class="{'has-logo':showLogo}">
      <el-menu
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="variables.menuBg"
        :text-color="variables.menuText"
        :unique-opened="false"
        :active-text-color="variables.menuActiveText"
        :collapse-transition="false"
        mode="vertical"
      >
      </el-menu>
  </div>
</template>


<script>
// 以js的形式导入样式文件
import variables from '@/styles/variables.scss'
<script>

复制代码

styles/element-variables

全局配置按钮的配色

element-variables

@import './variables';

/* theme color */
$--color-primary: $panGreen;
$--color-success: $success;
$--color-warning: $warning;
$--color-danger: $danger;
// $--col

// 配置主题色
:export {
  theme: $--color-primary;
}

复制代码
<el-button type="primary">按钮</el-button>
复制代码

效果图💗

utils/tip

对提示框进行包装,这里最主要的是对error提示的封装处理

tip.js

import { Message } from 'element-ui'
import { isObject } from '@/utils/index'
import Vue from 'vue'

function Tip(type, value, options = {}) {
  if (!type) {
    throw new Error('type must pass!')
  }
  const {
    duration = 5000,
    showClose = false,
  } = options

  const message = isObject(value) && (value.msg || value.message) || value || '操作失败'
  Message.closeAll()
  Message({
    message: message,
    type: type,
    showClose: showClose,
    duration: duration
  })
}

export function showSuccess(msg, ...options) {
  // 如果传递了配置项实用自定义的配置项
  Tip('success', msg, { ...options })
}
export function showError(msg, ...options) {
  Tip('error', msg, { ...options })
  throw new Error(msg)
}
export function showWarning(msg, ...options) {
  Tip('warning', msg, { ...options })
}


Vue.prototype.$showSuccess = showSuccess
Vue.prototype.$showError = showError
Vue.prototype.$showWarning = showWarning
复制代码

Message.closeAll(): 调用前关闭之前的实例

效果图💗

使用前 使用后

最主要的还是对error的处理,相信大家肯定遇到过这种情况:当我们使用forEach去遍历数据当没有达到条件的时候想阻断程序的运行并抛出错误提示给用户,这个时候就发现return的是forEach的回调,执行方法并没有return。(注⚠:这里要并讨论用Array方法能代替的问题: 如 find、findIndex、some、every)

handleClick() {
  /**
    这里只是模拟,实际应用中肯定条件比这个复杂
   */
  let arr = [
    {index: 1},
    {index: 2},
    {index: 3}
  ]; 
  arr.forEach(i =>{
    // 真实场景中的肯定不是对象index的条件
    if (i.index === 2) {
      console.log('提示用户,整个方法不在执行!指的是 => handleClick')
    }
    // 执行一大堆逻辑....
    
  })
  // 一大坨业务处理
      
}
复制代码

我们一般两种做法:

handleClick() {
  let flag = false;
	arr.forEach(i =>{
 	 	// 真实场景中的肯定不是对象index的条件
 	 	if (i.index === 2) {
    	console.log('提示用户,整个方法不在执行!指的是 => handleClick')
    	flag = true;
 	 	}
 	 	if (flag === true) return;
  		// 执行一大堆逻辑....
		})
	if (flag === true) return;
}

复制代码

更直接一点的做法

handleClick() {
	try {
  	arr.forEach(i =>{
    	if (i.index === 2) {
      	throw new Error('提示用户')
    	}
  	})
  } catch(e) {
  	this.$message.error(e.message)
  	console.error(e)
  	return 
	}
	console.log(11111); // =>下面的程序不在执行
}

复制代码

我自己还研究出来一种更骚的做法

export function showError(msg, ...options) {
  // 如果传递了配置项实用自定义的配置项
  Tip('error', msg, { ...options })
  return throw new Error(msg)
}

复制代码

然后在mian.js中使用Vue.config.errorHandler对全局的错误进行异常监控

// 对全局的错误进行异常监控
Vue.config.errorHandler = function (err, vm, info) {
	let { 
	    message, // 异常信息
	    name, // 异常名称
	    script,  // 异常脚本url
	    line,  // 异常行号
	    column,  // 异常列号
	    stack  // 异常堆栈信息
	} = err;
	
	// vm为抛出异常的 Vue 实例
	// info为 Vue 特定的错误信息,比如错误所在的生命周期钩子
  console.error(message);
}

复制代码

效果图💗

vuex-persist

Vuex 解决了多视图之间的数据共享问题,但是Vuex 的状态存储并不能持久化,当你存储在 Vuex 中的 store 里的数据,只要一刷新页面,数据就丢失了。前面我们封装的Storage可以解决这个问题,现在我们再了解一个解决vuex刷新页面数据丢失问题

vuex-persist 插件,它就是为 Vuex 持久化存储而生的一个插件。不需要你手动存取 storage ,而是直接将状态保存至 cookie 或者 Storage 中。

install

npm install vuex-persist --save
复制代码

vuex-persist 的详细属性:

属性类型描述
keystring将状态存储在存储中的键。默认: 'vuex'
storageStorage (Web API)可传localStorage, sessionStorage, localforage 或者你自定义的存储对象. 接口必须要有get和set. 默认是: window.localStorage
saveStatefunction (key, state[, storage])如果不使用存储,这个自定义函数将保存状态保存为持久性。
restoreStatefunction (key[, storage]) => state如果不使用存储,这个自定义函数处理从存储中检索状态
reducerfunction (state) => object将状态减少到只需要保存的值。默认情况下,保存整个状态。
filterfunction (mutation) => boolean突变筛选。看mutation.type并返回true,只有那些你想坚持写被触发。所有突变的默认返回值为true。
modulesstring[]要持久化的模块列表。

store/index.js

先简单使用下看

import VuexPersistence from 'vuex-persist'

// 创建对象,借助浏览器缓存,存入localStorage
const vuexLocal = new VuexPersistence()

// 使用该插件
const store = new Vuex.Store({
  modules,
  getters,
  plugins: [vuexLocal.plugin]
})
复制代码

这种用法直接将所有module中的state都缓存下来了。

saveState配置项

将函数保存为持久化,每次页面刷新都会执行下该函数

const vuexLocal = new VuexPersistence({
  key: 'myVuex', // defalut: vuex
  storage: window.sessionStorage, // 可选,localStorage/indexDB defalut: localStorage
  // 将函数保存为持久化,每次页面刷新都会执行下该函数
  saveState: (key, state, storage) => {
    console.log(key, state, storage)
  }
})
复制代码

restoreState配置项

将Storage中的数据进行筛选添加到state

const vuexLocal = new VuexPersistence({
  key: 'myVuex', // defalut: vuex
  storage: window.sessionStorage, // 可选,localStorage/indexDB defalut: localStorage
  // Storage中的数据进行筛选添加到state
  restoreState: (key, storage) => {
    // console.log(key, storage)
    return { [key]: storage }
  },
})
复制代码

reducer配置项

默认情况下保存整个状态, 该方法可以筛选state根据需求减少到只需要保存的状态

const vuexLocal = new VuexPersistence({
  key: 'myVuex', // defalut: vuex
  storage: window.sessionStorage, // 可选,localStorage/indexDB defalut: localStorage
  // 默认情况下保存整个状态, 该方法可以筛选state根据需求减少到只需要保存的状态 。
  reducer: (state) => {
    const userState = Object.entries(state).find(s => s[0] === 'user')
    console.log(userState)
    return { [userState[0]]: userState[1] } // 这个就是存入Storage的值
  },
  
   // => modules: ['user'], // 和reducer一样的效果
})
复制代码

登录模块

下面我们讲下单点登录的流程相关的知识点。

流程图💗 登录流程

实现的功能

用户在登录页面输入用户名密码,点击登录这个时候就可以登进我们的平台,我们在平台当中,我们可以选择退出登录,回到登录页面登录流程

大概流程:

  • 用户输入信息点击登录的时候会调用登录接口将用户名和密码传到我们的后端
  • 后端通过数据库中与验证我们的用户名密码是否正确验证正确之后会通过jwt生成一个token令牌(里面包含用户的信息)
  • 前端收到这个token之后就会把它保存在本地(cookie),然后通过axios请求拦截器在每次发送请求前都将token附带至header头中,之后前端会再次发送一个获取用户信息的接口,后端通过token进行校验来检查信息是否一致,通过之后将数据返回给前端。
  • 前端可以通过返回数据中的角色信息来进行用户权限校验,动态添加路由来生成侧边栏。

所以这个后台系统的根据权限动态加载路由的方式是前端实现的,后端只是提供了权限的角色。

这里设计到了三个核心点

  • 通过路由守卫来根据token及roles中的角色信息来做路由跳转处理
  • 根据路由表中配置的roles来匹配当前用户角色能进入的页面然后通过router.addRoutes方式来异步挂载路由
  • 通过编写redirect.vue组件的方式来解决router中同样URL 组件不会重新渲染的问题

通过token验证做路由的跳转处理⚡

中后台常见路由的跳转如下:

  • 已获取 Token:
    • 访问 /login:重定向到 /
    • 访问 /login?redirect=/xxx:重定向到 /xxx
    • 访问 /login 以外的路由:直接访问 /xxx
  • 未获取 Token:
    • 访问 /login:直接访问 /login
    • 访问 /login 以外的路由:如访问 /dashboard,实际访问路径为 /login?redirect=%2Fdashboard,登录后会直接重定向 /dashboard

为了更好的让大家理解,我通过调式的方式来呈现,因为帧数限制的关系所以中间一些不重要的点我就直接跳过了。肯定是不能把所有点的都展现出来,所以还是建议大家自己也动手去调式。

验证没有token访问/login的情况💗

没有token访问login

验证没有token访问其他组件的情况💗

进入不在白名单中的else逻辑,直接重定向到login将要去的路由当做redirect的参数

没有token访问其他组件

调用登录接口来访问跟路由的情况💗

这里可以重点分析下根据角色信息筛选路由表的逻辑

const accessRoutes = await store.dispatch('permission/generateRoutes', roles)

复制代码

store/modules/permission.js

通过store.dispatch触发actions方法,这里因为用了modules模块分格并且namespaced: true每个模块有自己的命名空间,所以需要加上前缀。

const actions = {
  // actions方法中第一个参数就是默认传递进到的store实例对象,解构出来commit用来手动触发mutations中的方法
  // 第二个参数就是当前的用户角色 => 假设当前roles = 'Beige'
  generateRoutes({ commit }, roles) { 
	// actions中允许我们执行异步操作
    return new Promise(resolve => {
      let accessedRoutes
      if (roles.includes('admin')) { // admin角色不需要做权限过滤,所有的异步路由都可以看
        accessedRoutes = asyncRoutes || [] // => asyncRoutes = router文件中定义的异步路由表
      } else {
			 	// 其他角色需要进行角色匹配看路由表中是否有路由配置当前角色可以看
        accessedRoutes = filterAsyncRoutes(asyncRoutes, roles) 
      }
      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

复制代码

filterAsyncRoutes方法

// routes 所有的异步路由表
// roles 当前角色 'Beige'
export function filterAsyncRoutes(routes, roles) {
  const res = []

  routes.forEach(route => {
    const tmp = { ...route }
    if (hasPermission(roles, tmp)) { // 调用hasPermission来进行角色鉴权
      if (tmp.children) {  
        // 如果有子组件递归按照上面的方式进行筛选,如果子组件都没符合的哪就不能挂载了
        // 比如下面,B组件就不能显示在侧边栏中了
			{
        component: 'A', 
        meta: meta: {roles: ['Beige']}
        children: [
          {component: 'B', meta: {roles: ['editor']}}
        ]
        tmp.children = filterAsyncRoutes(tmp.children, roles)
      }
      res.push(tmp)
    }
  })
  return res
}

复制代码

hasPermission方法

// roles 当前角色 'Beige'
// route 异步路由表
// 吐槽一下这里就有点不讲武德了,刚还是先路由表再角色,怎么就换了个位置
function hasPermission(roles, route) {
  if (route.meta && route.meta.roles) {
	  ['admin', 'Beige', 'editor'].inclues('Beige') => 回到上面的方法
    return roles.some(role => route.meta.roles.includes(role)) 
  } else {  // 如果没有配置就默认允许所有角色访问
    return true
  }
}

复制代码

redirect.vue

接着解析下redirect.vue组件实现的功能

  1. 记录重定向自己的组件,在得到权限之后方便跳转
  2. 用来解决访问同样的url导致页面不重新渲染的问题

问题一

记录跳转路由

相同路由下的页面渲染问题

内部实现的方式很简单,就是通过redirect.vue组件做中转来跳到想去的路由同时也达到了组件重新构建的目的

<script>
export default {
  created() {
		// 从路由实例对象中获取params中的path, query:传递的参数
    const { params, query } = this.$route
    const { path } = params
    // 直接跳转到想去的path并且将参数传递进去
    this.$router.replace({ path: '/' + path, query })
  },
  render: function(h) { 
    return h() // avoid warning message
  }
}
</script>
复制代码

流程图

总结

关于路由处理
  • vue-element-admin 对所有访问的路由进行拦截;
  • 访问路由时会从 Cookie 中获取 Token,判断 Token 是否存在:
    • 如果 Token 存在,将根据用户角色生成动态路由,然后访问路由,生成对应的页面组件。这里有一个特例,即用户访问 /login 时会重定向至 / 路由;
    • 如果 Token 不存在,则会判断路由是否在白名单中,如果在白名单中将直接访问,否则说明该路由需要登录才能访问,此时会将路由生成一个 redirect 参数传入 login 组件,实际访问的路由为:/login?redirect=/xxx
关于动态路由和权限校验
  • vue-element-admin 将路由分为:constantRoutes 和 asyncRoutes
  • 用户登录系统时,会动态生成路由,其中 constantRoutes 必然包含,asyncRoutes 会进行过滤;
  • asyncRoutes 过滤的逻辑是看路由下是否包含 meta 和 meta.roles 属性,如果没有该属性,所以这是一个通用路由,不需要进行权限校验;如果包含 roles 属性则会判断用户的角色是否命中路由中的任意一个权限,如果命中,则将路由保存下来,如果未命中,则直接将该路由舍弃;
  • asyncRoutes 处理完毕后,会和 constantRoutes 合并为一个新的路由对象,并保存到 vuex 的 permission/routes 中;
  • 用户登录系统后,侧边栏会从 vuex 中获取 state.permission.routes,根据该路由动态渲染用户菜单。

sidebar/sidebar-item 中的值得学习的点⚡

其实在vue-element-admin动态生成路由来对应生产侧边栏的实现中,sidebar/sidebar-item 的实现还是有很多细节的值的我们学习的。

  • isCollapse:NavBar 中点击按钮,会修改 Cookie 中的 sidebarStatus,从 vuex 取值时会将 sidebarStatus 转为 Boolean,并判断默认是否需要收缩左侧菜单栏

  • showLogo:判断 settings.js 中的配置项是否需要展示 Logo

  • variables:从 @/styles/variables.scss 中获取 scss 对象,从而获取样式

  • 菜单是否展示

  • 区分菜单中的跳转是路由还是外链

  • 通过路由表中的children来递归实现无限极菜单

  • 通过路由表中hidden来判断是否渲染组件到菜单中

  • 当路由中只有一个子级列表就将子列表当做父列表展示

template部分

首先最外层就是通过hidden属性来判断是否要渲染菜单,所以配置hidden是压根不会渲染组件的

<div v-if="!item.hidden">
 
</div>

复制代码

当路由中只有一个子级列表就将子列表当做父列表展示

<template v-if="hasOneShowingChild(item.children,item) &&(!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
 
</template>

复制代码

对于hasOneShowingChild方法里面注释其实写的已经很详细了,需要满足的条件:

  • 当子级路由没有设置hidden
  • 子级路由保持一个
  • 路由表中不设置alwaysShow: true

内部通过resolvePath方法来判断路径是否是外链

// isExternal => 判断是否是外链是否合法的正则  /^(https?:|mailto:|tel:)/
resolvePath(routePath) {
  if (isExternal(routePath)) {
    return routePath
  }
  if (isExternal(this.basePath)) {
    return this.basePath
  }
  return path.resolve(this.basePath, routePath)
}

复制代码

自己动手实现登录功能

通过上面的讲解大家应该对登录功能了解的差不多了,我们开始自己动手来搭个后台服务(node.js)实现登录接口

在开始前准备的工作

  • 将mock数据api删掉
  • 修改vue.config.js中的配置
  • 自己建个user/login请求API的方法

不懂的小伙伴可以看vue.config.js文档

后台服务搭建工作

考虑到有些小伙伴可能暂时没有学node所以我这里提供了后台服务代码,改下配置项就能用,对于要改的配置项可以看我写的文档,对于这部分就不过多讲了

代码仓库:github.com/it-beige/bo…

文档地址:www.beige.world/project/

在线卑微,如果觉得这篇文章对你有帮助的话欢迎大家点个赞👻

第三方登录

Github

建议先把文档过一篇,大概有个了解之后下面开始会非常容易理解 gitHup 文档

效果图💗

前期准备:

  • 申请OAuth App

步骤图

注意⚠:这里有个坑需要注意下,gitHub回调url并不能支持VueRouter这种 Url/#/ 因为这本就不是一个有效的URI

需要改成这种: http://book.beige.world/login, 这里给出两种解决方案

  • vue-router中mode设置成history(通过window.localtionAPI实现路由跳转)
  • 继续使用/#/这种方式,后续在路由前置钩子中进行拦截,将code截取出来

这里还是偏向于第二种,第一种需要后端支持,最起码得提供一个请求不存在的URI的统一处理。当mode为history意味着你URI中的访问路径都是访问文件,必须保证有效

第一种方式弊端

第二种方式弊端

gitHub(中控平台)跳转到指定redirect_url并且将code返回的时候,我们指定http:localhost:9527/login导致code放在了url里面,其实我们通过route是获取不到参数的只能写正则进行截取出来

申请完成之后我们大概了解下第三方登录核心Oathu2.0,常用于我们多系统开发做统一认证处理。

流程图

多系统统一认证处理

这里的GitHub类似于中控平台,我们需要通过认证中控平台来做多服务的认证处理,所以我们用第三方平台提供好的接口流程会简单很多。

第三方登录流程

主要就是上面流程, 我们需要调用3个接口

前端工作比较简单就是,放一个按钮写一个方法跳转到gitHub进行认证

gitHubHandleClick(thirdpart) {
  // 准备参数
  const client_id = 'Iv1.xxxxxx'
  const redirect_uri = encodeURIComponent('http://book.beige.world/#/login')
  // const redirect_uri = encodeURIComponent('http://localhost:9527/#/login') // 本地测试的时候可以用这个
  const url = 'https://github.com/login/oauth/authorize?client_id=' + client_id + '&redirect_uri=' + redirect_uri
  // openWindow(url, thirdpart, 540, 540)
}
复制代码

这里说一下因为我们不可能一次就成功,所以可以在修改我们之前申请的OAuth App回调地址为当前的本地页面,例:http://localhost:9527/login 等测试通过再改成你上线后的地址(这里后面跳转的时候会有点小问题)

本地测试改回调地址

后端实现第三方登录接口

其实主要的工作还是放在后端做的,这里贴下我写的代码逻辑,感兴趣的可以去gitHub仓库down下来

设置gitHub配置信息

let githubConfig = {
  // 客户ID
  client_ID: 'Iv1.dxxxxadasd',
  // 客户密匙
  client_Secret: '395sxxxxxxxxxxxxxxb5bc9ed385',
  // 获取 access_token
  // eg: https://github.com/login/oauth/access_token?client_id=7***************6&client_secret=4***************f&code=9dbc60118572de060db4&redirect_uri=http://manage.hgdqdev.cn/#/login
  access_token_url: 'https://github.com/login/oauth/access_token',
  // 获取用户信息
  user_info_url: 'https://api.github.com/user?',
  // 回调地址
  //  redirect_uri: 'http://book.beige.world'
  redirect_uri: 'http://localhost:9527/login' // -> 本地测试
}
复制代码
router.get('/thirdpart/login', async function (req, res, next) {
  console.log(req.query.code);
  if (!req.query.code) return;
  request({
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      url: githubConfig.access_token_url,
      form: {
        client_id: githubConfig.client_ID,
        client_secret: githubConfig.client_Secret,
        code: req.query.code,
        redirect_uri: githubConfig.redirect_uri
      }
    },
    function (error, response, body) {
      console.log(`------------->${body}`);
      let params = body && JSON.parse(body)
      if (!error && response.statusCode == 200) {
        let urlStr = githubConfig.user_info_url + body;
        try {
          request({
              url: urlStr,
              headers: {
                'User-Agent': 'it-beige',
                'Authorization': `bearer ${params.access_token}`
              }
            },
            async function (error, response, resbody) {

              if (!error) {
                let data = JSON.parse(resbody)
                // console.log(data);
                const user = await userService.findUser({
                  username: data.login
                })
                // 第一次gitHub授权向数据库存入用户数据
                if (!user) {
                  db.insert({
                    username: data.login,
                    password: md5(`${data.login}${PWD_SALT}`),
                    role: 'editor',
                    nickname: data.login,
                    avatar: 'http://resource.beige.world/imgs/logo.jpg',
                  }, 'admin_user')
                }

                const token = jwt.sign({
                    username: data.login
                  },
                  PRIVATE_KEY, {
                    expiresIn: JWT_EXPIRED
                  }
                )

                new Result(Object.assign(data, {
                  roles: 'editor',
                  role: 'editor',
                  token,
                  name: data.login,
                  password: md5(`${data.login}${PWD_SALT}`)
                }), '登录成功').success(res)
              } else {
                new Result(null, '获取用户信息失败').fail(res)
              }
            })
        } catch (e) {
          console.log('1111111111111');
        }
      } else {
        new Result(null, '获取用户信息失败').fail(res)
      }
    }
  )
})
复制代码

QQ & 微信

流程比gitHub多一点,首先你需要填写资料审核通过成为开发者才能去创建应用,审核地址:open.tencent.com/

注意⚠:如果需要微信接入应用需要填写的是企业信息(个人也可以,因为没有提供人个填写页面),但是审核需要300元人工费。

咳咳,基于上面一点所以这里就只演示一下QQ第三方登录功能,其实流程都大差不差。

前期准备大家可以看这篇文章 QQ第三方登录-QQ互联开发者申请的坑(个人接入,时间:2019-6-3)

看到这个状态安心等通过

第一次审核失败了😓后续补上吧....

完善登录功能

此部分为补上后续的第三方登录,因为是学习项目所以还是尽可能多做一些功能,目前支持:gitHubgitee百度oschina,整体实现大差不差,前端代码就不贴了,这里主要给下后端对于多个第三方登录请求同一个路由处理的逻辑。

let githubConfig = {
  // 获取 access_token
  access_token_url: 'https://github.com/login/oauth/access_token',
  // 获取用户信息
  user_info_url: 'https://api.github.com/user?',

  // 携带的参数
  form: {
   // 客户ID
    client_id: 'Iv1.xxxxxxxxxxxxxxx',
    // 客户密匙
    client_secret: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
    // 回调地址
    redirect_uri: 'http://localhost:9527/login' // 本地测试可以用
  },

  async requestCallBack(res, error, response, body){  // 请求后的处理函数
    // 使用code获取到的token后的操作,因为个别平台通过token传参方式不同,避免写太多if语句,将处理回调抽离
  }
}


let giteeConfig = {
  // 获取 access_token的Url
  access_token_url: 'https://gitee.com/oauth/token',
  // 获取用户信息
  user_info_url: 'https://gitee.com/api/v5/user',

  // 携带的参数
  form: {
    grant_type: 'authorization_code', // code码的方式
    // 客户ID
    client_id: 'xxxx',
    // 客户密匙
    client_secret: 'xxxx',
    // 回调地址
    redirect_uri: 'http://localhost:9527/login' 
  },

  async requestCallBack(res, error, response, body) { 
  },
}

// 同上
let tencentConfig = {....xxxxxxx}
let baiduConfig = {....xxxxxxx}
let oschinaConfig = {....xxxxxxx}

let authConfigHash = {
  tencent: tencentConfig,
  gitHuh: githubConfig,
  gitee: giteeConfig,
  baidu: baiduConfig,
  oschina: oschinaConfig,
}
复制代码

路由的统一处理

router.get('/thirdpart/login', async function(req, res, next) {
  let {
    code, // 第三方应用返回的code码
    authType, // 第三方应用名称
  } = req.query
  if (!code) return;
  let config = authConfigHash[authType] // 第三方应用配置项
  if (!config) {next()}
 
  let form = Object.assign({}, config.form, {code})
  request(
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      },
      url: config.access_token_url,
      form
    },
    config.requestCallBack.bind(null, res)
  )
复制代码

后续也加上了验证码实现起来非常简单,这里贴个图讲下大概的实现思路就好了

效果图💗

33.gif

  • 前端通过发送请求获取code码
  • 后端返回base64格式的图片给前端展示,响应时将codeText种在cookie上,方便前端校验证码合法性
  • 前端通过后端响应的数据进行校验

至此补充完成;贴下仓库地址,感兴趣的可以拉下来。

前端

后端

写在最后

如果文章中有那块写的不太好或有问题欢迎大家指出,我也会在后面的文章不停修改。也希望自己进步的同时能跟你们一起成长。喜欢我文章的朋友们也可以关注一下

我会很感激第一批关注我的人。此时,年轻的我和你,轻装上阵;而后,富裕的你和我,满载而归。

往期文章

【建议追更】以模块化的思想来搭建中后台项目

【前端体系】从一道面试题谈谈对EventLoop的理解 (更新了四道进阶题的解析)

【前端体系】从地基开始打造一座万丈高楼

【前端体系】正则在开发中的应用场景可不只是规则校验

「函数式编程的实用场景 | 掘金技术征文-双节特别篇」

【建议收藏】css晦涩难懂的点都在这啦

文章分类
前端
文章标签