Vue2 综合实战:从零搭建完整购物车系统
本文以"商品管理 + 购物车"为业务主线,综合运用 Vue2 全套核心技术栈------Vuex 模块化状态管理、Vue Router 权限拦截、keep-alive 缓存优化、路由懒加载、作用域插槽复用以及 v-model 与 Vuex 的优雅集成------构建一个具备登录鉴权、商品增删、购物车统计的完整前端应用。所有代码均来自真实工程实践,可直接参考落地。
目录
- 零、导读与学习价值
- [0.1 项目功能清单](#0.1 项目功能清单)
- [0.2 技术栈与架构总览](#0.2 技术栈与架构总览)
- [0.3 学习路径说明](#0.3 学习路径说明)
- 一、项目架构设计
- [1.1 目录结构规划](#1.1 目录结构规划)
- [1.2 路由层级设计](#1.2 路由层级设计)
- [1.3 整体数据流](#1.3 整体数据流)
- [二、Vuex 模块化状态管理设计](#二、Vuex 模块化状态管理设计)
- [2.1 Vuex 核心概念](#2.1 Vuex 核心概念)
- [2.2 模块划分与抽离](#2.2 模块划分与抽离)
- [2.3 命名空间](#2.3 命名空间)
- [2.4 辅助函数:mapState / mapMutations / mapActions / mapGetters](#2.4 辅助函数:mapState / mapMutations / mapActions / mapGetters)
- 三、商品列表:数据获取与渲染
- [3.1 商品添加(mutations 同步操作)](#3.1 商品添加(mutations 同步操作))
- [3.2 商品列表渲染与过滤器](#3.2 商品列表渲染与过滤器)
- [3.3 actions 异步获取远端数据](#3.3 actions 异步获取远端数据)
- 四、购物车核心功能实现
- [4.1 加入购物车(幂等处理)](#4.1 加入购物车(幂等处理))
- [4.2 购物车列表渲染](#4.2 购物车列表渲染)
- [4.3 总价计算(getters)](#4.3 总价计算(getters))
- [4.4 vuex-persistedstate 持久化](#4.4 vuex-persistedstate 持久化)
- 五、路由权限控制
- [5.1 路由元信息与全局前置守卫](#5.1 路由元信息与全局前置守卫)
- [5.2 登录流程与 token 管理](#5.2 登录流程与 token 管理)
- [六、性能优化:keep-alive 与路由懒加载](#六、性能优化:keep-alive 与路由懒加载)
- [6.1 keep-alive 按需缓存](#6.1 keep-alive 按需缓存)
- [6.2 路由懒加载与 Magic Comment](#6.2 路由懒加载与 Magic Comment)
- [七、v-model 与 Vuex 的优雅集成](#七、v-model 与 Vuex 的优雅集成)
- [7.1 问题起源](#7.1 问题起源)
- [7.2 计算属性 setter 方案](#7.2 计算属性 setter 方案)
- 八、插槽体系:从匿名到作用域
- [8.1 匿名插槽](#8.1 匿名插槽)
- [8.2 具名插槽](#8.2 具名插槽)
- [8.3 作用域插槽](#8.3 作用域插槽)
- 总结
零、导读与学习价值
0.1 项目功能清单
| 页面/功能 | 路由 | 核心技术点 |
|---|---|---|
| 添加商品 | / |
Vuex mutations、表单收集、路由跳转 |
| 商品列表 | /goodsList |
mapState、mapMutations、过滤器 |
| 购物车列表 | /cartList |
getters 总价计算、路由权限控制 |
| 个人中心 | /my |
actions 异步请求、keep-alive 缓存 |
| 登录页 | /login |
前置路由守卫、token 持久化 |
0.2 技术栈与架构总览

【代码注释】该图展示本应用的四大支柱:蓝色「根节点」是购物车应用本身;绿色「状态管理 Vuex」负责跨组件共享数据(三大模块 + 四个辅助函数);紫色「路由层 Vue Router」负责页面跳转与权限拦截;黄色「视图层」负责组件复用(插槽 / keep-alive / v-model);橙色「持久化」用 vuex-persistedstate 把状态写入 localStorage、用单独的 token 存储维持登录态。理解这张图的关键:四个支柱通过组件内的 this.$store 与 this.$router 两个全局入口协同------所有"数据问题"找 store,所有"跳转/权限问题"找 router。这种"职责按入口分流"的设计,正是中大型 Vue 工程能横向扩展的根基。
0.3 学习路径说明
本文按"架构 → 状态管理 → 业务功能 → 权限 → 优化 → 复用"的顺序展开,每个章节在前一章的基础上递进。建议先跟着整体架构图建立全貌,再逐章深入代码细节。
一、项目架构设计
名词解释
- SPA(Single Page Application):单页应用,所有页面在一个 HTML 内通过 JS 动态渲染,无需整页刷新。
- 嵌套路由 :路由配置中父级路由包含
children数组,父组件通过<router-view>渲染子路由对应的组件。 - 路由元信息(meta) :路由配置对象上的
meta字段,可存放任意自定义数据,常用于权限标记、页面标题等。 - 布局组件(Layout) :充当页面外框的组件,包含导航栏等公共 UI,内嵌
<router-view>渲染页面内容。
1.1 目录结构规划
src/
├── main.js # 入口:注册 store、router、filters
├── App.vue # 根组件:只放 <router-view>
├── router/
│ └── index.js # 路由配置、权限守卫、懒加载
├── store/
│ ├── index.js # 汇总模块,配置 plugins
│ ├── goods/index.js # 商品模块:goodsList、ADD_GOODS
│ ├── cart/index.js # 购物车模块:cartList、JOIN_CART、sumPrice
│ └── user/index.js # 用户模块:token、info、login/logout
├── views/
│ ├── Index.vue # 布局组件(导航 + router-view)
│ ├── AddGoods.vue # 添加商品页
│ ├── GoodsList.vue # 商品列表页
│ ├── CartList.vue # 购物车列表页
│ ├── My.vue # 个人中心页
│ └── Login.vue # 登录页
├── components/ # 通用组件(如 BookList 作用域插槽示例)
└── filters/index.js # 全局过滤器:date、currency
【代码注释】这是典型的 Vue2 CLI 脚手架目录结构。store/ 按业务域拆分成三个独立模块,每个模块只关心自己的数据,避免单一 store 文件随业务增长无限膨胀。views/ 存放页面级组件,components/ 存放可跨页面复用的组件。
1.2 路由层级设计

【代码注释】该图展示两层路由结构:根层级只有蓝色 /login 和紫色 /(布局层)两个入口;紫色 Index.vue 持有导航栏和子 <router-view>,所有业务页面(绿色 AddGoods/GoodsList、红色 CartList/My)作为它的 children 渲染在内层 <router-view>。红色节点标记 meta.isAuthor 为 true、需要登录的路由。这种"布局组件 + 嵌套子路由"的设计避免了导航栏在每个页面重复书写------导航只写在 Index.vue 一处,切换子路由时它保持挂载、不重新渲染。这是 Vue Router 嵌套路由的标准落地形态,几乎所有后台管理系统都采用同款骨架。
1.3 整体数据流

【代码注释】该图展示 Vuex 单向数据流的两条典型链路:异步链路(蓝色组件 → 橙色 Actions → 绿色 Mutations → 蓝色 State,如登录 dispatch('user/login') 后 commit('SET_TOKEN')),同步链路(组件直接 commit('JOIN_CART') → Mutations → State)。无论哪条链路,最终都收口到绿色 Mutations 这唯一的 state 修改入口,State 变更后经 Vue 响应式系统自动回流到组件视图。关键约束:异步操作(接口请求)必须放在 Actions,同步修改只能在 Mutations------Vuex 官方文档明确指出"更改状态的唯一方法是提交 mutation,且 mutation 必须是同步函数",因为 devtools 在每次 mutation 时拍下 state 快照实现时间旅行调试,异步若直接改 state 会让快照时间点无法确定。
【实战要点】
- 经典应用场景:大型电商应用(如京东商城移动端)的购物车数量角标、用户信息栏均依赖 Vuex 全局状态驱动,组件间无需 prop 传递就能共享。
- 常见坑 :直接修改
this.$store.state.xxx = yyy会触发响应式更新,但绕过了 devtools 追踪,导致状态变更不可调试。一旦项目出现数据异常,根本无法回溯。 - 性能与最佳实践 :频繁小量修改比一次性批量修改开销更大。把"批量操作"封装成一个 mutation,在函数内一次性完成多个字段更新,比在组件里多次
commit高效。
【面试考点】
Q:Vuex 中 mutations 和 actions 的区别是什么,为什么要区分?
A:mutations 是同步函数,是修改 state 的唯一合法途径;actions 可以包含任意异步操作(如 await axios.get()),但不能直接修改 state,必须通过 commit 调用 mutations。区分的原因:Vuex devtools 在每次 mutation 执行时拍下 state 快照,支持时间旅行调试。如果异步操作直接改 state,快照时间点无法确定,调试就失去意义。
【本章小结】
| 设计维度 | 关键决策 | 理由 |
|---|---|---|
| 目录组织 | 按业务域拆分 store | 模块内聚,团队分工清晰 |
| 路由结构 | 嵌套路由 + 布局组件 | 导航栏只写一次,代码复用 |
| 数据流 | 严格单向:组件→actions→mutations→state | 调试可追踪,状态变更可预期 |
记忆口诀 :「域拆 store、框套子由、流走单向 」------按业务域 拆 store,布局框 套住子路由 ,数据流永远单向不回头。
二、Vuex 模块化状态管理设计
名词解释
- state :存储应用共享数据的对象,相当于组件的
data,但作用于整个应用。 - mutations :同步修改 state 的方法集合,命名惯例全部大写加下划线(如
JOIN_CART)。 - actions :可包含异步逻辑的方法集合,通过
commit调用 mutations。 - getters:基于 state 计算衍生数据的函数集合,相当于 store 的计算属性,结果会被缓存。
- modules:将 store 拆分为独立模块,每个模块拥有自己的 state/mutations/actions/getters。
- namespaced :模块的命名空间开关,开启后该模块的 mutation/action/getter 需加
模块名/前缀访问。 - payload(荷载):commit/dispatch 传递的第二个参数,即携带给 mutation/action 的数据。
2.1 Vuex 核心概念
以下示例展示 Vuex 最基础的用法------将全局数字存入 store,并在任意组件读取。
js
// src/store/index.js(入门示例:最简 Vuex store)
import Vue from "vue";
import Vuex from "vuex";
Vue.use(Vuex);
const store = new Vuex.Store({
state: {
num: 0 // 共享数据
},
mutations: {
// 全大写命名是社区惯例,方便在 devtools 中一眼辨认
INCREMENT(state) {
state.num++;
}
}
});
export default store;
【代码注释】Vue.use(Vuex) 把 Vuex 作为插件安装到 Vue,之后每个组件实例都能通过 this.$store 访问 store。state 中的数据是响应式的------任何组件读取它,Vue 就建立依赖,state 变化时组件自动重渲染。mutations 中的 state 参数是当前最新的状态对象,直接修改它的属性即可触发更新。

【代码注释】这张图是 Vuex 工作原理的完整视图,可对照上一张数据流图理解四个角色的静态关系:蓝色 state 是单一数据源(single source of truth);绿色 mutations 是唯一合法的修改入口;橙色 actions 处理异步,最终仍通过 commit 走 mutations;紫色 getters 是只读的衍生数据(如总价),不修改 state 且带缓存。组件读取走 state/getters 两条只读边,写入走 mutations/actions 两条变更边------读写分离让数据流向永远单向、可预测。市面应用 :京东、美团等电商 App 的购物车角标、用户信息栏都依赖这套模型,组件间无需 prop 逐层传递就能共享全局状态(参考 Vuex State 设计实践)。
2.2 模块划分与抽离
当应用变大,把所有数据挤在一个 store/index.js 会让文件变成几百行的"巨无霸"。Vuex 的 modules 选项支持把 store 拆分成独立文件:
js
// src/store/goods/index.js(商品模块)
const state = {
// 商品列表:[{ id, goodsName, goodsPrice, addTime }]
goodsList: []
};
const mutations = {
// 添加商品:payload 包含 goodsName 和 goodsPrice
ADD_GOODS(state, { goodsName, goodsPrice }) {
state.goodsList = [
{
id: Date.now(), // 用时间戳作临时 ID(生产环境用后端返回的 ID)
goodsName,
goodsPrice: goodsPrice / 1, // 强制转为数字
addTime: Date.now()
},
...state.goodsList // 新商品插到列表头部
];
}
};
export default { state, mutations };
【代码注释】goodsPrice / 1 是一种简洁的字符串转数字技巧(等价于 Number(goodsPrice)),因为 input 的 value 始终是字符串,不转型会导致价格计算出错。...state.goodsList 展开旧数组,新数组引用发生变化,Vue 能检测到列表更新。
js
// src/store/cart/index.js(购物车模块)
const state = {
cartList: [] // [{ id, goodsName, goodsPrice, buyNum, ... }]
};
const mutations = {
JOIN_CART(state, payload) {
// 幂等处理:购物车已有该商品则增加数量,否则新增条目
const cartInfo = state.cartList.find(v => v.id === payload.id);
if (cartInfo) {
cartInfo.buyNum++; // 直接修改已有条目的数量
} else {
state.cartList = [
{ ...payload, buyNum: 1 }, // 首次加入,buyNum 初始化为 1
...state.cartList
];
}
}
};
const getters = {
// 总价:所有商品的 单价 × 数量 的加总
sumPrice(state) {
return state.cartList.reduce((sum, item) => {
return sum + item.buyNum * item.goodsPrice;
}, 0);
}
};
export default { state, mutations, getters };
【代码注释】JOIN_CART 设计了"幂等"逻辑:重复点击"加入购物车"不会产生重复条目,而是累加数量。reduce 是实现总价计算最语义化的数组方法,初始值 0 确保空购物车时返回 0 而非 undefined。
js
// src/store/user/index.js(用户模块)
import axios from "axios";
const state = {
token: localStorage.getItem("token"), // 初始化时从 localStorage 读取已有 token
info: {}
};
const mutations = {
SET_TOKEN(state, token) {
state.token = localStorage.token = token; // 同时更新内存和 localStorage
},
SET_INFO(state, info) {
state.info = info;
},
OUTLOG(state) {
localStorage.clear(); // 清除所有本地存储
state.token = null;
state.info = {};
}
};
const actions = {
// 登录:发请求 → 成功则存 token
async login({ commit }, body) {
const { data } = await axios.post(
"http://api.example.com/admin/acl/index/login",
body
);
if (data.code === 200) {
commit("SET_TOKEN", data.data);
} else {
alert("登录失败:" + JSON.stringify(data));
return new Promise(() => {}); // 返回永远 pending 的 Promise,阻断后续逻辑
}
},
// 获取用户信息
async getInfoAsync({ state, commit }) {
const { data } = await axios.get(
"http://api.example.com/admin/acl/index/info",
{ headers: { token: state.token } }
);
commit("SET_INFO", data.data);
},
// 退出登录
async postLogout({ commit }) {
await axios.post("http://api.example.com/admin/acl/index/logout");
commit("OUTLOG");
}
};
export default { state, mutations, actions };
【代码注释】return new Promise(() => {}) 是一个"停机"技巧:登录失败时不 resolve 也不 reject,调用方的 await 会一直等待,不执行后续的路由跳转。这比 throw new Error() 在界面上更柔和------调用方无需 try/catch,失败信息已通过 alert 展示。
js
// src/store/index.js(汇总模块)
import Vue from "vue";
import Vuex from "vuex";
import goods from "@/store/goods";
import cart from "@/store/cart";
import user from "@/store/user";
Vue.use(Vuex);
const store = new Vuex.Store({
modules: { goods, cart, user }
});
export default store;
【代码注释】根 store 文件的职责仅是汇总模块,业务逻辑各归其模块。访问模块数据的方式是 this.$store.state.模块名.数据,例如 this.$store.state.cart.cartList。
2.3 命名空间
未开启 namespaced 时,不同模块中同名的 mutation/action 会被全部触发------这是个隐患。开启命名空间可以隔离各模块:
js
// src/store/goods/index.js(开启命名空间)
export default {
namespaced: true, // 开启后:commit("goods/ADD_GOODS") 才能访问本模块
state,
mutations,
actions,
getters: {}
};
【代码注释】开启 namespaced: true 后,三种调用方式均需加模块前缀:
this.$store.commit("goods/ADD_GOODS", payload)this.$store.dispatch("user/login", body)this.$store.getters["cart/sumPrice"]
辅助函数的命名空间写法:mapMutations("goods", ["ADD_GOODS"]) 或 mapState("goods", ["goodsList"])。
2.4 辅助函数
Vuex 提供四个辅助函数,把冗长的 this.$store.state.xxx、this.$store.commit('XXX') 简化为普通属性/方法调用:
vue
<script>
// 实战示例:在组件中综合使用四个辅助函数
import { mapState, mapMutations, mapGetters, mapActions } from "vuex";
export default {
computed: {
// 从 goods 模块取 goodsList
...mapState("goods", ["goodsList"]),
// 从 cart 模块取 sumPrice getter
...mapGetters("cart", ["sumPrice"])
},
methods: {
// 映射 cart 模块的 JOIN_CART mutation
...mapMutations("cart", ["JOIN_CART"]),
// 映射 user 模块的 login action(重命名为 doLogin)
...mapActions("user", { doLogin: "login" })
}
};
</script>
【代码注释】mapState / mapGetters 展开到 computed;mapMutations / mapActions 展开到 methods。对象形式 { 本地名: "store中的名字" } 用于重命名,避免与组件自有方法冲突。开启命名空间后,第一个参数传入模块名字符串。
【实战要点】
- 经典应用场景 :美团外卖的购物车气泡角标数量(来自
cartList.length),以及顶栏的用户头像(来自user.info.avatar),都是 mapState 在生产环境的典型落地。 - 常见坑 :
mapState(["goodsList"])在未开启命名空间时直接映射根 state,模块内的同名 state 访问不到。需要用对象形式:mapState({ goodsList: state => state.goods.goodsList }),或开启命名空间后用mapState("goods", ["goodsList"])。 - 性能与最佳实践 :
mapGetters有缓存,只有依赖的 state 变化时才重新计算;mapState无缓存,每次读取都执行函数。总价这类聚合计算应用getters而非mapState。
【本章小结】
| 选项 | 调用方式(组件内) | 适合做什么 |
|---|---|---|
| state | $store.state.模块.key |
存储共享数据 |
| mutations | $store.commit("名字", payload) |
同步修改 state |
| actions | $store.dispatch("名字", payload) |
异步操作(接口请求) |
| getters | $store.getters["名字"] |
基于 state 的派生计算 |
记忆口诀 :「state 存、commit 改、dispatch 异、getter 算 」------四个角色四件事;改 state 走 commit(同步)、要异步走 dispatch、派生值找 getter。命名空间则记「开了空间加前缀,模块名当门牌号」。
【面试考点】
Q:Vuex 模块开启命名空间前后,commit/dispatch/getters 的访问方式有什么变化?
A:未开启 namespaced(默认 false)时,所有模块的 mutation/action/getter 都注册在全局命名空间,commit("ADD_GOODS") 会触发所有模块中同名的方法------这在多模块存在同名时会产生非预期行为。开启 namespaced: true 后,必须加模块路径前缀:commit("goods/ADD_GOODS")、dispatch("user/login")、getters["cart/sumPrice"]。辅助函数也需要加第一个参数:mapMutations("goods", ["ADD_GOODS"])。命名空间的本质是让模块成为真正的"黑盒",对外暴露的接口有明确的命名域,避免全局污染。
三、商品列表:数据获取与渲染
名词解释
- 过滤器(filter) :Vue2 特有的模板格式化工具,用管道符
|调用,如{``{ price | currency }}。Vue3 已移除,推荐用计算属性替代。 - ref :模板引用,
ref="xxx"后通过this.$refs.xxx获取 DOM 元素或子组件实例。 - padStart(2, 0) :字符串方法,在字符串左侧补位至指定长度,常用于日期格式化(
"9"→"09")。
3.1 商品添加
以下示例展示商品添加页如何收集表单数据并提交到 Vuex:
vue
<!-- src/views/AddGoods.vue(完整添加商品组件) -->
<template>
<div>
<p>商品名称:<input ref="goodsNameRef" type="text" /></p>
<p>商品价格:<input ref="goodsPriceRef" type="text" /></p>
<button @click="addGoods">提交</button>
</div>
</template>
<script>
import { mapMutations } from "vuex";
export default {
name: "AddGoods",
methods: {
// 将 goods 模块的 ADD_GOODS mutation 映射为本地方法
...mapMutations("goods", ["ADD_GOODS"]),
addGoods() {
// 鉴权:未登录时跳转登录页,并携带回调路径
if (!this.$store.state.user.token) {
this.$router.push({ path: "/login", query: { cb: this.$route.path } });
return;
}
const goodsName = this.$refs.goodsNameRef.value.trim();
const goodsPrice = this.$refs.goodsPriceRef.value.trim();
if (!goodsName || !goodsPrice) {
alert("商品名称或价格不允许为空!");
return;
}
// 调用已映射的 mutation 方法,等价于 this.$store.commit("goods/ADD_GOODS", {...})
this.ADD_GOODS({ goodsName, goodsPrice });
// 提交成功后跳转商品列表
this.$router.push("/goodsList");
}
}
};
</script>
【代码注释】ref 是 Vue2 中获取原生 input 值的常用手段------比 document.querySelector 更语义化,且不依赖 DOM 选择器字符串。this.$route.path 获取当前路由路径,存入 query 参数 cb,登录成功后可跳回原页面。这是 SPA 中实现"登录重定向"的标准模式。
3.2 商品列表渲染与过滤器
js
// src/filters/index.js(全局过滤器注册)
import Vue from "vue";
const filters = {
// 时间戳转格式化日期:1700000000000 → "2023-11-14 22:13:20"
date(t) {
const timer = new Date(t);
return (
timer.getFullYear() + "-" +
(timer.getMonth() + 1).toString().padStart(2, 0) + "-" +
timer.getDate().toString().padStart(2, 0) + " " +
timer.getHours().toString().padStart(2, 0) + ":" +
timer.getMinutes().toString().padStart(2, 0) + ":" +
timer.getSeconds().toString().padStart(2, 0)
);
},
// 金额格式化:99.9 → "$99.90"(支持自定义小数位和货币符号)
currency(v, n = 2, type = "$") {
return type + v.toFixed(n);
}
};
// 批量注册全局过滤器
for (let key in filters) {
Vue.filter(key, filters[key]);
}
【代码注释】padStart(2, 0) 中第二个参数 0 会被隐式转为字符串 "0",所以写 0 和写 "0" 效果相同。toFixed(n) 对浮点数保留 n 位小数并四舍五入,同时返回字符串类型。全局过滤器在 main.js 中 import "@/filters" 后即对所有模板生效。
vue
<!-- src/views/GoodsList.vue(商品列表完整组件) -->
<template>
<div>
<div v-for="item in goodsList" :key="item.id">
<p>商品名称:{{ item.goodsName }}</p>
<!-- 管道符调用过滤器,currency 默认保留2位小数、前缀$ -->
<p>商品价格:{{ item.goodsPrice | currency }}</p>
<p>上架时间:{{ item.addTime | date }}</p>
<button @click="joinCart(item)">加入购物车</button>
<hr />
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from "vuex";
export default {
name: "GoodsList",
computed: {
// 从 goods 模块映射 goodsList,等价于 this.$store.state.goods.goodsList
...mapState("goods", ["goodsList"])
},
methods: {
...mapMutations("cart", ["JOIN_CART"]),
joinCart(item) {
if (!localStorage.getItem("token")) {
this.$router.push({ path: "/login", query: { cb: this.$route.path } });
return;
}
this.JOIN_CART(item); // 将商品加入购物车
this.$router.push("/cartList"); // 跳转购物车列表
}
}
};
</script>
【代码注释】mapState("goods", ["goodsList"]) 展开后等价于一个计算属性 goodsList() { return this.$store.state.goods.goodsList }。这比在模板里写 $store.state.goods.goodsList 简洁很多,且当 state 变化时计算属性自动重新求值,驱动 v-for 重渲染列表。
3.3 actions 异步获取远端数据
当商品数据来自服务端而非本地添加时,需要用 actions 发起异步请求:
js
// src/store/goods/index.js(加入 actions 异步获取数据)
import axios from "axios";
const state = { goodsList: [] };
const mutations = {
UP_GOODS(state, list) {
state.goodsList = list;
}
};
const actions = {
// context 对象包含 commit/dispatch/state/getters
// payload 是组件传来的参数(如搜索关键词)
async fetchGoods({ commit }, keyword = "") {
const { data } = await axios.get(
`https://api.github.com/search/repositories?q=${keyword}&sort=stars`
);
// 异步完成后通过 commit 更新 state
commit("UP_GOODS", data.items);
}
};
export default { namespaced: true, state, mutations, actions };
【代码注释】actions 中的第一个参数 context 是一个简化版 store 对象,实战中通常解构取需要的部分 { commit, dispatch, state, getters }。async/await 比 .then() 链更易读,错误处理用 try/catch 包裹即可。关键点 :actions 不直接改 state,通过 commit 间接改------这保证了所有 state 变更都经过 mutations,devtools 能完整追踪。

【代码注释】该图按时序拆解一次异步取数:蓝色组件 dispatch('goods/fetchGoods', keyword) 触发橙色 action,action 调 axios.get 向灰色远端 API 取数,拿到 { items: [...] } 后 commit('UP_GOODS', items) 交给绿色 mutation,mutation 同步把 state.goodsList = items,State 更新驱动组件 v-for 重渲染。每一步职责边界清晰、没有跨越------action 绝不直接改 state,这正是 devtools 能完整追踪的前提。工程实践 :首页常需并行拉多个接口,可用 Promise.all([dispatch('fetchBanner'), dispatch('fetchGoods')]) 同时发起,比串行 await 快一个数量级。
【实战要点】
- 经典应用场景 :电商首页初始化时同时拉取轮播图、推荐商品、促销活动三个接口,可以用
Promise.all([dispatch("fetchBanner"), dispatch("fetchRecommend"), dispatch("fetchPromo")])并行发起。 - 常见坑 :在
v-for的:key上用数组下标index而非item.id,会导致删除中间项时后续项的 DOM 被错误复用,出现界面错位。务必用稳定唯一的 ID。 - 性能与最佳实践 :列表数据较多时,配合
v-show而非v-if做显示切换(避免反复销毁重建 DOM);如果列表超长,考虑虚拟滚动(如vue-virtual-scroller)。
【本章小结】
| 数据来源 | 写入方式 | 读取简写 |
|---|---|---|
| 组件本地添加 | commit("mutations名") |
mapMutations |
| 服务端异步获取 | dispatch("actions名") → action 内 commit |
mapActions |
| 读取列表 | $store.state.模块.列表 |
mapState |
记忆口诀 :「本地 commit 直改、远端 dispatch 转手 」------本地数据直接 commit 进 mutation;远端数据先 dispatch 给 action,由 action 拿到结果再 commit。一句话:异步永远先经 action 这道闸,绝不让接口请求直闯 mutation。
【面试考点】
Q:actions 中能否直接修改 state?如果能,为什么不推荐?
A:技术上可行------actions 的 context.state 就是当前 state 对象,直接赋值会触发响应式更新。但这绕过了 mutations,devtools 的"状态快照"功能就失效了:由于异步操作的时序不确定,devtools 无法在 action 完成时准确记录 state 变更。Vuex 的设计哲学是:mutations 是"受控的、可追踪的唯一修改入口",让 state 变更始终有迹可循。
四、购物车核心功能实现
名词解释
- 幂等(idempotent):多次执行同一操作,结果与执行一次相同。购物车中"加入已有商品"只增加数量而非重复添加,就是幂等设计。
- reduce:数组方法,从左到右累积执行回调,将数组归并为单一值。常用于求和、求最大值等。
- vuex-persistedstate:第三方 Vuex 插件,将指定 state 路径序列化存储到 localStorage,页面刷新后自动恢复。
4.1 加入购物车
购物车模块的核心 mutation JOIN_CART 已在第二章展示。这里重点说明幂等性的实现:
js
// 幂等判断逻辑(来自 cart/index.js)
JOIN_CART(state, payload) {
const cartInfo = state.cartList.find(v => v.id === payload.id);
if (cartInfo) {
// 商品已在购物车:只增加数量,不新增条目
cartInfo.buyNum++;
} else {
// 第一次加入:创建新条目,buyNum 初始化为 1
state.cartList = [
{ ...payload, buyNum: 1 },
...state.cartList
];
}
}
【代码注释】Array.find() 遍历数组找到第一个满足条件的元素并返回其引用。由于 Vuex state 中的对象是响应式的,cartInfo.buyNum++ 直接修改对象属性会触发视图更新------无需创建新对象。而当添加新商品时,使用展开运算符创建新数组([ 新商品, ...旧数组 ]),这样 Vue 能检测到数组引用变化。
4.2 购物车列表渲染
vue
<!-- src/views/CartList.vue(完整购物车列表组件) -->
<template>
<div>
<!-- 购物车非空时显示列表和合计 -->
<template v-if="$store.state.cart.cartList.length > 0">
<h3>合计:{{ sumPrice | currency }}</h3>
<div v-for="item in $store.state.cart.cartList" :key="item.id">
<p>商品名称:{{ item.goodsName }}</p>
<p>商品价格:{{ item.goodsPrice | currency }}</p>
<p>购买数量:{{ item.buyNum }}</p>
<hr />
</div>
</template>
<!-- 购物车为空时的占位提示 -->
<h3 v-else>购物车空空如也!</h3>
</div>
</template>
<script>
import { mapGetters } from "vuex";
export default {
name: "CartList",
computed: {
// 从 cart 模块映射 sumPrice getter,自动缓存
...mapGetters("cart", ["sumPrice"])
}
};
</script>
【代码注释】<template v-if> 是 Vue 的虚拟占位符,不渲染真实 DOM 节点,用于多个兄弟元素的条件渲染。mapGetters 把 getter 映射为计算属性,只有 cartList 变化时才重新计算 sumPrice,不会在每次渲染时重复执行 reduce。
4.3 总价计算
js
// src/store/cart/index.js --- getters 定义
const getters = {
sumPrice(state) {
return state.cartList.reduce((sum, item) => {
return sum + item.buyNum * item.goodsPrice;
}, 0);
}
};
【代码注释】reduce 的回调接收两个参数:sum(累加器)和 item(当前元素)。初始值 0 保证空数组时返回 0,而非 undefined。模板中用 | currency 过滤器格式化:sumPrice 是纯数字,格式化展示逻辑留给 filter 处理,符合关注点分离原则。

【代码注释】该图展示总价从原始数据到最终展示的完整链路:蓝色 cartList 原始数组 → 紫色 sumPrice getter 用 reduce 累加每项的 buyNum × goodsPrice → 黄色模板经 currency 过滤器格式化 → 绿色最终显示"299.00 元"。关键设计:总价是派生数据,必须用 getter 而非存进 state ------若在 state 里冗余存一个 total 字段,每次增删商品都要手动同步它,极易出现"数量改了总价没变"的脏数据。getter 承担"计算"、filter 承担"格式化",两者各司其职。getter 还自带缓存:只有 cartList 变化时才重新跑 reduce,纯展示重渲染不会重复计算(参考 Vuex Getters 文档,官方强调 getter 应保持纯函数,不在内部 fetch 或 mutate,才能让缓存与响应式正常工作)。
4.4 持久化
Vuex 数据默认存于内存,页面刷新后归零。vuex-persistedstate 插件通过 localStorage 解决这个问题:
js
// src/store/index.js(加入持久化插件)
import createPersistedState from "vuex-persistedstate";
const store = new Vuex.Store({
plugins: [
createPersistedState({
key: "shop-cache", // localStorage 中的键名
paths: ["goods", "cart.cartList"] // 只持久化 goods 模块全部数据和 cart 的 cartList
// user 模块的 token 已单独写入 localStorage,不需要纳入
})
],
modules: { goods, cart, user }
});
【代码注释】paths 支持 lodash 风格的路径字符串,"cart.cartList" 表示只序列化 cart 模块的 cartList 字段,而非整个 cart 模块(避免 getter 函数被序列化报错)。不传 paths 则序列化全部 state,通常比较危险------敏感数据(如 token)不应该通过此插件存储,因为它无法加密。
【实战要点】
- 经典应用场景 :电商购物车的商品数量徽章(购物车图标右上角的数字)可以直接用
cartList.length或专门的 getter 提供,登录/未登录均可读取。 - 常见坑 :
cartInfo.buyNum++直接修改嵌套对象属性,在 Vuex 中是合法且响应式的(因为 state 已经被 Vue.observable 处理)。但直接赋值数组的某个下标(如state.cartList[0] = newItem)不会触发响应式------这是 Vue2 的已知限制,需用Vue.set或替换整个数组。 - 性能与最佳实践 :购物车列表较长时,避免在 getter 里嵌套
map+filter,考虑将过滤结果缓存在 state 中,或用mapGetters保证 getter 的缓存机制生效。
【持久化的工程权衡与安全边界】
本节用 vuex-persistedstate 做持久化只是入门级方案,工程落地还有三个绕不开的取舍,逐一拆解(参考 vuex-persistedstate 官方 README 与 安全持久化实践):
-
存什么 vs 不存什么 :
localStorage是明文存储、且任何同源 JS 都能读取,因此只持久化非敏感的 UI 状态 (购物车cartList、筛选条件、主题偏好),绝不把 token 这类凭证用此插件存进去 。本文paths: ["goods", "cart.cartList"]刻意排除了 user 模块------token 单独走 localStorage,更严谨的做法是后端下发HttpOnly + Secure的 Cookie,让 JS 根本读不到 token,从源头堵死 XSS 窃取。 -
rehydration 时序陷阱 :插件在 store 创建时把 localStorage 的数据"回灌"(rehydrate)回 state。但若某组件的
mounted早于回灌完成执行,就会读到空 state------这是 SPA 常见的"刷新后短暂丢数据"bug。对策:插件提供fetchBeforeUse: true(使用前先从存储取数)和rehydrated(store)回调(回灌完成后再做依赖该数据的初始化),不要把"依赖持久化数据"的逻辑硬塞进mounted。 -
加密与库的维护现状 :若确有需求在 localStorage 存敏感信息,应配合
secure-ls(AES 加密)或自行用 crypto-js 加密、把密钥隔离在 Secure Cookie 中。另需知悉:vuex-persistedstate原仓库已停止维护,Vue3 新项目应迁移到 Pinia + pinia-plugin-persistedstate,API 更简洁且对 TypeScript 友好。
【本章小结】
| 功能点 | 实现方式 | 核心 API |
|---|---|---|
| 幂等添加 | find 判断 + 条件分支 | Array.find |
| 总价统计 | getters + reduce | Array.reduce |
| 数据持久化 | vuex-persistedstate 插件 | localStorage |
| 空购物车提示 | v-if / v-else | 模板指令 |
记忆口诀 :「find 防重、reduce 求和、paths 留白 」------加购先 find 防重复(幂等)、总价用 reduce 一把归并、持久化只挑非敏感字段(paths 排除 token)。改数组牢记「下标赋值不响应,splice/Vue.set 才管用」。
【面试考点】
Q:Vue2 中直接修改数组下标(arr0 = val)为什么不响应式?如何解决?
A:Vue2 的响应式系统基于 Object.defineProperty,它能拦截对象属性的 getter/setter。但直接修改数组下标是属性赋值,Vue2 没有对每个下标做 defineProperty 拦截(性能代价太高),所以无法检测到变化。解决方案:① Vue.set(arr, index, newVal);② arr.splice(index, 1, newVal)(Vue2 对 splice 等变更方法做了拦截);③ 替换整个数组 this.arr = [...this.arr]。购物车场景中,state.cartList = [newItem, ...state.cartList] 替换数组引用,就是方案③的体现。
五、路由权限控制
名词解释
- 全局前置守卫(beforeEach) :每次路由切换前执行的钩子,通过
next()决定是否放行。 - 全局后置守卫(afterEach):路由切换完成后执行的钩子,无法取消导航,常用于修改页面标题。
- 路由元信息(meta) :路由配置对象上自定义的附加数据,在守卫中通过
to.meta读取。 - next(路由对象) :在守卫中调用
next({ path: "/login" })可重定向到另一个路由。
5.1 路由元信息与全局前置守卫
js
// src/router/index.js(完整路由配置 + 守卫)
import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
// 路由懒加载(下一章详述)
const Login = () => import("@/views/Login");
const Index = () => import("@/views/Index");
const AddGoods = () => import("@/views/AddGoods");
const GoodsList = () => import("@/views/GoodsList");
const CartList = () => import("@/views/CartList");
const My = () => import("@/views/My");
const routes = [
{
path: "/login",
component: Login,
meta: { title: "登录" }
},
{
path: "/",
component: Index,
children: [
{
path: "/",
component: AddGoods,
meta: { title: "添加商品", isKeep: true }
},
{
path: "/goodsList",
component: GoodsList,
meta: { title: "商品列表" }
},
{
path: "/cartList",
component: CartList,
meta: { title: "购物车", isAuthor: true } // isAuthor: 需要登录
},
{
path: "/my",
component: My,
meta: { title: "个人中心", isAuthor: true, isKeep: true }
}
]
}
];
const router = new VueRouter({
mode: "history", // 使用 HTML5 History API,URL 无 #
routes,
linkActiveClass: "active" // router-link 激活时添加的 class
});
// 全局前置守卫:每次路由切换前执行
router.beforeEach(function(to, from, next) {
if (to.meta.isAuthor) {
// 需要权限的页面:检查 token
if (localStorage.getItem("token")) {
next(); // 已登录,放行
} else {
// 未登录,跳转登录页,携带当前路径作为回调参数
next({ path: "/login", query: { cb: to.path } });
}
} else {
next(); // 不需要权限,直接放行
}
});
// 全局后置守卫:路由切换完成后修改页面标题
router.afterEach(function(to) {
document.title = to.meta.title || "购物车系统";
});
export default router;
【代码注释】to.meta.isAuthor 是在路由配置里自定义的权限标记。前置守卫的三个参数:to(即将进入的路由)、from(当前路由)、next(放行函数)。next() 无参数表示正常放行;next({ path }) 表示重定向;next(false) 表示中断导航。to.path 作为 cb 参数传给登录页,让登录成功后能跳回原本想去的页面------这是 SPA 权限系统的标准 UX 模式。
5.2 登录流程与 token 管理
vue
<!-- src/views/Login.vue(登录表单组件) -->
<template>
<form name="loginForm" @submit.prevent="handleLogin">
<p>用户名:<input name="username" type="text" /></p>
<p>密 码:<input name="password" type="password" /></p>
<button type="submit">登录</button>
</form>
</template>
<script>
export default {
name: "Login",
methods: {
async handleLogin() {
const username = document.loginForm.username.value.trim();
const password = document.loginForm.password.value.trim();
// 通过 dispatch 调用 user 模块的 login action
// action 内部发请求、存 token、失败则挂起 Promise
await this.$store.dispatch("user/login", { username, password });
// 登录成功后跳回来源页(cb 参数),没有则去个人中心
this.$router.replace(this.$route.query.cb || "/my");
}
}
};
</script>
【代码注释】@submit.prevent 阻止了表单的默认提交行为(页面刷新),由 handleLogin 接管。document.loginForm 利用 form 标签的 name 属性,可以直接通过 document.表单名.字段名 访问表单控件------这是传统 DOM API,在 Vue 项目中不推荐(更规范的做法是 v-model + data),但在快速原型中简洁有效。$router.replace 而非 push,确保登录页不进入历史记录(否则用户登录后点浏览器后退会回到登录页)。

【代码注释】这张流程图完整呈现了权限拦截的闭环:用户访问 /cartList → 黄色判断节点 to.meta.isAuthor → 有 token 则绿色 next() 放行,无 token 则红色 next('/login?cb=/cartList') 重定向;登录页提交后 dispatch('user/login'),接口返回 200 则绿色 SET_TOKEN + router.replace 跳回原页面,失败则红色 alert 留在登录页。每个决策节点都有明确的成功(绿色)和失败(红色)路径。设计精髓在 cb 参数:把"用户原本想去哪"暂存进 query,登录成功后用 replace(而非 push)跳回,既还原了用户意图、又不让登录页进历史栈污染后退。这是 SPA 权限系统的工业标准 UX,vue-admin-template 等主流后台模板均采用同款。
【实战要点】
- 经典应用场景 :企业后台管理系统(如基于 vue-admin-template 的内部系统)普遍采用全局前置守卫实现权限控制,并结合路由
meta.roles字段做角色级精细控制。 - 常见坑 :
next()未加参数调用意味着"继续导航",如果在守卫中调用了next({ path: '/login' }),不要 在同一分支再调用next(),否则会触发无限重定向。 - 性能与最佳实践 :对于需要根据后端动态权限生成路由的场景(如不同角色看到不同菜单),使用
router.addRoutes()动态注册路由,而非在前置守卫中做全部判断。
【本章小结】
| 守卫类型 | 触发时机 | 常见用途 |
|---|---|---|
| beforeEach | 路由切换前 | 权限鉴定、重定向 |
| afterEach | 路由切换后 | 修改页面标题、上报 PV |
| beforeRouteLeave | 离开当前路由前 | 提示"是否保存草稿" |
记忆口诀 :「前守拦截、后守收尾、cb 找回路 」------beforeEach 在进门前查 meta.isAuthor 拦截,afterEach 在进门后改标题/埋点,重定向时把原路径塞进 cb 让登录后能跳回。再记一条铁律「每条分支必有且仅有一次 next()」,漏了导航就永久挂起。
【面试考点】
Q:Vue Router 全局守卫 beforeEach 中,如果忘记调用 next(),会发生什么?
A:导航会永久挂起,路由切换不会完成,页面保持在当前路由。这是最常见的守卫 bug------写了条件判断却漏掉了 else 分支的 next(),导致某些路径无法跳转。调试时可以在守卫末尾加一个 console.log("next called:", called) 变量追踪。最佳实践 :守卫的结构应该保证每个代码分支都有且仅有一次 next() 调用。
六、性能优化
名词解释
- keep-alive:Vue 内置组件,将其包裹的组件实例缓存在内存中,切换时不销毁,保留状态和 DOM。
- activated / deactivated:被 keep-alive 缓存的组件独有的生命周期,分别在"进入缓存"和"离开缓存"时触发。
- 路由懒加载:将路由对应的组件打包为独立 chunk,首屏只加载当前路由需要的 JS,其余路由按需加载。
- Magic Comment(魔法注释) :Webpack 特有的内联注释,
/* webpackChunkName:"xxx" */可指定 chunk 文件名,并将多个路由组件打包到同一个 chunk。
6.1 keep-alive 按需缓存
不是所有页面都适合缓存:个人中心每次进入都需要获取最新用户数据,不适合缓存;添加商品表单在操作过程中希望保留输入内容,适合缓存。通过路由元信息动态控制:
vue
<!-- src/views/Index.vue(布局组件,按路由 meta 控制缓存) -->
<template>
<div class="container">
<nav>
<router-link exact to="/">添加商品</router-link> |
<router-link to="/goodsList">商品列表</router-link> |
<router-link to="/cartList">购物车</router-link> |
<router-link to="/my">个人中心</router-link>
</nav>
<!-- 需要缓存的路由:渲染在 keep-alive 内的 router-view -->
<keep-alive>
<router-view v-if="$route.meta.isKeep"></router-view>
</keep-alive>
<!-- 不需要缓存的路由:普通 router-view -->
<router-view v-if="!$route.meta.isKeep"></router-view>
</div>
</template>
【代码注释】两个 <router-view> 通过 v-if="$route.meta.isKeep" 和 v-if="!$route.meta.isKeep" 互斥,确保当前路由只渲染其中一个。当路由的 meta.isKeep 为 true(如 AddGoods、My),组件渲染在 keep-alive 内,切换路由时不销毁,组件实例保留在内存中;其余路由的组件正常创建/销毁。
vue
<!-- 利用 activated 生命周期刷新数据 -->
<script>
export default {
name: "My",
// activated 在 keep-alive 激活时触发(相当于每次进入该路由时)
// 替代 mounted(mounted 被 keep-alive 缓存后只触发一次)
activated() {
this.$store.dispatch("user/getInfoAsync");
}
};
</script>
【代码注释】被 keep-alive 缓存的组件,mounted 只在第一次渲染时触发,之后切换回来不再触发。如果需要每次进入页面时刷新数据,应该用 activated 钩子------它在每次组件从缓存"激活"时触发,正好满足"每次进入都拉最新数据"的需求。

【代码注释】该图按时间顺序展示 keep-alive 组件的特殊生命周期:首次进入 My 时绿色「mounted + activated」都触发;离开切到 GoodsList 时组件不销毁,进入黄色「静默」状态(DOM 与数据都保留在内存),同时触发橙色 deactivated;再次进入时只触发绿色 activated、mounted 不再触发 。黄色节点是理解 keep-alive 的关键------组件没死,只是"睡着了"。这就解释了为什么"每次进入都要刷新的数据"(如个人中心拉最新用户信息)必须写在 activated 而非 mounted:mounted 被缓存后只跑一次,写在那里第二次进入就拿不到新数据了(参考 Vue keep-alive 文档)。
6.2 路由懒加载与 Magic Comment
js
// src/router/index.js(路由懒加载 + 分组打包)
// 不使用懒加载(所有组件打包进主 bundle,首屏加载时间长):
// import Login from "@/views/Login";
// import GoodsList from "@/views/GoodsList";
// 使用懒加载(各组件按需加载,减小首屏 bundle 体积):
const Login = () => import(/* webpackChunkName:"auth" */ "@/views/Login");
const Index = () => import(/* webpackChunkName:"layout" */ "@/views/Index");
// 相同 webpackChunkName 的组件打包到同一 chunk
// 商品相关页面合并为 goods chunk,一次加载即可
const AddGoods = () => import(/* webpackChunkName:"goods" */ "@/views/AddGoods");
const GoodsList = () => import(/* webpackChunkName:"goods" */ "@/views/GoodsList");
// 购物车和个人中心各自独立 chunk
const CartList = () => import("@/views/CartList");
const My = () => import("@/views/My");
【代码注释】箭头函数 () => import("...") 是 ES2020 动态导入语法,Webpack 在打包时识别它并生成独立 chunk 文件。/* webpackChunkName:"xxx" */ 是 Webpack 的 Magic Comment,相同 chunkName 的动态导入会被合并到一个 chunk 中------AddGoods 和 GoodsList 共用 goods chunk,首次访问任意一个页面时都会加载该 chunk,后续另一个页面无需再请求网络。

【代码注释】该图展示路由懒加载下的网络请求时序:用户访问 / 时浏览器只下载 main.js + 灰色 vendors 库,再按需动态加载紫色 layout chunk 和橙色 goods chunk(AddGoods 和 GoodsList 因共用 webpackChunkName:"goods" 被打进同一个 chunk);当访问 /goodsList 时,绿色节点表示该 chunk 已在前一步加载、直接命中缓存无需再请求;访问 /cartList 才触发橙色 cartlist chunk 的下载。橙色 = 真实发起网络请求,绿色 = 缓存命中零开销。这套机制把首屏 JS 体积从"全量打包的几 MB"压到"仅当前路由所需的几百 KB"------大型后台(50+ 路由)若不做懒加载,首屏白屏时间会成倍恶化(参考 Vue Router 路由懒加载)。
【实战要点】
- 经典应用场景:大型后台管理系统(如权限管理、数据报表平台)通常有 50+ 路由页面,全部打包进主 bundle 会让首屏超过 2MB。路由懒加载配合 Webpack 的代码分割,可将首屏体积压缩至 200KB 以内。
- 常见坑 :keep-alive 的
include属性匹配的是组件的name选项,不是路由的name。如果组件没有定义name,include控制将无效------所有被 keep-alive 包裹的组件都必须定义name。 - 性能与最佳实践:路由懒加载和 keep-alive 是互补的------懒加载减少首屏加载时间,keep-alive 减少重复渲染开销。对于高频切换的页面(如商品列表),两者结合使用效果最佳。
【本章小结】
| 优化手段 | 解决的问题 | 适用场景 |
|---|---|---|
| keep-alive | 避免重复渲染,保留组件状态 | 表单页、长列表页 |
| activated 钩子 | 缓存组件进入时刷新数据 | 需要每次获取最新数据的缓存页 |
| 路由懒加载 | 减小首屏 JS 体积 | 所有路由组件 |
| webpackChunkName | 合并相关组件到同一 chunk | 关联性强的多个页面 |
记忆口诀 :「缓存睡不死、激活拉数据、懒加载切包 」------keep-alive 让组件「睡着不销毁」,每次唤醒走 activated(别写 mounted),路由懒加载把首屏「切」成按需小包。chunk 分组记「同名 chunkName 打成一个包」。
【面试考点】
Q:keep-alive 缓存的组件,生命周期有什么变化?
A:正常组件的完整生命周期是 beforeCreate → created → beforeMount → mounted → beforeDestroy → destroyed。被 keep-alive 包裹后,首次进入仍会走完整生命周期;但此后切换离开时不再触发 beforeDestroy/destroyed,而是触发 deactivated(组件进入休眠);再次进入时不触发 beforeCreate 到 mounted,而是直接触发 activated。因此,如果需要每次进入都执行的逻辑(如接口请求),应放在 activated 而非 mounted,否则只会执行一次。
七、v-model 与 Vuex 的优雅集成
名词解释
- v-model :Vue 的双向数据绑定语法糖,等价于
:value="xxx" @input="xxx = $event.target.value"。 - 计算属性 setter :计算属性不仅可以有
get(读取),还可以有set(写入),将 v-model 的写操作重定向到 Vuex commit。 - v-model.number:修饰符,将 input 的 value 自动转为数字类型。
7.1 问题起源
v-model 直接绑定 Vuex state 会触发警告:
vue
<!-- 错误做法:直接 v-model 绑定 store state -->
<input v-model="$store.state.quantity" />
<!-- Vue 警告:Avoid mutating a Vuex store state outside of mutation handlers -->
【代码注释】v-model 在用户输入时执行 this.$store.state.quantity = newValue,这是直接修改 state,绕过了 mutations,违反 Vuex 单向数据流原则,控制台会报错。这个问题在"搜索框实时同步到 store"这类场景中非常常见。
7.2 计算属性 setter 方案
js
// src/store/index.js --- 添加支持 v-model 同步的 mutation
mutations: {
// 1. 添加专门用于 v-model 同步的 mutation
UP_QUANTITY(state, num) {
state.quantity = num;
}
}
【代码注释】这是计算属性 setter 方案的第一步------为待同步的 state 字段补一个专用 mutation UP_QUANTITY。它把"修改 state"这个动作收口成一个具名、可被 devtools 记录的合法入口,后续计算属性的 set 只需 commit 它即可。命名沿用全大写下划线惯例,一眼可辨"这是一次状态变更"。市面应用 :表单页常有十几个字段都要与全局 store 同步,工程上会用一个通用 mutation(如 SET_FIELD(state, { key, val }) { state[key] = val })替代为每个字段单写一个,配合工厂函数批量生成计算属性,避免样板代码爆炸。
vue
<!-- src/views/AddGoods.vue(v-model 与 Vuex 集成) -->
<template>
<div>
<!-- 2. 绑定到计算属性 qty,.number 修饰符确保得到数字 -->
<input v-model.number="qty" type="number" />
<p>当前值:{{ $store.state.quantity }}</p>
</div>
</template>
<script>
export default {
name: "AddGoods",
computed: {
qty: {
// getter:从 store 读取值
get() {
return this.$store.state.quantity;
},
// setter:用户输入时通过 mutation 更新 store
set(v) {
this.$store.commit("UP_QUANTITY", v);
}
}
}
};
</script>
【代码注释】计算属性的 get 函数负责从 store 读取最新值,set 函数负责将用户输入通过合法途径(commit mutation)写回 store。v-model 展开后是 :value="qty" + @input="qty = $event.target.value",两者分别触发 get 和 set,整个流程符合 Vuex 规范。这是 Vue 官方文档推荐的最佳实践方案。

【代码注释】这是一个完整的合规响应式闭环:用户在 input 输入 → 黄色 v-model 触发计算属性的 set(newValue) → 绿色 commit('UP_QUANTITY', newValue) 走合法的 mutation → 蓝色 state.quantity 更新 → 紫色计算属性 get() 重新求值 → input 显示最新值。注意绿色 commit 这一环------它正是与"直接 v-model 绑 state"的本质区别:后者会直接执行 state.xxx = val 绕过 mutation,导致 devtools 无法追踪、时间旅行失效。计算属性 get/set 方案让读(get 从 state 取)和写(set 经 commit 改)都走合法通道,是 Vuex 官方推荐的表单处理方式。
【实战要点】
- 经典应用场景 :搜索页的关键词输入框与 Vuex 中的
keyword状态同步;后台管理表单中的设置项与全局配置 store 同步。这两种场景都需要 v-model 与 Vuex 集成。 - 常见坑 :
v-model.number只在用户输入时生效。如果 store 中的初始值是字符串,get()返回字符串,input 显示正常,但计算时会变成字符串拼接而非数字相加。应在 mutation 中统一做类型转换:state.xxx = +num。 - 性能与最佳实践 :如果表单字段较多,每个字段都写一个计算属性会很冗余。可以封装一个工厂函数批量生成:
computed: createVuexFields(['field1', 'field2', 'field3'], 'moduleName')。
【本章小结】
| 方案 | 优点 | 缺点 |
|---|---|---|
| 直接 v-model 绑定 state | 最简单 | 违反 Vuex 规范,devtools 无法追踪 |
| 计算属性 get/set | 符合规范,完整追踪 | 每个字段需要写一个计算属性 |
| @input + commit | 灵活,可加额外逻辑 | 模板稍繁琐 |
记忆口诀 :「get 读、set 提、commit 改 」------计算属性 get 从 state 读、set 在用户输入时 commit 提交 mutation 改 state,读写都走合法通道。一句话点破本质:v-model 绑 state 是「越权直改」,绑计算属性才是「持证上岗」。
【面试考点】
Q:在 Vue2 中,为什么不能直接用 v-model 绑定 Vuex 的 state?正确做法是什么?
A:v-model 的 setter 会执行 this.$store.state.xxx = newVal,这是直接修改 state,绕过了 mutations。Vuex 的响应式会生效(视图更新),但 devtools 无法记录这次变更,时间旅行调试功能失效,团队协作时状态变更难以追溯。正确做法:在 computed 中定义带 get/set 的计算属性,get 从 state 读取,set 通过 commit 写入------这样 v-model 的读写都经过合法通道,devtools 能完整记录。
八、插槽体系
名词解释
- 插槽(slot):Vue 组件的"内容分发"机制,父组件可以向子组件内部插入自定义 HTML 内容,实现组件的灵活复用。
- 匿名插槽(默认插槽) :
<slot>无 name 属性,父组件中直接包裹的内容填充到此。 - 具名插槽 :
<slot name="xxx">有名字,父组件用v-slot:xxx或#xxx指定填充位置。 - 作用域插槽 :子组件通过
<slot :data="data">把自己的数据暴露给父组件,父组件在插槽模板中使用子组件数据。
8.1 匿名插槽
vue
<!-- src/components/BookList.vue(带匿名插槽的书单组件) -->
<template>
<div>
<!-- slot 占位符:父组件包裹的内容会渲染在这里 -->
<slot></slot>
<div v-for="item in bookList" :key="item.id">
<p>《{{ item.bookName }}》------ {{ item.author }}</p>
</div>
</div>
</template>
<script>
export default {
name: "BookList",
props: ["bookList"]
};
</script>
【代码注释】这是带匿名插槽的子组件 BookList:模板里的 <slot></slot> 是一个占位符,父组件包在 <BookList>...</BookList> 之间的内容会替换到这个位置;下方 v-for 是组件自身固定的列表渲染逻辑。子组件用 props: ["bookList"] 接收外部数据,自己只关心"怎么遍历渲染",把"标题展示成什么样"的决定权交给父组件。市面应用:列表/卡片类通用组件(如带标题的内容面板)普遍采用这种"骨架固定、头部可插"的设计,一处组件复用于多个业务场景。
vue
<!-- 父组件使用 BookList,传入不同标题 -->
<template>
<div>
<BookList :bookList="boyBooks">
<h3 style="color: blue;">男生爱看</h3>
</BookList>
<hr />
<BookList :bookList="girlBooks">
<h3 style="color: pink;">女生爱看</h3>
</BookList>
</div>
</template>
<script>
import BookList from "@/components/BookList";
export default {
components: { BookList },
data() {
return {
boyBooks: [
{ id: "1", bookName: "三国演义", author: "罗贯中" },
{ id: "2", bookName: "水浒传", author: "施耐庵" }
],
girlBooks: [
{ id: "3", bookName: "红楼梦", author: "曹雪芹" },
{ id: "4", bookName: "西游记", author: "吴承恩" }
]
};
}
};
</script>
【代码注释】匿名插槽让 BookList 组件在不同场景复用,父组件决定标题样式(蓝色/粉色),子组件负责列表渲染逻辑。这是"行为固定、外观可定制"的经典插槽用法。<slot> 标签会被替换为父组件传入的内容,如果父组件没有传内容,可以在 <slot> 内写默认内容。
8.2 具名插槽
当组件有多个插槽位置时,用具名插槽区分:
vue
<!-- 子组件:BookList.vue 使用具名插槽 -->
<template>
<div>
<slot name="header"></slot> <!-- 头部插槽 -->
<div v-for="item in bookList" :key="item.id">
<p>{{ item.bookName }}</p>
</div>
<slot name="footer"></slot> <!-- 底部插槽 -->
</div>
</template>
【代码注释】这是带具名插槽的子组件:通过给 <slot> 加 name="header" / name="footer",子组件声明了"头部"和"底部"两个独立的内容分发点,中间夹着自己固定的列表渲染。name 就像插槽的"地址",父组件按地址精准投递内容,互不串位。市面应用:典型如卡片组件(标题区 / 正文区 / 操作按钮区)、对话框组件(header / body / footer),具名插槽是组件库实现"多区域可定制"的标准手段。
vue
<!-- 父组件:用 v-slot:名字 或 # 简写填充具名插槽 -->
<BookList :bookList="boyBooks">
<!-- v-slot:header 的简写是 #header -->
<template #header>
<h3 style="color: blue;">男生书单</h3>
</template>
<template #footer>
<p>共 {{ boyBooks.length }} 本</p>
</template>
</BookList>
【代码注释】v-slot:header 等价于 #header(# 是 v-slot: 的简写)。<template #header> 是虚拟包裹,不渲染真实 DOM,只是告诉 Vue"这段内容填充到 header 插槽"。具名插槽解决了"同一组件有多个内容分发点"的问题,常见于卡片组件(标题/内容/底部)。
8.3 作用域插槽
作用域插槽允许子组件向父组件"反向传递数据",父组件的插槽模板可以访问子组件的数据:
vue
<!-- 子组件:通过 slot 的属性暴露内部数据 -->
<template>
<div>
<!-- :item="item" 把当前遍历的 item 暴露给父组件的插槽模板 -->
<div v-for="item in list" :key="item.id">
<slot :item="item" :index="list.indexOf(item)"></slot>
</div>
</div>
</template>
<script>
export default {
name: "SmartList",
props: ["list"]
};
</script>
【代码注释】这是作用域插槽的子组件 SmartList:关键在 <slot :item="item" :index="...">------子组件在遍历时把当前 item 和 index 作为属性"挂"到 slot 上,向父组件「反向」暴露内部数据。子组件掌控遍历(v-for),但每条数据具体渲染成什么样交给父组件决定。这与匿名/具名插槽的本质区别是:普通插槽只能"父传子",作用域插槽多了一条"子传父"的数据通道。市面应用 :这正是 <el-table>、<a-table> 这类表格组件的核心机制------组件控制行循环,使用者通过作用域插槽控制每个单元格怎么渲染。
vue
<!-- 父组件:通过 v-slot="slotProps" 接收子组件暴露的数据 -->
<SmartList :list="goodsList">
<!-- slotProps 包含 item 和 index -->
<template #default="slotProps">
<p>{{ slotProps.index + 1 }}. {{ slotProps.item.goodsName }}</p>
<span>单价:{{ slotProps.item.goodsPrice | currency }}</span>
</template>
</SmartList>
【代码注释】v-slot="slotProps" 接收一个对象,属性名对应子组件 <slot> 上的绑定。作用域插槽打破了"父组件只能传数据给子组件"的限制,实现了"子组件把数据传回来让父决定怎么展示"。这是 Vue 中最强大的插槽形式,Ant Design Vue、Element Plus 的 Table 组件自定义列渲染就大量使用作用域插槽。

【代码注释】该图揭示作用域插槽的双向通道本质:绿色父组件先把"插槽模板(展示逻辑)"传给蓝色子组件 SmartList;子组件在 v-for 每次迭代时通过紫色 slot :item='item' 把当前数据「向上」暴露;黄色 v-slot='slotProps' 让父组件接收数据并按自己的样式渲染;渲染结果再回到父组件呈现。这是"数据从子向父、模板从父向子"的精妙编排------子组件掌控遍历逻辑与数据,父组件掌控每条数据长什么样,两者通过插槽协议解耦。本质上作用域插槽是一个"以子组件数据为参数的渲染函数"。市面应用 :Element Plus 的 <el-table> 自定义列、Ant Design Vue 的 <a-table> 都是同款模式,这也是组件库能做到"列结构由使用者完全定制"的底层机制。
【实战要点】
- 经典应用场景 :Element Plus
<el-table>的自定义列(<template #default="scope">{``{ scope.row.name }}</template>)是作用域插槽的最经典生产级应用;Ant Design Vue 的<a-table>也是同样的模式。 - 常见坑 :混用具名插槽和作用域插槽时语法容易出错。正确写法:
v-slot:slotName="slotProps"或简写#slotName="slotProps"------名字和数据对象在同一个指令中指定。 - 性能与最佳实践 :作用域插槽的本质是函数(渲染函数),每次父组件渲染时都会调用。如果插槽内容复杂,可以把插槽内的组件提取出来并用
v-memo优化(Vue3),或在 Vue2 中配合functional组件减少开销。
【本章小结】
| 插槽类型 | 子组件写法 | 父组件写法 | 核心能力 |
|---|---|---|---|
| 匿名插槽 | <slot> |
<Child>内容</Child> |
内容替换 |
| 具名插槽 | <slot name="x"> |
#x="..." |
多占位点 |
| 作用域插槽 | <slot :data="d"> |
#default="slotProps" |
数据反传 |
记忆口诀 :「匿名一坑、具名分坑、作用域回传 」------匿名插槽一个坑位填内容,具名插槽按 name 分多个坑位,作用域插槽在坑位上「子把数据回传给父 」。一句话区分前两者与作用域:普通插槽只能父→子,作用域插槽多一条子→父的数据线。
【面试考点】
Q:作用域插槽和普通插槽有什么本质区别?应用场景是什么?
A:普通插槽(匿名/具名)只是"父→子"的内容注入------父组件决定插槽内容,但无法访问子组件的数据。作用域插槽则打通了"子→父"的数据通道:子组件在 <slot :key="value"> 上绑定属性,父组件在 v-slot="slotProps" 中接收这些属性。本质上,作用域插槽是一个"作用域函数"------父组件提供一个以子组件数据为参数的渲染函数,子组件在合适时机(如 v-for 每次迭代)调用它。典型应用:通用表格组件(子组件控制行循环,父组件控制每行的单元格渲染)、下拉选择器(子组件控制选项列表,父组件控制每个选项的展示形式)。
总结
架构回顾

【代码注释】这张图是整篇文章的知识结构总览,从蓝色「根节点」放射出五大支柱:灰色「项目架构」(目录结构 + 嵌套路由 + history 模式)、绿色「状态管理 Vuex」(三大模块 + 四大选项 + 命名空间)、黄色「业务功能」(商品管理的 ref 收集/v-for/过滤器、购物车的幂等 find/reduce/持久化)、红色「路由权限」(beforeEach + meta.isAuthor + token 鉴权 + cb 重定向)、紫色「性能优化与复用」(keep-alive/activated/懒加载/chunk 分组 + 插槽体系 + v-model 集成)。把它当作复习地图:每个支柱对应文中一章,从架构到功能、从优化到复用,完整呈现一个 Vue2 工程项目涉及的核心技术全景。
关键技术决策分析
| 技术决策 | 选择 | 理由 | 替代方案 |
|---|---|---|---|
| 状态管理 | Vuex 模块化 | 多页面共享数据,单一 store 会膨胀 | Pinia(Vue3 推荐) |
| 持久化 | vuex-persistedstate | 无需手动序列化,配置 paths 精确控制 | 手动在 mutation 中写 localStorage |
| 权限控制 | 路由元信息 + beforeEach | 声明式权限配置,守卫统一处理 | 在每个组件 mounted 中判断 |
| 缓存策略 | meta.isKeep + keep-alive | 灵活的页面级缓存,无需修改组件 | keep-alive include 字符串 |
| 懒加载分组 | webpackChunkName | 关联页面同一 chunk,减少请求数 | 每个路由独立 chunk |
| v-model + Vuex | 计算属性 get/set | Vue 官方推荐方案,符合数据流规范 | @input + commit(更冗长) |
高频面试题速查
-
Vuex 的单向数据流是什么?为什么不能直接修改 state?
mutations 是唯一合法的 state 修改入口,devtools 通过拦截 mutations 记录快照,实现时间旅行调试。直接修改 state 绕过了这一机制,调试困难。
-
actions 和 mutations 的区别?
mutations 必须同步;actions 可以异步,内部通过 commit 调用 mutations 修改 state。
-
Vuex 模块命名空间开启前后的访问方式变化?
开启后必须加前缀:
commit("goods/ADD_GOODS")、dispatch("user/login")、getters["cart/sumPrice"]。辅助函数第一个参数传模块名。 -
keep-alive 缓存的组件生命周期有哪些变化?
首次进入走完整生命周期;之后离开触发
deactivated(不触发beforeDestroy);再次进入触发activated(不触发mounted)。 -
路由懒加载如何实现?Magic Comment 有什么作用?
() => import("@/views/Xxx")实现懒加载;/* webpackChunkName:"xxx" */让多个路由组件合并到同一 chunk,减少 HTTP 请求数。 -
v-model 直接绑定 Vuex state 为什么不行?正确做法?
会绕过 mutations 直接修改 state。正确做法:计算属性
get()从 state 读,set(v)通过commit写。 -
作用域插槽和普通插槽的本质区别?
普通插槽是"父→子"的内容注入;作用域插槽在此基础上多了"子→父"的数据传递,父组件的插槽模板能访问子组件的数据。
-
Vue2 为什么不能直接通过下标修改数组?如何解决?
Object.defineProperty无法拦截下标赋值。解决:Vue.set(arr, index, val)、arr.splice(index, 1, val)或替换整个数组。
延伸思考
-
全选/反选的购物车实现 :需要一个"全选状态"的 getter(所有商品的
isChecked是否全为 true),以及一个批量修改isChecked的 mutation。结合计算属性 get/set 与 v-model,可以实现优雅的全选框双向绑定。 -
本地缓存安全性 :
vuex-persistedstate将数据以明文 JSON 存入 localStorage,敏感数据(如 token)不应通过此插件存储。token 应单独存储,并考虑设置过期时间、HTTPS 传输等安全措施。 -
从 Vuex 迁移到 Pinia:Vue3 生态中 Pinia 已成为官方推荐的状态管理库。它去除了 mutations,直接在 actions 中修改 state;支持 TypeScript 类型推断;体积更小(约 1KB)。了解两者的设计差异,有助于理解状态管理的演进方向。
-
服务端渲染(SSR)与 Vuex :在 Nuxt.js 中,Vuex 需要用工厂函数模式(
export default () => new Vuex.Store({}))创建 store,避免多个请求共享同一个 store 实例导致的状态污染------这是 SSR 场景下最常见的 Vuex 坑之一。