主题
滚动容器与页面交互(二)
本页对应 滚动容器与页面交互,共 8 段视频,约 1 小时 5 分。
学习目标
- 能在获取验证码和提交登录表单时依据接口返回的
code === 10000判断调用是否成功,失败时提示message并阻止后续逻辑执行。 - 能创建
userStore,使用ref定义并暴露token,在登录成功后写入接口返回的 token,并验证 Pinia 可直接赋值修改状态。 - 能安装并配置
pinia-plugin-persistedstate,在 Pinia 实例上注册插件,并通过persist: true验证刷新页面后 token 仍保留在本地存储中。 - 能使用
persist.pick仅持久化token字段,并说明按需持久化对性能和存储合理性的作用。 - 能调用
uni-forms实例的validateField(['mobile'])对手机号进行部分表单校验,并在校验成功后再发送请求、失败时不发送请求。 - 能实现协议勾选状态切换与登录前校验,并在请求拦截器中从 Pinia 读取 token,存在时添加
Authorization请求头,通过测试请求验证 token 已随请求发送。
知识点
先跑通主流程,再补结果验证
为什么需要:登录功能里有两个关键接口:获取验证码、提交登录。转写稿里先让这两个接口“跑通”,因为主流程不通时,补细节容易把问题混在一起。
怎么想:先确认“能发请求、能拿到返回”,再回头检查“返回是否成功、失败怎么提示、成功后代码放哪”。
怎么做:先完成获取验证码与登录提交的基本调用,再在调用处补 code 判断。
易错点:接口通了就以为功能完成,忽略失败分支;或者一边调接口一边改交互,出了问题不知道是接口错还是逻辑错。
倒计时必须等验证码发送成功后再启动
为什么需要:用户点“获取验证码”后,只有短信真的发送成功,才应该出现倒计时。否则接口失败也会倒计时,用户会以为已经发送。
怎么想:转写稿中发现原代码“一点按钮倒计时立马就变成 true”,于是提出:应该等 await 执行完,再决定是否显示倒计时。
怎么做:把启动倒计时的代码下移到获取验证码接口调用之后,并先判断结果:
js
// 示意代码:按转写稿逻辑整理
const res = await 获取验证码接口(手机号)
if (res.code !== 10000) {
showToast(res.message)
return
}
// 只有成功后才开始倒计时
startCountdown()易错点:倒计时写在请求之前;忘记 await,请求还没回来就判断;失败后忘记 return,导致倒计时或后续逻辑继续执行。
用 code 判断接口是否调用成功
为什么需要:调用接口不等于一定成功,可能发送验证码失败、登录失败。后端约定 code === 10000 表示成功,不等于 10000 表示失败。
怎么想:先判断 code,成功才走后续;失败时后端会返回 message,用它提示用户。
怎么做:在获取验证码和登录提交两处都加判断:
js
if (res.code !== 10000) {
showToast(res.message)
return
}易错点:把 10000 写成 1000;只判断获取验证码,不判断登录提交;失败提示不用后端 message,自己乱写文案。
失败后要中断,不要继续执行
为什么需要:验证码失败还启动倒计时、登录失败还保存 token,都会造成状态错误。
怎么想:失败分支提示完就结束当前流程,成功分支才继续。
怎么做:在失败判断里加 return。转写稿中反复强调“失败了,后面就没有必要执行了”。
易错点:只写 showToast 不写 return;return 位置放错,导致成功逻辑也被跳过。
调试过程:从现象反推代码位置
转写稿里的调试很典型:先点验证码,发现倒计时立即出现,于是判断“倒计时位置不对”;把倒计时下移到接口调用之后,再补 code 判断。随后用手机号获取验证码、粘贴验证码并登录,观察登录成功返回中有 token。这说明接口验证通过后,才能进入“保存登录状态”的下一步。
易错点:调试时只看页面现象,不看网络请求返回;不确认 code 和 message 就改代码。
登录提交处同样要验证结果
为什么需要:登录接口也可能失败,失败后不应继续保存 token 或跳转。
怎么想:获取验证码和登录提交是同一类问题:先判断结果,再执行成功后的动作。
怎么做:在登录提交的 await 后加同样的 code 判断,失败 showToast(res.message) 并 return;成功后再处理返回的 token。转写稿结尾也提到,登录成功返回 token 后,后续应把登录信息存到 Pinia。
易错点:登录失败仍保存 token;把 token 当成固定值;忘记失败提示,用户不知道错在哪。
先校验接口结果,再决定倒计时和后续流程
为什么需要:获取验证码接口不一定成功,只有短信真正发送成功后才应出现倒计时;登录接口失败时也不应继续执行保存 token 等后续逻辑。
怎么想:一般后端约定 code === 10000 表示成功,不等于则失败,并用 message 提示原因。
怎么做:把倒计时放到接口成功之后,失败时 return:
js
const res = await getCode(mobile)
if (res.code !== 10000) {
ElMessage.error(res.message)
return
}
// 接口成功后再开始倒计时
startCountdown()登录提交同理:
js
const res = await login(form)
if (res.code !== 10000) {
ElMessage.error(res.message)
return
}
userStore.token = res.data.token易错点:倒计时写在请求前;失败后忘记 return;把 code、message 字段名写错;提示方法误写成 ElMessage 以外的名字。
用 Pinia 的 user store 记录 token
为什么需要:登录成功后接口会返回 token,它是后续请求识别登录状态的凭据,需要集中保存。
怎么想:用户相关数据单独建 store/user.js,与之前练习的 store 区分开。组合式写法中用 ref 定义响应式数据,并且必须 return。
怎么做:
js
// store/user.js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', () => {
const token = ref('')
return { token }
}, {
persist: true
})组件中使用:
js
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
// 登录成功后
userStore.token = res.data.token易错点:忘记 return { token };组件中只导入不调用 useUserStore();Pinia 中直接赋值即可,不需要 Vuex 的 mutation,Pinia 也没有 mutation 这个概念。
持久化与部分持久化
为什么需要:token 只放在 Pinia 内存中,一刷新页面就丢失,用户需要重新登录,体验很差。
怎么想:把需要长期保留的数据同步到本地存储,使用 Pinia 持久化插件;并非所有数据都值得持久化,只保留必要字段。
怎么做:安装 pinia-plugin-persistedstate 后注册插件:
js
// main.js
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
pinia.use(piniaPluginPersistedstate)只持久化 token:
js
export const useUserStore = defineStore('user', () => {
const token = ref('')
return { token }
}, {
persist: {
paths: ['token']
}
})易错点:先 pinia.use(...) 再创建 pinia 的顺序错误;paths 写成 path;数组里写变量而不是字符串;把所有数据都持久化导致性能和无用数据问题。
表单局部校验与是否同意协议
为什么需要:未填手机号就获取验证码不合理;未勾选协议就登录也不合理。
怎么想:获取验证码前只校验手机号字段,用 validateField(['mobile']);协议勾选用布尔变量控制,点击时取反,提交前判断。
怎么做:
js
try {
await formRef.value.validateField(['mobile'])
// 校验通过后再发请求
} catch (err) {
// 校验失败,不继续
}协议:
js
const isAgree = ref(false)
function toggleAgree() {
isAgree.value = !isAgree.value
}
if (!isAgree.value) {
ElMessage.error('请勾选协议')
return
}易错点:validateField 前漏 await;字段名与表单 prop 不一致;协议判断忘记取反;提示文案在公司项目中通常由产品规定,不能随意改。
请求拦截器统一携带 token
为什么需要:访问首页、个人中心等接口时通常都要带 token,每个请求手写太啰嗦,也容易漏。
怎么想:在请求拦截器里从 Pinia 取 token,统一放进请求头;没有 token 就不添加。响应拦截器再判断 401,提示未登录并跳转登录页。
怎么做:
js
import { useUserStore } from '@/store/user'
const userStore = useUserStore()
const defaultHeader = {}
if (userStore.token) {
defaultHeader.Authorization = 'Bearer ' + userStore.token
}
config.headers = {
...config.headers,
...defaultHeader
}易错点:在 .vue 组件外不要在顶层导入后立刻 use,应在拦截器实际执行处调用;Bearer 后要有空格;无 token 时不要强行拼接;不要每个接口单独手写请求头。
调试验证
登录后查看 Network 中登录接口返回值是否含 token;在 Application 的 Local Storage 中确认 user 下是否有 token;刷新页面验证 token 是否还在;再发一次普通请求,查看请求头是否出现 Authorization。这样能把“记录登录状态、持久化、统一带 token”串成完整闭环。
先保证登录主流程可靠
为什么需要:持久化保存的是登录后的 token,如果登录接口本身没跑通,后面存什么、取什么都不可靠。
怎么想:先验证接口结果,再更新界面状态。获取验证码时,要等接口成功后再启动倒计时;登录时也要判断 code 是否表示成功。
怎么做:转写稿中反复强调:倒计时代码要放到请求之后;若 code 不等于约定成功值,就用提示组件显示后端 message,并 return,不再执行后续逻辑。
易错点:一点按钮就立刻倒计时;失败后仍继续执行登录成功逻辑;忘记 return。
用独立 user store 保存 token
为什么需要:登录信息会被多个页面和拦截器使用,应该和练习 store 区分开。
怎么想:用户相关数据放到 user.js,用组合式写法定义 store,token 必须是响应式数据。
怎么做:按转写稿整理关键结构:
js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore('user', () => {
const token = ref('')
return { token }
})登录成功后直接赋值:
js
user.token = res.data.token易错点:组合式 store 忘记 return;token 不用 ref;忘记导出 useUserStore,导致导入时没有提示。
Pinia 中直接改状态
为什么需要:Vuex 时代改状态要写 mutation,步骤多;Pinia 取消了这套写法。
怎么想:转写稿明确说,Pinia 里可以直接赋值,不需要再定义 mutation。
怎么做:在登录成功回调里拿到接口返回的 token,直接赋给 user.token。调试时打开 Vue 调试工具或大菠萝面板,确认 user 下的 token 已变化。
易错点:还按 Vuex 思路找 mutation;赋值对象路径写错;把测试值 12345678 留在代码里。
刷新丢失与持久化插件
为什么需要:Pinia 数据在内存中,一刷新就丢,用户又得重新登录,体验很差。
怎么想:把需要长期保留的数据同步到本地存储,这就是持久化。转写稿中的方案是给 Pinia 配合持久化插件。
怎么做:先安装插件,再在 Pinia 实例上 use,最后在创建 store 时加第三个参数:
js
persist: true验证方式:登录后打开浏览器 Application / Local Storage,能看到 user 下存了 token;刷新页面后数据仍在。
易错点:一定是先有 Pinia 实例,再 pinia.use(...),顺序不能反;组合式 store 的持久化配置要放在第三个参数;只配了插件但 store 没写 persist,不会生效。
只持久化 token:paths
为什么需要:不是所有数据都值得长期存,全量持久化会存多余数据,性能也不好。
怎么想:只把需要跨刷新保留的字段列出来。当前最需要的是 token。
怎么做:把 persist: true 改成对象,用 paths 指定字段:
js
persist: {
paths: ['token']
}转写稿解释过:插件内部拿到的 key 就是 store 名 user,value 里就是 store 中的数据;paths 里的 'token' 要写字符串,不要写成变量。
易错点:写成 paths: [token];需要多个字段时不会用数组追加;误以为所有数据都必须持久化。
持久化后的 token 如何被请求拦截器使用
为什么需要:访问个人中心等接口要带登录状态,不能每个接口手写一遍 token。
怎么想:在请求拦截器里统一从 Pinia 取 token,放到请求头 Authorization 中。
怎么做:在拦截器使用处调用 useUserStore(),判断 token 是否存在,再追加头信息:
js
const user = useUserStore()
const defaultHeader = {}
if (user.token) {
defaultHeader.Authorization = 'Bearer ' + user.token
}
config.headers = { ...config.headers, ...defaultHeader }易错点:在 .js 文件中导入 store 后立刻 use;token 不存在仍强行拼接;固定前缀后的空格漏写。转写稿特别提醒:在 Vue 组件里常见“导入后立刻 use”,但在拦截器这类 .js 中应“哪儿用去哪儿 use”。
调试与验收
登录后先看 Network 返回中是否有 token;再看 Application 的 Local Storage 是否出现 user 下的 token;刷新页面确认不丢;最后发一次请求,在请求头中确认出现 Authorization。这样才算把“Pinia 记录登录状态 + 数据持久化 + 请求携带 token”串通。
代码示例
示例一:Pinia 定义用户 store + 数据持久化(stores/user.js + main.js)
js
// stores/user.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useUserStore = defineStore(
'user',
() => {
// 记录用户登录状态(token)
const token = ref('')
return { token }
},
{
// 第三个参数:持久化配置
// persist: true 表示该 store 所有数据都存本地
// persist: { pick: ['token'] } 只持久化指定数据,性能更好
persist: {
pick: ['token']
}
}
)js
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
const pinia = createPinia()
// 先创建 pinia,再 use 插件,顺序不能反
pinia.use(piniaPluginPersistedstate)
createApp(App).use(pinia).mount('#app')关键行与易错点
defineStore('user', setup 函数, { persist }):本项目的 store 采用组合式写法(第二个参数是函数),持久化配置必须写在第三个参数里;写成对象式(选项式)时位置不同,容易写串行。persist: true会把该 store 的所有 state 都存进 localStorage,数据一多就浪费性能;用pick: ['token']只存需要的字段。pick数组里必须是字符串(数据名),不要写成变量[token],那是 ref 对象,不是字段名。- 插件
pinia.use(...)一定要在createPinia()之后执行,否则不生效。 - setup 里定义的
token必须return出去,不 return 既取不到值,也不会有持久化。
示例二:登录页(获取验证码结果校验 + 倒计时位置 + 记录 token + 协议校验)
vue
<template>
<van-form ref="iformRef" @submit="onFormSubmit">
<van-field v-model="form.mobile" name="mobile" type="tel" placeholder="请输入手机号" />
<van-field v-model="form.code" name="code" placeholder="请输入验证码">
<template #button>
<van-button size="small" type="primary" @click="onTextButtonClick">
{{ timer > 0 ? `${timer}s后重发` : '获取验证码' }}
</van-button>
</template>
</van-field>
<div class="agree" @click="toggleAgree">
<van-radio :checked="isAgree" />
<span>我已阅读并同意《用户协议》</span>
</div>
<van-button type="primary" block native-type="submit">登录</van-button>
</van-form>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { showToast } from 'vant'
import { useUserStore } from '@/stores/user'
import { sendCode, login } from '@/api/user'
const iformRef = ref(null)
const form = reactive({ mobile: '', code: '' })
const timer = ref(0)
let timerId = null
const isAgree = ref(false) // 记录是否同意协议
const userStore = useUserStore() // 用户相关数据
// 切换是否同意协议
const toggleAgree = () => {
isAgree.value = !isAgree.value
}
// 获取验证码
async function onTextButtonClick() {
// 只校验手机号这一个字段
try {
await iformRef.value.validateField(['mobile'])
} catch (err) {
return // 校验失败,不发请求
}
const { data: res } = await sendCode({ mobile: form.mobile })
// 验证接口是否调用成功
if (res.code !== 10000) {
showToast(res.message)
return // 失败就不再往下执行
}
// 只有发送成功,才开始倒计时
startCountdown()
}
function startCountdown() {
timer.value = 60
timerId = setInterval(() => {
timer.value--
if (timer.value <= 0) clearInterval(timerId)
}, 1000)
}
// 提交表单(登录)
async function onFormSubmit() {
if (!isAgree.value) {
showToast('请先勾选协议')
return
}
const { data: res } = await login(form)
if (res.code !== 10000) {
showToast(res.message)
return
}
// 登录成功:直接给 store 的数据赋值即可(Pinia 无需 mutation)
userStore.token = res.data.token
}
</script>关键行与易错点
- 倒计时代码必须写在接口调用之后:一开始就把
timer置为 60 是常见瑕疵,短信没发出去也在倒计时;应放在await sendCode()成功之后。 res.code !== 10000判定失败后一定要return,否则失败也继续走倒计时/存 token。validateField(['mobile'])只校验数组里列出的字段(部分表单验证),返回 Promise,必须await并 try/catch,否则校验失败也照样发请求。- Pinia 中改数据直接赋值
userStore.token = res.data.token,不要照搬 Vuex 的commit/mutation,Pinia 里根本没有 mutation 这个概念。 - 协议用
van-radio时,选中与否取决于checked属性绑定的值,所以要绑一个 ref 变量而不是写死true/false,点击时取反切换。 - 提示文案(如“请先勾选协议”)实际项目里由产品规定,标点都不能错。
示例三:请求拦截器统一携带 token(utils/request.js)
js
import axios from 'axios'
import { useUserStore } from '@/stores/user'
const instance = axios.create({
baseURL: '/api',
timeout: 10000
})
// 请求拦截器:统一把 token 带到请求头
instance.interceptors.request.use(
(config) => {
// 注意:useUserStore 必须写在拦截器函数内部,不能写在文件顶层
const userStore = useUserStore()
const defaultHeader = {} // 预留全局头信息,方便扩展
if (userStore.token) {
// Bearer 后面有一个空格,别丢
defaultHeader.Authorization = 'Bearer ' + userStore.token
}
config.headers = { ...config.headers, ...defaultHeader }
return config
},
(error) => Promise.reject(error)
)
// 响应拦截器:401 表示未登录/过期,跳转登录页
instance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// 跳转登录
}
return Promise.reject(error)
}
)
export default instance关键行与易错点
useUserStore()只能在拦截器回调内部调用(此时 pinia 已挂载);写在模块顶层会报 “getActivePinia was called with no active Pinia”。- 在
.js文件里不要像组件中那样“导入即调用”,要“哪儿用去哪儿调”。 - 先建
defaultHeader空对象,再把局部config.headers展开合并进去,这样将来新增头信息直接往对象里加,扩展性好。 Authorization拼固定前缀'Bearer ',末尾空格不能少,写错或漏空格后端识别不到 token。- 用
if (userStore.token)判断,没登录就不加这个头,避免带一个空 token 出去。
常见错误
1. 一点"获取验证码",倒计时就启动了
- 现象:手机号还没校验、短信也没真正发出去,按钮已经变成"60s 后重发",用户干等。
- 原因:倒计时那段代码写在了
await getCode()之前,或者写在了接口调用外面,跟请求结果无关。 - 修法:把倒计时挪到请求成功之后;同时确认
res.data.code === 10000才进入倒计时分支。
2. 接口失败了,后面的登录/跳转照样执行
- 现象:验证码错误或短信没发出去,控制台照样打登录成功的日志,甚至带着空 token 跳页。
- 原因:只弹了
ElMessage.error(...),没有中断流程——提示不等于拦截。 - 修法:在失败分支补
return(或在拦截器里Promise.reject),让"失败"真正意味着"后面不执行"。
3. 在 http.ts 顶部直接 const userStore = useUserStore() 报 "no active Pinia"
- 现象:一刷新页面就白屏,控制台提示 getActivePinia / no active Pinia。
- 原因:
.ts模块顶层代码在app.use(pinia)之前就执行了,此时 Pinia 还没装到应用上;而组件里之所以能这么写,是因为组件渲染时机晚于安装。 - 修法:把
useUserStore()移到真正使用它的地方——请求拦截器的回调函数内部(每次请求时再取)。
4. 刷新页面 token 就没了
- 现象:登录后正常,F5 一下跳回登录页。
- 原因:数据只活在 Pinia 内存里;或者装了持久化插件但
pinia.use(...)写在createPinia()之前,插件没生效。 - 修法:检查顺序为"先
createPinia()→ 再pinia.use(插件)→ 最后app.use(pinia)",并到 DevTools → Application → Local Storage 里确认有对应 key。
5. defineStore 写完,组件里拿不到任何数据
- 现象:
store.token是undefined,或者干脆报"没有导出 useUserStore"。 - 原因:组合式写法的 store 忘了写
return { token };或者忘了export。 - 修法:setup store 里必须显式 return 要暴露的 state / 方法;文件顶部补
export const useUserStore = defineStore(...)。
6. Authorization 头带上了,但后端仍判未登录
- 现象:Network 里能看到
Authorization,值形如Bearerxxx,接口返回 401。 - 原因:
Bearer与 token 之间漏了空格;或者把Bearer ${token}写成了Bearer+ 变量没拼进去。 - 修法:统一用模板字符串
Bearer ${token},并顺手判断 token 不存在时整个头都不要加,避免发出Bearer undefined。
7. 部分校验写了,但没拦住请求
- 现象:手机号空着点"获取验证码",校验提示闪一下,请求还是发出去了。
- 原因:
validateField忘了await,Promise 还没 settle 就往下走了;或者传参写成了字符串'mobile'而不是数组['mobile']。 - 修法:
await formRef.value.validateField(['mobile']),并把发请求的代码放进try的成功路径,失败走catch。
8. 协议勾选框点了没反应
- 现象:
isAgree永远是false,怎么点都不变,登录永远被拦。 - 原因:只做了
:checked="isAgree"的单向绑定,没有绑定点击事件去改值;或者用了v-model又同时手写:checked冲突。 - 修法:
@click里isAgree.value = !isAgree.value,保持"一个数据源"。
练习
练习 1|让失败真的失败 把"获取验证码"和"提交登录"两处调用改成统一结构:try { await 接口 → 判 code → 成功逻辑 } catch { 提示 }。要求:故意把验证码填错,确认控制台不再打印登录成功的日志,也不发生跳转。
练习 2|持久化范围收窄 先让 store 里同时存在 token 和一个临时字段 loading,观察默认配置下两者是否都进了 Local Storage;然后用 pick 只保留 token,再对比一次。写出你观察到的差异(哪个 key、哪个字段消失)。
练习 3|拦截器边界测试 在请求拦截器里加一句临时 console.log,分别测试三种状态:未登录(无 token)、已登录(有 token)、登录后手动删掉 Local Storage 里的 token 再刷新页面。记录每次请求头里 Authorization 的实际值,并解释第三种情况下你的代码走到了哪个分支。
练习 4|把校验做全 给"获取验证码"补上手机号格式校验(不只是非空):先做 validateField,再自行判断是否为 11 位数字。要求两种失败给出不同提示文案,且都不发请求。
练习 5|迁移到短信登录页 用同样的四步(校验 → 提交 → 判 code → 存 store)把另一个不带验证码的登录页做完。做完后自问:哪几行是重复的、可以抽成公共函数?把你的抽法写出来。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 优医咨询-接口调用结果验证 | 05:12 |
| 2 | 优医咨询-Pinia记录登录状态 | 09:27 |
| 3 | 优医咨询-Pinia数据持久化 | 09:51 |
| 4 | 优医咨询-Pinia数据持久化进阶 | 09:51 |
| 5 | 优医咨询-Pinia部分数据持久化 | 05:18 |
| 6 | 优医咨询-uniForms部分表单数据验证 | 07:11 |
| 7 | 优医咨询-是否同意协议 | 06:33 |
| 8 | 优医咨询-权限验证发送token | 12:22 |
小结
- 主流程跑通只是起点,真正的工程质量体现在"失败路径"上——每个
await之后都要问一句"失败了怎么办"。 ElMessage是给人看的,return是给代码看的,两者缺一不可。- 倒计时、跳转、写 store 这类"副作用",一律放在接口确认成功之后。
- Pinia 的 store 用法在组件内和
.ts模块里不一样:组件里可以顶层use,模块里必须"哪儿用哪儿use"。 - Pinia 没有 mutation,直接赋值即可;但"能直接改"不等于"随便改",改的地方应集中在动作触发点。
- 持久化不是"全存就好":默认全量存会带来性能与脏数据问题,用
pick白名单才是常规做法。 - 插件生效有三个前置条件:装包、
pinia.use、以及在createPinia()之后调用——顺序错了不报错但无效。 - 表单校验的价值在于"拦在请求之前":
validateField+await+try/catch三件套,缺一个就等于没校验。 - token 的传递与失效判断分居两个拦截器:请求拦截器负责"带上",响应拦截器负责"过期了就赶去登录"。
- 文案、提示语这类细节在真实项目里由产品定义,写代码时就要养成"不自己编词"的习惯。