Skip to content

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

本页对应 记住功能与月卡管理,共 6 段视频,约 1 小时 6 分。

学习目标 ​

  1. 能够依据月卡管理页面需求搭建搜索区、操作区、表格区、分页区和新增弹窗的静态组件结构。
  2. 能够使用响应式数据、methods 封装列表请求方法,并在组件初始化生命周期中调用,通过表格列 prop 完成月卡列表基础渲染。
  3. 能够使用 formatter 或映射对象将状态码转换为“有效/已过期”等中文显示,并说明三元表达式与映射匹配方式的适用场景。
  4. 能够实现分页逻辑:根据总数与每页条数渲染分页器,监听页码变化,修改请求参数并复用列表请求方法刷新表格数据。
  5. 能够实现搜索功能:通过双向绑定收集车牌号、姓名、状态等查询条件,将其作为请求参数调用接口并重新渲染列表。
  6. 能够基于 js-cookie 封装存取方法,在 Vuex 中通过提交 action 管理状态,并对比 Vuex+Cookies 方案与 cookie、localStorage 的差异。

知识点 ​

先说明转写稿与笔记的对应关系 ​

本节笔记的核心是“组件中提交 action”,代码围绕登录表单校验、提交 user/doLogin action、成功后跳转首页展开。你给出的转写稿记录的是月卡管理功能,包括表格渲染、状态映射、分页和搜索,并没有出现登录表单、dispatch、$router.push 等调试过程。为避免编造,下面不虚构转写稿中不存在的接口返回和调试日志,只依据本节笔记代码做推导。转写稿中类似“吊接口”的表述,按语义应理解为“调接口”,但它属于月卡管理内容,不在本节强行套用。

整体流程:先校验,再提交,最后跳转 ​

为什么需要:登录提交不能一上来就把用户名和密码发给后端。用户可能没填、填错格式,或者重复点击。组件中提交 action 的职责,是把“表单校验结果”和“Vuex 中的登录逻辑”连接起来。

怎么想:这段代码的顺序很清楚:validate 校验表单;valid 为真才继续;从 formData 中取 username、password;dispatch 提交带命名空间的 action;等待完成后跳转首页。

怎么做:保留笔记中的代码结构:

javascript
doLogin() {
  this.$refs.form.validate(async(valid) => {
    console.log(valid)
    if (valid) {
      // TODO
      console.log('登录')
      const { username, password } = this.formData
      await this.$store.dispatch('user/doLogin', { username, password })
      this.$router.push('/')
    }
  })
}

登录表单校验与提交 action 示意图

易错点:validate 的回调函数写成 async,表示回调内部可以使用 await;它不是“await this.$refs.form.validate()”的写法。还要注意,只有 valid 为 true 时才提交,否则应停在校验失败状态。

表单校验回调:为什么用 validate 包住提交逻辑 ​

为什么需要:登录按钮点击后,组件必须先知道表单是否通过校验。Element UI 这类表单组件通常通过 validate 回调给出结果。

怎么想:把提交逻辑放在 if (valid) 内部,相当于把“校验失败”和“校验成功”两条路径分开。校验失败时不做网络请求,避免无效请求和错误提示混乱。

怎么做:通过 this.$refs.form 拿到表单实例,调用 validate,回调参数 valid 是布尔值。笔记中先 console.log(valid),这是调试校验结果的手段;实际项目可以在开发阶段保留,上线前按规范清理。

易错点:this.$refs.form 必须和模板中 ref="form" 对应;如果 ref 名字不一致,会拿到 undefined。另外,validate 回调里写 async 后,内部异常不会自动被外层捕获,后续应结合 try/catch 或 action 内部错误处理。

解构表单数据:payload 要明确、精简 ​

为什么需要:action 只需要用户名和密码,不应该把整个 formData 原样传过去。payload 越明确,action 的职责越清晰,也避免把无关字段带到请求里。

怎么想:笔记用 const { username, password } = this.formData 解构,说明提交参数就是这两个字段。这样写比 this.formData.username、this.formData.password 更集中,也方便检查字段名是否与后端约定一致。

怎么做:确认 formData 中确实存在 username 和 password,再以对象形式传给 dispatch:{ username, password }。

易错点:字段名不能想当然。若表单里叫 userName,解构 username 就会得到 undefined。还要避免把密码明文打印到控制台;笔记中的 console.log('登录') 只是流程标记,不要扩展成打印密码。

dispatch 提交 action:命名空间与返回值 ​

为什么需要:组件不直接写登录请求,而是把登录逻辑交给 Vuex action。这样组件只负责收集参数和跳转,登录接口、token 存储、用户信息处理可以集中在 user 模块中。

怎么想:dispatch('user/doLogin', payload) 中的 user/doLogin 表示带命名空间的模块 action。第一个参数是 action 类型,第二个参数是 payload。前面加 await,说明希望等 action 完成后再跳转。

怎么做:确认 Vuex 模块开启了 namespaced: true,并且 user 模块中确实定义了 doLogin action。action 内部应返回 Promise,这样组件里的 await 才有意义。

易错点:如果模块没有开启命名空间,却写成 user/doLogin,会提示 action 不存在;如果 action 没有返回 Promise,await 不会等待真正的登录完成,可能出现“请求还没成功就跳首页”的问题。还要避免在 action 失败时仍然执行跳转,实际项目中应处理失败分支。

await 与路由跳转:成功后再离开登录页 ​

为什么需要:登录通常是异步请求。只有 action 成功完成,才应该跳转首页;否则用户可能进入一个没有登录态的首面。

怎么想:await this.$store.dispatch(...) 放在 push('/') 前面,表达“先登录,后跳转”。如果 action 内部抛出错误,await 后面的 push 不会执行,这符合基本流程。

怎么做:保持 await 与 dispatch 的先后关系。若需要更完整的处理,可在组件中加 try/catch,在 catch 中提示登录失败;但这属于扩展,不是笔记现有代码。

易错点:不要写成 this.$store.dispatch(...) 后立刻 push 而不 await,否则异步顺序不可控。也不要在 valid 为假时仍然跳转。图片展示的是“校验—提交—跳转”的链路,理解时应把 await 看成链路中的等待点。

本节易错点小结 ​

为什么需要:这段代码短,但把表单、Vuex、路由三个点串在一起,任何一处名字或顺序错了都可能表现为“点了没反应”。

怎么想:先查 ref 和 formData 字段,再查 dispatch 的命名空间和 action 是否存在,最后查 await 与跳转顺序。

怎么做:按“校验结果是否为真 → payload 字段是否正确 → action 是否返回 Promise → 成功后再跳转”的顺序排查。

易错点:validate 回调不是普通同步函数;dispatch 第二个参数必须是 action 需要的 payload;user/doLogin 不是接口地址而是 action 类型;$router.push('/') 应放在登录成功之后。掌握这条链路后,其他“组件提交 action”的场景都可以按同样思路迁移。

4. 更改流程图:把数据操作收进流程,让组件只负责触发 ​

月卡管理更改流程图

本节笔记只有一句核心思想:数据操作的逻辑都由 Vuex 维护,组件只负责把 action 函数触发起来即可。转写稿记录的是月卡管理的落地过程,虽然没有展开 Vuex 代码,但它反复在讲同一件事:列表、状态、分页、搜索都不是在组件里“东写一笔西写一笔”,而是把“参数变化—请求接口—写回数据—视图更新”收进统一流程。下面按这个流程扩写,代码只整理转写稿中出现的关键形态,不编造 Vuex 实现。

先定流程:组件触发,数据操作归口 ​

为什么需要: 月卡管理里有列表渲染、状态显示、分页、搜索,如果每个按钮里都直接请求、直接赋值,逻辑会重复,调试时也很难判断是哪一步改了数据。
怎么想: 把“拿数据、改状态、刷新视图”看成一条流程:组件只负责触发入口,真正请求和赋值放到统一方法或 Vuex action 中。转写稿里的 getList 就是在承担这一层;按本节笔记思想,后续应把它迁到 store action,组件只 dispatch。
怎么做: 组件里保留 getList 或 action 触发入口;初始化、分页、搜索都调用同一入口;请求参数统一放在 params。
易错点: 不要在组件里直接改 Vuex state;不要只写 action 不提交 mutation;异步请求完成后要确认写回的是列表和总数,而不是只打印。

列表渲染:先准备响应式数据,再统一取数 ​

为什么需要: 表格要显示后端 rows,还要用 total 算分页,必须先把数据接住。
怎么想: 转写稿的思路是四步:data 准备 list、total、params;methods 封装 getList;生命周期里调用一次;模板按组件文档绑定 prop。
怎么做: 转写稿先测接口参数,Network 里确认 page=1、pageSize=10;无权限时切换账号;返回后把 rows 赋给 list,total 赋给总数。关键代码整理如下:

js
data() {
  return {
    list: [],
    total: 0,
    params: { page: 1, pageSize: 10, carNumber: '', personName: '', cardStatus: undefined }
  }
},
methods: {
  async getList() {
    const res = await getCardListAPI(this.params)
    this.list = res.data.rows
    this.total = res.data.total
  }
}

易错点: prop 字段名必须和 list 对象字段一致;page/pageSize 不要写成口误里的“配置/配 side”;total 不是列表长度,而是接口返回的总条数。

状态显示:优先用组件 formatter,多状态用映射 ​

为什么需要: 后端返回 cardStatus 的 0/1,页面要显示“有效/已过期”。
怎么想: 先看表格列组件是否提供格式化能力;转写稿在文档里找到 formatter,它 return 什么就显示什么。若状态超过两个,三元会难维护,改用对象映射。
怎么做:

js
formatter(row) {
  const map = { 0: '有效', 1: '已过期' }
  return map[row.cardStatus]
}

模板中给状态列绑定 :formatter="formatter"。转写稿调试时先 log(row),确认当前行数据里有 cardStatus,再写判断。
易错点: 对象取值不能用 map.row.cardStatus,变量键必须写 map[row.cardStatus];formatter 的返回值就是单元格内容;字段名不能猜。

分页与搜索:本质都是“改参数,再调统一入口” ​

为什么需要: 分页要按当前页取数,搜索要把车牌号、姓名、状态作为条件发给后端。
怎么想: 转写稿明确说:分页先算页数,再在点击页码时改 params.page,然后复用 getList;搜索先双向绑定收集条件,再点查询调用 getList。
怎么做: 分页事件拿到当前页后先改参数再请求;搜索用 v-model 绑定 params.carNumber、personName、cardStatus;状态下拉用 el-option 的 label/value,全部状态可传 undefined,让 axios 不发送该参数。
易错点: 必须先改 params.page 再调 getList,否则请求的还是旧页;分页事件参数要接收;下拉框 value 要和后端状态码对应;空字符串和 undefined 的传参行为要区分。

调试检查:看请求参数、返回结构、绑定值 ​

为什么需要: 这条流程长,单看页面容易误判。
怎么想: 转写稿的调试顺序很稳定:先看 Network 的请求参数,再看返回的 rows/total,最后看 Vue 调试工具里的双向绑定值。
怎么做: 刷新看首次请求是否为 page=1、pageSize=10;点第二页看 page 是否变 2;输入车牌号看 carNumber 是否进参数;切换状态看 cardStatus 是否为 0/1;确认列表是否重新渲染。
易错点: 账号权限不足会误以为接口坏了;字段名写错时页面可能只显示空;状态默认值写成 null 或 undefined 会影响“全部”的语义。把这条流程固定下来,组件就只负责触发,数据操作归口维护,后续功能才能复用同一套逻辑。

为什么需要: 登录 token 通常要跨页面、跨刷新保存,Cookie 是常见载体。如果业务代码里到处直接写 document.cookie 或到处写同一个 key,读写删容易不一致,后续改 key、加配置也会很散。
怎么想: 把“操作 Cookie 的细节”收进一个独立方法包,业务层只关心“拿 token、存 token、删 token”,不关心底层怎么写。转写稿里也反复强调把请求、赋值等逻辑收进独立方法,业务组件只调用方法;这个“统一入口、便于复用”的思路同样适用于 Cookie 封装。
怎么做: 新建一个专门文件,导入 js-cookie 和统一常量 TOKEN_KEY,只暴露 getToken、setToken、removeToken 三个函数。
易错点: 不要在每个页面手写字符串 key;不要让 get/set/remove 使用不同 key;不要把所有敏感信息都放前端可读 Cookie 里。

先想清楚:封装的目标与边界 ​

为什么需要: js-cookie 已经帮我们简化了 Cookie 操作,但项目仍需要一层“业务封装”,否则组件里会出现 Cookies.get('token')、Cookies.set('token', ...) 这类重复代码。
怎么想: 这层封装只做三件事:读、写、删。key 从 @/constants/KEY 的 TOKEN_KEY 来,保证全项目一致;函数名用 getToken/setToken/removeToken,语义明确。
怎么做: 保留笔记中的代码结构,不在这个文件里混入请求、路由跳转等业务逻辑。
易错点: 不要把“封装 Cookie”写成“封装登录流程”;不要在这个文件里做权限判断;不要随意改函数返回值含义。

具体实现:导入、常量与三个方法 ​

为什么需要: 直接操作 Cookie 字符串繁琐,还要处理参数;js-cookie 的 API 更直观。
怎么想: 用 import Cookies from 'js-cookie' 引入库,用 TOKEN_KEY 作为唯一 key;三个函数分别对应 Cookies.get、Cookies.set、Cookies.remove。
怎么做: 代码如下,保留原注释与结构:

javascript
// 专门用来操作cookie的方法包
// 内部封装了繁琐的操作方法 参数处理 暴露三个函数 get,set,remove
import Cookies from 'js-cookie'
import { TOKEN_KEY } from '@/constants/KEY'
// 获取token的方法
export function getToken() {
  return Cookies.get(TOKEN_KEY)
}

// 设置方法
export function setToken(token) {
  return Cookies.set(TOKEN_KEY, token)
}

// 删除方法
export function removeToken() {
  return Cookies.remove(TOKEN_KEY)
}

易错点: setToken 的 token 不能传 undefined 或空字符串;如果项目需要过期时间,应在 Cookies.set 的第三个参数传 options,而不是在业务层到处写;removeToken 要和 setToken 使用同一路径/域配置,否则可能出现“删不掉”的现象。

调用方式与调试检查 ​

为什么需要: 封装完成后,业务层应只调用这三个函数,避免再直接依赖 js-cookie。
怎么想: 登录成功后调 setToken(token);请求拦截或页面初始化时调 getToken();退出登录调 removeToken()。
怎么做: 调用示例可以写成 setToken('xxx')、getToken()、removeToken()。调试时不要只看函数返回值,要到浏览器开发者工具的 Application/Storage/Cookies 面板确认 TOKEN_KEY 是否存在、值是否更新、删除后是否消失。本节笔记没有提供对应调试截图或转写调试片段,因此这里只给可验证的检查点,不虚构执行结果。
易错点: 如果后端把 token 设为 HttpOnly,前端 js-cookie 读不到,这是安全设计,不是封装错误;如果发现读不到,先确认 key、路径、域、过期时间,而不是盲目改封装。

易错点与安全提醒 ​

为什么需要: Cookie 封装很小,但登录态相关,错误会影响整个应用。
怎么想: 统一 key、统一入口、统一删除;敏感信息不要随便放前端可读 Cookie。
怎么做: 保持 TOKEN_KEY 单一来源;不要把 token 打印到生产日志;删除时确认路径和域一致;需要过期策略时在 setToken 内部统一加 options。
易错点: 不要用 document.cookie 和 js-cookie 混写同一个 key;不要在不同模块里写 'token'、'TOKEN'、'access_token' 多个 key;不要把“删除失败”误判为库问题,优先检查 key 与 Cookie 属性是否一致。说明:给定转写稿主要记录月卡管理的表格、分页和搜索,未包含本节 Cookie 封装的推导与调试片段,因此本节不把月卡逻辑混入,也不编造对应调试过程。

先明确本节目标:把 token 做成“全局可用、刷新不丢” ​

这一节的核心不是写页面,而是把登录后的 token 放进 store,并保证刷新浏览器后还能恢复。转写稿里反复强调一个开发顺序:先准备响应式数据,再封装方法,调用后看参数和返回值,最后绑定到视图。这个顺序迁移到 token 上就是:先准备 state,再提供 mutation,登录成功后提交 mutation,最后验证 state 与本地存储是否一致。转写稿里“详示数据”应写作“响应式数据”,“meaths/metter”应写作“methods”,这些术语先纠正,避免后面读代码时混淆。

为什么需要 TOKEN_KEY 常量 ​

javascript
export const TOKEN_KEY = 'token_key'

token 要存到本地,就会反复用到同一个 key。如果每个文件都手写 'token_key',一旦拼错或以后要改 key,就会到处漏改。把它导出成常量,getToken、setToken 以及需要读取 token 的地方都引用同一个值,来源唯一。

怎么想:凡是“多处共享、不能写错”的字符串,都应提成常量。怎么做:在工具模块中导出 TOKEN_KEY,本地存取函数内部使用它。易错点:有的地方写 'token',有的地方写 'token_key',结果 state 能取到、请求拦截器取不到;或者大小写不一致。

为什么 state 要优先从本地取 ​

javascript
import { loginAPI } from '@/apis/user'
import { getToken, setToken } from '@/utils/auth'

export default {
  namespaced: true,
  state: () => {
    return {
      // 优先从本地取
      token: getToken() || ''
    }
  },
  mutations: {
    setToken(state, token) {
      state.token = token
      // 本地存一份
      setToken(token)
    }
  }
}

Vuex 的状态保存在内存里,刷新页面后 store 会重新初始化。如果只在登录时把 token 放进 state,刷新后 token 就没了,后续请求会丢失登录态。因此 state 初始化时要先从本地取:getToken() || ''。这里的 || '' 是为了保证初始值是字符串,避免工具函数返回 null 或 undefined 时,组件里做字符串判断出现意外。

怎么想:token 是“全局共享 + 跨刷新保持”的数据,所以它既要进 store,也要进本地存储。怎么做:初始化 state 时调用 getToken();登录成功后通过 mutation 更新。易错点:在组件里直接读本地存储,绕过 store,导致数据来源不统一;或者 state 初始值写成 getToken() 但不给兜底,空值时类型不稳定。

为什么修改 token 必须走 mutation ​

Vuex 要求 state 的修改通过 mutation 完成,这样状态变化可追踪。代码里 setToken(state, token) 做了两件事:第一,更新 state.token;第二,调用工具函数 setToken(token) 把 token 存到本地。注意这里 mutation 名和导入的工具函数同名,语法上不冲突,但阅读时容易混。理解时抓住:state.token = token 是改内存,setToken(token) 是写本地。

怎么想:内存和本地要同步,否则会出现“页面当前能用,刷新就退出”或“本地有 token,store 里还是空”的问题。怎么做:登录接口成功后 commit('setToken', token);退出登录时提交空值并清理本地。易错点:直接 state.token = token 不走 mutation;只写本地不更新 state;只更新 state 不写本地;在 mutation 里发登录请求或做异步逻辑。转写稿里强调“先调接口拿数据,再把数据赋给响应式数据”,这里同理:先拿到登录返回的 token,再提交 mutation,不要把接口调用塞进 mutation。

调试时按“参数—返回值—状态—本地”顺序看 ​

转写稿里调试列表时强调看请求参数、看返回数据、再看视图绑定。token 也可以按这个习惯验证:登录请求返回后,先确认拿到的 token 字段;再确认 mutation 是否被提交;然后看 Vuex 中 state.token 是否有值;最后看本地存储里对应 key 是否写入。刷新页面后,再确认 state 是否能通过 getToken() 恢复。这样排查比直接盯页面有效。

易错点小结 ​

namespaced: true 表示模块命名空间,调用时要带模块名,例如 commit('user/setToken', token),不能漏掉前缀。state 用函数返回对象,是为了模块复用时避免多个实例共享同一份引用。工具函数 getToken/setToken 的具体实现本节未给出,不要臆造存储方式;但无论底层是 localStorage 还是 cookie,key 都必须统一使用 TOKEN_KEY。最后,token 的空值、过期清理和请求拦截器注入,是后续要接上的逻辑;本节先把“state 初始化取本地、mutation 同步内存与本地”这条核心链路搭稳。

本节目标与边界 ​

本节要解决的是“为什么使用 Vuex + Cookies(或 localStorage)”。笔记给出的核心判断很明确:Vuex 基于内存,存取快,但刷新会丢失;localStorage/cookie 基于磁盘,存取速度稍慢,但刷新不丢失,能持久化。因此组合使用,是为了既享受 Vuex 的速度与封装优势,又保持数据持久化。需要说明:本节笔记未附图片和代码,所以不额外补图补码;转写稿内容属于月卡管理功能,与本节 Vuex+Cookies 主题不一致,故不强行引用,避免编造。

知识点一:Vuex 为什么快,又为什么会丢 ​

为什么需要:页面中有很多状态需要被多个组件共享,例如用户信息、筛选条件、列表数据等。如果都靠组件逐层传参,代码会难维护;Vuex 把状态集中到内存中,读写直接、访问快,也便于统一封装。

怎么想:先把 Vuex 理解成“运行时状态仓库”。它保存在当前页面的 JS 运行环境里,所以组件读取 state 很快;但浏览器刷新会重建 JS 环境,Vuex 的 state 也会回到初始值。这不是 Vuex 的缺陷,而是内存状态的自然结果。

怎么做:把需要共享、需要快速响应的数据放进 Vuex;组件通过 state/getters 读取,通过 mutation/action 修改。若发现刷新后数据消失,不要先怀疑接口,而要先确认它是否只存在 Vuex 中。

易错点:把 Vuex 当成永久存储;刷新后直接用默认值覆盖已有数据;所有数据都塞进 Vuex,导致状态臃肿。要区分“运行时共享状态”和“需要持久化的数据”。

为什么需要:刷新页面后,用户不希望登录态、偏好设置、草稿等立刻消失。localStorage/cookie 存在浏览器侧,刷新后仍可读取,正好补上 Vuex 刷新丢失的短板。

怎么想:把磁盘存储当成“备份仓库”。它不负责组件间的快速响应,而负责在页面重新加载后把数据找回来。笔记说它“存取速度稍慢”,是因为读写浏览器存储通常涉及序列化、字符串解析或请求携带,不能像内存变量一样随意高频操作。

怎么做:选择存储方式时,按笔记的简化理解:localStorage 适合容量稍大、纯前端使用的数据;cookie 容量小且可能随请求发送,适合少量、与请求相关的标记。写入前做序列化,读取后做解析和容错。

易错点:cookie 容量小、频繁写会影响性能;敏感信息不应随意放 cookie/localStorage;存储值都是字符串,对象要序列化;读取时要处理空值和解析失败。

知识点三:Vuex + 持久化如何组合 ​

为什么需要:只使用 Vuex,刷新丢;只使用 localStorage/cookie,组件读写不够统一,也缺少 Vuex 的集中封装。组合后,Vuex 负责运行时快速读写,持久化存储负责刷新恢复。

怎么想:让 Vuex 做“唯一状态源”,持久化存储做“备份”。页面初始化时,先从存储读回数据,提交到 Vuex;运行时修改 Vuex 后,再把关键状态同步到存储。这样组件仍然面向 Vuex 编程,不需要到处直接读 storage。

怎么做:第一,应用启动时读取存储并回填 Vuex;第二,在 mutation 或 action 中同步需要持久化的字段;第三,提供清理逻辑,例如退出登录时同时清 Vuex 和存储。注意只持久化必要字段,不要整个仓库无脑写入。

易错点:只写不读,刷新后仍然丢失;初始化时默认值覆盖持久化值;每次小改动都写 cookie;多标签页同时修改导致状态不一致;退出登录只清 Vuex 不清存储。调试时可在 DevTools 的 Application 面板查看 localStorage/cookie,在 Vue Devtools 查看 state,刷新页面验证“读取—回填—再同步”链路是否闭合。

小结 ​

本节的关键不是二选一,而是分工:Vuex 解决内存中的快速共享与封装,localStorage/cookie 解决刷新后的持久化。按“为什么需要→怎么想→怎么做→易错点”落地时,要牢记 Vuex 是运行时状态源,存储是备份;初始化要回填,修改要同步,清理要成对,敏感数据要谨慎。这样才真正实现笔记所说的“既享受 Vuex 速度优势与封装优势,同时保持持久化”。

材料范围说明 ​

本节笔记只给出“cookie vs ls”的三点对比:存储空间大小、是否允许后端操作、是否跟随接口发送。转写稿讲的是月卡管理的表格渲染、分页和搜索,与本节主题不相关,不能用来补 cookie/localStorage 的推导和调试过程;强行套用会变成编造。本节笔记也未附图片和代码,因此以下不虚构图片与代码,只围绕笔记已有结论扩写。这里的“ls”按前端常见缩写理解为 localStorage。

前端开发中经常要“存一点数据”。但存到哪里,不能凭习惯,而要看三个问题:数据有多大、后端要不要参与、请求时要不要自动带上。笔记给出的三点,正好对应这三个判断维度。

怎么想:不要先问“哪个更高级”,而要先问“这份数据的使用者是谁”。如果只是前端自己用,后端不关心,也不希望它跟着每次接口跑,那就不该优先选 cookie;如果后端要读写,或者希望请求时自动携带,那 cookie 更合适。

怎么做:把待存数据列出来,逐项过三个问题:大小是否超过 KB 级?后端是否要操作?是否要跟随接口发送?三个答案会直接指向 cookie 或 ls。

易错点:把“能存”当成“该存”。ls 空间大不等于什么都能塞;cookie 能存也不等于适合存大对象。另一个易错点是以为 cookie 只能后端操作——笔记明确说“前端可操作,后端也可操作(占多数)”。

为什么需要:容量决定你能存什么。列表缓存、较大的前端状态,如果硬塞进 KB 级的 cookie,很容易不够用;而 cookie 本来也不是为大块数据设计的。

怎么想:先估算数据体积。笔记给出的量级是 ls 5M、cookie KB。这里要把“量级”理解成选择依据,而不是精确承诺。数据接近 KB 级、只是一个小标记,cookie 可以考虑;数据明显更大,应优先考虑 ls。

怎么做:把要存的内容按大小分类。小标记、小状态放 cookie 的讨论范围;较大的前端数据放 ls 的讨论范围。若数据大到超过 ls 的量级,也不应继续硬塞,而应重新设计存储方案。

易错点:把 5M 当成无限容量;把 cookie 的 KB 级容量用来存长 JSON;只凭“现在测试能存进去”就判断长期没问题。笔记没有展开浏览器差异和具体上限细节,这里也不额外编造,只强调按量级选型。

为什么需要:有些数据不是前端自娱自乐,后端也要读、要写、要参与判断。如果选了纯前端存储,后端拿不到,就会造成“前端以为存好了,后端完全不知道”的断层。

怎么想:问一句“这份数据后端要不要参与?”笔记说 ls 是纯前端操作;cookie 前端可操作,后端也可操作,而且后端操作占多数。也就是说,需要后端参与时,cookie 更符合笔记描述;只在前端内部使用时,ls 更符合“纯前端操作”的定位。

怎么做:需要后端读写或后端参与校验的数据,按笔记结论优先考虑 cookie;只服务于前端页面状态、后端不关心的数据,按笔记结论优先考虑 ls。

易错点:把 ls 当成后端也能直接读的存储;把 cookie 理解成“只有后端能操作”。笔记的原话是 cookie 前端可操作、后端也可操作,不能把“后端占多数”误读成“前端不能操作”。另一个易错点是:后端可操作不代表所有 cookie 都必须由后端写入,具体仍要看业务分工。

为什么需要:是否自动跟随请求,直接影响后端能不能在接口里直接拿到这份数据,也影响请求本身是否被额外携带内容。笔记第三点只写了“cookie”,在对比语境下,核心意思是:cookie 会跟随接口发送;ls 不属于这种自动跟随机制。

怎么想:如果希望后端在接口请求中直接拿到某个标记,cookie 符合笔记描述;如果只是前端页面自己用,不希望它自动跟着接口跑,就不应优先选 cookie。这里的“跟随接口发送”是 cookie 的关键特征,也是它和纯前端存储拉开差异的地方。

怎么做:把“是否希望请求自动携带”作为选型问题。需要自动携带,按笔记结论考虑 cookie;不需要自动携带,按笔记结论考虑 ls。不要把大块数据放进会跟随接口发送的存储里,否则请求会背负不必要的内容。

易错点:只记住“cookie 能存”,忘了它还会跟随接口发送;把 ls 也当成会自动跟随请求的存储;需要后端读取时选了 ls,结果接口里根本拿不到。笔记没有展开路径、域名、过期等细节,这里不补造,只守住“跟随接口发送”这一条。

三点合并后的选择顺序 ​

可以按笔记顺序做决策:先看大小——ls 是 5M 量级,cookie 是 KB 量级;再看后端是否操作——ls 纯前端,cookie 前后端都可操作且后端占多数;最后看是否跟随接口——cookie 会跟随接口发送。三点不是孤立的,要合起来看。

比如一份数据很大、后端不操作、也不希望跟随接口发送,按笔记三点都指向 ls;一份数据很小、后端要参与、还希望接口自动携带,按笔记三点都指向 cookie。真正容易出错的是“只满足一点就下结论”:只看容量大就选 ls,却忽略后端要读;只看后端要读就选 cookie,却忽略数据很大。

易错点总结 ​

第一,不要把“ls 5M”理解成可以无限存;第二,不要把“cookie KB”理解成什么小数据都适合;第三,不要把“ls 纯前端操作”误解成后端也能直接操作;第四,不要把“cookie 后端也可操作(占多数)”误解成前端不能操作;第五,不要忽略 cookie 会跟随接口发送这一关键差异。按“大小—后端是否操作—是否跟随接口”三问依次判断,比单独记结论更稳。

代码示例 ​

示例一:月卡列表(表格渲染 + 状态映射 + 分页 + 搜索) ​

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>月卡管理</title>
  <style>
    body { font-family: system-ui, "Microsoft YaHei"; padding: 20px; }
    .search { display: flex; gap: 8px; margin-bottom: 12px; }
    table { border-collapse: collapse; width: 100%; }
    th, td { border: 1px solid #dcdfe6; padding: 8px 12px; text-align: left; }
    th { background: #f5f7fa; }
    .pager { margin-top: 12px; display: flex; gap: 6px; align-items: center; }
    .pager button.active { background: #409eff; color: #fff; }
  </style>
</head>
<body>
  <div class="search">
    <input id="carNumber" placeholder="车牌号码" />
    <input id="personName" placeholder="车主姓名" />
    <select id="cardStatus">
      <option value="">全部</option>
      <option value="0">有效</option>
      <option value="1">已过期</option>
    </select>
    <button id="searchBtn">查询</button>
  </div>

  <table>
    <thead>
      <tr>
        <th>车主姓名</th><th>联系方式</th><th>车牌号码</th><th>剩余天数</th><th>状态</th>
      </tr>
    </thead>
    <tbody id="tbody"></tbody>
  </table>

  <div class="pager">
    <span id="totalInfo"></span>
    <span id="pagerBtns"></span>
  </div>

<script>
  /* ===== 1. 模拟后端(真实项目里换成 request 调 getCardListAPI) ===== */
  const DB = [
    { id: 1, personName: '苏轼',   phoneNumber: '13800000001', carNumber: '川A12345', remainDays: 30, cardStatus: 0 },
    { id: 2, personName: '孙道川', phoneNumber: '13800000002', carNumber: '川A23456', remainDays: 12, cardStatus: 0 },
    { id: 3, personName: '李白',   phoneNumber: '13800000003', carNumber: '川B34567', remainDays: 0,  cardStatus: 1 },
    { id: 4, personName: '杜甫',   phoneNumber: '13800000004', carNumber: '川B45678', remainDays: 5,  cardStatus: 0 },
    { id: 5, personName: '王安石', phoneNumber: '13800000005', carNumber: '川C56789', remainDays: 0,  cardStatus: 1 }
  ]

  function getCardListAPI(params) {
    const { page, pageSize, carNumber, personName, cardStatus } = params
    const filtered = DB.filter(item => {
      const okCar = !carNumber || item.carNumber.includes(carNumber)
      const okName = !personName || item.personName.includes(personName)
      // 状态为空串表示“全部”,此时不参与过滤
      const okStatus = cardStatus === '' ? true : String(item.cardStatus) === String(cardStatus)
      return okCar && okName && okStatus
    })
    const start = (page - 1) * pageSize
    return Promise.resolve({
      rows: filtered.slice(start, start + pageSize),
      total: filtered.length
    })
  }

  /* ===== 2. 响应式数据 ===== */
  let list = []   // 月卡列表
  let total = 0   // 总条数

  const params = {  // 基础参数 + 查询参数,放一起
    page: 1,
    pageSize: 2,
    carNumber: '',
    personName: '',
    cardStatus: ''
  }

  /* ===== 3. 状态映射(状态超过两个,用映射代替三元) ===== */
  const STATUS_MAP = { 0: '有效', 1: '已过期' }
  const formatStatus = (row) => STATUS_MAP[row.cardStatus]

  /* ===== 4. 封装 getList:调接口 + 赋值 ===== */
  async function getList() {
    const res = await getCardListAPI(params)
    list = res.rows
    total = res.total
    renderTable()
    renderPager()
  }

  /* ===== 5. 渲染 ===== */
  function renderTable() {
    document.getElementById('tbody').innerHTML = list.map(row => `
      <tr>
        <td>${row.personName}</td>
        <td>${row.phoneNumber}</td>
        <td>${row.carNumber}</td>
        <td>${row.remainDays}</td>
        <td>${formatStatus(row)}</td>
      </tr>`).join('')
  }

  function renderPager() {
    const pageCount = Math.ceil(total / params.pageSize)   // 总条数 / 每页条数
    document.getElementById('totalInfo').textContent =
      `共 ${total} 条,第 ${params.page}/${pageCount || 1} 页`
    document.getElementById('pagerBtns').innerHTML =
      Array.from({ length: pageCount }, (_, i) => i + 1)
        .map(n => `<button class="${n === params.page ? 'active' : ''}" data-page="${n}">${n}</button>`)
        .join('')
  }

  /* ===== 6. 事件 ===== */
  document.getElementById('pagerBtns').addEventListener('click', e => {
    const btn = e.target.closest('button')
    if (!btn) return
    params.page = Number(btn.dataset.page)  // 先改查询字段
    getList()                               // 再复用 getList 重新请求
  })

  document.getElementById('searchBtn').addEventListener('click', () => {
    params.carNumber  = document.getElementById('carNumber').value.trim()
    params.personName = document.getElementById('personName').value.trim()
    params.cardStatus = document.getElementById('cardStatus').value
    params.page = 1                          // 搜索后必须回到第 1 页
    getList()
  })

  getList() // 初始化即调用
</script>
</body>
</html>

关键行 / 易错点

  • 先准备响应式数据 list / total,再在方法里“调接口 + 赋值”,最后在初始化时调用 getList()——这就是“数据驱动视图”的固定套路。
  • params 里把基础参数(page、pageSize)和查询参数(carNumber、personName、cardStatus)放在一起,所谓搜索就是把搜索条件当成请求参数发给后端。
  • 分页公式:Math.ceil(total / pageSize)。分页要做两件事:①把页数分出来(靠 total + pageSize)②点页码时用当前页重新请求并重渲染。
  • 点页码的顺序不能反:先改 params.page,再调 getList(),否则发出去的还是旧页码。
  • 状态只有 0/1 两个值时三元勉强够用;一旦状态码变多,用 STATUS_MAP 映射对象,逻辑里只写 STATUS_MAP[row.cardStatus],新增状态只加键值对,不改判断逻辑。
  • 易错点:cardStatus 传空串表示“全部”,过滤时要用 cardStatus === '' 判断跳过;如果后端是 0/1,注意别把 0 当成 falsy 给过滤掉了。
  • 易错点:搜索时忘记把 page 重置为 1,会出现“搜索后停在第 3 页但结果只有 1 页”的空表格。

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Vuex + Cookie 存 token</title>
  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/vuex@3/dist/vuex.js"></script>
  <script src="https://unpkg.com/js-cookie@3/dist/js.cookie.min.js"></script>
</head>
<body>
<div id="app">
  <form @submit.prevent="doLogin">
    <input v-model="formData.username" placeholder="用户名" />
    <input v-model="formData.password" type="password" placeholder="密码" />
    <button type="submit">登录</button>
  </form>

  <p>Vuex 中的 token:{{ token || '(空)' }}</p>
  <p>Cookie 中的 token:{{ cookieToken }}</p>
  <button @click="logout">清空 token</button>
</div>

<script>
/* ===== constants/KEY.js ===== */
const TOKEN_KEY = 'token_key'

/* ===== utils/auth.js:基于 js-cookie 封装存取方法 ===== */
function getToken() { return Cookies.get(TOKEN_KEY) }
function setToken(token) { return Cookies.set(TOKEN_KEY, token) }
function removeToken() { return Cookies.remove(TOKEN_KEY) }

/* ===== apis/user.js:模拟登录接口 ===== */
function loginAPI({ username, password }) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (!username || !password) return reject(new Error('用户名和密码不能为空'))
      resolve({ data: { token: 'mock-token-' + Date.now() } })
    }, 300)
  })
}

/* ===== store/modules/user.js ===== */
const user = {
  namespaced: true,
  state: () => ({
    token: getToken() || ''   // 优先从本地取,刷新页面不丢
  }),
  mutations: {
    setToken(state, token) {
      state.token = token
      setToken(token)         // 本地存一份(持久化)
    }
  },
  actions: {
    async doLogin(context, payload) {
      const res = await loginAPI(payload)
      context.commit('setToken', res.data.token)
    }
  }
}

const store = new Vuex.Store({ modules: { user } })

new Vue({
  el: '#app',
  store,
  data() {
    return { formData: { username: '', password: '' } }
  },
  computed: {
    token() { return this.$store.state.user.token },
    cookieToken() { return getToken() || '(空)' }
  },
  methods: {
    async doLogin() {
      const valid = this.formData.username && this.formData.password
      if (!valid) return alert('请先填写用户名和密码')

      const { username, password } = this.formData
      await this.$store.dispatch('user/doLogin', { username, password })
      alert('登录成功')
      // this.$router.push('/')
    },
    logout() {
      this.$store.commit('user/setToken', '')
      removeToken()   // cookie 也清掉,避免刷新后又“登录”回来
    }
  }
})
</script>
</body>
</html>

关键行 / 易错点

  • utils/auth.js 只暴露 getToken / setToken / removeToken 三个函数,把 js-cookie 的细节收在内部;key 统一放在 constants/KEY.js 里,不要在多个文件里写死字符串。
  • state 用 getToken() || '' 初始化——刷新页面时 Vuex 内存会被清空,但 cookie 还在,所以优先从本地取。
  • 组件只负责“把 action 触发起来”,真正的请求和赋值在 actions 里做:this.$store.dispatch('user/doLogin', { username, password })。
  • 模块加了 namespaced: true,所以 dispatch 必须带前缀 'user/doLogin',commit 也要写 'user/setToken',写成 'doLogin' 会报 “unknown action type”。
  • mutation 里同时改 state.token 和写 cookie,两边保持同步;这是 Vuex(内存快)+ cookie(刷新不丢)组合的目的。
  • 易错点:async/await 要写在 action 或组件的 async 方法里,await dispatch(...) 之后再跳路由,否则 token 还没落库就跳走了。
  • 易错点:清空登录态时只清 Vuex 不清 cookie,刷新后 getToken() 又把 token 读回来了,看起来像“退不出去”。
  • 笔记里 this.$refs.form.validate(...) 是 Element UI 的表单校验写法,这里用原生 valid 判断替代,校验通过后才 dispatch。

常见错误 ​

  1. 表格某列整列空白或显示 undefined 现象:接口有数据,但"车主姓名"列渲染不出来。 原因:prop 的值与后端字段名不一致(如后端是 person_name,模板里写成 personName 或 personname)。 修法:先在 Network → Response 里核对 rows[0] 的真实 key,再逐字复制到 prop;命名风格(下划线/驼峰)不要凭记忆写。

  2. 状态列直接显示 0 / 1,或格式化后变成 undefined 现象:加了 formatter 但页面没变中文。 原因:① 忘了 return,函数返回 undefined;② 用 map.0、map.row.cardStatus 这类点语法取动态 key。 修法:函数体必须 return;动态状态码一律用中括号取值 map[row.cardStatus];调试时先 console.log(row) 确认字段名。

  3. 分页器页数不对(永远只有 1 页 / 页数写死) 现象:数据 5 条,分页器却显示 50 页或始终 1 页。 原因:total 传了硬编码常量,没接收接口返回的 total 字段;或 page-size 与请求参数里的 pageSize 不是同一个来源。 修法:在 state 里单独维护 total,请求成功后 this.total = res.data.total;page-size 与请求参数统一绑定同一个值,避免两处各写一份。

  4. 点击第 2 页,表格内容不变或直接变空 现象:页码高亮了,数据还是第一页;或者变空。 原因:先调 getList() 后才修改 params.page,请求发出时参数仍是旧值;或只改了页码没重新请求。 修法:顺序固定为"先改请求参数 → 再调 getList()",并在 Network 面板核对本次请求的 page 是否为新值。

  5. 搜索后列表为空,或搜完再点分页又变回全量数据 现象:在第 3 页做搜索,返回空数组;或翻页后搜索条件失效。 原因:搜索时没把 page 重置为 1;或搜索条件写在组件局部变量里,没并进同一份请求参数对象。 修法:所有查询条件(page、pageSize、carNumber、personName、cardStatus)放在同一个 params 对象中;搜索回调第一行先 params.page = 1,再调接口。

  6. 状态下拉框显示 0/1 而不是中文,或传参传了中文 现象:选中后框里显示数字,接口收到 "有效"。 原因:el-option 的 label 与 value 绑反了。 修法:label 绑 item.name(展示中文),value 绑 item.id(提交给后端);"全部"项用 id: null,让该字段不参与查询。

  7. 登录后刷新页面就退出登录 现象:登录成功,F5 后回到登录页。 原因:只把 token 存进了 Vuex(内存),没同步写 Cookie;或 state 初始化没读本地。 修法:state.token 初始值写 getToken() || '';只在 mutation 里改 token 并同时 setToken(),组件层不直接操作 state。

练习 ​

  1. 字段核对:故意把某一列的 prop 改成错误值,用 Network 面板定位并修正,记录你从"发现异常 → 定位 → 修复"的完整步骤。

  2. 状态映射扩展:给状态映射表新增 2 → 已停用、3 → 已冻结,要求不改动 formatter 函数体,只在映射对象里加键值对,验证 4 种状态都能正确显示。

  3. 分页联动:把 pageSize 改成 3,点击第 2 页,用 Network 核对请求参数 page、pageSize 是否正确,并检查分页器总页数是否随 total 变化。

  4. 搜索 + 分页组合:先翻到第 2 页,再按"车牌号"搜索,观察请求参数中 page 的值。如果结果是空列表,请修正并说明原因。

  5. 持久化验证:完成登录后刷新页面,确认 token 仍在;再手动清除 Cookie 后刷新,确认能正确回到登录态,写出两种情况下 state.token 的取值来源。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1月卡管理-功能说明和结构搭建06:29
2月卡管理-table的基础渲染16:33
3月卡管理-适配状态显示07:35
4月卡管理-通过映射方式做匹配04:52
5月卡管理-分页逻辑实现12:22
6月卡管理-搜索功能实现18:10

小结 ​

  • 表格渲染的稳定套路是四步:响应式数据 → methods 封装取数方法 → 生命周期调用 → 模板绑定,缺一步页面就"不动"。
  • prop、prop 里的字段名、接口返回的 key,三者必须完全一致;排查渲染问题永远先看 Response,而不是先改模板。
  • 状态码适配优先看组件文档有没有现成能力(formatter),能复用就不自己造轮子;函数里 return 什么,单元格就显示什么。
  • 状态分支 ≤ 2 用三元即可;> 2 用映射对象 + 中括号取值,新增状态只加配置不改逻辑,这是可维护性的分水岭。
  • 分页是两件事:算页数(靠 total 和 pageSize)和翻页取数(改参数 → 重请求 → 重渲染),两者要分开验证。
  • 搜索的本质是"把条件塞进请求参数",因此搜索、分页、列表初始化可以共用同一个取数方法,这也是封装 getList 的最大收益。
  • 所有查询条件应放在同一份 params 里,搜索时重置 page = 1,能规避绝大多数"翻页后数据对不上"的诡异问题。
  • Vuex 负责速度与组件通信,Cookie/localStorage 负责持久化;写入永远走 mutation,初始化永远先读本地,二者才不会被刷新拆散。