主题
记住功能与月卡管理(二)
本页对应 记住功能与月卡管理,共 6 段视频,约 1 小时 6 分。
学习目标
- 能够依据月卡管理页面需求搭建搜索区、操作区、表格区、分页区和新增弹窗的静态组件结构。
- 能够使用响应式数据、methods 封装列表请求方法,并在组件初始化生命周期中调用,通过表格列
prop完成月卡列表基础渲染。 - 能够使用
formatter或映射对象将状态码转换为“有效/已过期”等中文显示,并说明三元表达式与映射匹配方式的适用场景。 - 能够实现分页逻辑:根据总数与每页条数渲染分页器,监听页码变化,修改请求参数并复用列表请求方法刷新表格数据。
- 能够实现搜索功能:通过双向绑定收集车牌号、姓名、状态等查询条件,将其作为请求参数调用接口并重新渲染列表。
- 能够基于
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('/')
}
})
}
易错点: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 会影响“全部”的语义。把这条流程固定下来,组件就只负责触发,数据操作归口维护,后续功能才能复用同一套逻辑。
为什么要把 Cookie 存取单独封装
为什么需要: 登录 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 为什么能持久化
为什么需要:刷新页面后,用户不希望登录态、偏好设置、草稿等立刻消失。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 和 localStorage
前端开发中经常要“存一点数据”。但存到哪里,不能凭习惯,而要看三个问题:数据有多大、后端要不要参与、请求时要不要自动带上。笔记给出的三点,正好对应这三个判断维度。
怎么想:不要先问“哪个更高级”,而要先问“这份数据的使用者是谁”。如果只是前端自己用,后端不关心,也不希望它跟着每次接口跑,那就不该优先选 cookie;如果后端要读写,或者希望请求时自动携带,那 cookie 更合适。
怎么做:把待存数据列出来,逐项过三个问题:大小是否超过 KB 级?后端是否要操作?是否要跟随接口发送?三个答案会直接指向 cookie 或 ls。
易错点:把“能存”当成“该存”。ls 空间大不等于什么都能塞;cookie 能存也不等于适合存大对象。另一个易错点是以为 cookie 只能后端操作——笔记明确说“前端可操作,后端也可操作(占多数)”。
存储空间大小:ls 约 5M,cookie 为 KB 级
为什么需要:容量决定你能存什么。列表缓存、较大的前端状态,如果硬塞进 KB 级的 cookie,很容易不够用;而 cookie 本来也不是为大块数据设计的。
怎么想:先估算数据体积。笔记给出的量级是 ls 5M、cookie KB。这里要把“量级”理解成选择依据,而不是精确承诺。数据接近 KB 级、只是一个小标记,cookie 可以考虑;数据明显更大,应优先考虑 ls。
怎么做:把要存的内容按大小分类。小标记、小状态放 cookie 的讨论范围;较大的前端数据放 ls 的讨论范围。若数据大到超过 ls 的量级,也不应继续硬塞,而应重新设计存储方案。
易错点:把 5M 当成无限容量;把 cookie 的 KB 级容量用来存长 JSON;只凭“现在测试能存进去”就判断长期没问题。笔记没有展开浏览器差异和具体上限细节,这里也不额外编造,只强调按量级选型。
是否允许后端操作:ls 纯前端,cookie 前后端都可操作
为什么需要:有些数据不是前端自娱自乐,后端也要读、要写、要参与判断。如果选了纯前端存储,后端拿不到,就会造成“前端以为存好了,后端完全不知道”的断层。
怎么想:问一句“这份数据后端要不要参与?”笔记说 ls 是纯前端操作;cookie 前端可操作,后端也可操作,而且后端操作占多数。也就是说,需要后端参与时,cookie 更符合笔记描述;只在前端内部使用时,ls 更符合“纯前端操作”的定位。
怎么做:需要后端读写或后端参与校验的数据,按笔记结论优先考虑 cookie;只服务于前端页面状态、后端不关心的数据,按笔记结论优先考虑 ls。
易错点:把 ls 当成后端也能直接读的存储;把 cookie 理解成“只有后端能操作”。笔记的原话是 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 页”的空表格。
示例二:js-cookie 封装 + Vuex action 提交(token 持久化)
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。
常见错误
表格某列整列空白或显示
undefined现象:接口有数据,但"车主姓名"列渲染不出来。 原因:prop的值与后端字段名不一致(如后端是person_name,模板里写成personName或personname)。 修法:先在 Network → Response 里核对rows[0]的真实 key,再逐字复制到prop;命名风格(下划线/驼峰)不要凭记忆写。状态列直接显示
0 / 1,或格式化后变成undefined现象:加了formatter但页面没变中文。 原因:① 忘了return,函数返回undefined;② 用map.0、map.row.cardStatus这类点语法取动态 key。 修法:函数体必须return;动态状态码一律用中括号取值map[row.cardStatus];调试时先console.log(row)确认字段名。分页器页数不对(永远只有 1 页 / 页数写死) 现象:数据 5 条,分页器却显示 50 页或始终 1 页。 原因:
total传了硬编码常量,没接收接口返回的total字段;或page-size与请求参数里的pageSize不是同一个来源。 修法:在 state 里单独维护total,请求成功后this.total = res.data.total;page-size与请求参数统一绑定同一个值,避免两处各写一份。点击第 2 页,表格内容不变或直接变空 现象:页码高亮了,数据还是第一页;或者变空。 原因:先调
getList()后才修改params.page,请求发出时参数仍是旧值;或只改了页码没重新请求。 修法:顺序固定为"先改请求参数 → 再调getList()",并在 Network 面板核对本次请求的page是否为新值。搜索后列表为空,或搜完再点分页又变回全量数据 现象:在第 3 页做搜索,返回空数组;或翻页后搜索条件失效。 原因:搜索时没把
page重置为 1;或搜索条件写在组件局部变量里,没并进同一份请求参数对象。 修法:所有查询条件(page、pageSize、carNumber、personName、cardStatus)放在同一个params对象中;搜索回调第一行先params.page = 1,再调接口。状态下拉框显示
0/1而不是中文,或传参传了中文 现象:选中后框里显示数字,接口收到"有效"。 原因:el-option的label与value绑反了。 修法:label绑item.name(展示中文),value绑item.id(提交给后端);"全部"项用id: null,让该字段不参与查询。登录后刷新页面就退出登录 现象:登录成功,F5 后回到登录页。 原因:只把 token 存进了 Vuex(内存),没同步写 Cookie;或
state初始化没读本地。 修法:state.token初始值写getToken() || '';只在mutation里改 token 并同时setToken(),组件层不直接操作state。
练习
字段核对:故意把某一列的
prop改成错误值,用 Network 面板定位并修正,记录你从"发现异常 → 定位 → 修复"的完整步骤。状态映射扩展:给状态映射表新增
2 → 已停用、3 → 已冻结,要求不改动formatter函数体,只在映射对象里加键值对,验证 4 种状态都能正确显示。分页联动:把
pageSize改成 3,点击第 2 页,用 Network 核对请求参数page、pageSize是否正确,并检查分页器总页数是否随total变化。搜索 + 分页组合:先翻到第 2 页,再按"车牌号"搜索,观察请求参数中
page的值。如果结果是空列表,请修正并说明原因。持久化验证:完成登录后刷新页面,确认 token 仍在;再手动清除 Cookie 后刷新,确认能正确回到登录态,写出两种情况下
state.token的取值来源。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 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,初始化永远先读本地,二者才不会被刷新拆散。