主题
解决刷新个人信息丢失与退出登陆(Lesson21 2/2)
本页对应 Lesson21,共 6 段视频,约 1 小时 30 分。
学习目标
- 能够通过路由前置守卫判断 token 与用户信息状态,在刷新后信息缺失时请求并保存用户信息,且切换路由不重复请求。
- 能够使用响应拦截器识别 token 异常状态码,完成信息清理、友好提示并跳转登录页。
- 能够实现退出登录:调用退出接口、清空 token 与用户信息、跳转登录页,并说明如何阻止后续逻辑执行。
- 能够为交易页配置路由与权限元信息,未登录访问时重定向登录页,登录成功后按 redirect 参数回跳原目标页。
- 能够请求并保存用户地址列表,渲染地址数据,计算默认地址并实现点击地址切换选中状态。
- 能够请求并保存交易页订单信息,渲染商品清单、图片、标题、价格、数量及金额汇总,并正确处理初始空数据避免渲染报错。
知识点
先看现象:刷新后个人信息为什么丢了
为什么需要:登录成功后,代码会请求个人信息并保存到 Vuex 的 store.state.user.userInfo。但 Vuex 的状态保存在内存里,页面一刷新,整个应用重新初始化,userInfo 就变空了。此时 token 可能还在,于是出现“有 token、却没有用户信息”的状态,页面上的头像、昵称等信息自然丢失。
怎么想:转写稿里明确说,不建议把个人信息长期塞进本地存储,因为那样会一直存在,不够实时。也不要把请求随手放到使用个人信息的组件里,比如 header。因为 header 这类公共组件在切换路由时不一定重新执行 mounted,你把它放在组件里,切换路由并不会重新拉取;而刷新时又不可靠。更稳的思路是:在进入路由之前统一检查,缺用户信息就补一次。
怎么做:在路由前置守卫里判断两个条件:有 token,并且 Vuex 里还没有 userInfo,才发请求。请求完成后必须等待,再放行路由。
易错点:转写稿里的“死日志/死号日志”就是 store;“比报遗产”就是 beforeEach;“冒题”就是 mounted。不要把这些同音词带进代码。另一个易错点是把请求放进公共组件的 mounted,以为切换路由会重新渲染,其实不会。
方案:用路由前置守卫补齐用户信息
为什么需要:路由守卫能在每次进入路由前统一处理“用户信息是否齐全”,比在每个页面里各写一遍更集中,也不会因为组件是否重新渲染而漏掉。
怎么想:不是每次路由都请求,而是“有 token 且没有用户信息”才请求。已经有 userInfo 时直接 next(),避免切换每个路由都重复请求。请求是异步的,所以守卫要写成 async,并用 await 等 dispatch 完成后再 next()。
怎么做:本节笔记的核心代码在 src/router/index.js:
js
router.beforeEach(async function(to,from,next){
if(getToken() && !store.state.user.userInfo)
await store.dispatch("user/getUserInfoAsync");
next();
})这里的 getToken() 判断是否登录;!store.state.user.userInfo 判断当前是否缺个人信息;store.dispatch("user/getUserInfoAsync") 调用 user 模块里获取个人信息的 action;await 保证请求完成后再执行 next()。
易错点:第一,next() 必须调用,否则路由会卡住。第二,不要漏掉 await,否则请求还没回来就放行,页面可能仍拿不到信息。第三,模块名和 action 名要和 store 里一致,转写稿里用的是 user/getUserInfoAsync。第四,条件不要写反:有 token 且没有 userInfo 才请求,不是每次路由都请求。
调试过程:刷新会请求,切换路由不会重复请求
为什么需要:只看代码容易误以为“每次路由都会请求”。实际调试时要区分刷新和普通路由切换。
怎么想:刷新时应用重新初始化,Vuex 为空,守卫会命中条件并请求个人信息;普通切换路由时,如果之前已经拿到 userInfo,条件不成立,就不会再请求。转写稿中验证到:刷新后个人信息出现;切换路由时 getUserInfoAsync 不会再次调用。这正是想要的效果。
怎么做:打开浏览器 Network,刷新页面,观察是否发出获取个人信息的请求;再切换路由,观察该请求是否重复发出。页面上检查头像、昵称等是否恢复。转写稿里还提到,刷新时这个请求也能顺带验证 token 是否正确;但 token 过期、208 状态码、退出登录属于后续问题,本节先不展开。
易错点:不要把“刷新时请求一次”写成“每次路由都请求”,否则会造成不必要的接口压力。也不要在守卫里直接改 store.state,应通过 action/mutation 维护状态。若请求失败,要意识到这可能是 token 异常,需要后续在响应拦截中处理,而不是在本节代码里硬塞逻辑。
完整落地与检查清单
为什么需要:这段代码很短,但依赖 getToken、store、user 模块 action 三处配合,任何一处名字不对都会导致刷新后仍然丢信息。
怎么想:先确认 getToken 已正确导入;再确认 store 已导入;最后确认 user 模块里确实有 getUserInfoAsync,并且请求成功后会把数据保存到 state.user.userInfo。守卫只负责“缺了就补”,不负责重复拉取。
怎么做:保留上面的守卫代码,检查 src/router/index.js 中导入语句和 store 模块命名。刷新页面后,如果 userInfo 能恢复,说明链路通了;切换路由不重复请求,说明条件判断正确。
易错点:常见问题是模块命名空间写错、action 名写错、忘记 next()、忘记 await,以及把请求放到 header 的 mounted 里。记住转写稿的结论:刷新时调一次,切换路由不重复调,这才是本节要的效果。
退出登录要解决什么问题
退出登录不是只把页面上的用户名藏起来,而是要把“登录态”完整清掉:state.userInfo、本地保存的 token,以及当前页面跳转。转写稿里反复强调:主动点“退出登录”和 token 过期/异常,本质上要做同一件事——清空用户信息、移除 token、回到登录页。所以这段逻辑不适合散落在每个页面里,而应集中封装到 Vuex 的 user 模块中。这样主动退出和拦截器发现 208 时都能复用。
怎么想:先调后端退出接口,再清前端登录态。因为接口职责是通知服务端“该用户退出了”,转写稿也提到这个接口本应记录退出时间,但实际项目里可能没做处理,我们按接口存在就调用。
怎么做:在 src/api/user.js 中封装退出接口。
易错点:退出接口是 GET,不要写成 POST;路径不要拼错。
js
// 退出登陆 /api/user/passport/logout
export const getLogout = ()=> sphRequest("/user/passport/logout");在 Vuex 中集中清理登录态
为什么需要:如果每个组件退出时都写一遍 userInfo = null、removeToken()、router.push("/login"),重复且容易漏。转写稿明确建议把行为封装到 store/user.js,拦截器里也能直接 commit。
怎么想:mutation 只负责同步改状态,action 负责先请求接口,成功后再提交 mutation。
怎么做:保留本节笔记代码。注意笔记目录写作 sotre,实际项目目录通常是 store;removeTOken 的大小写也要和项目工具函数一致。
js
const mutations = {
OUT_LOG(){
state.userInfo = null;
removeTOken();
router.push("/login");
}
}
const actions = {
async getLogOutAsync({commit}){
await getLogout();
commit("OUT_LOG");
}
}
export default {
namespaced:true,
actions,
state,
mutations
}易错点:namespaced: true 后,组件里要写 user/getLogOutAsync;commit("OUT_LOG") 在模块内部可简写,外部提交要带命名空间。router 要正确引入,否则跳转失败。转写稿还提到 action 可返回 Promise,用于阻止后续程序执行;如果业务需要“退出后不再继续”,可在 action 中返回 Promise,但本节笔记核心是 await getLogout() 后再 commit。
在页面按钮上触发退出
为什么需要:退出入口通常在头部用户区域。转写稿中在头部按钮上加点击事件,并调用 user/getLogOutAsync。
怎么想:按钮只负责触发 action,不直接改 state,也不直接删 token。
怎么做:模板中加 @click.prevent,方法里 this.$store.dispatch("user/getLogOutAsync")。转写稿调试时看到退出接口返回 200,说明请求成功;随后执行 OUT_LOG 完成清理和跳转。
易错点:不要忘记 prevent 导致页面刷新;不要只调接口不 commit;如果接口失败,是否仍退出要按业务决定,转写稿当前实现是 await 成功后再 commit。
token 过期与主动退出复用同一套逻辑
为什么需要:刷新页面时会重新获取个人信息,如果 token 被改坏或过期,后端会返回未登录,转写稿中看到 code 为 208。如果只在获取个人信息接口里判断,其他隐私接口还要重复写。
怎么想:在响应拦截器统一判断 res.data.code === 208,一旦发现就提交 user/OUT_LOG,并提示“请重新登录”。
怎么做:拦截器中判断后 store.commit("user/OUT_LOG");转写稿还建议加 Message.error("请重新登录") 之类提示。这样主动退出和异常退出都走同一 mutation。
易错点:不要在每个接口里各写一遍 208 判断;拦截器里要确保 store 已引入;208 判断用数字比较,注意接口返回结构是 res.data.code。转写稿调试时改坏 token 后刷新,请求返回 208,随后清空信息并跳登录页,说明链路正确。
调试顺序与易错点
先确认退出接口能返回 200;再确认 OUT_LOG 中 state.userInfo 被清空、token 被移除;最后确认 router.push("/login") 生效。转写稿中刷新个人信息时还用了路由守卫:有 token 且 state.userInfo 为空才请求用户信息,避免每次切换路由都请求。退出逻辑与它配合:退出后 token 没了,守卫自然不会继续拉用户信息。易错点集中在命名空间、函数名大小写、接口方法、以及“只清页面不清 token”。按“接口 → action → mutation → 页面/拦截器”的顺序排查即可。
先明确要解决的问题:token 异常不能只在一个接口里处理
为什么需要:登录后我们把 token 和用户信息保存到 store。刷新页面时,路由守卫发现“有 token 但没有 userInfo”,会重新请求用户信息;如果 token 被篡改或过期,后端会返回未登录状态码 208。此时如果不处理,页面可能继续渲染空数据,或者后续接口继续报错。
怎么想:转写稿里先试过在“获取个人信息”接口里判断 code === 208,但这样每个隐私接口都要写一遍,麻烦且容易漏。更合理的是放到 src/request/sphRequest.js 的响应拦截器里统一判断:只要响应体里的 code 是 208,就说明 token 异常,统一清空登录态并跳登录页。
怎么做:在响应拦截器成功回调里,先结束进度条,再判断 response.data.code === 208。注意 208 是数字,不要写成字符串。
js
sphRequest.interceptors.response.use(response => {
nprogress.done();// 结束进度条
// token异常
if(response.data.code === 208){
// 清空token
// 跳转至登陆界面
store.commit("user/OUT_LOG");
return new Promise(()=>{});
}
return response.data;// 返回响应体
}, (err) => {
nprogress.done();// 结束进度条
alert(err);
return new Promise(() => {
});// 返回一个状态为pending的Promise
});易错点:不要只清 token 不清 userInfo;不要忘记 return new Promise(()=>{}),否则原请求还会继续走 then,组件可能拿到异常数据继续渲染。
把“退出登录”的行为封装到 store
为什么需要:token 异常后的动作和用户主动退出登录本质一样:清空用户信息、移除 token、跳转登录页。如果拦截器里直接写一堆逻辑,后续退出登录又要写一遍,容易不一致。
怎么想:在 store/user 里提供 OUT_LOG 这个 mutation,或者在 actions 里封装退出逻辑。转写稿里强调:先把 userInfo 复原成空,再 removeToken,最后 router.push 到登录页。这样拦截器只负责“发现异常”,具体清理交给 store。
怎么做:拦截器中通过 store.commit("user/OUT_LOG") 触发清理。转写稿还建议在拦截器里补一个友好提示,例如“token 超时,请重新登录”,但核心阻断逻辑仍是清空并跳转。
易错点:跳转要放在清理之后;如果先跳转再清理,可能出现登录页仍带着旧状态。还要注意 store 中 mutation 名称要和 commit 一致,不能把自定义函数名和服务端字段名混淆。
为什么要返回 pending 的 Promise
为什么需要:响应拦截器里发现 208 后,当前请求已经失败。如果直接 return response.data,调用方还会拿到一个“看似成功”的响应,继续执行 then 里的保存用户信息、渲染页面等逻辑。
怎么想:返回一个永远 pending 的 Promise,等于告诉调用方“这次请求没有后续结果”,从而阻止后续 then 和 catch 继续执行。转写稿里明确说这是“阻止你后续程序的执行”。
怎么做:成功回调里 return new Promise(()=>{});错误回调里也返回 pending Promise,避免错误继续向下抛。注意错误回调里仍然要先 nprogress.done(),否则进度条可能卡住。
易错点:写成 return Promise.resolve() 会继续执行;写成 return null 也可能让调用方误判。pending Promise 不是“返回空数据”,而是“主动阻断”。
调试流程:改坏 token 观察 208 与跳转
为什么需要:只看代码容易漏掉执行顺序。转写稿中的调试方式是:登录成功后打开应用,找到本地保存的 token,故意改一个字符,然后刷新页面。
怎么想:刷新后路由守卫会请求用户信息;请求携带错误 token,后端返回 code: 208;响应拦截器命中 208,执行 OUT_LOG,清空 userInfo 和 token,跳转登录页。此时网络面板里能看到该请求的响应码字段是 208,不是 HTTP 状态码。
怎么做:按“登录成功 → 改 token → 刷新 → 观察请求响应 → 观察是否跳登录 → 再登录验证”的顺序排查。若没有跳转,先检查拦截器是否真的拿到 response.data.code,再检查 OUT_LOG 是否被正确 commit。
易错点:把 code 和 HTTP 状态码混为一谈;忘记 nprogress.done();在拦截器里直接写 router.push 却没有引入 router;只处理成功响应,忽略错误回调里的进度条结束。
一、交易页路由与结算入口
为什么需要:购物车点“结算”后要进入独立交易页,交易页需要自己的路由和组件,不能塞在购物车里。
怎么想:先在 src/pages/Trade/index.vue 建页面,再在 src/router/index.js 注册 /trade,最后把购物车“结算”改成 router-link。转写稿里强调:交易页是“填写并核对订单信息”的完整页面,先把路由骨架搭起来,再谈权限。
怎么做:保留本节关键模板与图片标签,alt 改为中文:
vue
<!-- src/pages/Trade/index.vue -->
<template>
<div>
<h3 class="title">填写并核对订单信息</h3>
<div class="content">
<h5 class="receive">收件人信息</h5>
<div class="address clearFix">
<span class="username selected">张三</span>
<p>
<span class="s1">北京市昌平区宏福科技园综合楼6层</span>
<span class="s2">15010658793</span>
<span class="s3">默认地址</span>
</p>
</div>
<div class="address clearFix">
<span class="username selected">李四</span>
<p>
<span class="s1">北京市昌平区宏福科技园综合楼6层</span>
<span class="s2">13590909098</span>
<span class="s3">默认地址</span>
</p>
</div>
<div class="address clearFix">
<span class="username selected">王五</span>
<p>
<span class="s1">北京市昌平区宏福科技园综合楼6层</span>
<span class="s2">18012340987</span>
<span class="s3">默认地址</span>
</p>
</div>
<div class="line"></div>
<h5 class="pay">支付方式</h5>
<div class="address clearFix">
<span class="username selected">在线支付</span>
<span class="username" style="margin-left:5px;">货到付款</span>
</div>
<div class="line"></div>
<h5 class="pay">送货清单</h5>
<div class="way">
<h5>配送方式</h5>
<div class="info clearFix">
<span class="s1">天天快递</span>
<p>配送时间:预计8月10日(周三)09:00-15:00送达</p>
</div>
</div>
<div class="detail">
<h5>商品清单</h5>
<ul class="list clearFix">
<li><img src="./images/goods.png" alt="商品图片"></li>
<li>
<p>Apple iPhone 6s (A1700) 64G 玫瑰金色 移动联通电信4G手机硅胶透明防摔软壳 本色系列</p>
<h4>7天无理由退货</h4>
</li>
<li><h3>¥5399.00</h3></li>
<li>X1</li>
<li>有货</li>
</ul>
<ul class="list clearFix">
<li><img src="./images/goods.png" alt="商品图片"></li>
<li>
<p>Apple iPhone 6s (A1700) 64G 玫瑰金色 移动联通电信4G手机硅胶透明防摔软壳 本色系列</p>
<h4>7天无理由退货</h4>
</li>
<li><h3>¥5399.00</h3></li>
<li>X1</li>
<li>有货</li>
</ul>
</div>
<div class="bbs">
<h5>买家留言:</h5>
<textarea placeholder="建议留言前先与商家沟通确认" class="remarks-cont"></textarea>
</div>
<div class="line"></div>
<div class="bill">
<h5>发票信息:</h5>
<div>普通发票(电子) 个人 明细</div>
<h5>使用优惠/抵用</h5>
</div>
</div>
<div class="money clearFix">
<ul>
<li><b><i>1</i>件商品,总商品金额</b><span>¥5399.00</span></li>
<li><b>返现:</b><span>0.00</span></li>
<li><b>运费:</b><span>0.00</span></li>
</ul>
</div>
<div class="trade">
<div class="price">应付金额: <span>¥5399.00</span></div>
<div class="receiveInfo">
寄送至:<span>北京市昌平区宏福科技园综合楼6层</span>
收货人:<span>张三</span><span>15010658793</span>
</div>
</div>
<div class="sub clearFix">
<a href="##" class="subBtn">提交订单</a>
</div>
</div>
</template>
<script>
export default {
name: "Trade"
}
</script>vue
<!-- src/pages/Cart/index.vue -->
<template>
<div class="sumbtn">
<router-link class="sum-btn" to="/trade">结算</router-link>
</div>
</template>易错点:router-link 的 to 要写 /trade;图片路径按笔记放在 Trade/images 下;交易页模板外层要有包裹标签。
二、给交易页增加权限验证
为什么需要:未登录也能直接访问 /trade 不合理,交易页必须登录后才能看。
怎么想:转写稿里先用 router.beforeEach 打印日志,确认每次路由切换都会执行;再给路由加 meta.isAuth,由守卫统一判断,而不是每个页面各写一遍。
怎么做:
js
// src/router/index.js
const routes = [{
path: "/trade",
component: Trade,
meta: {
isAuth: true
}
}];
router.beforeEach(async function (to, from, next) {
if (getToken() && !store.state.user.userInfo)
await store.dispatch("user/getUserInfoAsync");
// 判断路由是否需要登录
if (to.meta.isAuth) {
if (getToken()) next();
else next({
path: "/login",
query: {
cb: to.path
}
});
} else {
next();
}
});易错点:同音字要纠正为 beforeEach、next、to.meta.isAuth、token、code;getUserInfoAsync 前要 await,否则守卫可能先放行;没有 token 时用 next({ path:"/login", query:{ cb:to.path } }) 记住原目标。
三、刷新丢失个人信息与登录后回跳
为什么需要:登录后 userInfo 存在 store 里,刷新会丢;如果每次切路由都请求又太频繁。转写稿的结论是:有 token 且 store 里没有 userInfo 时才请求。
怎么想:在全局前置守卫里先补个人信息,再判断权限;登录成功时如果有 cb,就回到交易页。
怎么做:
js
// src/store/user.js
async postLoginAsync({ dispatch }, body) {
const { code, data } = await postLogin(body);
if (code === 200) {
Message.success("恭喜您,登陆成功!");
saveToken(data.token);
const { cb } = router.history.current.query;
if (cb) router.push(cb);
else router.push("/"); // 跳转至首页
} else {
Message.error("账号或密码错误!");
}
}易错点:router.history.current.query.cb 才是登录页地址栏里的回跳参数;不要写成 router.query;刷新时只请求一次,切换路由不会重复请求,这是预期效果。
四、token 异常与退出登录
为什么需要:token 过期或异常时,隐私接口会返回 208,页面不能继续假装已登录。转写稿中改坏 token 后刷新,接口返回未登录、code 为 208。
怎么想:不要在每个接口里判断 208,应在响应拦截器统一处理:清空用户信息、移除 token、跳登录;退出登录也复用同一套清理逻辑。
怎么做:转写稿演示了 logout:清空 userInfo、removeToken()、router.push("/login"),并可在拦截器里给“token 异常,请重新登录”的提示。
易错点:208 是数字比较;清理后要跳登录;退出登录和 token 异常行为要一致,避免残留用户信息。
交易页要渲染什么:先理清数据链路
为什么需要:交易页同时要展示收件人地址、商品清单、总数量、总金额等信息,这些数据不在页面里写死,而是来自“用户地址”和“交易页信息”两个接口。怎么想:地址属于用户模块,交易信息属于订单模块,所以分别建 API 和 Vuex 模块;页面只负责 dispatch 和渲染。怎么做:先补 API,再在 Vuex 中保存。易错点:namespaced:true 后,dispatch/commit 要带模块名,如 user/findUserAddressListAsync、order/getTradeListAsync。
js
// src/api/user.js
// 获取用户地址信息 /user/userAddress/auth/findUserAddressList
export const findUserAddressList = ()=>sphRequest("/user/userAddress/auth/findUserAddressList");js
// src/api/order.js
import {sphRequest} from "@/request";
// 获取交易页信息:/order/auth/trade get
export const getTradeList = ()=>sphRequest.get("/order/auth/trade");js
// src/store/user.js
import {findUserAddressList} from "@/api/user";
const state = { addressList:[] }
const mutations = {
SAVE_ADDRESS_LIST(state,payload){ state.addressList = payload; }
}
const actions = {
async findUserAddressListAsync({commit}){
const {data} = await findUserAddressList();
commit("SAVE_ADDRESS_LIST",data);
}
}
const getters = {
addressDefault(state){
return state.addressList.find(v=>v.isDefault==='1') || {}
}
}
export default { namespaced:true, actions, state, mutations, getters }js
// src/store/order.js
import {getTradeList} from "@/api/order";
const state = {
tradeInfo:{
orderDetailVoList:[ { orderDetailList:[] } ]
}
}
const mutations = {
SAVE_TRADE_INFO(state,payload){ state.tradeInfo = payload; }
}
const actions = {
async getTradeListAsync({commit}){
const result = await getTradeList();
commit("SAVE_TRADE_INFO",result.data);
console.log(result);
}
}
export default { namespaced:true, actions, state, mutations }js
// src/store/index.js
import Vue from "vue";
import Vuex from "vuex";
import order from "@/store/order";
Vue.use(Vuex);
const store = new Vuex.Store({ modules:{ order } });
export default store;路由与权限:交易页不能裸奔
为什么需要:交易页涉及订单和地址,未登录不应进入;从购物车点“结算”跳转,登录成功后还要回到交易页。怎么想:不要在每个组件 mounted 里写权限,放到全局路由前置守卫 beforeEach。怎么做:转写稿中的做法是给需要登录的路由在 meta 加标识,守卫里判断是否有 token;有 token 放行,没有就跳登录,并带上原本要去的路径。登录页成功后读取回跳地址,有回跳地址就 push 回去,否则回首页。易错点:登录后回跳地址别丢;不要在组件里重复写权限判断;meta 标识名要和守卫判断一致。
地址列表:默认地址与点击切换
为什么需要:地址接口返回数组,默认地址只能有一个,页面要显示“默认地址”并让下方寄送信息跟着切换。怎么想:Vuex 存 addressList;getter 用 find(v=>v.isDefault==='1') 找默认地址;初次没有数据时 find 返回 undefined,所以加 || {}。怎么做:模板 v-for 地址列表,用 item.isDefault==='1' 控制“默认地址”和选中样式;点击时提交 user/CHANGE_ADDRESS_DEFAULT_BY_ID,按 item.id 改默认项。注意:笔记模板里提交了这个 mutation,但笔记 store/user.js 未给出它的实现,转写稿中补了“按 ID 改默认地址”的逻辑,实际项目要补上,否则点击无效。易错点:isDefault 是字符串 '1',不要写成数字 1;不要直接改 state;下方寄送信息要用 getter 的 addressDefault,否则点击后不联动。
交易页组件与商品清单渲染
为什么需要:交易信息结构较深,商品在 tradeInfo.orderDetailVoList[0].orderDetailList,初始值不构造好会报 undefined[0]。怎么想:order 模块 state 初始值写成嵌套对象;页面 mounted 依次 dispatch 地址和交易信息;金额用 currency(2,"¥") 过滤器。怎么做:保留下面组件代码,图片 alt 改为中文“商品图片”。
vue
<template>
<div>
<h3 class="title">填写并核对订单信息</h3>
<div class="content">
<h5 class="receive">收件人信息</h5>
<div @click="$store.commit('user/CHANGE_ADDRESS_DEFAULT_BY_ID',item.id)" v-for="item in $store.state.user.addressList" :key="item.id" class="address clearFix">
<span :class="'username'+ (item.isDefault==='1'?' selected':'')">{{item.consignee}}</span>
<p>
<span class="s1">{{item.fullAddress}}</span>
<span class="s2">{{item.phoneNum}}</span>
<span v-show="item.isDefault==='1'" class="s3">默认地址</span>
</p>
</div>
<div class="line"></div>
<h5 class="pay">支付方式</h5>
<div class="address clearFix">
<span class="username selected">在线支付</span>
<span class="username" style="margin-left:5px;">货到付款</span>
</div>
<div class="line"></div>
<h5 class="pay">送货清单</h5>
<div class="way">
<h5>配送方式</h5>
<div class="info clearFix">
<span class="s1">天天快递</span>
<p>配送时间:预计8月10日(周三)09:00-15:00送达</p>
</div>
</div>
<div class="detail">
<h5>商品清单</h5>
<ul v-for="item in $store.state.order.tradeInfo.orderDetailVoList[0].orderDetailList" class="list clearFix">
<li><img width="82" :src="item.imgUrl" alt="商品图片"></li>
<li><p>{{item.skuName}}</p><h4>7天无理由退货</h4></li>
<li><h3>{{item.orderPrice | currency(2,"¥")}}</h3></li>
<li>X{{item.skuNum}}</li>
<li>有货</li>
</ul>
</div>
<div class="bbs">
<h5>买家留言:</h5>
<textarea placeholder="建议留言前先与商家沟通确认" class="remarks-cont"></textarea>
</div>
<div class="line"></div>
<div class="bill">
<h5>发票信息:</h5>
<div>普通发票(电子) 个人 明细</div>
<h5>使用优惠/抵用</h5>
</div>
</div>
<div class="money clearFix">
<ul>
<li><b><i>{{$store.state.order.tradeInfo.totalNum}}</i>件商品,总商品金额</b><span>{{$store.state.order.tradeInfo.totalAmount | currency(2,"¥")}}</span></li>
<li><b>返现:</b><span>0.00</span></li>
<li><b>运费:</b><span>0.00</span></li>
</ul>
</div>
<div class="trade">
<div class="price">应付金额: <span>{{$store.state.order.tradeInfo.totalAmount | currency(2,"¥")}}</span></div>
<div class="receiveInfo">
寄送至:<span>{{addressDefault.userAddress}}</span>
收货人:<span>{{addressDefault.consignee}}</span>
<span>{{addressDefault.phoneNum}}</span>
</div>
</div>
<div class="sub clearFix"><a href="##" class="subBtn">提交订单</a></div>
</div>
</template>
<script>
import {mapGetters} from "vuex";
export default {
name: "Trade",
computed:mapGetters("user",["addressDefault"]),
async mounted(){
await this.$store.dispatch("user/findUserAddressListAsync");
await this.$store.dispatch("order/getTradeListAsync");
}
}
</script>调试顺序与易错点
转写稿里的调试顺序是:先调接口,在 Network 看 data 是否返回数组;再 commit 保存;在 Vuex 面板看 state 是否变化。地址不显示时检查 isDefault==='1';下方寄送信息不联动时补 getter 并 mapGetters;商品清单报错时检查 state 初始值,必须给 orderDetailVoList:[{orderDetailList:[]}],否则 orderDetailVoList[0] 会因 undefined 报错。图片不显示先看路径,再设 width="82"。金额显示用 currency(2,"¥")。易错点还有:接口返回是 result.data 还是解构 data;mutation 名拼写;commit 路径带命名空间;v-for 加 key;find 无结果时用 || {} 兜底。
课堂画面






代码示例
示例一:路由前置守卫 —— 解决刷新丢失个人信息 + 交易页权限验证
js
// src/router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import store from "@/store";
import { getToken } from "@/utils/token";
import Trade from "@/pages/Trade";
Vue.use(VueRouter);
const routes = [
{ path: "/", component: () => import("@/pages/Home") },
{ path: "/login", component: () => import("@/pages/Login") },
{ path: "/cart", component: () => import("@/pages/Cart") },
{
path: "/trade",
component: Trade,
meta: {
isAuth: true // 标记该路由需要登录
}
}
];
const router = new VueRouter({ routes });
router.beforeEach(async function (to, from, next) {
// 1. 有 token 但仓库里没有用户信息 → 重新拉取(解决刷新丢失)
if (getToken() && !store.state.user.userInfo)
await store.dispatch("user/getUserInfoAsync");
// 2. 判断路由是否需要登陆
if (to.meta.isAuth) {
if (getToken()) next();
else
next({
path: "/login",
query: {
cb: to.path // 记住想去哪,登录后回跳
}
});
} else {
next();
}
});
export default router;js
// src/store/user.js —— 登录成功后回跳 cb
async postLoginAsync({ dispatch }, body) {
const { code, data } = await postLogin(body);
if (code === 200) {
Message.success("恭喜您,登陆成功!");
saveToken(data.token);
const { cb } = router.history.current.query;
if (cb) router.push(cb);
else router.push("/"); // 跳转至首页
} else {
Message.error("账号或密码错误!");
}
}关键行与易错点
if (getToken() && !store.state.user.userInfo)两个条件缺一不可:没有 token 去拉信息必然 208;已有userInfo还拉就是每次切路由都发请求。await必须写:不等待,next()会先放行,页面渲染时userInfo还是空的。- 这段逻辑不能放进
mounted:切换路由时组件若被复用不会重新执行mounted,所以只能放全局守卫(或watch $route,但更乱)。 to.meta.isAuth只对声明了该 meta 的路由生效,else分支里必须next(),否则其他页面白屏。- 跳登录时带
query.cb = to.path,登录成功后从router.history.current.query取cb回跳,这是"登录后回到结算页"的关键。
示例二:响应拦截器处理 token 过期(208)+ 退出登录
js
// src/request/sphRequest.js
import axios from "axios";
import nprogress from "nprogress";
import store from "@/store";
const sphRequest = axios.create({
baseURL: "/api",
timeout: 5000
});
sphRequest.interceptors.response.use(
response => {
nprogress.done(); // 结束进度条
// token 异常:隐私接口未登录/过期统一返回 208
if (response.data.code === 208) {
// 清空个人信息与 token,并跳转登录
store.commit("user/OUT_LOG");
return new Promise(() => {}); // 阻断后续 then,避免拿到脏数据
}
return response.data; // 返回响应体
},
err => {
nprogress.done(); // 结束进度条
alert(err);
return new Promise(() => {}); // 返回状态为 pending 的 Promise
}
);
export default sphRequest;js
// src/api/user.js
import { sphRequest } from "@/request";
// 退出登陆 /api/user/passport/logout
export const getLogout = () => sphRequest("/user/passport/logout");js
// src/store/user.js
import router from "@/router";
import { getLogout } from "@/api/user";
import { removeToken } from "@/utils/token";
const mutations = {
OUT_LOG(state) {
state.userInfo = null;
removeToken();
router.push("/login");
}
};
const actions = {
async getLogOutAsync({ commit }) {
await getLogout();
commit("OUT_LOG");
}
};
export default {
namespaced: true,
actions,
state,
mutations
};关键行与易错点
response.data.code === 208判断的是业务状态码,不是 HTTP 状态码,所以写在use(onFulfilled)里而不是onRejected。return new Promise(() => {})返回一个永远 pending 的 Promise,让调用方的then/await不再往下走,避免用空数据继续渲染。返回undefined或Promise.reject()都不合适(前者报Cannot read property 'data' of undefined,后者会再触发一次错误流程)。OUT_LOG同时做三件事:清userInfo、removeToken、跳登录。退出登录和 token 过期复用同一个 mutation,行为一致,不要写两份。- 拦截器里用
store.commit("user/OUT_LOG"),命名空间user/前缀不能丢,否则 mutation 找不到,控制台静默失败。 import store from "@/store"与store/index.js里的router容易形成循环依赖,实操中把router的引入放在store之后、并确认OUT_LOG里才用router即可。
示例三:交易页渲染(地址列表 + 商品清单 + 默认地址)
js
// src/store/order.js
import { getTradeList } from "@/api/order";
const state = {
tradeInfo: {
orderDetailVoList: [
{
orderDetailList: []
}
]
}
};
const mutations = {
SAVE_TRADE_INFO(state, payload) {
state.tradeInfo = payload;
}
};
const actions = {
async getTradeListAsync({ commit }) {
const result = await getTradeList();
commit("SAVE_TRADE_INFO", result.data);
console.log(result);
}
};
export default {
namespaced: true,
actions,
state,
mutations
};js
// src/store/user.js —— 地址列表 + 默认地址 getter
import { findUserAddressList } from "@/api/user";
const state = {
addressList: []
};
const mutations = {
SAVE_ADDRESS_LIST(state, payload) {
state.addressList = payload;
},
CHANGE_ADDRESS_DEFAULT_BY_ID(state, id) {
state.addressList = state.addressList.map(item => {
if (item.id === id) item.isDefault = "1";
else item.isDefault = "0";
return item;
});
}
};
const actions = {
async findUserAddressListAsync({ commit }) {
const { data } = await findUserAddressList();
commit("SAVE_ADDRESS_LIST", data);
}
};
const getters = {
addressDefault(state) {
return state.addressList.find(v => v.isDefault === "1") || {};
}
};
export default {
namespaced: true,
actions,
state,
mutations,
getters
};vue
<!-- src/pages/Trade/index.vue -->
<template>
<div>
<h3 class="title">填写并核对订单信息</h3>
<div class="content">
<h5 class="receive">收件人信息</h5>
<div
@click="$store.commit('user/CHANGE_ADDRESS_DEFAULT_BY_ID', item.id)"
v-for="item in $store.state.user.addressList"
:key="item.id"
class="address clearFix"
>
<span :class="'username' + (item.isDefault === '1' ? ' selected' : '')">
{{ item.consignee }}
</span>
<p>
<span class="s1">{{ item.fullAddress }}</span>
<span class="s2">{{ item.phoneNum }}</span>
<span v-show="item.isDefault === '1'" class="s3">默认地址</span>
</p>
</div>
<div class="line"></div>
<h5 class="pay">送货清单</h5>
<div class="detail">
<h5>商品清单</h5>
<ul
v-for="item in $store.state.order.tradeInfo.orderDetailVoList[0].orderDetailList"
:key="item.id"
class="list clearFix"
>
<li><img width="82" :src="item.imgUrl" alt="" /></li>
<li>
<p>{{ item.skuName }}</p>
<h4>7天无理由退货</h4>
</li>
<li><h3>{{ item.orderPrice | currency(2, "¥") }}</h3></li>
<li>X{{ item.skuNum }}</li>
<li>有货</li>
</ul>
</div>
</div>
<div class="money clearFix">
<ul>
<li>
<b><i>{{ $store.state.order.tradeInfo.totalNum }}</i>件商品,总商品金额</b>
<span>{{ $store.state.order.tradeInfo.totalAmount | currency(2, "¥") }}</span>
</li>
</ul>
</div>
<div class="trade">
<div class="price">
应付金额: <span>{{ $store.state.order.tradeInfo.totalAmount | currency(2, "¥") }}</span>
</div>
<div class="receiveInfo">
寄送至:
<span>{{ addressDefault.userAddress }}</span>
收货人:<span>{{ addressDefault.consignee }}</span>
<span>{{ addressDefault.phoneNum }}</span>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "Trade",
computed: mapGetters("user", ["addressDefault"]),
async mounted() {
await this.$store.dispatch("user/findUserAddressListAsync");
await this.$store.dispatch("order/getTradeListAsync");
}
};
</script>关键行与易错点
tradeInfo.orderDetailVoList[0].orderDetailList是两层数组:orderDetailVoList是"包裹/商家"维度,[0]才是商品明细。直接写orderDetailVoList.orderDetailList拿不到数据。- 报
Cannot read property '0' of undefined的原因:初始state里没有把orderDetailVoList预置成[{ orderDetailList: [] }],undefined[0]直接抛错。初始值必须把整条链路上的空壳补齐。 getters里find(...)查不到会返回undefined,undefined.userAddress会报错,所以|| {}兜底;find返回的是元素,filter返回的是数组,这里必须用find。mapGetters("user", ["addressDefault"])的命名空间参数不能省,user模块是namespaced: true。- 切换默认地址用
map + 返回 item的方式整体替换,直接改原数组元素item.isDefault在某些场景下不会触发视图更新;改完必须保证只有一个isDefault === '1'。 - 接口返回的
isDefault是字符串'1',用=== '1'判断,写成=== 1永远不成立。 currency是全局过滤器(main.js中Vue.filter注册),模板里不写this.。
常见错误
现象:刷新页面后
userInfo丢失,头像/用户名不显示。
原因:把获取个人信息放在某个组件的mounted里,切换路由时组件不重新渲染,刷新时也没有统一入口恢复登录态。
修法:在router.beforeEach中判断getToken() && !store.state.user.userInfo,await store.dispatch("user/getUserInfoAsync")后再next()。现象:只有某个接口遇到
code === 208时跳登录,其他隐私接口异常时页面卡住或继续渲染。
原因:在单个接口/组件里判断 token 异常,没有统一处理。
修法:在sphRequest响应拦截器中统一判断response.data.code === 208,执行store.commit("user/OUT_LOG"),并返回new Promise(() => {})阻断后续逻辑。现象:退出登录后仍能继续执行后续请求/跳转,或清空信息与跳转分散在多处。
原因:退出逻辑没有统一封装,OUT_LOG后仍返回有效数据或继续走正常流程。
修法:把清userInfo、removeToken()、router.push("/login")统一放进OUT_LOG;退出 action 调接口后commit("OUT_LOG"),异常拦截处返回 pending Promise。现象:未登录也能直接进入
/trade,或登录后没有回到原本要去的交易页。
原因:路由meta.isAuth写了但守卫没判断;登录成功固定router.push("/"),没有读取cb。
修法:守卫中判断to.meta.isAuth,有 token 放行,否则next({ path: "/login", query: { cb: to.path } });登录成功后读取router.history.current.query.cb,有则回跳,否则回首页。现象:交易页地址列表全部显示“默认地址”,或点击地址后底部寄送信息不变。
原因:selected写死;默认地址没有按isDefault === '1'判断;底部信息没有使用随默认地址变化的 getter。
修法:用:class按isDefault拼接selected;点击时commit("user/CHANGE_ADDRESS_DEFAULT_BY_ID", item.id);底部使用mapGetters("user", ["addressDefault"])。现象:
addressDefault报错,提示无法读取undefined的属性。
原因:addressList.find(...)首次没有数据时返回undefined,后续直接取userAddress、consignee等属性。
修法:getter 中返回state.addressList.find(v => v.isDefault === '1') || {}。现象:交易页商品清单渲染时报错,提示不能读取
orderDetailList或[0]。
原因:tradeInfo初始结构不完整,orderDetailVoList[0]或orderDetailList为undefined时直接访问。
修法:给tradeInfo设置完整初始结构,例如orderDetailVoList: [{ orderDetailList: [] }],再渲染嵌套数据。
练习
- 实现刷新恢复登录态:在全局前置守卫中恢复
userInfo,并验证切换路由时不会重复请求个人信息。 - 在响应拦截器中统一处理
code === 208:清空登录态、提示“请重新登录”、跳转登录页,并阻断后续业务逻辑。 - 完成退出登录闭环:调用退出接口、清空
userInfo、移除 token、跳转登录页,并保证退出后不能再进入需要权限的页面。 - 给
/trade增加meta.isAuth权限验证;未登录进入时跳登录并携带cb,登录成功后回到/trade。 - 完成交易页渲染:地址列表默认选中、点击切换默认地址、底部寄送信息联动;商品清单、总数量、总金额正常渲染,并处理初始空数据不报错。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 解决刷新个人信息丢失 | 08:24 |
| 2 | token过期或异常问题 | 06:44 |
| 3 | 退出登陆 | 08:11 |
| 4 | 交易页路由搭建及权限验证 | 23:17 |
| 5 | 完成地址的渲染 | 21:48 |
| 6 | 完成交易商品渲染 | 21:47 |
小结
- 登录态恢复应放在全局路由守卫,不要依赖某个业务组件的
mounted。 - token 异常处理应集中在响应拦截器,避免每个接口重复判断
208。 - 退出登录要作为一个统一动作封装,清信息、清 token、跳登录不可分散。
- 权限路由必须同时具备
meta.isAuth标记和守卫判断,缺一不可。 - 登录回跳依赖
cb参数形成闭环:守卫写入、登录页读取、登录成功后消费。 - 交易页默认地址建议用 getter 派生,点击切换用 mutation 按 id 修改,保证列表与底部信息联动。
- 复杂交易数据必须给足初始结构,尤其是
orderDetailVoList[0].orderDetailList这类嵌套字段。 - 页面接口调用后要
commit保存到对应模块,渲染层只消费 store 中的稳定结构。