2. 登录页

166 阅读10分钟

1. css初始化

1.1 搭建css目录结构

src/assets/目录下新建css目录用于存放css文件,并创建index.css作为入口,在这里面导入别的css文件,由于创建项目时选择了scss,所以这里我全创建的是scss文件

body {
  padding: 0;
  margin: 0;
}

html {
  font-size: 10px;
}

html,
body,
#app,
.app {
  width: 100%;
  height: 100%;
}

**html****font-size**设置为**10px**是为了更好地使用**rem**,方便移动端布局

@import url('./base.scss');

再在项目中导入即可

import { createApp } from 'vue'
import App from './App.vue'

import '@/assets/css/index.scss'

const app = createApp(App)
app.mount('#app')

2. 组件目录结构

src/components/:用于存放和业务紧密相关的组件,放到别的项目里不具备可复用性的组件 src/base-ui/:可以抽离的组件,放到别的项目中也同样能够使用

只属于页面的零散组件,比如登录页面的登录表单,这个表单组件只会在登录页面用到,放到src/components/下感觉没必要,这时候就可以在页面所在目录下新建一个cpns目录(components的缩写)来存放页面专属的组件 以登录页面专属的组件为例,创建src/pages/login/cpns/


3. 登录页面

3.1 创建登录表单面板组件LoginPanel.vue

<template>
  <div class="login-panel">LoginPanel</div>
</template>

<script lang="ts">
import { defineComponent } from 'vue'

export default defineComponent({
  setup() {
    return {}
  }
})
</script>

<style scoped></style>

3.2 Login.vue中导入组件并使用

<template>
  <div class="login">
    <LoginPanel />
  </div>
</template>

<script setup lang="ts">
import LoginPanel from './cpns/LoginPanel.vue'
</script>

<style scoped lang="scss">
.login {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  background: url('~@/assets/img/login-bg.jpg') center no-repeat;
  background-size: cover;
}
</style>

3.2.1 VueCLI环境下CSS中引用静态资源

VueCLI环境下,CSS中引用静态资源,比如上面这里用到了一张图片,引用时路径要按照如下要求写:

如果 URL 以 ~ 开头,其后的任何内容都会作为一个模块请求被解析。这意味着你甚至可以引用 Node 模块中的资源 如果 URL 以 @ 开头,它也会作为一个模块请求被解析。它的用处在于 Vue CLI 默认会设置一个指向 /src 的别名 @

因此,我们引用图片时,应当先写波浪号~,用来表明接下来的路径要作为模块请求被解析,也就是说我们再在后面紧跟着写上@的话就可以正确解析到src/目录


3.3 LoginPanel.vue

先放代码再做讲解

<template>
  <div class="login-panel">
    <!-- el 表单组件 -->
    <el-tabs type="border-card" stretch>
      <!-- 账号登录 -->
      <el-tab-pane>
        <template #label>
          <span class="custom-tabs-label">
            <el-icon><user-filled /></el-icon>
            <span>账号登录</span>
          </span>
        </template>
      </el-tab-pane>
      <!-- 手机号登录 -->
      <el-tab-pane>
        <template #label>
          <span class="custom-tabs-label">
            <el-icon><cellphone /></el-icon>
            <span>手机号登录</span>
          </span>
        </template>
      </el-tab-pane>
    </el-tabs>
    <!-- login-control -->
    <div class="login-control">
      <el-checkbox v-model="isRememberPwd" label="记住密码" />
      <el-link type="primary" :underline="false">忘记密码?</el-link>
    </div>
    <!-- 登录按钮 -->
    <el-button class="login-btn" type="primary">立即登录</el-button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

import { UserFilled, Cellphone } from '@element-plus/icons-vue'

// data
let isRememberPwd = ref<boolean>(true)
</script>

<style scoped lang="scss">
.login-panel {
  width: 32rem;
  // 登录面板顶部标签中的内容垂直居中
  .custom-tabs-label {
    .el-icon,
    span {
      vertical-align: middle;
    }

    span {
      margin-left: 4px;
    }
  }

  .login-control {
    display: flex;
    justify-content: space-between;
    margin-top: 8px;
  }

  .login-btn {
    width: 100%;
    letter-spacing: 2px;
    font-weight: 700;
    margin-top: 8px;
  }
}
</style>

登录表单直接拿element-plustabs标签页组件 [https://element-plus.gitee.io/zh-CN/component/tabs.html](https://element-plus.gitee.io/zh-CN/component/tabs.html)

<el-tabs type="border-card" stretch>stretch能够让标签的宽度自动撑开。 将.login-panel的宽度设置为32rem,由于根htmlfont-size已经设置为10px,因此登录面板的宽度为320px image.pngimage.png


3.3.1 用到的知识点

具名插槽

链接:[https://v3.cn.vuejs.org/guide/component-slots.html#%E5%85%B7%E5%90%8D%E6%8F%92%E6%A7%BD](https://v3.cn.vuejs.org/guide/component-slots.html#%E5%85%B7%E5%90%8D%E6%8F%92%E6%A7%BD) 我的理解就是el-tabs组件中有一个<slot name="label">的部分,然后父组件调用时,使用<template v-slot:label>的方式即可在父组件中将自定义的内容插入到子组件el-tabs的对应位置

v-slot和v-on、v-bind类似,有自己的缩写,即井号#,所以父组件中使用插槽的时候也可以写成<template #label>


css的vertical-align属性

CSS 的属性 vertical-align 用来指定行内元素(inline)或表格单元格(table-cell)元素的垂直对齐方式。 注意 vertical-align 只对行内元素、行内块元素和表格单元格元素生效:不能用它垂直对齐块级元素

我的理解是这里的.el-icon渲染出来是<i>标签,是一个行内元素,所以根据mdn的介绍vertical-align可以对其生效,让其垂直方向上居中,这个垂直居中是以谁为标准呢?以它的父元素为标准

baseline 使元素的基线与父元素的基线对齐。HTML规范没有详细说明部分可替换元素的基线,如 ,这意味着这些元素使用此值的表现因浏览器而异。

middle 使元素的中部与父元素的基线加上父元素x-height(译注:x高度)的一半对齐。

vertical-align默认是baseline,使用baseline和使用middle的对比效果如下 image.pngimage.png

组件props是布尔属性时可以简写

如上面的el-tabs组件的stretch属性,它是一个布尔类型的属性。 那么常规写法应当是<el-tabs type="border-card" :stretch="true">但也可以直接写该属性表示true,即<el-tabs type="border-card" stretch>,如果要是false的话仍然要用常规写法,不过默认值就是false,不写即可。


3.4 账号登录和手机号登录组件分离

可以直接将账号登录和手机号登录写在LoginPanel.vue组件中,但是这样会让组件结构变得不清晰,改动代码的时候不方便,因此再分别创建两个组件去实现对应功能 创建src/pages/login/cpns/LoginAccount.vuesrc/pages/login/cpns/LoginPhone.vue


3.5 LoginAccount.vue

<template>
  <div class="login-account">
    <el-form :rules="rules" :model="accountForm">
      <el-form-item label="账号" label-width="64px" prop="username">
        <el-input v-model="accountForm.username" />
      </el-form-item>
      <el-form-item label="密码" label-width="64px" prop="password">
        <el-input v-model="accountForm.password" />
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from 'vue'

// data
const accountForm = reactive({
  username: '',
  password: ''
})

const rules = {
  username: [
    {
      required: true,
      message: '账号不能为空',
      trigger: 'blur'
    }
  ],
  password: [
    {
      required: true,
      message: '密码不能为空',
      trigger: 'blur'
    },
    {
      pattern: /^[0-9a-zA-Z]{6,}$/,
      message: '密码不能少于6位',
      trigger: 'blur'
    }
  ]
}
</script>

<style scoped></style>

<el-form>:rules用于绑定校验规则,由于校验规则不会变动,即数据流是单向的,所以用v-on绑定即可,:model则是用于绑定表单对象 校验规则中trigger可以有blurchange两个值

  • blur失去焦点的时候出发校验规则
  • change如多选框、单选框等组件的值改变时会触发校验规则

3.5.1 校验规则抽离

一般来说,对于用到ref/reactive/useStore/onMounted(所有声明周期相关的均是)的数据,对它们抽离时,会封装到hooks里面,而像这里的rules,并不是响应式的,所以没必要弄到hooks里,直接抽离成配置文件即可

新建目录src/pages/login/configsrc/pages/login/hook,并在config中新建一个account-config.ts,将rules剪切到account-config.ts

export const rules = {
  name: [
    {
      required: true,
      message: '账号不能为空',
      trigger: 'blur'
    }
  ],
  password: [
    {
      required: true,
      message: '密码不能为空',
      trigger: 'blur'
    },
    {
      pattern: /^[0-9a-zA-Z]{6,}$/,
      message: '密码不能少于6位',
      trigger: 'blur'
    }
  ]
}

LoginAccount.vue中导入即可

<script setup lang="ts">
import { rules } from '../config/account-config'
</script>

3.6 LoginPhone.vue

校验规则

export const rules = {
  phone: [
    {
      required: true,
      message: '手机号不能为空',
      trigger: 'blur'
    }
  ],
  verificationCode: [
    {
      required: true,
      message: '验证码不能为空',
      trigger: 'blur'
    },
    {
      pattern: /^[0-9]{6}$/,
      message: '验证码是6位数字',
      trigger: 'blur'
    }
  ]
}
<template>
  <div class="login-phone">
    <el-form :rules="rules" :model="phoneForm">
      <el-form-item label="手机号" label-width="68px" prop="phone">
        <el-input v-model="phoneForm.phone" />
      </el-form-item>
      <el-form-item label="验证码" label-width="68px" prop="verificationCode">
        <div class="verification-item">
          <el-input v-model="phoneForm.verificationCode" />
          <el-button type="primary" class="verfication-code-btn"
            >获取验证码</el-button
          >
        </div>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from 'vue'

import { rules } from '../config/phone-config'

// data
const phoneForm = reactive({
  phone: '',
  verificationCode: ''
})
</script>

<style scoped lang="scss">
.verification-item {
  display: flex;

  .verfication-code-btn {
    margin-left: 16px;
  }
}
</style>

3.7 登录页面效果

why-mobile.gif


4. 登录逻辑

登录逻辑可以在父组件LoginPanel.vue中处理,也可以在具体的子组件: LoginAccount.vueLoginPhone.vue中处理,由于账号登陆和手机号登录的逻辑是不一样的,为了解耦合,这里采用在子组件中处理。 如果是在父组件中处理,就涉及到子组件向父组件传值,将输入的内容传给父组件再去提交登录请求,而如果是在子组件中处理,则父组件中要获取到子组件的对象引用,然后给登录按钮绑定一个点击事件,点击登录按钮后,调用子组件的处理登录逻辑的方法即可,这里就要用到ref来获取子组件的引用对象。

4.1 子组件中定义好处理登录逻辑的方法

<script setup lang="ts">
// methods
const handleAccountLogin = () => {
  console.log('LoginAccount -- 发起登录请求')
}

// 暴露给父组件,这样父组件就能通过 ref 调用该方法
defineExpose({
  loginAction
})
</script>

因为用了<script setup>语法糖,父组件如果要通过模板ref引用子组件的话,需要子组件将要用到的数据或方法通过defineExpose暴露给父组件


4.2 调用子组件时添加模板ref属性

父组件中使用子组件的时候,添加一个ref属性并设定引用名称为loginAccountRef

<LoginAccount ref="refLoginAccount" />

4.3 父组件中用ref定义一个和模板ref引用名称一样的响应式变量

// 对子组件 LoginAccount 的引用
const refLoginAccount = ref<InstanceType<typeof LoginAccount>>()

为什么要用<InstanceType<typeof LoginAccount>>这么复杂的泛型呢?先看看TypeScript官方文档对InstanceType泛型的介绍:

InstanceType

Constructs a type consisting of the instance type of a constructor function in Type

也就是说<InstanceType<typeof LoginAccount>>这样一个泛型能够拿到LoginAccount这个构造函数的类型,说白了,就是拿到了一个新的类型,这个类型就叫LoginAccount,这样一来我们在使用的时候,loginAccountRef.value就是LoginAccount类型了。


4.4 父组件给登录按钮绑定点击事件

父组件中点击登录按钮后通过模板ref去调用子组件的登录逻辑

// methods
const handleLoginClick = () => {
  console.log('login-panel -- 点击登录按钮')
  refLoginAccount.value?.loginAction() // 调用子组件的方法
}

由于前面声明响应式ref数据loginAccountRef的时候,用泛型指明了子组件的类型,因此现在调用的时候是可以获得类型提示的 image.png


4.5 封装localStorage工具类

工具类不用实例化,因此全部设计成静态方法,同时使用泛型以便提供类型提示

export class LocalCache {
  static setCache<T = any>(key: string, value: T): void {
    window.localStorage.setItem(key, JSON.stringify(value))
  }

  static getCache<T = any>(key: string): T | null {
    const value = window.localStorage.getItem(key)
    if (value) {
      return JSON.parse(value)
    }

    return null
  }

  static deleteCache(key: string): void {
    window.localStorage.removeItem(key)
  }

  static clearCache(): void {
    window.localStorage.clear()
  }
}

4.6 子组件LoginAccount.vue中编写登录逻辑代码

4.6.1 拿到ElForm组件实例

既然要登录,那肯定要拿到登录表单对象,这里由于用的是element-plusel-form组件,因此用模板ref拿到ElForm组件实例,这样才能调用它的validate方法校验表单。

<template>
  <div class="login-account">
    <!-- 1. 添加模板ref引用 - ref="refElForm -->
    <el-form :rules="rules" :model="accountForm" ref="refElForm">
      ...
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { ElForm } from 'element-plus'

// 2. 拿到 ElForm 的组件实例才能调用它的 validate 方法
const refElForm = ref<InstanceType<typeof ElForm>>()

// methods
const handleAccountLogin = () => {
  console.log('LoginAccount -- 发起登录请求')
}
</script>

4.6.2 记住密码的传递

由于记住密码选项是在父组件LoginPanel.vue中的,而登录逻辑在子组件LoginAccount.vue中处理,所以需要父组件向子组件传值,而由于已经建立了一个调用关系,由父组件调用子组件的loginAction方法,所以在该方法中添加参数,记录是否要记住密码即可

const handleAccountLogin = (isRememberPassword: boolean) => {
  refElForm.value?.validate((isValid) => {
    if (isValid) {
      // 记住密码
      if (isRememberPassword) {
        LocalCache.setCache('username', accountForm.username)
        LocalCache.setCache('password', accountForm.password)
      } else {
        LocalCache.deleteCache('username')
        LocalCache.deleteCache('password')
      }
    } else {
      console.log('表单校验失败,不发起登录请求')
    }
  })
}

登录时将用户名和密码保存到localStorage中,并且下次登录时先看看localStorage中有没有数据,有的话就从里面拿来直接放进表单中,因此在响应式对象accountForm中添加如下逻辑

const accountForm = reactive({
  username: LocalCache.getCache('username') ?? '',
  password: LocalCache.getCache('password') ?? ''
})

父组件LoginPanel.vue中调用

const handleLoginClick = () => {
  refLoginAccount.value?.loginAction(isRememberPwd.value) // 调用子组件的方法
}

4.7 登录逻辑梳理

  1. 发送网络请求,对拿到的数据进行处理
  2. 数据的保存到某一个位置(Vuex,Pinia)
  3. 发送其他的请求(比如登陆后后端只返回了token,用户信息需要再到别的接口请求)
  4. 拿到用户的菜单
  5. 跳转到首页

其实上述逻辑全都可以放在Vuex中处理,网络请求用actions异步处理,数据修改用mutations处理


4.8 Vuex中处理登录逻辑

4.8.1 声明API接口数据类型

首先需要声明登录接口的请求数据类型以及响应数据类型,为了统一管理,在service目录中创建一个api目录,并且为了分模块管理接口,每个模块的接口都放在单独的文件夹中,因此再建一个login目录,然后创建type.ts。 其次,考虑到接口返回的数据格式都是统一的,比如可能都会有API状态码API调用信息API数据这三项,比如登录接口就是: image.png image.png

4.8.1.1 统一响应体的数据类型

api目录下新建一个typs.ts,用来管理各个API接口都会用到的类型

/**
 * 接口数据统一类型
 */
export interface IResponseData<T = any> {
  code: number
  message: string
  data: T
}

4.8.1.2 登录接口的数据类型

login/type.ts中则存放属于登录接口的类型

import { IResponseData } from '../type'

/**
 * 登录接口请求体类型 -- /login POST
 */
export interface ILoginRequestData {
  username: string
  password: string
}

/**
 * 登录接口响应体中 data 的类型
 */
export interface ILoginUserInfo {
  id: number
  token: string
}

/**
 * 登录接口响应体类型 -- /login POST
 */
export type ILoginResponseData = IResponseData<ILoginUserInfo>

/**
 * 使用枚举定义接口 url 避免硬编码 方便修改
 */
export enum LoginAPI {
  AccountLogin = '/login'
}

4.8.1.3 用户详细信息接口的数据类型

user/type.ts中存放用户详细信息接口用到的数据类型

import { IResponseData } from '../type'
import { IDepartment } from '../department/type'
import { IRole } from '../role/type'

/**
 *
 * @export
 * @interface IUserDetails
 */
export interface IUserDetails {
  /**
   * 用户id
   * @type {number}
   * @memberof IUserDetails
   */
  id: number
  /**
   * 用户姓名
   * @type {string}
   * @memberof IUserDetails
   */
  name: string
  /**
   * 用户真实姓名
   * @type {string}
   * @memberof IUserDetails
   */
  realname: string
  /**
   * 用户手机号
   * @type {number}
   * @memberof IUserDetails
   */
  cellphone: number
  /**
   * 用户是否允许登录系统
   * @type {boolean}
   * @memberof IUserDetails
   */
  enable: boolean
  /**
   * 用户创建时间
   * @type {string}
   * @memberof IUserDetails
   */
  createAt: string
  /**
   * 用户信息更新时间
   * @type {string}
   * @memberof IUserDetails
   */
  updateAt: string
  /**
   * 用户所属角色列表
   * @type {Array<IRole>}
   * @memberof IUserDetails
   */
  role: Array<IRole>
  /**
   *
   * @type {IDepartment}
   * @memberof IUserDetails
   */
  department: IDepartment
}

// 接口返回的类型
export type IUserDetailsResponseData = IResponseData<IUserDetails>

// 接口 url 枚举
export enum UserAPI {
  GetUserDetailsById = '/users'
}

4.8.1.4 菜单接口的数据类型

import { IResponseData } from '../type'

export interface IMenu {
  /**
   * 菜单id
   * @type {number}
   * @memberof IMenu
   */
  id: number
  /**
   * 菜单名字
   * @type {string}
   * @memberof IMenu
   */
  name: string
  /**
   * 菜单类型
   * @type {number}
   * @memberof IMenu
   */
  type: number
  /**
   * 菜单url
   * @type {string}
   * @memberof IMenu
   */
  url: string
  /**
   * 菜单图标
   * @type {string}
   * @memberof IMenu
   */
  icon: string
  /**
   * 菜单排序,越小排越前
   * @type {number}
   * @memberof IMenu
   */
  sort: number
  /**
   * 子菜单
   * @type {Array<IMenuChildren>}
   * @memberof IMenu
   */
  children: Array<IMenuChildren> | null
}

export interface IMenuChildren {
  /**
   * 菜单id
   * @type {number}
   * @memberof IMenuChildren
   */
  id: number
  /**
   * 菜单url
   * @type {string}
   * @memberof IMenuChildren
   */
  url: string
  /**
   * 菜单名字
   * @type {string}
   * @memberof IMenuChildren
   */
  name: string
  /**
   * 菜单排序,越小排越前
   * @type {number}
   * @memberof IMenuChildren
   */
  sort: number
  /**
   * 菜单类型
   * @type {number}
   * @memberof IMenuChildren
   */
  type: number
  /**
   * 父级菜单id
   * @type {number}
   * @memberof IMenuChildren
   */
  parentId: number
  /**
   * 子菜单
   * @type {Array<IMenuChildren1>}
   * @memberof IMenuChildren
   */
  children: Array<IMenuChildren>
}

// 接口返回的数据类型
export type IMenuResponseData = IResponseData<IMenu[]>

// 接口 url 枚举
export enum MenuAPI {
  GetMenusByUid = '/user-menu'
}

4.8.2 创建Vuex Login Module

由于多个组件都会用到Vuex,所以应当分模块去编写。 首先创建登录组件对应的模块src/store/login/,用于登录状态管理的state是有自己的属性的,所以定义一个接口来管理。

import { ILoginUserInfo } from '@/service/api/login/type'
import { IMenu } from '@/service/api/menu/type'
import { IUserDetails } from '@/service/api/user/type'

export interface ILoginState {
  loginUserInfo: ILoginUserInfo
  userDetails: IUserDetails
  userMenus: IMenu[]
}

再在src/store/login/index.ts中写一个Module,写好的Module可以在Vuex实例中注册,Module中也有自己的stateactionsmutations等内容。 但是这里Module需要两个泛型,一个是该模块所需要管理的state的类型,也就是我们上面定义的接口ILoginState,另一个泛型则是根state的类型,在src/store/type.d.ts中定义一个根state

import { ILoginState } from './login/type'

export interface IState {
  login: ILoginState
}

然后在src/store/login/index.ts中定义Module

import { Module } from 'vuex'
import { IState } from '../type'
import { ILoginState } from './type'

const loginModule: Module<ILoginState, IState> = {
  namespaced: true,
  actions: {
    actionAccountLogin({ commit }, payload: any) {
      console.log('actionAccountLogin...', payload)
    }
  }
}

export default loginModule

不需要显式声明state,因为已经用泛型指定了该模块存放的state是什么类型的了,因此没必要写一些没意义的初始化为空值的statenamespaced开启了命名空间,在使用的时候,比如要使用actionAccountLogin这一个action,就要加上注册时填写的命名空间,下面会说。


4.8.3 在根state中注册Login Module

import { createStore } from 'vuex'
import loginModule from './login'

const store = createStore({
  modules: { login: loginModule }
})

export default store

注册的时候,模块名为login,那么调用actionAccountLogin时,就要这样写 store.dispatch('login/actionAccountLogin', {...accountForm}),这就是前面开启命名空间的作用,可以避免命名冲突。


4.8.4 在main.ts中注册vuex

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import '@/assets/css/index.scss'
import store from './store'

const app = createApp(App)

app.use(router) // vue-router
app.use(store) // vuex
app.mount('#app')

4.8.5 useStore类型声明

为了在组件中使用store实例的时候,能够获得类型声明,明确管理的状态是什么类型的,根据vuex官方文档,需要做如下步骤:

  1. 定义类型化的 InjectionKey
  2. 将 store 安装到 Vue 应用时提供类型化的 InjectionKey
  3. 将类型化的 InjectionKey传给 useStore方法。

那就一步步来呗,首先是定义InjectionKey,由于它是一个会被多处用到的类型对象,因此将它放在typs.ts中更合适。

import { InjectionKey } from 'vue'
import { Store } from 'vuex'

import { ILoginState } from './login/type'

// useStore 类型声明 -- 从而能够获得类型化的 store 实例
// 定义 injection key
export const key: InjectionKey<Store<IState>> = Symbol()

export interface IState {
  login: ILoginState
}

然后是在注册vuex的时候将key也传入

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

import '@/assets/css/index.scss'
import store, { key } from './store'

const app = createApp(App)

app.use(router) // vue-router
app.use(store, key) // vuex
app.mount('#app')

在使用到store实例的组件中,将类型化的InjectionKey传给useStore方法从而获得类型化的store实例

<script setup lang="ts">
import { key } from '@/store/type'

// 获取 vuex store 实例
const store = useStore(key)
</script>

现在store实例就是类型化的了,类型化有什么好处?方便啊!能让vscode给我们提供友好的类型提示,比如像下面这样,我们可以一直点下去获取属性,不怕以后项目规模变大后,总是要去翻源码看看这个属性存不存在,那个属性是否允许为空,这不爽到起飞? image.png image.png


4.8.6 简化useStore

思考一下,现在我们只是在登录的时候要用到vuex,获取store的时候传入一个key好像也没什么,但是如果之后项目规模变大了,vuex的模块越来越多,也就意味着我们需要在很多地方用到store实例,那么每次获取store实例的时候都要传入key不就太麻烦了吗? 理想情况应当是能调用无参数的useStore函数获取到类型化的store实例,这才是懒到极致! 事实上vuex官方也考虑到这种情况了,因此官方文档中十分友好地提供了解决方案,很简单,我们在store的入口文件中导出一个新的useStore函数,并且在这个函数内部调用原本vuex的useStore函数,且传入key,然后将得到的store实例返回出去不就解决了吗!

import { createStore, useStore as baseUseStore } from 'vuex'
import loginModule from './login'
import { IState, key } from './type'

const store = createStore<IState>({
  modules: {
    login: loginModule
  }
})

export function useStore() {
  return baseUseStore(key)
}

export default store

这样一来,在任何组件中要用到store实例的时候,只用像下面这样操作即可:

<script setup lang="ts">
import { useStore } from '@/store/index'

// 获取 vuex store 实例
const store = useStore()
</script>

此时store实例已经是类型化的了 image.png


4.8.7 LoginAcount.vue将登录逻辑交给Vuex Login Module处理

vuex搭建完毕,接下来在LoginAccount.vue的登录按钮绑定事件中调用即可,后面的逻辑全部移步到vuex处理

const handleAccountLogin = (isRememberPassword: boolean) => {
  refElForm.value?.validate((isValid) => {
    if (isValid) {
      // 记住密码
      if (isRememberPassword) {
        LocalCache.setCache('username', accountForm.username)
        LocalCache.setCache('password', accountForm.password)
      } else {
        LocalCache.deleteCache('username')
        LocalCache.deleteCache('password')
      }

      // 让 vuex 中的子模块 loginModule 处理登录逻辑
      store.dispatch('login/actionAccountLogin', { ...accountForm })
    } else {
      console.log('表单校验失败,不发起登录请求')
    }
  })
}

4.8.8 使用mapActions辅助函数映射方法名

4.8.8.1 mapActions无法访问到this.$store

目前在组件中分发actions是用类似这样的方式:

store.dispatch('login/actionAccountLogin', { ...accountForm })

这种方式每次分发都要写一长串字符串,极容易出现拼写错误的低级错误,然后导致调试半天才发现一个这么低级的错误,又或者总是要打开对应的模块的actions去赋值方法名称,这样太麻烦了! 为此vuex官方提供了mapActions这样的方法名映射功能,将方法映射到store实例上,这样就可以直接.出来对应的actions方法名,十分方便!

const loginActions = {
  ...mapActions('login', ['actionAccountLogin']),
  ...mapActions('login', {
    accountLogin: 'actionAccountLogin'
  })
}
// 下面两个都等价于 -- store.dispatch('login/actionAccountLogin')
loginActions.actionAccountLogin()
loginActions.accountLogin()

将它放在vuex的login模块中导出,这样一来,如果组件只是需要调用actions而没有别的操作的话,甚至可以不用导入store实例,直接导入这个loginActions就可以调用里面的actions了,又能进一步偷懒了~

import { Module, mapActions } from 'vuex'
import { IState } from '../type'
import { ILoginState } from './type'

const loginModule: Module<ILoginState, IState> = {
  namespaced: true,
  actions: {
    actionAccountLogin({ commit }, payload: any) {
      console.log('actionAccountLogin...', payload)
    }
  }
}

export const loginActions = {
  ...mapActions('login', ['actionAccountLogin'])
}
// 可以直接 loginActions.actionAccountLogin() 调用 actions
// 等价于 -- store.dispatch('login/actionAccountLogin')

export default loginModule

但是这样子其实不能够正确工作,这是因为mapActions源码中用到了this.$store,而我们现在这样以导出的方式,在调用的时候是loginActions.actionAccountLogin(),这样的话this被隐式绑定到了loginActions中,而loginActions中并没有$store这一属性,因此无法正常工作。 image.png 那该怎么办呢?难道就没办法享受mapActions带来的便利了吗?其实有办法,但是这个办法并不是vuex官方提供的,所以只能是等vuex官方提供解决办法以后才能用mapActions或者其他的mapXX函数了,或者考虑使用Pinia


4.8.9 区分账号登陆和手机号登录

现在点击登录走的是账号登陆的,但如果我点击到了手机号登录的界面,并且输入了手机号和验证码,此时点击登录按钮还是走的账号登陆的逻辑,因此需要对手机号登录还是账号登陆做一个区分,这一点element-plustabs组件有提供当前组件处在那个tab中,通过这个tab的值去dispatch不同的action即可 image.png 那么我们就先添加一个响应式变量currentTab,并和tabs组件进行双向绑定

<el-tabs type="border-card" v-model="currentTab" stretch></el-tabs>
<script setup lang="ts">
// data
const currentTab = ref('account') // 默认是账号密码登录
</script>

然后给两个tab-panel添加自己的name用以区分当前点击的是哪个tab image.png

<el-tab-pane name="account" />
<el-tab-pane name="phone" />

然后在LoginPanel.vue中完善区分的逻辑即可

const handleLoginClick = () => {
  if (currentTab.value === 'account') {
    // 账号密码登录
    refLoginAccount.value?.handleAccountLogin(isRememberPwd.value) // 调用子组件的方法
  } else {
    // 手机号登录
    console.log('手机号登录...')
  }
}

4.8.10 封装登录请求

由于登录接口不需要携带token,而原先的wfRequest是携带token发送请求的,为了区分,我们需要在创建一个请求拦截器中没有携带token的请求实例

// 不携带 token 的请求实例
export const wfRequestNoToken = new WFRequest({
  baseURL: BASE_URL,
  timeout: TIME_OUT
})

创建文件src/service/api/login/index.ts,用于封装登录接口的请求

import { wfRequestNoToken } from '@/service'
import { ILoginRequestData, ILoginResponseData, LoginAPI } from './type'

/**
 * 账号登录接口
 * @param accountRequestData 登录接口请求体
 * @returns id name token
 */
export function accountLogin(
  accountRequestData: ILoginRequestData
): Promise<ILoginResponseData> {
  return wfRequestNoToken.post({
    url: LoginAPI.AccountLogin,
    data: accountRequestData
  })
}

4.8.12 封装获取用户详细信息请求

import wfRequest from '@/service'
import { IUserDetailsResponseData, UserAPI } from './type'

/**
 * 获取用户详细信息
 * @param id 用户 id
 * @returns 用户详细信息
 */
export function getUserInfoById(id: number): Promise<IUserDetailsResponseData> {
  return wfRequest.get<IUserDetailsResponseData, number>({
    url: UserAPI.GetUserDetailsById + `/${id}`
  })
}

4.8.12 封装获取用户菜单请求

import wfRequest from '@/service'
import { IMenuResponseData, MenuAPI } from './type'

/**
 * 根据用户 id 获取其菜单
 * @param uid 用户 id
 * @returns 统一响应数据中包装了 IMenu
 */
export function getMenusByUid(uid: number): Promise<IMenuResponseData> {
  return wfRequest.get<IMenuResponseData, number>({
    url: MenuAPI.GetMenusByUid + `/${uid}`
  })
}

4.8.13 实例请求拦截器中添加token

因为获取用户详细信息和用户菜单接口需要token才能请求,因此要在实例请求拦截器中从state中取到用户的token并添加到请求头中,然后再发起请求。

为什么不是放到全局请求拦截器呢?因为可能有的接口并不需要携带token就能访问,那么我们可能会需要一个不需要拦截添加token的axios实例去发送请求。

import { LocalCache } from '@/utils/cache'
import { ILoginUserInfo } from './api/login/type'
import { IResponseData } from './api/type'
import WFRequest from './request'
import { BASE_URL, TIME_OUT } from './request/config'

// 不携带 token 的请求实例
export const wfRequestNoToken = new WFRequest({
  baseURL: BASE_URL,
  timeout: TIME_OUT,
  interceptors: {
    requestInterceptor: (config) => config,
    requestInterceptorCatch: (err) => err,
    responseInterceptor: (res) => res,
    responseInterceptorCatch: (err) => err
  }
})

// 携带 token 的请求实例
const wfRequest = new WFRequest<IResponseData>({
  baseURL: BASE_URL,
  timeout: TIME_OUT,
  interceptors: {
    requestInterceptor: (config) => {
      // 发送请求的时候带上 token
      const token = LocalCache.getCache<ILoginUserInfo>('loginUserInfo')?.token
      console.log(token)
      if (token) {
        config.headers = {
          Authorization: `Bearer ${token}`
        }
      }

      return config
    },
    requestInterceptorCatch: (err) => err,
    responseInterceptor: (res) => res,
    responseInterceptorCatch: (err) => err
  }
})

export default wfRequest

注意:获取token的时候不能从**Vuex**中获取,应当从**localStorage**中获取,这是因为登录请求接口数据返回后,虽然在actions中有立刻**commit**,但是由于**commit**是异步提交的,数据并不会立刻添加到state中,而actions中又调用了**LocalCache.setCache**将数据存放到localStorage中,这一操作是同步执行的,因此能够保证后面的请求用户详细信息和用户菜单的异步请求中能够从localStorage中拿到token。


4.8.13 actions中请求登录接口以及相关接口

  • 涉及到异步操作要在**actions**中处理
  • 涉及到**state**的修改统一在**mutations**中操作,不要在**actions**中写!
import { accountLogin } from '@/service/api/login'
import { ILoginRequestData, ILoginUserInfo } from '@/service/api/login/type'
import { getMenusByUid } from '@/service/api/menu'
import { IMenu } from '@/service/api/menu/type'
import { getUserInfoById } from '@/service/api/user'
import { IUserDetails } from '@/service/api/user/type'
import { LocalCache } from '@/utils/cache'
import { Module } from 'vuex'
import { IState } from '../type'
import { ILoginState } from './type'

const loginModule: Module<ILoginState, IState> = {
  namespaced: true,
  mutations: {
    setLoginUserInfo(state, loginUserInfo: ILoginUserInfo) {
      state.loginUserInfo = loginUserInfo
    },
    setUserDetails(state, userDetails: IUserDetails) {
      state.userDetails = userDetails
    },
    setUserMenus(state, userMenus: IMenu[]) {
      state.userMenus = userMenus
    }
  },
  actions: {
    /**
     * 请求登录接口
     * @param requestData 登录接口请求体
     */
    async actionAccountLogin({ commit }, requestData: ILoginRequestData) {
      // 请求登录接口 拿到 token
      const {
        code,
        message,
        data: loginUserInfo
      } = await accountLogin(requestData)

      if (code !== 0) {
        alert(message)
      } else {
        // commit token 同时缓存到本地
        commit('setLoginUserInfo', loginUserInfo)
        LocalCache.setCache<ILoginUserInfo>('loginUserInfo', loginUserInfo)

        // 请求用户详细信息
        const {
          code,
          message,
          data: userDetails
        } = await getUserInfoById(loginUserInfo.id)

        if (code !== 0) alert(message)
        else {
          commit('setUserDetails', userDetails)
          LocalCache.setCache<IUserDetails>('userDetails', userDetails)
        }

        // 请求用户菜单
        const {
          code: code1,
          message: message1,
          data: userMenus
        } = await getMenusByUid(loginUserInfo.id)

        if (code1 !== 0) alert(message1)
        else {
          commit('setUserMenus', userMenus)
          LocalCache.setCache<IMenu[]>('userMenus', userMenus)
        }
      }
    }
  }
}

export default loginModule

一定要先请求登录接口,然后将loginUserInfocommit到state中,并同时存入到**localStorage**中。

因为commit是异步提交的,不能保证后面调用携带token的实例发送请求的时候能够从state中拿到token,因此需要先存到localStorage中,因为存到localStorage是同步代码。


4.8.14 将API状态码的处理抽离到响应拦截器中

现在我们的actions中处理登录接口的逻辑确实已经完成了,但是不觉得太繁琐了吗?每请求一次接口都要拿出code状态码进行判断,非0就alert一下错误信息,这部分逻辑是重复的,明显可以将其抽离出来,放到响应拦截器中统一处理。

import { LocalCache } from '@/utils/cache'
import { ILoginUserInfo } from './api/login/type'
import { IResponseData } from './api/type'
import WFRequest from './request'
import { BASE_URL, TIME_OUT } from './request/config'

// 不携带 token 的请求实例
export const wfRequestNoToken = new WFRequest({
  // ...
  interceptors: {
    responseInterceptor: (res) => {
      // 处理 API 状态码 code 异常
      const { code, message, data } = res.data
      if (code !== 0) {
        alert(message)
        return null
      }

      return data
    }
  }
  // ...
})

// 携带 token 的请求实例
const wfRequest = new WFRequest<IResponseData>({
    // ...
    interceptors: {
      responseInterceptor: (res) => {
        // 处理 API 状态码 code 异常
        const { code, message, data } = res.data
        if (code !== 0) {
          alert(message)
          return null
        }

        return data
      }
    }
    // ...
})

export default wfRequest

4.8.15 在App.vue的生命周期钩子onBeforeMount中初始化state

用户信息和用户菜单也要缓存在本地缓存中,由于vuex刷新后state的数据就会消失,因此需要有一个初始化state的函数,在每次刷新时都能初始化从本地缓存中读取数据。

并在声明周期钩子中调用它,这里我们选择App.vueonBeforeMount钩子中调用setupStore函数。

export function setupStore() {
  store.commit('login/loadLocalLogin')
}

由于不涉及异步操作,只是单纯从localStorage中取出数据,因此编写一个mutations用于初始化vuex state

/**
 * 初始化 state
 */
loadLocalLogin(state) {
  const loginUserInfo = LocalCache.getCache<ILoginUserInfo>('loginUserInfo')
  if (loginUserInfo) state.loginUserInfo = loginUserInfo

  const userDetails = LocalCache.getCache<IUserDetails>('userDetails')
  if (userDetails) state.userDetails = userDetails

  const userMenus = LocalCache.getCache<IMenu[]>('userMenus')
  if (userMenus) state.userMenus = userMenus
}

4.8.16 登陆成功跳转到首页

注意:在普通的**ts**文件中要想使用**router**,不应该调用**useRouter()****useRouter**只是在组件中使用才有效,在普通文件中要用**router**的话直接去导入即可

import router from '@/router'
actions: {
  /**
   * 请求登录接口
   * @param requestData 登录接口请求体
   */
  async actionAccountLogin({ commit }, requestData: ILoginRequestData) {
    // 1. 请求登录接口 拿到 token
    ...

    if (loginUserInfo) {
      // 1.1 commit token 同时缓存到本地
      ...

      // 2. 请求用户详细信息
      ...

      // 3. 请求用户菜单
      ...

      // 4. 跳转到首页
      router.push({
        name: 'home'
      })
    }
  }
}

src/router/index.ts中添加路由守卫

router.beforeEach((to, from, next) => {
  const token = LocalCache.getCache('token')

  // 未登录时跳到登录页
  if (to.name !== 'Login' && !token) {
    next({ name: 'Login' })
  } else {
    next()
  }
})

遇到的问题

1. 每次修改vuex的state类型都要重启服务器

比如原本的ILoginState接口如下

export interface ILoginState {
  loginUserInfo: ILoginUserInfo
  userDetails: IUserDetails
}

我增加了一个属性:userMenus后,就需要重启webpack开发服务器,热更新似乎检测不到这一变更

export interface ILoginState {
  loginUserInfo: ILoginUserInfo
  userDetails: IUserDetails
  userMenus: IMenu[]
}

2. wfRequest实例中拦截器不应为空

如果不设置拦截器,会导致无法传递configres给下一个拦截器


3. 请求拦截器中获取token的方式

获取token的时候不能从**Vuex**中获取,应当从**localStorage**中获取。

这是因为登录请求接口数据返回后,虽然在actions中有立刻commit,但是由于commit异步提交的,数据并不会立刻添加到state中,而actions中又调用了LocalCache.setCache将数据存放到localStorage中,这一操作是同步执行的,因此能够保证后面的请求用户详细信息和用户菜单的异步请求中能够从localStorage中拿到token。