Skip to content

记住功能与月卡管理(三) ​

本页对应 记住功能与月卡管理,共 5 段视频,约 31 分钟。

学习目标 ​

  • 能够对照 Element Plus 官方文档,说明 el-select 与 el-option 的嵌套结构,并解释 v-model、label、value 三个属性各自的数据流向与用途(显示值 vs 提交给后端的值)。
  • 能够定位并修复“翻页后搜索无结果”的缺陷:在查询方法中先将请求参数 page 重置为 1,再调用列表请求,并通过接口请求参数验证修复结果。
  • 能够为新增月卡页面注册一个不显示在左侧菜单、无嵌套的独立路由,并使用 router.push / 内联点击跳转从列表页进入该页面,同时用 router.back() 实现返回。
  • 能够为车辆信息表单完成 :model、:rules 绑定,为每个 el-form-item 指定 prop,并实现四个字段的非空校验,且能通过控制台确认双向绑定数据同步正常。
  • 能够依据官方文档为车牌号字段编写 validator 自定义校验规则,正确声明 (rule, value, callback) 三个固定参数,用正则 test 判断并在失败分支 callback(new Error(...))、通过分支直接 callback()。
  • 能够说明多条校验规则的执行顺序(按数组顺序,前一条不通过则后续不执行),并独立完成“非空 + 正则”双重校验的编写与调试验证。

知识点 ​

先说明这一节的边界 ​

本节笔记只有三条对照:存储空间大小、后端能否操作、是否跟随接口发送。笔记里没有配图,也没有代码块,所以下面的正文不补造截图和示例代码,只把这三条讲透。另外要交代一句:本节的转写稿记录的是同一课程里下拉框属性、搜索分页 bug、新增路由、表单校验等内容,并没有出现 cookie 与 localStorage 的直接推导。因此凡涉及“调试习惯”的部分,我只沿用课程里反复强调的那条做法——改一处就测一处,不要全写完再回头找问题——不虚构本节不存在的调试过程。

三条对照其实对应三个选型问题 ​

把这三条列出来,不是为了背结论,而是因为实际开发里选存储方式时,问的就是这三件事:能装多少、谁能读写、会不会自己跟着请求跑。任何一条答错,功能可能当场能跑,上线后却变成性能或安全问题。下面逐条拆。

存储空间:5M 和 KB 差了两个数量级 ​

为什么需要:先知道容量上限,才知道什么数据能往里放。笔记给的对照是 localStorage 约 5M,cookie 只有 KB 级。

怎么想:5M 与 KB 的差距不是“稍微小一点”,而是两个数量级。这意味着 cookie 天生不适合放“数据”,只适合放“标识”。而 localStorage 可以放得下一份列表缓存、一段表单草稿、一个较大的 JSON 配置。

怎么做:需要缓存列表数据、页面草稿、用户偏好设置这类体积可能较大的内容,走 localStorage;只放会话标识、令牌这类几十到几百字节的小值,走 cookie。

易错点:一是把整份 JSON 塞进 cookie,结果每次请求的请求头都被撑大,接口本身没变慢,带宽先被吃掉;二是以为 localStorage 的 5M 是“无限”,写满之后写入会直接失败,代码里没有兜底就会静默丢数据;三是 cookie 存中文等非 ASCII 内容需要编码,直接塞原文容易出现取值乱码。

为什么需要:有些数据必须由服务端说了算,比如登录态是否有效;有些数据只是浏览器本地的偏好,服务端根本不关心。

怎么想:localStorage 是浏览器提供给前端 JS 的仓库,服务端完全看不见它,也改不了它。cookie 不一样,它是 HTTP 协议自带的机制,浏览器负责维护,服务端可以在响应里写、在请求里读——笔记里“后端也可操作(占多数)”说的就是这个意思:真正决定登录态的 cookie,通常是服务端种下的,不是前端自己写的。

怎么做:需要服务端下发、服务端校验的数据,交给 cookie;纯前端的界面偏好、临时缓存,交给 localStorage。

易错点:第一,被标记为 HttpOnly 的 cookie,前端用 JS 是读不到的,这不是 bug,是安全设计,别在前端死磕;第二,前端手动改写了 cookie,并不代表服务端会认可,服务端只认自己签发和校验过的那份;第三,localStorage 里的内容用户随时可以在控制台改,绝不能拿它当权限凭证。

为什么需要:这一条直接决定“要不要写额外代码”,也决定安全和带宽。

怎么想:cookie 是 HTTP 的一部分,同源请求发出时浏览器会自动把它塞进请求头;localStorage 只是浏览器里的一块存储,浏览器不会替你把它放进任何请求。

怎么做:用 cookie 存登录标识,前端几乎不用写“带 token”的代码,请求自动就有;用 localStorage 存令牌,则每次请求都要自己取出来,手动塞进请求头或参数里。

易错点:一是 cookie 自动携带意味着连图片、静态资源请求都会带上,无谓消耗;二是用 localStorage 手动带令牌时,取值的时机和位置容易漏,比如某个请求封装分支忘了加;三是既然 localStorage 能被 JS 随意读取,一旦页面有 XSS,令牌就直接暴露,这也是很多项目宁可把关键凭证放 HttpOnly cookie 的原因。

三条合起来怎么选 ​

判断顺序可以固定成三问:这份数据大不大(大走 ls,小走 cookie)、这份数据要不要服务端参与(要就走 cookie)、这份数据需不需要每次请求自动带上(要就走 cookie)。三问里只要有一问答“要”,基本就落到 cookie;三问都答“不要”,才轮到 localStorage。调试时也照课程里的老规矩:先把一条对照验证清楚,比如先在控制台确认 localStorage 写入成功、再发一个请求看请求头里有没有 cookie,确认一条再往下走,别三条一起改,出了问题分不清是哪一条。

说明:转写稿与本节主题不匹配 ​

提供的转写稿主要讲下拉框属性、月卡搜索 bug、新增月卡路由、表单基础校验和自定义校验规则;其中“飞空/教验/靠 back”等应纠正为“非空/校验/callback”。这些内容与本节“编写清除用户信息 mutation”不直接对应,因此下面不把无关段落硬套进来,推导与自检只依据本节笔记和代码,不虚构项目中的其他字段或方法。

本节目标与代码位置 ​

本节要完成的是:在 Vuex 的 mutations 中新增 clearUserInfo,用于清除用户登录信息。笔记给出的代码如下:

javascript
  mutations: {
    clearUserInfo(state) {
      // 清除Token
      state.token = ''
      removeToken(TOKEN_KEY)
    }
  },

为什么需要:用户退出登录或需要清理登录态时,不能只改页面显示,还要把 Vuex 中的 token 和本地持久化的 token 一起清掉。
怎么想:state.token 是内存状态,removeToken(TOKEN_KEY) 是本地持久化清理,两者要同时处理。
怎么做:在 mutations 中声明 clearUserInfo(state),先置空 state.token,再调用已有的 removeToken。
易错点:不要只写 state.token = '' 而漏掉 removeToken;也不要在 mutation 里发异步请求。

为什么需要清除用户信息 mutation ​

为什么需要:登录态通常同时存在于 Vuex 和本地存储中。只清 Vuex,刷新页面后本地 token 可能还在;只删本地存储,当前页面内存里的 state.token 仍可能被使用。
怎么想:把“清除用户信息”视为一个明确的状态变更动作,交给 mutation 统一处理,组件或 action 只负责触发。
怎么做:在已有 Vuex store 的 mutations 对象里增加 clearUserInfo。笔记中已经给出了完整结构。
易错点:Vuex 要求 mutation 同步执行;如果 removeToken 是项目已有同步工具方法,可以直接调用,不要随意改成异步流程。

怎么想:状态与持久化要一起清 ​

为什么需要:state.token = '' 解决当前运行时的状态;removeToken(TOKEN_KEY) 解决本地保存的 token。两者职责不同。
怎么想:先清内存状态,再清本地存储,代码阅读上更直观;顺序不是本节重点,关键是两件事都做。
怎么做:保持笔记写法,TOKEN_KEY 必须与保存 token 时使用的 key 一致,removeToken 必须是当前文件可访问的方法。
易错点:TOKEN_KEY 拼错、没有导入 removeToken、保存时用的 key 和删除时不一致,都会导致“看起来退了,实际 token 还在”。

怎么做:在登出流程中提交 mutation ​

为什么需要:mutation 只是定义,必须被 commit 才会执行。
怎么想:在项目已有的登出逻辑中调用 commit('clearUserInfo'),不要在组件里直接改 state.token。
怎么做:如果登出逻辑在 action 中,就写 commit('clearUserInfo');如果在组件方法中,就通过 this.$store.commit('clearUserInfo') 触发。具体调用位置以项目已有登出流程为准,本节不虚构额外代码。
易错点:不要写成 this.$store.state.token = '' 绕过 mutation;也不要漏写 commit 后误以为 mutation 会自动执行。

调试与自检 ​

为什么需要:清除用户信息涉及内存状态和本地存储,必须两边都验证。
怎么想:触发登出后,先看 Vuex 中 token 是否变为空字符串,再看浏览器本地存储中对应 token 是否被删除,最后刷新页面确认不会恢复登录态。
怎么做:可用 Vue Devtools 观察 mutation 记录和 state.token;在浏览器 Application/Storage 面板检查 TOKEN_KEY 对应项。
易错点:只刷新页面看界面,可能漏掉本地存储仍在的情况;只检查本地存储,也可能漏掉当前内存状态未清。

小结 ​

本节的核心是:在 mutations 中新增 clearUserInfo,通过 state.token = '' 清除 Vuex 中的 token,通过 removeToken(TOKEN_KEY) 清除本地持久化 token。写的时候牢记 mutation 同步、key 一致、方法可访问,并在真实登出流程中 commit 触发。笔记只展示了 token 清理;若项目 state 中还有用户资料字段,应按实际代码补充,不能凭空编造字段名。

先看目标:退出登录要做两件事 ​

本节笔记只给了一个 logout 方法,但它包含了退出登录最核心的两个动作:第一,把 Vuex 中保存的用户信息清掉;第二,用路由跳回登录页。代码不长,关键在顺序和写法。

javascript
 methods: {
    // 退出登录
    logout() {
      this.$store.commit('user/clearUserInfo')
      this.$router.push('/login')
    }
}

这里没有图片,因此不额外插入图片。下面按“为什么需要 → 怎么想 → 怎么做 → 易错点”拆开讲。

为什么需要先提交 mutation 再跳转 ​

退出登录不是单纯换一个页面。用户信息通常存在 Vuex 里,比如 token、用户名、权限等。如果只跳转到 /login,而 Vuex 里的用户信息还在,那么登录页或后续页面可能仍然读到旧状态,出现“已经退出但页面还认为已登录”的问题。

所以这里先执行:

javascript
this.$store.commit('user/clearUserInfo')

commit 是提交 mutation 的固定写法,user/clearUserInfo 表示调用 user 模块下的 clearUserInfo。它的职责是清空用户信息。清完之后再执行:

javascript
this.$router.push('/login')

push 是编程式导航,把地址跳到登录页。这样“状态清理”和“页面跳转”各自负责一件事,逻辑清楚。

怎么想:把“清状态”和“导航”分开 ​

写退出登录时,不要把它想成“点一下按钮就跳走”。要拆成两步想:

  1. 当前登录状态存在哪里?在 Vuex 的 user 模块里。
  2. 清状态用什么?用 commit 提交 mutation。
  3. 清完之后去哪里?去 /login。
  4. 用哪种导航?用 this.$router.push('/login'),因为目标是固定登录页,不是“返回上一页”。

转写稿里新增月卡部分提到过“点击按钮跳到一个全新的路由”,也提到 router.back() 是“路由的回退,回退到上一级”。这两个概念要区分:back() 是回退历史记录,push('/login') 是明确跳到登录页。退出登录不应依赖“上一页是谁”,否则用户从不同页面退出时行为会不稳定。

怎么做:在 methods 中写 logout ​

在组件 methods 中声明 logout,方法体按笔记写:

javascript
 methods: {
    // 退出登录
    logout() {
      this.$store.commit('user/clearUserInfo')
      this.$router.push('/login')
    }
}

调用时,模板里的退出按钮绑定 logout 即可。验证时看两点:第一,提交后 Vuex 中 user 模块的用户信息是否被清空;第二,地址是否变成 /login。转写稿中新增月卡路由时,先“手动输入一个路径”测试路由是否配置正确,再绑定点击跳转。退出登录也可以先用同样思路确认 /login 路由存在,再检查按钮调用。

易错点 ​

第一,commit 的名字必须和 mutation 注册名一致。user/clearUserInfo 带命名空间,少写 user/ 或拼错 clearUserInfo,都会提交失败。

第二,clearUserInfo 必须已经在 user 模块的 mutations 中定义。本节只写调用,不写 mutation 本身,不能误以为 commit 会自动清空。

第三,跳转路径要和路由表一致。笔记写的是 /login,如果路由表里不是这个路径,页面不会按预期进入登录页。

第四,顺序不要反。先 push('/login') 再清用户信息,登录页可能短暂读到旧状态;按笔记先 commit 再 push 更稳。

第五,不要把这里的 push('/login') 写成 back()。转写稿里的 router.back() 是回退上一级,适合“返回列表”这类场景;退出登录的目标是登录页,应使用明确路径。

本节目标与代码位置 ​

这一节要完成的是“路由级权限控制”:在用户进入页面前先判断有没有登录凭证 token。没有 token 的用户只能访问登录页、404 等白名单页面,其他页面统一跳回登录页;有 token 的用户正常放行。

本节笔记中的核心代码如下,先保留原始代码:

javascript
// 权限控制
import router from './router'
import { getToken } from './utils/auth'

const WHITE_LIST = ['/login', '/404']
router.beforeEach((to, from, next) => {
  const token = getToken()
  // 有token
  if (token) {
    next()
  } else {
    // 没有token
    if (WHITE_LIST.includes(to.path)) {
      next()
    } else {
      next('/login')
    }
  }
})

注意:这段代码只有“守卫逻辑”,它要生效,必须确保该文件被项目入口引入执行。如果写完发现跳转完全没反应,先查这个文件有没有被 import,而不是先怀疑 beforeEach 写错。

为什么需要路由级权限控制 ​

如果只在前端页面上“隐藏按钮”,用户仍然可以直接在地址栏输入路径进入页面。路由守卫解决的是“进入某个路由之前先拦一道”的问题。它不替代后端鉴权,但能避免未登录用户看到业务页面结构,也能统一处理跳转。

怎么想:把权限判断放在全局前置守卫 router.beforeEach 中,因为它会在每次路由跳转前执行,适合做统一拦截。判断依据是 token 是否存在,判断目标是 to.path,即用户准备进入的路径。

怎么做:先拿到 token,再分两支:有 token 直接 next();没有 token 时看目标路径是否在白名单中,白名单放行,否则 next('/login')。

易错点:白名单必须写实际路径,比如 /login、/404,不要写成组件名;includes(to.path) 是精确匹配,路径多一个斜杠或少一个字符都可能失效。

怎么想:先放行登录页,再拦截业务页 ​

没有 token 时不能所有页面都跳登录页,否则登录页本身也会被拦截,形成循环。所以需要 WHITE_LIST。白名单的本质是“无需登录即可访问的路径集合”。

怎么做:把登录页和 404 放进数组,用 WHITE_LIST.includes(to.path) 判断。若命中,调用 next() 放行;若未命中,调用 next('/login') 重定向。

易错点:如果登录页路径不是 /login,白名单和重定向地址都要同步改;如果项目有注册页、忘记密码页,也需要按实际需求加入白名单,但不要把所有页面都加进去,否则权限控制形同虚设。

怎么做:token 判断与 next 放行 ​

有 token 时直接 next(),表示本次导航继续。这里代码没有继续校验 token 是否过期、是否合法,这是当前笔记的范围。实际项目中 token 失效通常还要结合接口 401 处理,但本节不扩展。

易错点:next() 在一次守卫中只能调用一次,不能既 next() 又 next('/login');next('/login') 后也不要再写后续放行逻辑。否则会出现跳转异常或死循环。

调试过程:从最小路径验证 ​

转写稿里反复强调“写完一个点就测一个点”,这个调试习惯同样适用于权限控制。可以先手动输入 /login,确认白名单能进;再清除 token 后输入业务路径,确认会跳到 /login;最后写入 token,再访问业务路径,确认能正常进入。

如果跳转不对,按顺序查:第一,守卫文件是否被入口引入;第二,getToken() 是否真的能取到值;第三,to.path 打印出来是否与白名单完全一致;第四,重定向地址是否写错。转写稿中出现的“飞空校验”“call back”“海对象”等说法,实际对应非空校验、callback 回调、Error 对象;这类同音转写要以代码和文档为准,不要照抄口误。

易错点汇总 ​

第一,白名单只写登录页,漏掉 404,导致未登录访问 404 也被踢回登录页。第二,to.path 与白名单大小写、斜杠不一致。第三,把 next('/login') 写成 next({ path: '/login' }) 虽也可用,但当前笔记是字符串写法,保持统一即可。第四,忘记引入该权限文件,守卫不执行。第五,token 存在但已过期时,本逻辑仍会放行;这属于后续接口层处理,不在本节代码范围内,不能误以为本节已经完成完整鉴权。

小结 ​

本节权限控制逻辑可以概括为:进入路由前取 token;有 token 放行;无 token 时判断白名单;白名单放行,否则跳登录页。代码不长,但关键是路径精确、next 只调用一次、守卫文件确实被加载。调试时不要一次改很多,按“登录页—白名单页—业务页”逐条验证,才能快速定位问题。

先明确本节要解决的问题 ​

本节笔记只有一句核心要求:在入口文件里引入权限模块,让它生效。

javascript
// 在router引入之后引入
import './permission'

需要说明:转写稿主要记录的是下拉框属性、搜索分页 bug、新增月卡路由绑定、表单校验等内容,和“入口文件引入 permission”不是同一主题。因此下面不把无关内容硬套进来,只借用转写稿里反复出现的调试思路:先验证一个最小点,确认没问题再继续往下写,避免全部写完才发现入口根本没执行。

为什么需要:permission 这类模块通常承担路由权限判断、登录状态检查、守卫注册等启动逻辑。它不应该等到某个页面组件加载后才执行,而应该在应用入口启动阶段就执行。

怎么想:import './permission' 是“副作用引入”,不是引入一个函数再手动调用。只要入口文件执行到这一行,permission 文件内部代码就会执行。

怎么做:在入口文件里,先引入 router,再写 import './permission',顺序保持笔记注释的要求。

易错点:不要把它放到 router 引入之前;不要误以为 import 会自动导出某个方法;路径大小写、相对路径要跟项目实际文件一致。

为什么必须放在 router 引入之后 ​

为什么需要:权限模块往往要依赖路由实例、路由表或路由守卫。如果 permission 先执行,而 router 还没准备好,就可能出现“拿不到 router”“守卫注册不生效”等问题。

怎么想:入口文件的执行顺序就是模块执行顺序。先创建/引入 router,再执行权限模块,才能保证权限模块运行时依赖已经存在。

怎么做:保留笔记中的顺序,不要被格式化工具或手动整理打乱:

javascript
// 在router引入之后引入
import './permission'

易错点:最常见的是“代码写了但没生效”,实际原因是 import './permission' 被放到了 router 之前,或者被放进了某个页面组件里。入口文件的引入是启动时执行,页面组件里的引入是进入页面后才执行,二者时机不同。

怎么验证它真的生效 ​

为什么需要:import './permission' 没有显式调用,很容易出现“我以为它执行了,其实没有”的情况。转写稿里调试搜索 bug 时,是通过看接口请求参数发现页码仍然是 3;这里同理,不能只看页面表面,要看执行时机和实际行为。

怎么想:先做最小验证:确认入口文件确实加载了 permission,再验证路由跳转、权限拦截是否符合预期。不要一上来就改一堆逻辑。

怎么做:可以在 permission 文件里临时加一行打印或断点,确认入口启动时执行到它;然后测试一个需要权限判断的路由跳转。调试完成后删除临时打印。转写稿里也强调“写完第一个点就测试一下,别都写完了才发现没出来”,这里同样适用。

易错点:开发环境热更新可能让模块重复执行,调试时要区分首次加载和热更新;生产环境不要保留调试输出;不要只看控制台有没有报错,还要看路由行为是否真的被处理。

入口引入和权限内部逻辑的边界 ​

为什么需要:本节笔记只负责“引入生效”,并没有给出 permission 内部实现。如果把内部逻辑也凭空补出来,就容易编造。

怎么想:入口文件的职责是保证权限模块在正确时机执行;具体守卫、白名单、token 判断应放在 permission 文件内部,按项目实际代码为准。

怎么做:本节只确认入口文件中有且仅有这行引入,并保证位置正确。后续排查权限问题时,再进入 permission 文件看具体逻辑。

易错点:不要把大段权限判断写进入口文件;不要重复引入多个权限模块;不要因为“页面能打开”就认为权限生效,必须测试未登录、无权限、正常登录等实际场景。

小结 ​

这一节的关键不是写复杂代码,而是保证 permission 在入口文件、router 引入之后执行。记住三点:副作用引入会直接执行、顺序不能反、写完先做最小验证。这样后续权限逻辑才有稳定的执行入口。

代码示例 ​

示例 1:新增月卡 —— 车辆信息表单校验(基础校验 + 自定义 validator) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>新增月卡 - 车辆信息表单校验</title>
  <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css" />
</head>
<body>
  <div id="app" style="padding: 20px">
    <!-- 外层 form 绑定 model 与 rules,内层 form-item 用 prop 指定规则 -->
    <el-form ref="carForm" :model="carInfo" :rules="carInfoRules" label-width="100px">
      <el-form-item label="车主姓名" prop="personName">
        <el-input v-model="carInfo.personName" placeholder="请输入车主姓名"></el-input>
      </el-form-item>

      <el-form-item label="联系方式" prop="phoneNumber">
        <el-input v-model="carInfo.phoneNumber" placeholder="请输入联系方式"></el-input>
      </el-form-item>

      <el-form-item label="车辆号码" prop="carNumber">
        <el-input v-model="carInfo.carNumber" placeholder="请输入车辆号码"></el-input>
      </el-form-item>

      <el-form-item label="车辆品牌" prop="carBrand">
        <el-input v-model="carInfo.carBrand" placeholder="请输入车辆品牌"></el-input>
      </el-form-item>

      <el-form-item>
        <el-button type="primary" @click="submit">确定</el-button>
        <el-button @click="reset">重置</el-button>
      </el-form-item>
    </el-form>
  </div>

  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  <script>
    // 车牌号正则:1 个汉字 + 5 位数字/字母(转写稿中的规则)
    const CAR_NUMBER_REG = /^[\u4e00-\u9fa5][A-Za-z0-9]{5}$/

    new Vue({
      el: '#app',
      data() {
        return {
          // 表单对象
          carInfo: {
            personName: '',
            phoneNumber: '',
            carNumber: '',
            carBrand: ''
          },
          // 规则对象:key 必须和 carInfo 里的字段名一一对应
          carInfoRules: {
            personName: [{ required: true, message: '请输入车主姓名', trigger: 'blur' }],
            phoneNumber: [{ required: true, message: '请输入联系方式', trigger: 'blur' }],
            carNumber: [
              { required: true, message: '请输入车辆号码', trigger: 'blur' },
              // 自定义校验规则:validator 是固定属性名
              {
                validator: (rule, value, callback) => this.checkCarNumber(rule, value, callback),
                trigger: 'blur'
              }
            ],
            carBrand: [{ required: true, message: '请输入车辆品牌', trigger: 'blur' }]
          }
        }
      },
      methods: {
        // rule: 当前规则对象(一般不用)
        // value: 用户当前输入的数据(要校验的就是它)
        // callback: 放行函数,必须调用
        checkCarNumber(rule, value, callback) {
          if (!CAR_NUMBER_REG.test(value)) {
            // 校验不通过:callback 里 new 一个 Error
            callback(new Error('请输入符合规范的车牌号'))
          } else {
            // 校验通过:直接 callback()
            callback()
          }
        },
        submit() {
          this.$refs.carForm.validate(valid => {
            if (!valid) return
            this.$message.success('校验通过,可以提交')
          })
        },
        reset() {
          this.$refs.carForm.resetFields()
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • :model="carInfo"、:rules="carInfoRules" 绑定在外层 form 上,prop="carNumber" 写在内层 form-item 上,二者缺一个校验就失效。
  • prop 的值、carInfo 里的字段名、carInfoRules 里的 key,三处名字必须完全一致,写错一个字母校验就“悄悄不生效”。
  • validator 是固定属性名,后面跟函数;参数顺序 (rule, value, callback) 固定不能乱。
  • callback 必须调用:通过就 callback(),不通过就 callback(new Error('提示语')),不调用则表单永远卡在“校验中”。
  • data() 里直接用 this.checkCarNumber 拿到的是 undefined(此时 methods 还没挂载),所以要用箭头函数包一层 (rule, value, callback) => this.checkCarNumber(...)。
  • 多条规则按数组顺序执行,第一条 required 卡住后不会走到 validator,所以空值先提示“请输入车辆号码”。

示例 2:搜索 bug 修复 —— 查询前把页码重置为 1 ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>月卡管理 - 搜索重置页码</title>
  <link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css" />
</head>
<body>
  <div id="app" style="padding: 20px">
    <div style="margin-bottom: 12px">
      <el-input
        v-model="params.carNumber"
        placeholder="车牌号码"
        style="width: 220px; margin-right: 8px"
      ></el-input>
      <el-button type="primary" @click="handleSearch">查询</el-button>
      <el-button @click="showAll">显示全部</el-button>
    </div>

    <el-table :data="list" border>
      <el-table-column prop="carNumber" label="车牌号码" />
      <el-table-column prop="personName" label="车主姓名" />
    </el-table>

    <el-pagination
      style="margin-top: 12px"
      layout="prev, pager, next, total"
      :current-page.sync="params.page"
      :page-size="params.pageSize"
      :total="total"
      @current-change="getList"
    />
  </div>

  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/element-ui/lib/index.js"></script>
  <script>
    // ===== 模拟后端数据 =====
    const DB = Array.from({ length: 23 }, (_, i) => ({
      id: i + 1,
      // 只有一条是“京A12345”,模拟真实业务里搜索结果很少的场景
      carNumber: i === 5 ? '京A12345' : '津B' + String(10000 + i),
      personName: '车主' + (i + 1)
    }))

    // ===== 模拟接口:按车牌号过滤 + 分页 =====
    function mockGetList({ page, pageSize, carNumber }) {
      const filtered = carNumber
        ? DB.filter(item => item.carNumber.includes(carNumber))
        : DB
      const start = (page - 1) * pageSize
      return new Promise(resolve => {
        setTimeout(() => {
          resolve({ rows: filtered.slice(start, start + pageSize), total: filtered.length })
        }, 100)
      })
    }

    new Vue({
      el: '#app',
      data() {
        return {
          // 请求参数
          params: {
            page: 1,
            pageSize: 10,
            carNumber: ''
          },
          list: [],
          total: 0
        }
      },
      created() {
        this.getList()
      },
      methods: {
        async getList() {
          const { rows, total } = await mockGetList(this.params)
          this.list = rows
          this.total = total
        },
        // 查询:先重置页码,再取数据
        handleSearch() {
          this.params.page = 1
          this.getList()
        },
        showAll() {
          this.params.carNumber = ''
          this.params.page = 1
          this.getList()
        }
      }
    })
  </script>
</body>
</html>

关键行与易错点

  • bug 表现:先翻到第 3 页,再输车牌号查询,接口参数里 page=3,而结果只有 1 条,后端返回空数组 → 页面“数据消失”。
  • 修复核心就一行:this.params.page = 1,且必须在 this.getList() 之前执行,否则发出去的还是旧页码。
  • getList 内部不要再偷偷改 page,页码的“重置”只放在查询/重置动作里,职责才清晰。
  • :current-page.sync="params.page" 保证点分页时 params.page 同步更新;如果写成 :current-page(不加 .sync)又不监听 current-change 赋值,翻页请求会一直取第 1 页。
  • 清空搜索条件(显示全部)同样要重置页码,否则会停在一个不存在的页上。

示例 3:退出登录 + 路由守卫权限控制(对应 permission.js) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>权限控制 - 路由守卫</title>
</head>
<body>
  <div id="app">
    <router-view></router-view>
  </div>

  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router@3/dist/vue-router.js"></script>
  <script>
    // ===== utils/auth.js(原笔记用 js-cookie,这里用 localStorage 等价实现) =====
    const TOKEN_KEY = 'hrsaas_token'
    const getToken = () => localStorage.getItem(TOKEN_KEY)
    const setToken = token => localStorage.setItem(TOKEN_KEY, token)
    const removeToken = () => localStorage.removeItem(TOKEN_KEY)

    // ===== 页面组件 =====
    const Login = {
      template: `<div style="padding:20px">
        <h3>登录页</h3>
        <button @click="login">登录</button>
      </div>`,
      methods: {
        login() {
          setToken('mock-token-123')
          this.$router.push('/home')
        }
      }
    }

    const Home = {
      template: `<div style="padding:20px">
        <h3>首页(需要 token 才能访问)</h3>
        <button @click="logout">退出登录</button>
      </div>`,
      methods: {
        // 等价于 this.$store.commit('user/clearUserInfo')
        logout() {
          removeToken(TOKEN_KEY) // 清掉本地 token
          this.$router.push('/login') // 跳回登录页
        }
      }
    }

    const NotFound = { template: `<div style="padding:20px"><h3>404</h3></div>` }

    // ===== router/index.js =====
    const router = new VueRouter({
      routes: [
        { path: '/login', component: Login },
        { path: '/home', component: Home },
        { path: '/404', component: NotFound },
        { path: '*', redirect: '/404' }
      ]
    })

    // ===== permission.js:权限控制 =====
    const WHITE_LIST = ['/login', '/404']

    router.beforeEach((to, from, next) => {
      const token = getToken()
      if (token) {
        // 有 token,放行
        next()
      } else {
        // 没有 token:白名单直接放行,否则踢回登录页
        if (WHITE_LIST.includes(to.path)) {
          next()
        } else {
          next('/login')
        }
      }
    })

    new Vue({ el: '#app', router })
  </script>
</body>
</html>

关键行与易错点

  • router.beforeEach((to, from, next) => {}) 三个参数顺序固定;to 是即将进入的路由,用 to.path 做白名单判断。
  • 每个分支都必须调用且只调用一次 next,漏调页面白屏,重复调会报错。
  • 白名单 WHITE_LIST 里必须包含 /login,否则没有 token 时跳登录页会被守卫再拦一次,形成死循环。
  • next('/login') 与 next({ path: '/login' }) 都可以,但不要写成 next() 后再 router.push('/login')。
  • 退出登录的顺序:先清 token(removeToken),再 $router.push('/login');如果只跳转不清 token,守卫会发现仍有 token 而直接放行。
  • 使用 Vuex 时对应写法是 this.$store.commit('user/clearUserInfo'),mutation 内部先 state.token = '' 再 removeToken(TOKEN_KEY);直接改 state.token 而不删本地存储,刷新后守卫依旧能拿到 token。
  • permission.js 要在入口文件中 import router from './router' 之后引入(import './permission'),否则 router 实例还没创建。

常见错误 ​

1. 下拉框能展开,选项却是空白或显示成 [object Object]

  • 现象:点开 select 有若干行,但看不到中文,或者只看到一串对象字符。
  • 原因:el-option 的 :label / :value 绑错了层级,写成了 :label="item";或者忘了加 :,把它当成普通字符串。
  • 修法::label="item.label"、:value="item.value"(字段名以后端返回为准),并保留 :key。

2. 选中某一项后,页面没变化,取到的值始终是初始值

  • 现象:点选项有高亮,但 data 里的变量不变,提交时拿到空值。
  • 原因:el-select 上漏写 v-model,或 v-model 绑的字段没在 data 中声明。
  • 修法:先在 data 中声明变量,再在 el-select 上写 v-model="xxx",用 devtools 确认值随选择变化。

3. 翻到第 3 页再搜索,明明有数据却查不出来

  • 现象:在列表第一页搜索正常,切到第 3 页再查,结果为空。
  • 原因:查询请求沿用了当前的 page,等于向后端要“第 3 页 + 该车牌号”,而后端只有 1 条数据。
  • 修法:查询方法里先 this.params.page = 1,再调 getList()。

4. 加了 rules 但输入框前不出现星号,提交也不拦截

  • 现象:规则对象写好了,页面上没有任何校验表现。
  • 原因:el-form 的 :model、el-form-item 的 prop、rules 里的键名三者中有一个字母对不上(大小写、下划线都算)。
  • 修法:写完第一个字段先只测这一个,星号出现后再往下写其余字段,避免一次性写完再排查。

5. 输入框敲了字,但表单对象里的字段不更新

  • 现象:校验规则本身没问题,但提交时字段全是初始值。
  • 原因:el-input 漏写 v-model="表单对象.字段",只写了 prop。
  • 修法:补齐双向绑定,改完在 devtools 里点开组件看对应字段是否随输入变化。

6. 自定义校验写完后,表单永远提交不了(或点确定毫无反应)

  • 现象:输入合法车牌号,红字不消失,确定按钮点了没动静。
  • 原因:validator 函数里某个分支忘了调用 callback;或者 new Error(...) 后忘了 return,导致继续往下走又调了一次。
  • 修法:通读函数,保证每条路径都以 callback() 或 return callback(new Error('提示')) 结尾。

7. 车牌号正则“怎么输都不通过”

  • 现象:输入 京A12345 仍提示不符合规范。
  • 原因:reg.test() 里传的是 rule(规则对象)而不是 value;或复用了带 g 标志的正则,lastIndex 残留导致时对时错。
  • 修法:改成 reg.test(value);自定义校验用的正则不要带 g。

8. 新增页跳过去后,左侧菜单多了一项 / 页面套了两层侧边栏

  • 现象:点“添加月卡”能进页面,但布局不对,或菜单里冒出一个不该有的入口。
  • 原因:把新增路由挂进了 layout 的 children 里。
  • 修法:参照 404 的写法,作为与 layout 同级的顶层路由配置,path: '/addCard',组件直接指向新增页。

9. 退出登录后手动输地址还能进业务页 / 打开就无限跳登录页

  • 现象:清完 token 仍能访问列表页;或一进项目就在 /login 打转。
  • 原因:permission.js 在 router 之前引入(守卫拿不到实例);或白名单漏了 /login,next('/login') 被自己又拦一次。
  • 修法:import './permission' 放在 router 引入之后;白名单至少含 /login、/404。

练习 ​

练习 1|下拉框取值 用后端返回的状态码数组渲染 el-select,选项显示中文、选中后把码值存进查询参数。要求:切换选项后,在 devtools 中能看到 params.status 变化,且首次进入有默认选中项。

验收:选项文案是中文、params.status 是码值而非中文。

练习 2|搜索重置页码 给列表页的“查询”按钮实现完整逻辑:重置页码 → 重置列表数据 → 重新请求。分别在“第一页搜索”和“先翻到第 3 页再搜索”两种场景下验证。

验收:Network 面板中两次请求的 page 都是 1。

练习 3|新增页路由 新增一个“添加月卡”页面路由并完成按钮跳转,页面顶部有返回按钮。要求该路由不出现在左侧菜单,也不套 layout。

验收:点击按钮跳转正常;左侧菜单无新增项;点返回回到列表页且列表数据仍在。

练习 4|表单基础校验 给车辆信息表单的 4 个字段加必填校验,其中“车辆号码”在必填之外还要加正则校验。先只写第一个字段并测试星号,再补齐其余字段。

验收:4 个字段都有星号;空提交全部报红;输入 123 时车牌号单独报“请输入符合规范的车牌号”。

练习 5|校验规则排错 故意把 rules 里某个键名改错一个字母,观察现象,然后把正确的排查步骤写下来(先看哪、再看哪、最后看哪)。

验收:能说清 :model / prop / rules 三者的对齐关系,并能在 1 分钟内定位问题字段。


课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1补充-下拉框的属性说明03:01
2月卡管理-搜索bug修复03:41
3新增月卡-路由绑定跳转04:52
4新增月卡-完成车辆信息基础校验08:38
5新增月卡-自定义校验规则11:37

小结 ​

  1. el-select 的取值链路是 v-model → 选中项 value,label 只负责给人看,发给后端的一律是 value。
  2. 分页列表的“查询”必须显式重置 page = 1,否则会出现“第一页能搜到、第三页搜不到”的假 bug。
  3. 查询类操作的标准顺序是:重置分页 → 重置数据 → 发请求,三步不要省。
  4. 新增/详情这类页面通常是顶层路由:不进 layout、不进菜单,参照 404 的写法最省事。
  5. 表单校验的三处命名(:model、prop、rules 键名)必须逐字一致,一个字母错整条规则就静默失效。
  6. 写校验的正确节奏是“写一个字段 → 测一个字段”,全部写完再测会把排错成本放大好几倍。
  7. 默认配置能覆盖大部分场景,只有复杂逻辑(正则、联动、异步)才上 validator;其中 callback 是所有分支都必须调用的。
  8. 权限守卫的两个硬性条件:permission.js 在 router 之后引入、白名单包含登录页,否则不是漏拦就是死循环。