主题
创建全局过滤器与猜你喜欢(可以不练,但必须掌握)(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:{...}})。
易错点:键名拼错会导致搜索页取不到;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 确认拿到值,再测试“空内容不跳转、有内容跳转、刷新后仍有值”。
课堂画面





代码示例
① 全局过滤器(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并回填到输入框。
常见错误
- 现象:改完
vue.config.js代理后,接口仍 404 或跨域。原因:devServer 未重启,或pathRewrite没把/my去掉。修法:停掉npm run serve重启;核对target、changeOrigin、"^/my":""。 - 现象:模板使用
currency/date报过滤器找不到。原因:filters/index.js未在入口循环注册,或注册晚于new Vue,或过滤器名不一致。修法:main.js先引入,再for...in执行Vue.filter(key, filters[key]),核对模板名。 - 现象:猜你喜欢点“换一换”只停在第二页或出现空白。原因:
pageNo自增后未与pageSum比较,或响应后未更新pageSum。修法:响应后同步pageSum;点击时先自增,再判断pageNo > pageSum归 1。 - 现象:点击分类容器空白区也跳搜索,或拿到
id为undefined。原因:事件委托未校验dataset.id/level。修法:goSearch先解构id/level,id不存在直接return,只处理带data-id的a。 - 现象:搜索页只收到
categoryId,拿不到category1Id/2/3。原因:动态 query key 未用中括号,或level取错。修法:写成["category"+level+"Id"]: id,核对data-level是否为 1/2/3。 - 现象:搜索后刷新页面,输入框关键词丢失。原因:只在点击时从
ref取值,未在mounted根据$route.query.keyword回填。修法:mounted判断this.$route.query.keyword,有值则赋给输入框。 - 现象:Mock 二维数组方案两页同时显示,或切换不循环。原因:未用
activeIndex控制v-show,或越界未归零。修法:外层v-show="activeIndex===index";切换后activeIndex++,大于length-1时归 0。
练习
- 新增全局过滤器
phone,隐藏手机号中间四位,并在猜你喜欢或搜索页使用;先故意不注册验证报错,再修复。 - 将
/likeList改为每页 4 条,补全pageSum与pageNo越界回第一页;前端连续点“换一换”至少两轮,记录每次请求参数。 - 在 TypeNav 中点击一/二/三级分类跳转
/search,query 同时包含categoryName和对应categoryNId;点击空白区不跳转,跳转后下拉隐藏;在 Search 展示 query 验证。 - Header 搜索:空关键词不跳转;有值跳
/search?keyword=...;刷新后输入框回填;再输入新词提交时能覆盖旧关键词。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 创建全局过滤器 | 19:05 |
| 2 | 完成猜你喜欢 | 14:28 |
| 3 | 完成猜你喜欢 | 13:49 |
| 4 | 获取分类的名字以及ID | 25:03 |
| 5 | 收集搜索关键词3 | 10:49 |
小结
- 全局过滤器应在入口统一注册,模板只负责调用;注册顺序和命名要先核对。
- “换一换”本质是分页,后端分页要形成
pageNo/pageSize/pageSum闭环,前端切页要处理越界归零。 - 分类跳搜索必须同时带层级、ID、名称;动态 query key 是搜索页区分一/二/三级分类的依据。
- 事件委托必须先校验目标,空白点击不处理;跳转后顺手隐藏下拉。
- 搜索关键词要覆盖“提交跳转”和“刷新回填”两条路径。
- 代理、Mock、接口返回结构要在联调前核对;改 devServer 必须重启。
- 调试顺序先看请求响应,再看 data 更新,最后看模板渲染;先画数据流再写代码,避免照抄。