Skip to content

创建全局过滤器与猜你喜欢(可以不练,但必须掌握)(Lesson16 1/3) ​

本页对应 Lesson16,共 5 段视频,约 1 小时 23 分。

学习目标 ​

  • 能够创建并注册全局过滤器,在任意组件模板中正确调用该过滤器完成数据格式化展示。
  • 能够基于接口返回数据实现“猜你喜欢”列表渲染,并说明至少两种换一换实现方案:一次性获取前端切换、按页码请求后端切换。
  • 能够实现“猜你喜欢”换一换逻辑,包括当前页/当前索引维护、边界判断、页码参数传递及列表更新。
  • 能够通过事件委托收集一、二、三级分类的名称、ID及分类级别,并携带参数跳转至搜索页,同时关闭分类下拉面板。
  • 能够收集搜索框关键词,判断关键词非空后携带查询参数跳转搜索页,并在页面刷新后从路由查询参数中回填搜索关键词。

知识点 ​

为什么需要全局过滤器 ​

为什么需要: 项目里日期、价格这类展示格式会反复出现。如果每个组件都手写一遍 new Date() 拼接、toFixed() 补小数,代码会重复,格式也容易不统一。转写稿里做排行、猜你喜欢价格时,直接用了 currency 过滤器验证效果,说明它的价值就是“一次定义,多处复用”。

怎么想: 不要先在模板里硬写格式化逻辑,而是先抽成函数。日期统一成 年-月-日 时:分:秒,价格统一成“符号 + 保留 n 位小数”。这些函数不依赖某个组件状态,适合做成全局过滤器。

怎么做: 在 src/filters/index.js 中默认导出一个对象,键名就是过滤器名。笔记代码如下:

js
export default {
	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);
	},
	currency(v,n=2,type="$"){
		return type+v.toFixed(n);
	}
}

易错点: getMonth() 从 0 开始,必须 +1;补零用 padStart(2, 0),不要手写一堆 if。currency 的 n 默认是 2,但传入 undefined 时默认值才生效,传 null 不会按 2 处理。

在入口注册全局过滤器 ​

为什么需要: 只写 filters/index.js 不会自动生效。Vue 需要知道“哪个名字对应哪个函数”,否则模板里写 | currency 会报找不到过滤器。转写稿里强调要把对象引入入口,再循环注册。

怎么想: 过滤器对象是“名字 → 函数”的映射,注册时遍历键名,把每个键和函数挂到 Vue.filter 上。这样组件里不用逐个 import。

怎么做: 在 src/index.js 中引入并循环注册,笔记代码如下:

js
import filters from "@/filters";
// 全局过滤器
for(let key in filters){
	Vue.filter(key,filters[key]);
}

易错点: 转写稿里的“报清款”就是 for...in 循环,不要写成 for...of 直接遍历对象。Vue.filter(key, filters[key]) 两个参数都不能少;如果项目入口实际是 main.js,就放到真实入口,不要机械照抄文件名。注册后组件模板可直接用 {{ price | currency }}。

在组件中使用并验证 ​

为什么需要: 注册完必须回到页面验证,否则不知道 key 是否写对、数据是否传对。转写稿中在排行价格处加上 currency 后看效果,确认“是不是就可以了”,这就是最小验证。

怎么想: 先拿一个确定有数字的字段试,比如价格;再拿时间字段试 date。模板里过滤器名要和对象键名完全一致。价格若要人民币符号,可传参 {{ price | currency(2, "¥") }},这符合 currency(v,n=2,type="$") 的定义。

怎么做: 在模板中写 {{ item.price | currency }} 或 {{ item.price | currency(2, "¥") }};日期写 {{ item.time | date }}。转写稿里还提到改完 vue.config.js 要重启,注意那是代理配置的调试,不是过滤器注册本身;过滤器没生效时先检查入口是否引入、循环是否执行、key 是否拼错。

易错点: 不要把“改 vue.config.js 要重启”和“改过滤器文件”混为一谈;转写稿里的“物业 copyer”应纠正为 vue.config.js。另外 toFixed 返回字符串,currency 里直接拼接符号没问题,但不要对非数字调用,否则会报错。

先明确“猜你喜欢”要练什么 ​

这一节的核心不是把页面抄一遍,而是掌握“换一换”背后的分页思路。转写稿里反复强调:可以不练,但必须掌握。因为只要理解两种方案,后面遇到推荐位、广告位、列表切换,都能迁移。

本节笔记未附图片,下面不虚构配图;代码按转写稿中的关键实现整理。

先设计接口数据,再写页面 ​

为什么需要:页面能不能顺利渲染,取决于接口返回的数据结构。转写稿说,猜你喜欢本质是新闻/商品列表,数组里每一项是一个对象,对象至少要有标题、类别、唯一标识、链接;商品列表还要有图片、价格、评论等。

怎么想:先问自己“我渲染时需要什么”,再反推接口字段。不要先写死 HTML 再硬套数据。

怎么做:数组包对象,例如每项有 id、title、category、link;商品则用 item.img、item.title、item.price 渲染。价格用全局过滤器保留两位小数并加人民币标识。

易错点:字段名前后不一致;v-for 不写唯一 key;把广告位接口和列表接口混在一起。转写稿特别提醒:这一块、下面一块、广告位是不同接口,不要合并理解。

创建全局过滤器 ​

为什么需要:价格、日期等展示格式会在多个组件复用,写在组件里重复。

怎么想:把过滤器函数集中到一个文件,导出对象,再在入口统一注册为全局过滤器。

怎么做:在 src 下新建过滤器文件,导出多个过滤器;在入口 main.js 引入后循环注册:

js
import filters from './filters'
for (let key in filters) {
  Vue.filter(key, filters[key])
}

易错点:只引入不注册;过滤器名字和模板里不一致;改完 vue.config.js 或入口配置后忘记重启服务。转写稿调试时就遇到“改了配置没生效”,重启后数据才出来。

方案一:后端分页,每次请求不同页 ​

为什么需要:数据量大时不能一次全拿,换一换就是切页码。

怎么想:维护 pageNum,点击“换一换”让页码加一;请求时把页码传给后端;超过总页数回到第一页。

怎么做:mounted 里请求第一页;点击事件里 pageNum++,再调请求方法;请求成功后更新 likeList。转写稿中后端一开始只返回 list,没有总页数,于是前端计算:

js
// 总页数 = 总条数 / 每页数量,向上取整

易错点:页码从 1 开始,别从 0 开始;总页数不是总条数;每次请求后要同时更新列表和总页数;代理要配 target 和 changeOrigin,路径前缀要按转写稿做 pathRewrite,否则跨域或 404。

方案二:一次性拿全部,前端切换显示 ​

为什么需要:数据量不大时,一次拿完,前端切换更快。

怎么想:把数据组织成二维数组,外层是“第几组”,内层是这一组商品;用 activeIndex 控制当前显示哪一组。

怎么做:activeIndex 默认 0,模板里用 v-show 或 v-if 判断当前组;点击“换一换”执行:

js
activeIndex++
if (activeIndex > list.length - 1) {
  activeIndex = 0
}

易错点:数组下标从 0 开始,判断越界要用 length - 1;只渲染时用下标做 key 可以,但不要对数组做频繁增删;v-show 和 v-if 别混用错。

分类与搜索关键词的收集 ​

为什么需要:搜索页要接收分类 ID、分类名称和关键词,才能调搜索接口。

怎么想:点击一级/二级/三级分类时,通过 data-* 拿到 ID、层级和名称;跳转搜索页时放进 query。搜索框用 ref 收集输入,有值才跳转。

怎么做:分类标签上写 data-id、data-level,点击事件里读 dataset;跳转用 this.$router.push({ path: '/search', query: { keyword, cid, level } })。刷新后从 this.$route.query.keyword 回填搜索框。

易错点:点击空白区域也触发事件,要先判断 id 是否存在;不同层级 ID 可能相同,必须带层级;关键词为空不要跳转;刷新后关键词丢失,要从 query 回填。

调试顺序别乱 ​

转写稿的调试过程很有代表性:先确认接口有数据,再看代理是否生效,再看模板是否渲染,最后处理换一换边界。改代理配置后必须重启;控制台多余输出用完就删;不要照着代码复制粘贴,先撇开代码复述流程,再自己敲。这样“换一换”无论用后端分页还是前端切换,你都能讲清楚为什么。

本节目标与数据契约 ​

为什么需要:前端要渲染“猜你喜欢”,必须先约定接口返回什么。否则组件里 v-for 不知道写 item.id 还是 item.goodsId。转写稿里反复强调:先想“后台返回的数据类型是什么”,再决定怎么渲染。

怎么想:列表数据应是数组,每项是一个对象;对象至少要有唯一标识、标题、图片、价格。分页还要有每页条数、页码、总页数。

怎么做:在 api/data/likeList.json 中准备数据:

json
[
  {
    "id": 1,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 2,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 3,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 4,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 5,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 6,
    "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
    "price": 89
  },
  {
    "id": 7,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  },
  {
    "id": 8,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  },
  {
    "id": 9,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  },
  {
    "id": 10,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  },
  {
    "id": 11,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  },
  {
    "id": 12,
    "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
    "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
    "price": 98
  }
]

易错点:JSON 最外层必须是数组;id 要唯一;字段名前后端必须一致;图片地址要能访问。

用 Express 写分页接口 ​

为什么需要:“换一换”本质是分页,不能让前端把 12 条全写死。转写稿说第一种方案是“第一次请求第一页,第二次请求第二页”,Node 接口负责按页码返回。

怎么想:接收 pageSize、pageNo,默认每页 6 条、第 1 页;用 slice 截取当前页;总页数用 Math.ceil(总条数 / pageSize)。

怎么做:

js
const express = require("express");
const likeData = require("./data/likeList.json");
const app = express();

app.get("/likeList", (req, res) => {
  let { pageSize = 6, pageNo = 1 } = req.query;
  pageNo = pageNo / 1;
  pageSize = pageSize / 1;
  res.json({
    ok: 1,
    msg: "success",
    likeList: likeData.slice((pageNo - 1) * pageSize, pageSize * pageNo),
    pageSum: Math.ceil(likeData.length / pageSize)
  });
});

app.listen(8082, () => {
  console.log("success");
});

易错点:req.query 拿到的是字符串,要 /1 转数字;slice 第二个参数是结束下标,不是条数;pageSum 必须返回,否则前端无法判断越界;端口别和前端冲突。

配置代理解决跨域 ​

为什么需要:前端开发服务器和 8082 接口不同源,直接请求会跨域。转写稿里说“不配置代理就直接跨域了”。

怎么想:给请求加 /my 前缀,代理转发到 http://zhangpeiyue.com:8082,再用 pathRewrite 把 /my 去掉。

怎么做:vue.config.js:

js
"/my": {
  target: "http://zhangpeiyue.com:8082",
  changeOrigin: true,
  pathRewrite: {
    "^/my": ""
  }
}

易错点:改完 vue.config.js 必须重启开发服务器,转写稿里报错就是“没有重启”;target 不要带 /my;前端请求写 /my/likeList。

Like 组件请求与换一换 ​

为什么需要:初次进入要显示第一页,点击“换一换”要切到下一页,并且到最后一页后回到第一页。

怎么想:data 保存 likeList、pageSum、pageNo;mounted 请求第一页;点击时先 pageNo++,若大于 pageSum 就重置为 1,再请求。

怎么做:

vue
<template>
  <div class="like">
    <div class="py-container">
      <div class="title">
        <h3 class="fl">猜你喜欢</h3>
        <a href="javascript:;" @click="getLikeList" class="fr tip changeBnt">换一换</a>
      </div>
      <div class="bd">
        <ul class="favourate">
          <li v-for="item in likeList" :key="item.id">
            <img :src="item.imgUrl" alt="商品图片" />
            <div class="like-text">
              <p>{{ item.title }}</p>
              <h3>{{ item.price | currency(2, "¥") }}</h3>
            </div>
          </li>
        </ul>
      </div>
    </div>
  </div>
</template>

<script>
import axios from "axios";
export default {
  name: "Like",
  data() {
    return {
      likeList: [],
      pageSum: 1,
      pageNo: 1
    };
  },
  methods: {
    getLikeList() {
      this.pageNo++;
      if (this.pageNo > this.pageSum) this.pageNo = 1;
      axios.get("/my/likeList?pageNo=" + this.pageNo).then(value => {
        console.log(value.data);
        this.likeList = value.data.likeList;
        this.pageSum = value.data.pageSum;
      });
    }
  },
  mounted() {
    axios.get("/my/likeList?pageNo=" + this.pageNo).then(value => {
      console.log(value.data);
      this.likeList = value.data.likeList;
      this.pageSum = value.data.pageSum;
    });
  }
};
</script>

易错点:先自增再判断;pageSum 初始给 1;mounted 和点击请求逻辑重复可抽方法但本节先保留;currency 过滤器要提前注册,否则价格不显示。

调试与两种方案 ​

怎么想:打开 Network 看 /my/likeList?pageNo=1、pageNo=2 的返回;如果 pageSum 缺失,服务端补 Math.ceil。转写稿还演示了第二种方案:Mock 一次性返回二维数组,前端用 activeIndex 切换显示;本节代码按 Node 分页实现。

易错点:改代理不重启不生效;页码从 1 开始;参数名 pageNo/pageSize 前后端一致;slice 边界算错会少一条或重复。

先想清楚接口要返回什么:二维数组 ​

为什么需要:做“猜你喜欢”时,后端接口还没就绪,前端不能干等;而且这一块要“换一换”,必须先把数据结构定下来,组件才能照着渲染。

怎么想:转写稿里反复强调——先想接口返回的数据类型。猜你喜欢是一屏一屏切换的,所以最外层是数组,数组里每一项代表“一屏”,每一项又是数组,里面才是商品对象。商品对象至少有 id、title、imgUrl、price 四个字段。

怎么做:在 src/mock/likeList.json 里放两组数据,每组 6 条:

json
[
    [
        {
            "id": 1,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        },
        {
            "id": 2,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        },
        {
            "id": 3,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        },
        {
            "id": 4,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        },
        {
            "id": 5,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        },
        {
            "id": 6,
            "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg",
            "price": 89
        }
    ],
    [
        {
            "id": 7,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        },
        {
            "id": 8,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        },
        {
            "id": 9,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        },
        {
            "id": 10,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        },
        {
            "id": 11,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        },
        {
            "id": 12,
            "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3",
            "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg",
            "price": 98
        }
    ]
]

易错点:JSON 里不能写注释、最后一项不能带逗号;两层数组的结构要和模板里的两层 v-for 对上,否则渲染不出来。

用 Mock 拦截请求 ​

为什么需要:不想启后端、也不想配代理,直接在前端把接口“造”出来,axios 照常发请求就能拿到数据。

怎么想:Mock.mock(url, method, 返回对象),url 必须和 axios 请求的地址一字不差;返回对象外层包 ok 和 data,data 才是 likeList。

怎么做:src/mock/index.js 里这样写,其中 likeList 要从 likeList.json 引入(转写稿里说“把你的这个真身文件引进来”):

js
// 猜你喜欢
Mock.mock("http://mock.com/product/likeList","get",{
	ok:200,
	data:likeList
})

易错点:method 写 "get" 小写;组件里取值是 value.data.data——第一个 data 是 axios 的响应体,第二个 data 才是 Mock 返回对象里的 data;改了 mock 文件或 vue.config.js 要重启 dev server,转写稿里就遇到“改了配置没重启,报错不生效”。

全局过滤器:价格显示成 ¥89.00 ​

为什么需要:接口给的是 89 这样的数字,页面上要显示 ¥89.00,每个组件都写一遍格式化太麻烦。

怎么想:转写稿视频1 的做法是把过滤器写成全局的:src/filters/index.js 导出一个对象,入口文件里遍历注册。

怎么做:模板里写 {{info.price | currency(2,"¥")}};入口文件里:

js
import filters from './filters'
for (let key in filters) {
  Vue.filter(key, filters[key])
}

易错点:全局过滤器要在 new Vue 之前注册;过滤器名要和模板里一致;currency 的第一个参数是保留小数位,第二个是货币符号。

组件里请求、渲染与“换一换” ​

为什么需要:数据有了,要让它在页面上按“一屏一屏”显示,并且点“换一换”能切换。

怎么想:mounted 里发一次请求,把二维数组存进 likeList;activeIndex 记录当前显示第几屏;外层 v-for 遍历 likeList,用 v-show="activeIndex===index" 控制哪一屏显示;内层 v-for 遍历每一屏的商品。换一换就是 activeIndex++,超过 length-1 就归零。

怎么做:

vue
<template>
    <div class="like">
        <div class="py-container">
            <div class="title">
                <h3 class="fl">猜你喜欢</h3>
                <a href="javascript:;" @click="changeActiveIndex" class="fr tip changeBnt">换一换</a>
            </div>
            <div class="bd">
                <ul v-show="activeIndex===index" v-for="(item,index) in likeList" :key="index" class="favourate">
                    <li v-for="info in item" :key="info.id">
                        <img :src="info.imgUrl" alt="商品图片" />
                        <div class="like-text">
                            <p>{{info.title}}</p>
                            <h3>{{info.price | currency(2,"¥")}}</h3>
                        </div>
                    </li>
                </ul>
            </div>
        </div>
    </div>
</template>

<script>
import axios from "axios";
export default {
    name: "Like",
    data(){
        return {
            likeList:[],
            activeIndex:0
        }
    },
    methods:{
        changeActiveIndex(){
            this.activeIndex++;
            if(this.activeIndex>this.likeList.length-1) this.activeIndex=0;
        }
    },
    mounted(){
        axios.get("http://mock.com/product/likeList").then(value=>{
            // console.log(value.data.data);
            this.likeList = value.data.data;
        })
    }
}
</script>

易错点:

  • axios 返回层级是 value.data.data,少一层就是 undefined。
  • 用 v-show 不用 v-if:切换频繁,v-show 只改 display,不销毁 DOM。
  • 越界判断写 > 而不是 >=,否则最后一屏会被跳过。
  • 外层 key 用 index(二维数组没有统一 id),内层 key 用 info.id。
  • 转写稿调试时出现“没有找到该个”,多半是方法名或组件名写错,或没重启;先在控制台看请求有没有被 Mock 拦截、Network 里返回结构对不对。

两种“换一换”方案的对比 ​

转写稿里带做了两遍:视频2 用 node 起接口 + 分页参数 + 代理,每次点换一换请求下一页,服务端用 Math.ceil(总条数/每页数量) 算总页数;视频3 用 mock 一次性返回全部数据,前端切换显示。

为什么本节用第二种:mock 做分页麻烦,数据量也小,一次性拿回来前端切最省事。

易错点:如果走分页方案,配了 devServer.proxy 后必须重启;pathRewrite 要把 /api 替换掉;页码从 1 开始还是从

需求:搜索页要拿到“分类名称 + 分级ID” ​

为什么需要:点击一级、二级、三级分类都要跳到搜索页,搜索页后续要根据分类筛选商品。只传一个 id 不够,因为一级、二级、三级分类的 ID 可能相同,搜索页还必须知道这个 ID 属于哪一级。
怎么想:在分类渲染时,把“分类 ID”和“分类等级”一起挂到 a 标签上;点击时统一收集,再通过路由 query 传给 /search。
怎么做:模板里给 a 增加 data-id、data-level,点击事件统一交给 goSearch。
易错点:不要只传 ID;不要用 innerHTML 取名称,转写稿里用的是 innerText.trim(),避免把空白带过去。

模板:渲染分类时埋入 data-id 与 data-level ​

为什么需要:后端返回的是三级分类树,c1/c2/c3 分别对应一、二、三级。渲染时顺手把 ID 和等级写进 DOM,点击时才能直接读。
怎么想:事件委托绑在外层 .all-sort-list2 上,点击任意 a 都能冒泡到外层;a 上写 data-level="1/2/3" 和 :data-id="cX.categoryId"。
怎么做:保留本节代码:

vue
<template>
    <div class="type-nav">
        <div class="container">
            <div class="nav-left" @mouseleave="categoryHide">
                <h2 class="all" @mouseenter="isShow=true">全部商品分类</h2>
                <div class="sort" v-show="isShow">
                    <div @click.prevent="goSearch" class="all-sort-list2">
                        <div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
                            <h3>
                                <a data-level="1" :data-id="c1.categoryId" href="">{{ c1.categoryName }}</a>
                            </h3>
                            <div class="item-list clearfix">
                                <div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
                                    <dl class="fore">
                                        <dt>
                                            <a data-level="2" :data-id="c2.categoryId" href="">{{ c2.categoryName }}</a>
                                        </dt>
                                        <dd>
                                            <em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
                                                <a data-level="3" :data-id="c3.categoryId" href="">{{ c3.categoryName }}</a>
                                            </em>
                                        </dd>
                                    </dl>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
            <nav class="nav">
                <a>服装城</a>
                <a>美妆馆</a>
                <a>尚品汇超市</a>
                <a>全球购</a>
                <a>闪购</a>
                <a>团购</a>
                <a>有趣</a>
                <a>秒杀</a>
            </nav>
        </div>
    </div>
</template>

<script>
export default {
    methods: {
        ...
        goSearch(e){
            // categoryName:分类的名称
            // category1Id:一级分类ID
            // category2Id:二级分类ID
            // category3Id:三级分类ID
            const {id,level} = e.target.dataset;
            if(id){
                // console.log("id",id);// id
                // console.log("level",level);// 等级
                // console.log("categoryName",e.target.innerText.trim());
                this.$router.push({
                    path:"/search",
                    query:{
                        ["category"+level+"Id"]:id,
                        categoryName:e.target.innerText.trim()
                    }
                });
                this.isShow = false;
            }

        }
    },
}
</script>

易错点:data-id 在 dataset 里会变成 id,data-level 会变成 level;dataset 取出来是字符串,后续拼 query 键名时正合适,但做数值比较要留意。

事件:用事件委托统一处理一二三级点击 ​

为什么需要:三级分类节点很多,给每个 a 单独绑事件既麻烦又浪费;点击空白区域也会触发外层事件,需要过滤。
怎么想:转写稿里调试时发现“点击空白区也会执行”,原因是事件委托绑在了 .all-sort-list2 上。解决办法是取 e.target.dataset,只有 id 存在才继续。
怎么做:goSearch(e) 中先 const {id,level} = e.target.dataset;,再 if(id){...}。点击 a 时 e.target 是 a,能拿到 data-id 和 data-level;点击空白时没有 id,直接不处理。
易错点:不要在外层直接取 dataset 后不判断;不要忘记 @click.prevent,否则 href="" 可能引起默认跳转;点击后要 this.isShow = false 隐藏菜单,转写稿里也强调点完分类后菜单应关闭。

传参:goSearch 组装 query 并跳转 ​

为什么需要:搜索页要知道“分类名”和“哪一级的哪个 ID”,所以 query 键名要按等级动态生成。
怎么想:一级传 category1Id,二级传 category2Id,三级传 category3Id,同时传 categoryName。
怎么做:用 ["category"+level+"Id"]:id 动态拼键名,再 this.$router.push({path:"/search",query:{...&#125;&#125;)。
易错点:键名拼错会导致搜索页取不到;categoryName 要用 e.target.innerText.trim();不要漏掉 level,否则无法区分同名 ID。

接收:Search 页面读取 $route.query ​

为什么需要:搜索页要验证参数是否成功传过来,后续接口也依赖这些 query。
怎么想:路由跳转后参数在 $route.query 中,模板里直接展示即可。
怎么做:保留本节代码:

vue
<template>
    <div>
        <h3>搜索界面,接收的参数:{{$route.query}}</h3>
    </div>
</template>

易错点:query 是字符串键值对;刷新页面后 query 仍在,但组件内状态会重置;调试时先打印 $route.query,确认 categoryName 和 categoryXId 是否同时存在。

调试与易错点汇总 ​

为什么需要:本节最容易出错的是“点击空白也跳转”“三级 ID 相同但等级没带”“菜单没隐藏”。
怎么想:转写稿的调试顺序是先输出 dataset,确认 id/level;再判断 if(id) 过滤空白;最后检查跳转后 isShow=false。
怎么做:先点一级、二级、三级各一次,看地址栏 query 是否为 category1Id/category2Id/category3Id;再点空白区域,确认不跳转;最后看搜索页是否显示 $route.query。
易错点:dataset 属性名是驼峰;innerText.trim() 不能省;一级、二级、三级 ID 可能重复,必须靠 level 区分;跳转后不隐藏菜单会遮挡页面。

需求:为什么要在 Header 收集搜索关键词 ​

搜索框在 Header 组件里,而搜索结果页是 /search。用户点击“搜索”后,搜索页需要知道“搜什么”,所以 Header 必须把输入框内容收集起来,并通过路由参数传给搜索页。转写稿里反复强调:先别急着跳转,先确认能不能拿到输入框的值,再判断有没有值,最后才携带 keyword 跳转。

结构:搜索表单与输入框 ​

搜索区域是一个 form,输入框用 ref="keyword" 标记,按钮用 @click="goSearch" 绑定点击事件。这里用 ref 而不是双向绑定,是因为输入内容不需要实时驱动视图,只在点击搜索时读取一次即可。button 写成 type="button",避免触发表单默认提交导致页面刷新;form 加 autocomplete="off",避免浏览器自动提示干扰。

vue
<template>
    <div>
        <!-- 头部 -->
        <header class="header">
            <!-- 头部的第一行 -->
            <div class="top">
                <div class="container">
                    <div class="loginList">
                        <p>尚品汇欢迎您!</p>
                        <p>
                            <span>请</span>
                            <router-link to="/login">登录</router-link>
                            <router-link to="/register" class="register">免费注册</router-link>
                            <router-link to="/search" class="register">搜索</router-link>
                        </p>
                    </div>
                    <div class="typeList">
                        <a>我的订单</a>
                        <a>我的购物车</a>
                        <a>我的尚品汇</a>
                        <a>尚品汇会员</a>
                        <a>企业采购</a>
                        <a>关注尚品汇</a>
                        <a>合作招商</a>
                        <a>商家后台</a>
                    </div>
                </div>
            </div>
            <!--头部第二行 搜索区域-->
            <div class="bottom">
                <h1 class="logoArea">
                    <router-link to="/" class="logo" title="尚品汇">
                        <img src="./images/logo.png" alt="尚品汇Logo">
                    </router-link>
                </h1>
                <div class="searchArea">
                    <form autocomplete="off" class="searchForm">
                        <input placeholder="请输入搜索的关键词" type="text" ref="keyword" id="autocomplete" class="input-error input-xxlarge" />
                        <button @click="goSearch" class="sui-btn btn-xlarge btn-danger" type="button">搜索</button>
                    </form>
                </div>
            </div>
        </header>
        <TypeNav v-if="$route.meta.isTypeNav"></TypeNav>
    </div>
</template>

<script>
import TypeNav from "@/components/Header/TypeNav";
export default {
    name: "Header",
    components: {TypeNav},
    methods:{
        goSearch(){
            // 收集输入的关键词
            const keyword = this.$refs.keyword.value.trim();
            // 判断关键词是否有值,如果有值跳转至搜索界面,并将关键词进行传递
            if(keyword)
                this.$router.push({
                    path:"/search",
                    query:{
                        keyword
                    }
                });
        }
    },
    mounted(){
        // 判断查询字符串中是否有keyword,如果有将其值设置为搜索框的内容。
        if(this.$route.query.keyword)
            this.$refs.keyword.value = this.$route.query.keyword;
    }
}
</script>

实现:点击搜索时收集并跳转 ​

为什么需要 trim():用户可能输入前后空格,直接传过去会影响搜索。怎么想:先通过 this.$refs.keyword.value 拿到输入值,再用 trim() 去空格。怎么做:把结果存到 keyword,判断 if(keyword),有值才 this.$router.push。跳转时用 query 传参,路径是 /search,参数名是 keyword。易错点:$refs 名字必须和模板 ref="keyword" 一致;$router 是跳转对象,$route 是当前路由信息,不要写反;不判断空值会跳到空搜索页。

回填:刷新后保持搜索框内容 ​

为什么需要回填:跳转到 /search?keyword=xxx 后,如果刷新页面,组件重新 mounted,输入框会变空,体验不好。怎么想:刷新后地址栏 query 还在,可以从 this.$route.query.keyword 取。怎么做:在 mounted 中判断 query 是否有 keyword,有就赋给 this.$refs.keyword.value。易错点:这里读的是 $route.query,不是 $router;只在 mounted 回填,不要写成每次输入都改;如果 query 没有值就不赋值,避免把空值写进去。转写稿里的调试过程也提醒:先输出 keyword 确认拿到值,再测试“空内容不跳转、有内容跳转、刷新后仍有值”。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

① 全局过滤器(src/filters/index.js + 入口文件注册)

js
// src/filters/index.js
export default {
	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);
	},
	currency(v,n=2,type="$"){
		return type+v.toFixed(n);
	}
}
js
// src/index.js(脚手架入口,一般叫 src/main.js)
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import filters from "@/filters";

// 全局过滤器
for(let key in filters){
	Vue.filter(key,filters[key]);
}

new Vue({
	router,
	render: h => h(App)
}).$mount("#app");
vue
<!-- 任意组件中使用 -->
<template>
	<div>
		<p>{{ 1690000000000 | date }}</p>
		<h3>{{ 89 | currency(2,"¥") }}</h3>
	</div>
</template>

关键行与易错点

  • filters/index.js 默认导出的是一个对象,对象的 key 就是过滤器名字(date、currency),value 是函数,所以入口里可以 for...in 批量注册。
  • Vue.filter(key, filters[key]) 必须在 new Vue() 之前执行,否则模板里用不到。
  • getMonth() 从 0 开始,必须 +1;padStart(2, 0) 补零,第二个参数写 0 也能跑(内部会转字符串)。
  • currency 里 toFixed(n) 返回的是字符串,n 有默认值 2、type 默认 "$",调用时 {{item.price | currency(2,"¥")}} 会覆盖默认值。

② 猜你喜欢(node 接口 + 代理 + Like 组件)

js
// api/data/likeList.json(前 6 条同款,后 6 条同款,共 12 条)
[
	{ "id": 1, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 2, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 3, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 4, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 5, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 6, "title": "大希地牛排 母后恩点儿童牛排 整切牛排 果蔬腌制宝宝", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/192352/27/35184/246085/64b8f357Fba2db77b/18c1c62aa5a6ff1d.jpg", "price": 89 },
	{ "id": 7, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 },
	{ "id": 8, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 },
	{ "id": 9, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 },
	{ "id": 10, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 },
	{ "id": 11, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 },
	{ "id": 12, "title": "食者道鸡排 鸡胸肉鸡扒 低脂高蛋白 奥尔良黑椒孜然3", "imgUrl": "https://img10.360buyimg.com/n3/jfs/t1/91472/39/33627/443660/64bf3963F60ccd12b/42cb86098908ec3c.jpg", "price": 98 }
]
js
// api/server.js
const express = require("express");
const likeData = require("./data/likeList.json");
const app = express();

// 请求方式:get
// 请求地址:http://zhangpeiyue.com:8082/likeList
// 请求参数:pageSize 每页条数,pageNo 页码
// 响应结果:{ ok:1, msg:"success", likeList:[], pageSum:1 }
app.get("/likeList",(req,res)=>{
	let {pageSize=6,pageNo=1} = req.query;
	pageNo = pageNo/1;
	pageSize = pageSize/1;
	res.json({
		ok:1,
		msg:"success",
		likeList:likeData.slice((pageNo-1)*pageSize,pageSize*pageNo),
		pageSum:Math.ceil(likeData.length/pageSize)
	})
})

app.listen(8082,()=>{
	console.log("success");
})
js
// vue.config.js
module.exports = {
	devServer: {
		proxy: {
			"/my":{
				target:"http://zhangpeiyue.com:8082",
				changeOrigin:true,
				pathRewrite:{
					"^/my":""
				}
			}
		}
	}
}
vue
<!-- src/pages/Home/Like/index.vue -->
<template>
	<div class="like">
		<div class="py-container">
			<div class="title">
				<h3 class="fl">猜你喜欢</h3>
				<a href="javascript:;" @click="getLikeList" class="fr tip changeBnt">换一换</a>
			</div>
			<div class="bd">
				<ul class="favourate">
					<li v-for="item in likeList" :key="item.id">
						<img :src="item.imgUrl" alt="" />
						<div class="like-text">
							<p>{{item.title}}</p>
							<h3>{{item.price | currency(2,"¥")}}</h3>
						</div>
					</li>
				</ul>
			</div>
		</div>
	</div>
</template>

<script>
import axios from "axios";
export default {
	name: "Like",
	data(){
		return {
			likeList:[],
			pageSum:1,
			pageNo:1
		}
	},
	methods:{
		getLikeList(){
			this.pageNo++;
			if(this.pageNo>this.pageSum) this.pageNo=1;
			axios.get("/my/likeList?pageNo="+this.pageNo).then(value=>{
				this.likeList = value.data.likeList;
				this.pageSum = value.data.pageSum;
			})
		}
	},
	mounted(){
		axios.get("/my/likeList?pageNo="+this.pageNo).then(value=>{
			this.likeList = value.data.likeList;
			this.pageSum = value.data.pageSum;
		})
	}
}
</script>

关键行与易错点

  • req.query 里拿到的 pageNo/pageSize 都是字符串,必须 pageNo/1 转数字,否则 (pageNo-1)*pageSize 会变成字符串拼接。
  • 分页公式:slice((pageNo-1)*pageSize, pageSize*pageNo);总页数 Math.ceil(总条数/每页条数)——转写稿里就是因为服务端一开始没返回 pageSum,前端 this.pageNo>this.pageSum 永远为真,才会一直停在第二页。
  • 前端请求 /my/likeList,代理里 pathRewrite:{"^/my":""} 把前缀去掉,真正打到后端的是 /likeList;改完 vue.config.js 必须重启服务,不重启代理不生效(转写稿里第一次请求 404 就是这个原因)。
  • mounted 里首次用 pageNo=1;点击"换一换"先 pageNo++ 再判断是否越界归 1,然后重新请求。
  • 价格用全局过滤器 currency(2,"¥") 渲染,前提是①里已经注册过全局过滤器。

③ 分类跳转收集 ID + 收集搜索关键词(TypeNav / Search / Header)

vue
<!-- src/components/Header/TypeNav/index.vue -->
<template>
	<div class="type-nav">
		<div class="container">
			<div class="nav-left" @mouseleave="categoryHide">
				<h2 class="all" @mouseenter="isShow=true">全部商品分类</h2>
				<div class="sort" v-show="isShow">
					<div @click.prevent="goSearch" class="all-sort-list2">
						<div v-for="c1 in categoryList" :key="c1.categoryId" class="item">
							<h3>
								<a data-level="1" :data-id="c1.categoryId" href="">{{ c1.categoryName }}</a>
							</h3>
							<div class="item-list clearfix">
								<div v-for="c2 in c1.categoryChild" :key="c2.categoryId" class="subitem">
									<dl class="fore">
										<dt>
											<a data-level="2" :data-id="c2.categoryId" href="">{{ c2.categoryName }}</a>
										</dt>
										<dd>
											<em v-for="c3 in c2.categoryChild" :key="c3.categoryId">
												<a data-level="3" :data-id="c3.categoryId" href="">{{ c3.categoryName }}</a>
											</em>
										</dd>
									</dl>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>
			<nav class="nav">
				<a>服装城</a><a>美妆馆</a><a>尚品汇超市</a>
				<a>全球购</a><a>闪购</a><a>团购</a><a>有趣</a><a>秒杀</a>
			</nav>
		</div>
	</div>
</template>

<script>
export default {
	name: "TypeNav",
	data(){
		return {
			categoryList: [],
			isShow: false
		}
	},
	methods: {
		categoryHide(){
			this.isShow = false;
		},
		goSearch(e){
			// categoryName:分类的名称
			// category1Id:一级分类ID
			// category2Id:二级分类ID
			// category3Id:三级分类ID
			const {id,level} = e.target.dataset;
			if(id){
				this.$router.push({
					path:"/search",
					query:{
						["category"+level+"Id"]:id,
						categoryName:e.target.innerText.trim()
					}
				});
				this.isShow = false;
			}
		}
	}
}
</script>
vue
<!-- src/pages/Search/index.vue -->
<template>
	<div>
		<h3>搜索界面,接收的参数:{{$route.query}}</h3>
	</div>
</template>
vue
<!-- src/components/Header/index.vue(搜索区域部分) -->
<template>
	<div>
		<header class="header">
			<div class="bottom">
				<h1 class="logoArea">
					<router-link to="/" class="logo" title="尚品汇">
						<img src="./images/logo.png" alt="">
					</router-link>
				</h1>
				<div class="searchArea">
					<form autocomplete="off" class="searchForm">
						<input placeholder="请输入搜索的关键词" type="text" ref="keyword" id="autocomplete" class="input-error input-xxlarge" />
						<button @click="goSearch" class="sui-btn btn-xlarge btn-danger" type="button">搜索</button>
					</form>
				</div>
			</div>
		</header>
		<TypeNav v-if="$route.meta.isTypeNav"></TypeNav>
	</div>
</template>

<script>
import TypeNav from "@/components/Header/TypeNav";
export default {
	name: "Header",
	components: {TypeNav},
	methods:{
		goSearch(){
			// 收集输入的关键词
			const keyword = this.$refs.keyword.value.trim();
			// 判断关键词是否有值,如果有值跳转至搜索界面,并将关键词进行传递
			if(keyword)
				this.$router.push({
					path:"/search",
					query:{
						keyword
					}
				});
		}
	},
	mounted(){
		// 判断查询字符串中是否有keyword,如果有将其值设置为搜索框的内容。
		if(this.$route.query.keyword)
			this.$refs.keyword.value = this.$route.query.keyword;
	}
}
</script>

关键行与易错点

  • 用事件委托:@click.prevent="goSearch" 绑在最外层 div 上,点到任意 a 都会触发;@click.prevent 阻止 a 标签默认跳转。
  • 自定义属性 data-level、data-id,取值时是 e.target.dataset.level / e.target.dataset.id(去掉 data- 前缀,短横线转驼峰),不是 dataset["data-id"]。
  • 点击空白区域时 e.target 是 div,dataset.id 是 undefined,所以必须 if(id) 判断,否则会跳到一个 categoryundefinedId 的错误地址——转写稿里"点空白区也跳走"就是这个坑。
  • ["category"+level+"Id"]:id 是动态计算 key,level 是 1/2/3,最终拼成 category1Id / category2Id / category3Id,跟接口参数一一对应;level 是字符串,拼接没问题。
  • 跳转后 this.isShow = false 把下拉菜单收起(v-show 控制),否则会挡住页面。
  • 搜索关键词用 ref="keyword" 收集,this.$refs.keyword.value,只有 keyword 有值才 push,避免空关键词跳转。
  • 刷新页面关键词会丢(ref 值不会保留,keep-alive 也救不了刷新),所以在 mounted 里判断 this.$route.query.keyword 并回填到输入框。

常见错误 ​

  1. 现象:改完 vue.config.js 代理后,接口仍 404 或跨域。原因:devServer 未重启,或 pathRewrite 没把 /my 去掉。修法:停掉 npm run serve 重启;核对 target、changeOrigin、"^/my":""。
  2. 现象:模板使用 currency/date 报过滤器找不到。原因:filters/index.js 未在入口循环注册,或注册晚于 new Vue,或过滤器名不一致。修法:main.js 先引入,再 for...in 执行 Vue.filter(key, filters[key]),核对模板名。
  3. 现象:猜你喜欢点“换一换”只停在第二页或出现空白。原因:pageNo 自增后未与 pageSum 比较,或响应后未更新 pageSum。修法:响应后同步 pageSum;点击时先自增,再判断 pageNo > pageSum 归 1。
  4. 现象:点击分类容器空白区也跳搜索,或拿到 id 为 undefined。原因:事件委托未校验 dataset.id/level。修法:goSearch 先解构 id/level,id 不存在直接 return,只处理带 data-id 的 a。
  5. 现象:搜索页只收到 categoryId,拿不到 category1Id/2/3。原因:动态 query key 未用中括号,或 level 取错。修法:写成 ["category"+level+"Id"]: id,核对 data-level 是否为 1/2/3。
  6. 现象:搜索后刷新页面,输入框关键词丢失。原因:只在点击时从 ref 取值,未在 mounted 根据 $route.query.keyword 回填。修法:mounted 判断 this.$route.query.keyword,有值则赋给输入框。
  7. 现象:Mock 二维数组方案两页同时显示,或切换不循环。原因:未用 activeIndex 控制 v-show,或越界未归零。修法:外层 v-show="activeIndex===index";切换后 activeIndex++,大于 length-1 时归 0。

练习 ​

  1. 新增全局过滤器 phone,隐藏手机号中间四位,并在猜你喜欢或搜索页使用;先故意不注册验证报错,再修复。
  2. 将 /likeList 改为每页 4 条,补全 pageSum 与 pageNo 越界回第一页;前端连续点“换一换”至少两轮,记录每次请求参数。
  3. 在 TypeNav 中点击一/二/三级分类跳转 /search,query 同时包含 categoryName 和对应 categoryNId;点击空白区不跳转,跳转后下拉隐藏;在 Search 展示 query 验证。
  4. Header 搜索:空关键词不跳转;有值跳 /search?keyword=...;刷新后输入框回填;再输入新词提交时能覆盖旧关键词。

原视频对照 ​

#视频时长
1创建全局过滤器19:05
2完成猜你喜欢14:28
3完成猜你喜欢13:49
4获取分类的名字以及ID25:03
5收集搜索关键词310:49

小结 ​

  1. 全局过滤器应在入口统一注册,模板只负责调用;注册顺序和命名要先核对。
  2. “换一换”本质是分页,后端分页要形成 pageNo/pageSize/pageSum 闭环,前端切页要处理越界归零。
  3. 分类跳搜索必须同时带层级、ID、名称;动态 query key 是搜索页区分一/二/三级分类的依据。
  4. 事件委托必须先校验目标,空白点击不处理;跳转后顺手隐藏下拉。
  5. 搜索关键词要覆盖“提交跳转”和“刷新回填”两条路径。
  6. 代理、Mock、接口返回结构要在联调前核对;改 devServer 必须重启。
  7. 调试顺序先看请求响应,再看 data 更新,最后看模板渲染;先画数据流再写代码,避免照抄。