Vue2综合实战-购物车系统架构

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.$storethis.$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.xxxthis.$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 展开到 computedmapMutations / 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.jsimport "@/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安全持久化实践):

  1. 存什么 vs 不存什么localStorage 是明文存储、且任何同源 JS 都能读取,因此只持久化非敏感的 UI 状态 (购物车 cartList、筛选条件、主题偏好),绝不把 token 这类凭证用此插件存进去 。本文 paths: ["goods", "cart.cartList"] 刻意排除了 user 模块------token 单独走 localStorage,更严谨的做法是后端下发 HttpOnly + Secure 的 Cookie,让 JS 根本读不到 token,从源头堵死 XSS 窃取。

  2. rehydration 时序陷阱 :插件在 store 创建时把 localStorage 的数据"回灌"(rehydrate)回 state。但若某组件的 mounted 早于回灌完成执行,就会读到空 state------这是 SPA 常见的"刷新后短暂丢数据"bug。对策:插件提供 fetchBeforeUse: true(使用前先从存储取数)和 rehydrated(store) 回调(回灌完成后再做依赖该数据的初始化),不要把"依赖持久化数据"的逻辑硬塞进 mounted

  3. 加密与库的维护现状 :若确有需求在 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.isKeeptrue(如 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;再次进入时只触发绿色 activatedmounted 不再触发 。黄色节点是理解 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 中------AddGoodsGoodsList 共用 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。如果组件没有定义 nameinclude 控制将无效------所有被 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(组件进入休眠);再次进入时不触发 beforeCreatemounted,而是直接触发 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",两者分别触发 getset,整个流程符合 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="...">------子组件在遍历时把当前 itemindex 作为属性"挂"到 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(更冗长)

高频面试题速查

  1. Vuex 的单向数据流是什么?为什么不能直接修改 state?

    mutations 是唯一合法的 state 修改入口,devtools 通过拦截 mutations 记录快照,实现时间旅行调试。直接修改 state 绕过了这一机制,调试困难。

  2. actions 和 mutations 的区别?

    mutations 必须同步;actions 可以异步,内部通过 commit 调用 mutations 修改 state。

  3. Vuex 模块命名空间开启前后的访问方式变化?

    开启后必须加前缀:commit("goods/ADD_GOODS")dispatch("user/login")getters["cart/sumPrice"]。辅助函数第一个参数传模块名。

  4. keep-alive 缓存的组件生命周期有哪些变化?

    首次进入走完整生命周期;之后离开触发 deactivated(不触发 beforeDestroy);再次进入触发 activated(不触发 mounted)。

  5. 路由懒加载如何实现?Magic Comment 有什么作用?

    () => import("@/views/Xxx") 实现懒加载;/* webpackChunkName:"xxx" */ 让多个路由组件合并到同一 chunk,减少 HTTP 请求数。

  6. v-model 直接绑定 Vuex state 为什么不行?正确做法?

    会绕过 mutations 直接修改 state。正确做法:计算属性 get() 从 state 读,set(v) 通过 commit 写。

  7. 作用域插槽和普通插槽的本质区别?

    普通插槽是"父→子"的内容注入;作用域插槽在此基础上多了"子→父"的数据传递,父组件的插槽模板能访问子组件的数据。

  8. Vue2 为什么不能直接通过下标修改数组?如何解决?

    Object.defineProperty 无法拦截下标赋值。解决:Vue.set(arr, index, val)arr.splice(index, 1, val) 或替换整个数组。

延伸思考

  1. 全选/反选的购物车实现 :需要一个"全选状态"的 getter(所有商品的 isChecked 是否全为 true),以及一个批量修改 isChecked 的 mutation。结合计算属性 get/set 与 v-model,可以实现优雅的全选框双向绑定。

  2. 本地缓存安全性vuex-persistedstate 将数据以明文 JSON 存入 localStorage,敏感数据(如 token)不应通过此插件存储。token 应单独存储,并考虑设置过期时间、HTTPS 传输等安全措施。

  3. 从 Vuex 迁移到 Pinia:Vue3 生态中 Pinia 已成为官方推荐的状态管理库。它去除了 mutations,直接在 actions 中修改 state;支持 TypeScript 类型推断;体积更小(约 1KB)。了解两者的设计差异,有助于理解状态管理的演进方向。

  4. 服务端渲染(SSR)与 Vuex :在 Nuxt.js 中,Vuex 需要用工厂函数模式(export default () => new Vuex.Store({}))创建 store,避免多个请求共享同一个 store 实例导致的状态污染------这是 SSR 场景下最常见的 Vuex 坑之一。