Skip to content

面经 PC 端(下)(三) ​

本页对应 面经 PC 端(下),共 7 段视频,约 1 小时 1 分。

学习目标 ​

  • 能说明响应拦截器为什么返回 response.data,并在组件中直接使用服务器响应数据而无需再次解构。
  • 能完成登录后的后续处理:使用 try/catch 区分有无响应结果的错误提示,保存 token,提示登录成功并跳转首页。
  • 能使用 el-container、el-aside、el-header、el-main 将首页划分为侧边栏、头部区、内容区,并在内容区放置二级路由出口。
  • 能使用 el-menu、el-menu-item、el-sub-menu 构建一至三级菜单,正确设置 index、router、default-active、mode 等属性并实现路由跳转与默认激活。
  • 能调用用户信息 API 获取数据,将头像和用户名渲染到头部区。
  • 能使用气泡确认框实现退出功能:确认后清除本地 token 并跳转到登录页。

知识点 ​

为什么需要响应拦截器 ​

响应拦截器数据流转示意图

响应拦截器是我们拿到数据的第一个“数据流转站”。为什么需要它?因为 axios 发请求后返回的并不是服务器数据本身,而是一个由 axios 包装出来的响应对象;如果每个组件都自己从大对象里取 data,再自己判断错误、弹提示,代码会重复且容易漏。怎么想?把“取服务器数据”和“统一错误提示”这两件事放到拦截器里做一次,组件只关心真正的业务数据。怎么做?在成功回调里返回 response.data,在失败回调里判断 error.response 并 Toast 提示。易错点:拦截器不是“改服务器数据”,它只是决定组件最终拿到什么;错误回调处理完不能吞掉错误,还要继续 Promise.reject(error)。

先想清楚:axios 返回的不是服务器数据本身 ​

为什么需要先讲这一点?因为很多同学会把 axios 返回的大对象误认为服务器响应结果,导致组件里多写一层解构。怎么想?转写稿里调试时把拦截器先去掉,在组件里打印返回值,看到对象里有配置、data、headers、request、状态码、状态描述等;再打开网络面板的“预览”,发现预览里的内容只对应这个大对象里的 data。也就是说,axios 作者包装了一个响应对象,其中 data 才是服务器返回的数据部分。怎么做?可以先临时输出检查:组件里拿到的对象和拦截器里拿到的对象一模一样;确认后再决定拦截器返回什么。易错点:不要把整个响应对象当成服务器结果;以前写 data: res 解构,本质就是从响应对象里取 data 并改名,现在用拦截器可以省掉这一步。

怎么做:成功回调返回 response.data ​

为什么需要返回 response.data?因为组件真正要用的往往是服务器返回的业务数据,而不是 axios 的包装对象。怎么想?拦截器已经先拿到响应对象了,那就在这个“流转站”把里面真正有用的 data 交出去,后面组件就不用再解构。怎么做?在响应拦截器成功回调中写 return response.data,代码如下:

jsx
import { Toast } from 'vant'

...

// 添加响应拦截器
request.interceptors.response.use(function (response) {
  // 对响应数据做点什么
  return response.data
}, function (error) {
  if (error.response) {
    // 有错误响应, 提示错误提示
    Toast(error.response.data.message)
  }
  // 对响应错误做点什么
  return Promise.reject(error)
})

这样组件里拿到的就是服务器返回结果,不用再写一层解构。易错点:如果这里仍然 return response,组件拿到的还是大对象,后面还得解构;返回 response.data 后,组件里就不要再按老习惯多点一层 data,否则会取错。

错误回调:有响应才提示服务器消息 ​

为什么错误也要统一处理?因为登录、获取用户信息等请求失败时,如果每个页面各写一套提示,很容易不一致。怎么想?错误对象里不一定总有 error.response:账号密码错误时,服务器通常有响应,可以取 error.response.data.message;但网络断开、服务器没返回、请求还没处理完等情况下,可能没有响应结果,这时不能直接点 data.message。怎么做?先判断 if (error.response),有响应才 Toast(error.response.data.message);没有响应则交给调用处做笼统提示,最后 return Promise.reject(error) 把错误继续抛给业务代码。转写稿里登录流程加 try/catch 就是这个分工:拦截器负责有响应时的统一提示,登录页 catch 里再判断 error.response,有响应提示服务器消息,没有响应提示“登录错误”之类的笼统信息。易错点:不判断 error.response 就直接取 data.message 会二次报错;Toast 没导入会报错;错误回调不 return Promise.reject(error),调用处就收不到失败状态。

放到登录流程里看:拦截器和业务 catch 各管一段 ​

为什么还要看登录流程?因为拦截器统一提示后,业务代码仍要处理失败分支。怎么想?请求成功时,组件拿到的是 response.data,可以直接取 token 存储、提示登录成功、跳转首页;请求失败时,拦截器已经对“有响应”的错误弹了服务器消息,业务 catch 主要兜底“没有响应”的情况。怎么做?登录逻辑放在 try 里,await 请求后存 token、提示、跳转;catch 中判断 error.response,有响应可提示服务器 message,没有响应提示笼统登录失败。易错点:不要把成功和失败都写在一个分支里;存 token 的键名要和后续读取处保持一致,不能这边存一个名、那边取另一个名;调试时若看到 401,要回头检查 token 是否已正确携带。

易错点小结 ​

为什么最后要集中列易错点?因为拦截器是全局的,一处写错会影响所有请求。怎么想?先确认返回的是不是 response.data,再确认错误分支有没有判断 error.response,最后确认错误有没有继续抛出。怎么做?按“成功返回 data、失败判断 response、最后 reject”三句话检查。易错点:把 response 和 response.data 混用;在有响应和无响应时都强行取 message;处理完错误却不 reject;组件里还按老代码多解构一层。把这些理顺,响应拦截器才算真正成为统一的数据流转站。

登录接口的封装位置 ​

登录功能不要把请求地址直接写进组件。组件只关心“提交表单、处理结果”,请求细节统一放到 api/user.js,这样后续接口地址、请求方法变化时只改一处。笔记里先提供登录 Api 函数:

jsx
// 登录接口
export const login = (data) => {
  return request.post('/user/login', data)
}

怎么想:login 接收表单数据 data,调用 request.post 发请求并返回 Promise;组件里用 async/await 接收结果。怎么做:在 login.vue 中按需导入 login,在表单校验通过后的 onSubmit 里调用。易错点:导入的方法名要和导出名一致;request 模块必须正确导出,否则运行时会报“不能读取 undefined 的 post 属性”。转写稿里就演示过:把 request 的导出注释掉后,控制台报 Cannot read properties of undefined (reading 'post'),根因不是组件写法,而是 request 没有导出,组件拿到的 request 是 undefined。

拦截器为什么返回 response.data ​

Axios 请求成功后拿到的并不是“服务器返回的业务数据”本身,而是一个响应对象。转写稿里反复强调:这个对象里有 config、data、headers、request、status、statusText 等,其中 response.data 才是服务器返回的响应体内容。若拦截器直接把整个 response 返回,组件里就得先解构出 data,否则拿到的是 Axios 造的大对象。

怎么想:拦截器统一把 Axios 外层响应对象处理掉,组件只拿服务器响应体,代码更省事。怎么做:在响应拦截器里返回 response.data。易错点:要区分两层 data——Axios 响应对象的 data,以及后端业务响应体里可能还有的 data 字段。笔记中的组件仍写 const { data } = await login(values),说明后端响应体里还有业务 data,再从业务 data 里取 token;不要因为“拦截器返回了 data”就把业务结构也混为一谈。

login.vue 中的登录提交 ​

笔记里的登录组件代码是:

jsx
import { login } from '@/api/user'

methods: {
  async onSubmit (values) {
    const { data } = await login(values)
    this.$toast.success('登录成功')
    localStorage.setItem('vant-mobile-exp-token', data.token)
    this.$router.push('/')
  }
}

怎么想:表单校验通过后进入 onSubmit,values 是表单数据;await login(values) 拿到服务器响应体;从业务 data 中取 token。怎么做:先存 token,再提示成功,再跳转首页。易错点:localStorage 的键名必须和后续请求拦截器读取 token 的键名完全一致。转写稿里特别提醒:这个键叫 vant-mobile-exp-token,如果改成别的名字,不是只改这一处,而是所有读取该 token 的文件都要同步改,否则会出现“登录成功但后续请求 401”的问题。

登录失败要加 try/catch ​

登录不只有成功,还有账号密码错误、网络异常、服务器无响应等情况。转写稿建议在提交逻辑外加 try/catch:成功走存 token、提示、跳转;失败时判断有没有响应结果。若有 e.response,说明服务器返回了错误信息,可提示 e.response.data.message;若没有响应结果,比如网络或服务器异常,就笼统提示“登录失败”。可按转写稿补成类似结构:

jsx
async onSubmit (values) {
  try {
    const { data } = await login(values)
    localStorage.setItem('vant-mobile-exp-token', data.token)
    this.$toast.success('登录成功')
    this.$router.push('/')
  } catch (e) {
    if (e.response) {
      this.$toast.fail(e.response.data.message)
    } else {
      this.$toast.fail('登录失败')
    }
  }
}

易错点:不要无条件写 e.response.data.message,因为无响应时 e.response 可能不存在,会二次报错;转写稿演示过服务器无响应时正好进入“登录失败”的兜底分支。若项目使用 Element UI 的提示,也可把 this.$toast.success 换成 this.$message.success,错误提示用 this.$message.error,但判断逻辑不变。

为什么要把本地存储单独封装 ​

为什么需要:登录成功后拿到 token,后续请求、退出登录都要用它。如果每个组件里都直接写 localStorage.getItem('vant-mobile-exp-token'),键名会散落在各处,一旦改键名就要全项目搜改,很容易漏。

怎么想:把“键名”和“读写删动作”收在一个模块里,组件只关心“存 token、取 token、删 token”,不关心底层是 localStorage 还是别的存储。这样键名只维护一份。

怎么做:新建 utils/storage.js,用按需导出暴露三个函数。笔记代码如下:

jsx
const KEY = 'vant-mobile-exp-token'

// 直接用按需导出,可以导出多个
// 获取
export const getToken = () => {
  return localStorage.getItem(KEY)
}

// 设置
export const setToken = (newToken) => {
  localStorage.setItem(KEY, newToken)
}

// 删除
export const delToken = () => {
  localStorage.removeItem(KEY)
}

易错点:setToken、getToken、delToken 必须共用同一个 KEY。转写稿里反复强调“这个键叫……能不能换别的名?可以,但两个文件都得改,所有地方都得改成一致的”。否则会出现“登录明明存了,请求却取不到”的假故障。

登录成功后把 token 存到本地 ​

为什么需要:登录接口返回 token 后,如果只放在组件变量里,刷新页面就丢了。存到本地后,后续页面才能继续取到登录凭证。

怎么想:登录接口的返回结构要先看清。转写稿里专门解释过:axios 原始响应是一个大对象,里面包含 config、data、headers、request、status 等,真正服务器返回的数据在 response.data 里。项目里如果拦截器已经 return response.data,组件里拿到的就是服务器响应体,不需要再解构一层 response.data。所以笔记里写 const { data } = await login(values),这个 data 是响应体里的 data 字段,再取 data.token。

怎么做:在登录提交方法里导入 setToken,登录成功后立即存:

jsx
import { login } from '@/api/user'
import { setToken } from '@/utils/storage'

methods: {
  async onSubmit (values) {
    const { data } = await login(values)
    setToken(data.token)
    this.$toast.success('登录成功')
    this.$router.push('/')
  }
}

易错点:第一,不要写成 setToken(data.data.token),除非你的拦截器没有返回 response.data;转写稿里正是通过打印和网络面板对比,确认“标红的 data 才是服务器返回结果”。第二,登录失败时不要继续存 token,否则可能把 undefined 写进本地。转写稿建议登录后续加 try/catch,有响应结果时提示服务器消息,没有响应结果时笼统提示“登录失败”,这个判断很有必要。

调试时先确认 request 是否正常导出 ​

为什么需要:转写稿里出现过一个典型报错:不能读取 undefined 的 post 属性。原因是 request 没有正确导出,组件里拿到的 request 是 undefined,再调用 request.post 就报错。

怎么想:看到“不能读取 undefined 的某属性”,先看这个变量从哪来。转写稿的推导是:组件里用了 request.post,那 request 来自导入文件;导入文件没导出,导入结果就是 undefined。这不是 token 存储的问题,而是请求模块导出问题。

怎么做:检查 utils/request.js 是否有 export,组件里是否按需导入正确。修好后登录请求才能正常返回,后面的 setToken(data.token) 才有数据可存。

易错点:不要把请求层错误误判成存储层错误。先保证 login(values) 能拿到响应体,再谈存 token。

退出登录时删除 token ​

为什么需要:退出后如果本地 token 还在,下次进入可能仍被当成已登录,造成状态不一致。

怎么想:存和删必须成对出现,并且删除也用封装好的 delToken,不要又手写一遍 localStorage.removeItem('...')。

怎么做:在退出确认事件里调用 delToken(),再跳转回登录页。转写稿里退出使用气泡确认框,确认按钮事件绑定方法,在方法里删除本地 token 并 this.$router.push(...)。删除时仍然走 utils/storage.js 里的 delToken。

易错点:删除的键名必须和设置时一致;如果设置用 vant-mobile-exp-token,删除却写成别的名字,就会“删了但没删掉”。另外,localStorage 只能存字符串,存 token 字符串没问题;不要把对象直接塞进去而不序列化。

本节小结 ​

本地存储这一节的核心不是 API 多复杂,而是“统一键名、统一出入口”。先封装 getToken/setToken/delToken,登录成功用 setToken(data.token) 保存,退出用 delToken() 清理。调试时先确认请求层正常返回,再确认 data.token 存在,最后检查读写删三处 KEY 是否完全一致。

课件图示 ​

课件第27页:目标:local 模块,本地存储封装 登录 & 注册功能 // 将token存入

课件第26页:目标:封装 api 完成登录功能 登录 & 注册功能 封装请求API 登录操作

课件第21页:目标:将 axios 请求方法,封装到 request 模块 登录 & 注册功能

课件第25页:目标:响应拦截器中统一处理错误提示 登录 & 注册功能 try { await

课件第3页:项目收获 目标:明确做完本项目,能够收获哪些内容 脚手架自定义项目构建 组件库v

课件第22页:目标:将请求封装成方法,统一存放到 api 模块,与页面分离 登录 & 注册功能

课件第23页:目标:将请求封装成方法,统一存放到 api 模块,与页面分离 登录 & 注册功能

课件第34页:目标:响应拦截器 - 处理 token 过期 面经列表 说明:token 是有过

代码示例 ​

代码示例一:响应拦截器 + 登录接口 + 本地存储(三个文件) ​

js
// src/utils/request.js
import axios from 'axios'
import { Toast } from 'vant'

// 创建实例(baseURL / timeout 按你项目的接口地址配置)
const request = axios.create({ timeout: 5000 })

// 添加响应拦截器
request.interceptors.response.use(function (response) {
  // 对响应数据做点什么
  return response.data
}, function (error) {
  if (error.response) {
    // 有错误响应, 提示错误提示
    Toast(error.response.data.message)
  }
  // 对响应错误做点什么
  return Promise.reject(error)
})

export default request
js
// src/api/user.js
import request from '@/utils/request'

// 登录接口
export const login = (data) => {
  return request.post('/user/login', data)
}

// 获取用户信息(首页头部用,路径按你项目的接口文档填)
export const getUserInfo = () => {
  return request.get('/user/profile')
}
js
// src/utils/storage.js
const KEY = 'vant-mobile-exp-token'

// 直接用按需导出,可以导出多个
// 获取
export const getToken = () => {
  return localStorage.getItem(KEY)
}

// 设置
export const setToken = (newToken) => {
  localStorage.setItem(KEY, newToken)
}

// 删除
export const delToken = () => {
  localStorage.removeItem(KEY)
}

要点解释 / 易错点

  • return response.data 是关键:axios 给你的 response 是作者自己造的大对象(含 config / data / headers / request / status / statusText),只有 response.data 才是服务器真正返回的响应体。拦截器里直接把它返回,组件里就不用再写 const { data: res } = await xxx() 解构了。
  • 错误回调里先判断 if (error.response):只有服务器真的返回了错误响应才有 error.response(网络断开、超时、服务器挂了时它是 undefined),此时直接读 error.response.data.message 会二次报错。
  • 必须 return Promise.reject(error),否则错误被吞掉,组件里的 try/catch 永远进不去。
  • utils/storage.js 里的 KEY 是全局唯一的键名,登录页存、拦截器取、退出时删,必须用同一份常量;要改名就得三个地方一起改,否则出现“存了却取不到 / 401”的问题。
  • api/user.js 只是把请求地址集中管理,request.post() 的返回值经过拦截器后已经是 { message, data },所以组件里 const { data } = await login(values) 拿到的 data 里才有 token。

代码示例二:登录页完整实现(含 try/catch) ​

vue
<template>
  <div class="login">
    <van-form @submit="onSubmit">
      <van-field
        v-model="form.username"
        name="username"
        label="用户名"
        placeholder="请输入用户名"
        :rules="[{ required: true, message: '请填写用户名' }]"
      />
      <van-field
        v-model="form.password"
        type="password"
        name="password"
        label="密码"
        placeholder="请输入密码"
        :rules="[{ required: true, message: '请填写密码' }]"
      />
      <div class="btn">
        <van-button round block type="primary" native-type="submit">
          登录
        </van-button>
      </div>
    </van-form>
  </div>
</template>

<script>
import { login } from '@/api/user'
import { setToken } from '@/utils/storage'

export default {
  name: 'Login',
  data () {
    return {
      form: { username: '', password: '' }
    }
  },
  methods: {
    // 表单校验通过后才会走到这里,values 就是表单数据
    async onSubmit (values) {
      try {
        const { data } = await login(values)
        // 一存 token
        setToken(data.token)
        // 二提示
        this.$toast.success('登录成功')
        // 三跳转
        this.$router.push('/')
      } catch (e) {
        if (e.response) {
          // 服务器返回了错误响应,提示服务器给的 message
          this.$toast(e.response.data.message)
        } else {
          // 没有响应结果(断网、超时、服务器崩了),笼统提示
          this.$toast('登录失败')
        }
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.login {
  padding: 40px 16px;
}
.btn {
  margin-top: 24px;
}
</style>

要点解释 / 易错点

  • const { data } = await login(values):因为拦截器已经 return response.data,这里解构出的 data 就是 { token: '...' },所以下面写 data.token;如果拦截器没返回 response.data,这里就得写 res.data.data.token。
  • try/catch 必须加:账号密码错误时接口会 reject,不加 catch 就是未捕获的 Promise 异常,页面毫无反馈。转写稿里演示的“服务器没响应、转圈半天最后提示登录错误”,走的就是 else 分支。
  • if (e.response) 这个判断很有必要,否则服务器没响应时读 e.response.data.message 会再抛一次错。
  • 易错点:重复提示。响应拦截器里已经 Toast(error.response.data.message) 了一次,组件 catch 里再 $toast 会弹两次。两者二选一(要么拦截器统一提示、组件只管流程;要么组件自己提示),别都留。
  • 存 token 用 setToken(),不要各处直接写 localStorage.setItem('xxx', ...),键名散落容易写不一致。
  • 如果你项目换成了 Element UI,提示写 this.$message.success('登录成功') / this.$message.error(e.response.data.message),其他逻辑不变。

代码示例三:首页 Layout —— 布局 + 菜单 + 用户信息 + 退出 ​

vue
<template>
  <el-container class="layout">
    <!-- 左侧边栏 -->
    <el-aside width="200px" class="aside">
      <div class="logo">黑马面筋</div>
      <el-menu
        router
        :default-active="$route.path"
        background-color="#304156"
        text-color="#bfcbd9"
        active-text-color="#409EFF"
      >
        <el-menu-item index="/dashboard">数据看板</el-menu-item>
        <el-menu-item index="/article">面筋管理</el-menu-item>
      </el-menu>
    </el-aside>

    <el-container>
      <!-- 头部区 -->
      <el-header class="header">
        <img :src="avatar" class="avatar" alt="" />
        <span class="username">{{ username }}</span>
        <el-popconfirm title="确认退出吗?" @confirm="logout">
          <el-button slot="reference" type="text">退出</el-button>
        </el-popconfirm>
      </el-header>

      <!-- 内容区:二级路由出口 -->
      <el-main class="main">
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<script>
// 注意:接口函数名与 methods 方法名重名会报错,这里重命名导入
import { getUserInfo as getUserInfoApi } from '@/api/user'
import { delToken } from '@/utils/storage'

export default {
  name: 'Layout',
  data () {
    return {
      avatar: '',
      username: ''
    }
  },
  created () {
    // 生命周期里自动执行
    this.getUserInfo()
  },
  methods: {
    async getUserInfo () {
      const res = await getUserInfoApi()
      // 拦截器已返回 response.data,所以这里是 res.data.xxx
      this.avatar = res.data.avatar
      this.username = res.data.username
    },
    logout () {
      delToken()
      this.$router.push('/login')
    }
  }
}
</script>

<style lang="scss" scoped>
.layout {
  height: 100vh;
}
.aside {
  background-color: #304156;
}
.logo {
  height: 60px;
  line-height: 60px;
  text-align: center;
  color: #fff;
}
.header {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  background-color: #fff;
}
.avatar {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  margin-right: 8px;
}
.main {
  background-color: #f2f3f5;
}
</style>

要点解释 / 易错点

  • 结构就是 el-container 套 el-aside + el-container(再套 el-header + el-main),即文档里“常见布局”的第 6 种。内容区里必须放 <router-view />,否则点菜单地址变了但页面不切换。
  • el-menu 上加 router 后,el-menu-item 的 index 会被当成跳转地址,所以 index 要写成真实路由路径(/dashboard、/article),不要写 1、2 这种。
  • :default-active="$route.path" 是默认高亮的最佳写法:刷新后按当前地址自动激活对应菜单,比写死 default-active="/dashboard" 灵活。
  • index 值不能重复,否则菜单高亮会乱、default-active 也失效。
  • 易错点:import { getUserInfo } 和 methods 里的 getUserInfo() 同名会冲突/递归,要么导入时 as getUserInfoApi,要么方法改名。
  • 数据取值是 res.data.avatar(拦截器已剥一层),不是 res.data.data.avatar。
  • 退出用 el-popconfirm,@confirm 才是点“确定”触发;事件名是 @confirm,别写成 @confirmClick 之类不存在的名字——属性/事件要加在 el-popconfirm 这个标签上,不是里面的 el-button。
  • logout 里先 delToken() 清 token,再 $router.push('/login');顺序反了不影响功能,但清完 token 再跳转能避免请求带上过期 token 触发 401。

常见错误 ​

  1. 登录页拿到 res 后 res.data.token 是 undefined,跳转后立刻 401 现象:login 成功但 token 存进去是 undefined,后续请求全 401。 原因:响应拦截器里 return response(返回了整个 Axios 包装对象),组件中却按 response.data 取值;或拦截器已 return response.data,组件里又写 res.data.token,多解了一层。 修法:全局统一约定——拦截器 return response.data 之后,组件里一律直接用 res.xxx;改完把项目里所有 res.data. 搜一遍删掉多余的一层。

  2. 一调接口就报 Cannot read properties of undefined (reading 'post') 现象:request.post is not a function / 读取 post 属性失败。 原因:utils/request.js 里漏写(或被注释掉)export default request,api/user.js 导入到的是 undefined。 修法:确认 request.js 末尾有默认导出;在 api/user.js 里 console.log(request) 自检一次,能打出实例再往下写。

  3. 接口挂了(无响应)时,控制台又抛 Cannot read properties of undefined (reading 'data') 现象:断网或后端 502 时,登录页不提示“登录失败”,反而报新错。 原因:catch 里直接写 error.response.data.message,但无响应时 error.response 是 undefined。 修法:先判空再取值——if (error.response) { 取 message } else { 提示“登录失败/网络异常” };同时整个提交逻辑用 try/catch 包住。

  4. 菜单点击后地址栏不变、页面不切换,或点了跳到 404 现象:加 router 属性后点击菜单无反应,或跳到 /ab 这种不存在的地址。 原因:el-menu 上的 router 没加,或加了但 el-menu-item 的 index 仍是 1/2/AB 这类随意值——router 模式会把 index 直接当路径跳。 修法:加 router 后,把 index 改成真实路由路径(如 /article、/board),并保证该路径在路由表里存在。

  5. 刷新后菜单高亮项丢了 / 高亮项和当前页面不一致 现象:直接访问某路由,菜单不高亮或高亮在别的项上。 原因:default-active 写死成某个字符串,不随地址栏变化。 修法:写成动态绑定 :default-active="$route.path",并确保对应 index 与路由 path 完全一致(含前导 /)。

  6. 菜单有子项时不展开、点父项报 index 缺失 现象:二级菜单渲染不出来,或控制台警告重复 index。 原因:父级用了 el-sub-menu 却把子项写在 template 外面;或某个 el-menu-item 忘写 index、index 与兄弟重复。 修法:子项必须写在 #title 模板之后、el-sub-menu 内部;逐个检查 index 唯一性(同级和跨级都别重)。

  7. 存 token 用一套 key、取 token 用另一套 key,登录后所有请求都 401 现象:登录成功但刷新/切页后立刻掉登录态。 原因:setToken 里 localStorage.setItem('xxx-token', ...),而请求头里读的是别的名字,两处没对齐。 修法:key 只在 utils/storage.js 里定义一次(常量),其他地方一律调 getToken/setToken/delToken,禁止手写 localStorage.getItem('...')。

  8. 退出确认框点了“确定”没反应 现象:气泡确认框弹出,点确定什么都不发生。 原因:事件挂错位置(写到内部按钮上),或事件名写错(@onConfirm、@ok),或方法名和 methods 里定义的不一致。 修法:事件挂在 el-popconfirm 组件上、用 @confirm="logout",并在 methods 中补上 logout,函数体内依次做「清 token → 跳登录页」。

练习 ​

  1. 改造响应拦截器:让拦截器在 error.response 不存在时,也 Toast 一句“网络异常,请稍后重试”,并在 finally 之外保证 Promise.reject 依然抛给调用方。写完后用“关掉后端服务”的方式自测一次。

  2. 统一 token 存取:把项目里所有直接操作 localStorage 的地方替换成 utils/storage.js 的 getToken/setToken/delToken,并给请求拦截器补上 Authorization 头。改完登录 → 刷新 → 退出,验证三处行为一致。

  3. 补全登录的异常分支:用 try/catch 重写 onSubmit,要求:有响应时提示后端 message,无响应时提示“登录失败”,成功时依次完成「存 token → 成功提示 → 跳首页」。故意输错密码和断网各测一次。

  4. 搭建首页三区布局:用 el-container / el-aside / el-header / el-main 组合出「左侧栏 + 右侧(头部 + 内容区)」,内容区放二级路由出口;给侧边栏 200px 宽、头部加底色,用 Vue DevTools 确认 DOM 嵌套层级正确。

  5. 菜单接路由:把菜单精简为「数据看板」「面筋管理」两项,开启 router 模式,index 写成真实路径,default-active 用 $route.path 动态绑定。验收标准:点击可切换页面、地址栏同步变化、刷新后高亮项仍正确。

  6. (选做)退出功能:用 el-popconfirm 实现退出,点“确定”清 token 并跳登录页,点“取消”不做任何事;再把成功/失败提示换成 ElMessage.success / ElMessage.error。

原视频对照 ​

#视频时长
1解释拦截器为什么返回response.data10:16
2登录后续处理06:41
3首页布局-分三个区10:52
4菜单的使用10:51
5菜单的属性13:20
6首页获取用户信息03:58
7退出功能05:29

小结 ​

  1. 拦截器的返回值决定了整个项目的数据形状——return response.data 之后,组件层就不该再出现 .data,这是全组约定,不是个人习惯。
  2. try/catch 不是可选项:只要调接口并要做后续跳转,就必须包,且 catch 里必须区分「有响应」和「无响应」两条路径。
  3. 所有本地存储的 key 收敛到一个模块里导出,key 改一次全项目生效,避免“存的和取的不是一个名字”这类低级事故。
  4. Element Plus 的布局容器是自由组合的:先分大块(左右),再分小块(上下),不要一上来就纠结具体像素。
  5. 菜单的两条铁律:有子项用 el-sub-menu + #title 插槽,没子项用 el-menu-item;index 全局唯一,开 router 后 index 就等于跳转路径。
  6. default-active 写成 $route.path 比写死字符串健壮得多——高亮状态由地址栏驱动,天然不会错位。
  7. 用组件库前先分清「属性 / 事件 / 插槽」三类,事件挂在定义它的那个组件上(如 el-popconfirm 的 @confirm),挂错位置等于没写。
  8. 文档看不懂时先看「属性 / 事件 / 插槽」三张表,比逐个示例代码抄更靠谱——示例往往只演示一半。