主题
调用接口获取数据与渲染数据-商品列表(Lesson16 3/3)
本页对应 Lesson16,共 5 段视频,约 1 小时 19 分。
学习目标
- 能够根据接口返回的
productList数据,使用循环渲染商品图片、价格、标题和评论数,并处理空数据展示。 - 能够将搜索筛选区域抽取为
SearchSelector组件,并基于接口返回的品牌、规格数据完成品牌列表与规格筛选项的动态渲染。 - 能够实现面包屑中类别标签的添加与移除:移除类别时清理类别相关查询参数,同时保留搜索关键字。
- 能够实现面包屑中关键字标签的添加与移除:展示当前关键字,并在移除时仅清理关键字查询参数。
- 能够定位并修复查询参数合并导致的类别条件残留问题,确保多次搜索时只携带当前有效查询条件。
- 能够使用事件总线在离开搜索页或清空关键字标签时,通知搜索框同步清空内容。
知识点
从路由查询参数触发接口请求
为什么需要:搜索页的数据不是写死的,而是由地址栏里的查询参数决定。用户从首页点分类、输入关键词、切换筛选条件,本质上都在改变 $route.query,页面要据此重新请求商品列表。
怎么想:既然“查询参数变了就要取数”,最自然的是监听 $route.query,并且首次进入搜索页也要立刻请求一次,所以要用 immediate: true。
怎么做:本节笔记里的代码就是核心写法,保留如下:
src->pages->Search->index.vue
js<script> export default { name: "Search", watch:{ "$route.query":{ handler(query){ this.$store.dispatch("product/postProductListAsync",query) }, immediate:true } } } </script>
易错点:只监听而不加 immediate,首次进入页面不会请求;监听后把整个 query 原样合并到下一次搜索里,会把上一次的分类条件带进来,后面会专门处理。
商品列表渲染与静态结构迁移
为什么需要:接口返回数据后,必须把静态页面里的商品列表结构改造成可循环渲染的结构,否则页面只有“壳”。
怎么想:转写稿的思路是先把静态页主体部分拿过来,头部、底部、分类导航已经存在,只迁移商品列表主体;然后只保留一个商品项作为模板,用循环渲染真实数据。
怎么做:复制静态结构后,先检查样式和图片路径。转写稿中图片路径一开始用 ./ 出错,后来按当前文件位置改为向上级目录再进入资源目录。商品项里依次绑定图片、价格、标题、评论数等字段;价格使用文档给出的货币格式。
易错点:第一,复制静态页时容易把外层结构重复,出现两个同级容器;第二,图片相对路径不按当前组件位置计算会 404;第三,列表数据初始值如果是空对象,直接 filter 会报“找不到属性”,应初始化为空数组,或在过滤前做兜底。
查询参数合并导致的类别混乱
为什么需要:搜索页可能同时有分类、关键词等条件。如果每次搜索都把旧 query 合并进来,会出现“一级分类是手机、三级分类却是抽油烟机”这种互相矛盾的条件,接口自然查不到数据。
怎么想:转写稿的调试过程很典型:先看请求载荷,发现里面同时有多个层级的分类 ID;原因是之前为了保留关键词,把 $route.query 合并进了新查询。关键词要保留,但旧分类不能保留。
怎么做:搜索时不要整体合并旧 query,只把当前关键词合并进去;也就是构造查询时只带 keyword,不要带上次的 categoryId、categoryName 等分类字段。
易错点:不要为了“少写代码”直接合并整个查询对象;合并后看起来关键词还在,但旧分类也悄悄保留了,最终表现为“第一次能搜,第二次搜不到”。
SearchSelector与面包屑的渲染和移除
为什么需要:搜索页不仅要出商品,还要出品牌、属性筛选和已选条件标签。用户点叉号要能移除分类或关键词。
怎么想:品牌、属性列表来自接口,属性往往是二维数组:外层是属性名,内层是属性值;面包屑的类别名来自查询参数里的分类名称,关键词来自 keyword。
怎么做:把筛选区域拆成 SearchSelector 组件,品牌没有图片时就只渲染名称;属性先循环外层行,再循环内层值。面包屑只保留一个 li 作为模板,有分类名才显示分类标签,有关键词才显示关键词标签。移除分类时,不能直接 delete this.$route.query 后立刻 push 同一个路由,因为引用地址没变,路由可能不跳转;应先复制一份 query,删掉分类相关字段,或者只保留 keyword 再跳转。移除关键词时,可让 query: { keyword: undefined }。
易错点:直接给空对象会连关键词一起清掉;删除后不复制 query,容易出现“代码执行了但页面没变化”。
用事件总线清空搜索框内容
为什么需要:面包屑里删掉关键词后,地址栏条件没了,但头部搜索框里的文字还在,界面状态不一致。
怎么想:搜索框在头部组件,删除动作在搜索页组件,跨组件通知适合用事件总线,避免在头部不断追加路由监听。
怎么做:在入口给 Vue 原型挂一个 $bus;头部组件在 mounted 中订阅“清空 keyword”的事件,收到后把搜索框绑定的值置空;搜索页在 beforeDestroy 离开页面时触发一次,在点击关键词标签的叉号、完成跳转后也触发一次。
易错点:事件总线要保证订阅和触发在同一个 Vue 实例上;不要只触发不订阅,也不要只订阅不触发。转写稿最后验证了两种场景:离开搜索页会清空,点击关键词叉号也会清空。
先修 TypeNav:别让旧分类查询混进新搜索
为什么需要:在 TypeNav 里点击分类跳转 /search 时,如果直接把 this.$route.query 整体合并进新 query,上一次搜索的分类条件会被保留。比如上次是 category1Id=100,这次点三级分类 id=3,实际会变成 category1Id=100&category3Id=3,相当于“手机里找抽油烟机”,自然搜不到数据。
怎么想:新搜索只应该带两类信息:当前点击的分类 category+level+Id,以及已有搜索关键词 keyword。不要再整体合并旧 query。
怎么做:从 e.target.dataset 取 id、level,只拼当前分类和 keyword:
js
goSearch(e){
const {id,level} = e.target.dataset;
if(id){
this.$router.push({
path:"/search",
query:{
["category"+level+"Id"]:id,
categoryName:e.target.innerText.trim(),
keyword:this.$route.query.keyword
}
});
this.isShow = false;
}
}易错点:level 和 id 来自 dataset,要注意命名;categoryName 要 trim();不要为了省事写 query: {...this.$route.query, ...},否则旧分类会残留。
把商品列表静态结构搬进 Search 页面
为什么需要:Search 页面已经有头部、侧边栏和底部,只缺主体商品列表。转写稿里说的“现静单页面”即静态页面,只需要把主体里的商品列表 ul 拿过来。
怎么想:先保留一个 li 作为模板,用 v-for 循环真实数据;图片、价格、标题、评价数分别绑定字段。数据来自 Vuex:$store.state.product.searchProductResult.goodsList。
怎么做:保留笔记中的结构:
vue
<ul class="yui3-g">
<li v-for="item in $store.state.product.searchProductResult.goodsList" class="yui3-u-1-5">
<div class="list-wrap">
<div class="p-img">
<a href="item.html" target="_blank"><img :src="item.defaultImg" /></a>
</div>
<div class="price">
<strong>
<em>¥</em>
<i>{{item.price}}</i>
</strong>
</div>
<div class="attr">
<a target="_blank" href="item.html" :title="item.title">{{item.title}}</a>
</div>
<div class="commit">
<i class="command">已有<span>{{item.hotScore}}</span>人评价</i>
</div>
<div class="operate">
<a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
<a href="javascript:void(0);" class="sui-btn btn-bordered">收藏</a>
</div>
</div>
</li>
</ul>易错点:不要把所有静态 li 都留下;先打开网络面板确认 goodsList 有数据,转写稿提到默认给 10 条,且不是所有类别都有数据,手机类数据较全。图片字段是 defaultImg,标题提示用 :title,评价数是 hotScore。
调试商品列表为空:先查 query 再查数据
为什么需要:页面没数据时不一定是模板错,可能是请求参数错。转写稿中先点手机 category1Id=2,再点三级分类,结果 query 同时保留一级和三级,后台查不到。
怎么想:先看 Network 里 product 请求的 query,再看响应 data 是否有 goodsList。若 query 里同时出现多个层级分类,就是 TypeNav 合并旧 query 的问题。
怎么做:按上面的 goSearch 改成只带当前分类和 keyword。修正后 category3Id=3 就只发三级分类。
易错点:删除 query 时不要直接赋空对象,否则会把 keyword 也丢掉;面包屑移除类别时要保留关键词,这是转写稿里反复调试的点。
SearchSelector 与面包屑的相邻提醒
为什么需要:商品列表渲染后,搜索页还要显示品牌、属性筛选和“全部结果”标签。转写稿把品牌区抽成 SearchSelector 组件,并用 v-for 渲染品牌;属性区是二维数组,外层决定行数,内层决定每行选项。
怎么想:品牌列表从搜索结果里取,属性列表也来自搜索结果;类别标签用 $route.query.categoryName 渲染,关键词标签用 query.keyword 渲染。
怎么做:类别标签加 v-if 判断 categoryName 是否存在;点叉移除类别时,复制 query、删除分类相关键、保留 keyword 后再 push。关键词标签关闭时只把 keyword 置空。
易错点:直接改 this.$route.query 的引用地址不变,路由可能不跳转;直接传空对象会丢关键词。转写稿还提到品牌过滤前要给初始空数组,否则 filter 会因 undefined 报错。
用事件总线清空搜索框
为什么需要:在搜索页点叉清空关键词后,Header 里的搜索框内容还留着,因为两个组件不直接关联。
怎么想:用事件总线:在入口给 Vue.prototype.$bus = new Vue(),Header 中订阅“清空 keyword”,Search 在组件销毁前或点叉后触发。
怎么做:Header 里 this.$bus.$on('clearKeyword', () => { this.$refs.keyword.value = '' });Search 里 this.$bus.$emit('clearKeyword')。触发时机可放在 beforeDestroy 和关键词移除方法中。
易错点:事件名要一致;$bus 必须是同一个 Vue 实例;不要在组件销毁后还触发订阅逻辑。这样商品列表、筛选标签和搜索框状态才能保持一致。
本节笔记里的组件名是 SearchSelector(转写稿中出现的“SeachSelector”是笔误)。转写稿里说的“现静单页面”即静态页面,“SS”多指 src,“配置”多指 components,理解时按项目目录纠正即可。下面按“为什么需要 → 怎么想 → 怎么做 → 易错点”展开。
1. 为什么把筛选区拆成 SearchSelector
为什么需要:搜索页主体已经有商品列表,而品牌、属性筛选区结构独立、数据又来自 Vuex,如果全写在 Search/index.vue 里,模板会越来越长,后续做品牌点击、属性点击也不方便维护。
怎么想:先拿静态页面里的筛选块,确认它只负责“展示品牌 + 展示属性”,不负责发请求;数据已经在 product 模块的 searchProductResult 里,所以不必通过 props 一层层传,直接在子组件里 mapState 取。
怎么做:在 src/pages/Search/SearchSelector/index.js 建组件,复制筛选区模板,补 name,再用 mapState("product", ...) 映射数据;父组件 Search/index.vue 引入并注册后使用 <SearchSelector/>。
易错点:组件目录、引入路径要写对;name 不要和父组件重名;命名空间字符串必须是 "product",否则取不到数据。
2. 用 mapState 从 product 模块取筛选数据
为什么需要:筛选数据不是 SearchSelector 自己请求的,而是搜索请求回来后存进 Vuex 的,直接映射可以保持单一数据源。
怎么想:searchProductResult 下有 trademarkList(品牌)和 attrsList(属性)。品牌要过滤空名称,属性直接返回。
怎么做:computed: mapState("product", { trademarkList(state){...}, attrsList(state){...} })。品牌用 filter(item => item.tmName),只保留有名字的品牌。
易错点:mapState 对象形式里每个函数都要 return;不要写成 state.product.searchProductResult,因为命名空间已经指定了 product。
3. 品牌列表渲染与空品牌过滤(含调试)
为什么需要:后台品牌数据里可能出现 tmName 为空的情况。转写稿里先直接渲染,发现品牌列表某一项没有名字,页面会出现空白 li,所以需要过滤。
怎么想:模板只负责循环 trademarkList,过滤放在 computed 里,模板保持干净。
怎么做:外层 v-for="item in trademarkList" :key="item.tmId",内容渲染 {{item.tmName}}。
调试过程:转写稿中加完 filter 后刷新报错,大意是 Cannot read property filter of undefined。原因是初始状态里 searchProductResult 还是空对象,trademarkList 为 undefined,不能直接 .filter。解决方式是在 product 模块里把 trademarkList 初始化为空数组(或在使用前加保护)。笔记最终代码直接 filter,说明前置状态已经保证它是数组。
易错点:filter 返回新数组,不会改原数据;key 用 tmId,不要用 index 代替唯一 id;空品牌过滤后仍要保证初始值是数组。
4. 属性列表的双层循环
为什么需要:attrsList 是二维结构:外层是属性名(如网络、屏幕),内层是属性值数组(如 4G、6G)。必须两层循环才能把“一行属性名 + 一行属性值”渲染出来。
怎么想:外层 v-for="item in attrsList" 渲染 .type-wrap,内层 v-for="(info,index) in item.attrValueList" 渲染 li > a。
怎么做:外层 :key="item.attrId",内层 :key="index",{{item.attrName}} 显示属性名,{{info}} 显示属性值。
易错点:内层不要写成 item in attrsList;attrValueList 可能为空,空数组不会渲染 li,这是正常的;内层 key 用 index 可接受,但值重复时要注意。
5. 完整代码与注册使用
笔记中的组件代码如下,保留原样:
js
<template>
<div class="clearfix selector">
<div class="type-wrap logo">
<div class="fl key brand">品牌</div>
<div class="value logos">
<ul class="logo-list">
<li v-for="item in trademarkList" :key="item.tmId">
{{item.tmName}}
</li>
</ul>
</div>
</div>
<div class="type-wrap" v-for="item in attrsList" :key="item.attrId">
<div class="fl key">{{item.attrName}}</div>
<div class="fl value">
<ul class="type-list">
<li v-for="(info,index) in item.attrValueList" :key="index">
<a>{{info}}</a>
</li>
</ul>
</div>
<div class="fl ext"></div>
</div>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: "SearchSelector",
computed:mapState("product",{
trademarkList(state){
return state.searchProductResult.trademarkList.filter(item=>item.tmName);
},
attrsList(state){
return state.searchProductResult.attrsList;
}
})
}
</script>注册使用时,在 Search/index.vue 中 import SearchSelector from "./SearchSelector",在 components 里注册,模板中写 <SearchSelector/>。样式类名 selector、type-wrap、logo-list、type-list 等要和静态页样式对应,否则结构对但样式会乱。
易错点:组件文件是 index.js 时,引入目录即可;模板最外层类名不要漏;如果样式里引用了图片,路径要按当前组件位置调整(转写稿中提过把 ./ 改成相对 src 的路径),但本节代码没有图片节点,不要额外加。
6. 易错点复盘
一是初始值问题:searchProductResult 初始为空对象时,trademarkList 为 undefined,filter 会报错,必须在 Vuex 中给数组初始值。二是命名空间:mapState("product", ...) 与模块注册名要一致。三是品牌空值:后台有空 tmName,前端用 filter 过滤,避免空白品牌。四是 key:品牌用 tmId,属性用 attrId,内层值用 index,不要全部用 index 造成更新错乱。五是组件拆分后,父页面只负责布局和商品列表,筛选展示交给 SearchSelector,职责清晰。
先定位:面包屑里的类别标签从哪里来
为什么需要:用户从首页点“手机”“台灯”等分类进入搜索页后,页面顶部要告诉用户“当前按哪个类别筛选”,并且允许一键取消。这个提示就是面包屑里的类别标签。
怎么想:类别信息不是组件自己存的,而是路由 query 带过来的。搜索页通过 $route.query.categoryName 拿到类别名称,通过 category1Id / category2Id / category3Id 拿到层级 ID。渲染时只判断 categoryName 是否存在,存在才显示标签,避免空标签。
怎么做:在 src/pages/Search/index.vue 的模板里保留一个 li,用 v-if 控制显示,名称取 $route.query.categoryName,右侧 × 绑定移除方法。笔记代码如下:
vue
<template>
<!-- 渲染类别名称 -->
<li v-if="$route.query.categoryName" class="with-x">
{{$route.query.categoryName}}<i @click="moveCategoryName">×</i>
</li>
</template>
<script>
import SearchSelector from "@/pages/Search/SearchSelector";
export default {
name: "Search",
components: {SearchSelector},
methods:{
// 移除类别标签
moveCategoryName(){
// 1- category3Id=249&categoryName=台灯
// const query = {...this.$route.query};
// delete query.categoryName;
// delete query.category3Id;
// delete query.category2Id;
// delete query.category1Id;
// // console.log(this.$route.query)
// this.$router.push({
// path:"/search",
// query
// })
// 2
const {keyword} = this.$route.query;
this.$router.push({
path:"/search",
query:{
keyword
}
})
}
},
}
</script>易错点:不要只删 categoryName 而留下 category1Id/2Id/3Id,否则地址栏还带着旧类别条件;也不要忘了 v-if,否则没有类别时也会出现一个空标签。
移除类别:两种改 query 的方案
为什么需要:点 × 不是简单隐藏标签,而是要让地址栏里的类别条件消失,并重新发起搜索请求。
怎么想:本质是“构造一个新的 query,再 this.$router.push({ path: '/search', query })”。转写稿里调试发现:如果直接 delete this.$route.query 上的属性再原样 push,路由可能认为地址没变,不跳转;因为对象引用地址没变。所以要先复制一份再删,或者干脆只保留关键字。
怎么做:方案一先 const query = {...this.$route.query},再删除 categoryName、category3Id、category2Id、category1Id,最后 push。方案二更简单:从原 query 中解构出 keyword,只把 keyword 放进新 query。这样类别相关参数全部清掉,关键字还保留。
易错点:直接传空对象会把用户正在搜的关键字也丢掉;方案二要意识到没有关键字时 keyword 是 undefined,地址栏可能不显示它,但类别已经被清掉。转写稿还提到旧逻辑曾把 this.$route.query 合并进新 query,导致上一次搜索的类别被保留,出现“手机 + 抽油烟机”这种类型混乱,所以这里要改成只保留当前需要的关键字。
关键字标签与事件总线:别让搜索框残留
为什么需要:除了类别,关键字也会形成面包屑标签。移除关键字后,地址栏条件清了,但 Header 里的搜索输入框可能还留着旧词,用户会以为没清干净。
怎么想:关键字标签同样用 v-if="$route.query.keyword" 渲染;点 × 时把 keyword 从 query 中移除。输入框的值是 Header 组件自己的数据,不会因为路由 query 变化自动清空,所以要用事件总线通知 Header。
怎么做:在入口创建全局总线,例如挂到 Vue.prototype.$bus;在 Header 的 mounted 里订阅“清空关键字”事件,收到后把输入框 ref 的值置空;在 Search 的移除关键字方法后触发一次,同时在 beforeDestroy 里也触发一次,解决“离开搜索页后输入框还残留”的情况。转写稿的调试顺序是:先移除关键字标签,发现地址栏没了但输入框还在;再补事件总线;最后分别验证“点 × 不移开页面”和“离开页面”两条路径。
易错点:事件总线必须是同一个 Vue 实例;订阅和触发的事件名要一致;只写 beforeDestroy 不够,因为点 × 时页面没销毁,还要在移除方法里主动触发。
调试顺序与检查清单
为什么需要:这类面包屑问题不是“写个标签”就完了,涉及路由 query、请求参数、组件本地状态三处。
怎么想:先看 Network 里搜索请求的 query 是否同时混入旧类别 ID;再看 $route.query 是否已经清掉;最后看 Header 输入框是否残留。
怎么做:按“渲染标签 → 点 × 改 query → 看地址栏 → 看请求参数 → 看输入框”的顺序排查。类别移除要删全 1/2/3 级 ID 和 name;关键字移除只动 keyword;保留当前需要的关键字;用 v-if 防止空标签。
易错点:只改显示不改 query;只改 query 不处理输入框;删除时漏掉某一级 ID;合并旧 query 导致上次筛选条件残留。
本节要解决的问题
搜索页的面包屑里要显示当前搜索关键字,并允许用户点“×”移除。这里有两个动作:一是把 $route.query.keyword 渲染到面包屑;二是点击“×”后修改路由 query,把 keyword 去掉。转写稿里还补了一个联动问题:关键字标签移除后,头部搜索框里的内容仍然存在,需要同步清空。下面按“为什么需要 → 怎么想 → 怎么做 → 易错点”拆开说明。
渲染关键字面包屑
为什么需要:用户从搜索框提交关键字后,地址栏里会有 keyword 查询参数。面包屑要明确告诉用户“当前搜的是什么”,并给出可移除入口。
怎么想:面包屑内容不要另存一份状态,直接读路由 query。有没有关键字,用 v-if 判断;显示文本用 $route.query.keyword。
怎么做:在 src->pages->Search->index.vue 的模板中保留一个 li,加上 with-x 类,内部放关键字文本和“×”图标:
vue
<template>
<!-- 关键词内容 -->
<li v-if="$route.query.keyword" class="with-x">
{{$route.query.keyword}}<i @click="moveKeyword">×</i>
</li>
</template>易错点:v-if 判断的是 $route.query.keyword,不是固定写死文本;点击事件绑在“×”上,不要绑到整个 li 上,否则点击文字也会触发移除。
移除关键字的两种写法
为什么需要:用户点“×”表示不再按这个关键字搜索。此时不能只把页面上的文字隐藏,而要改路由 query,让搜索条件和地址栏保持一致。
怎么想:移除就是构造一个新的 query,再 $router.push 到 /search。转写稿里给了两种方案:方案 1 直接把 keyword 设为 undefined;方案 2 先复制当前 query,再 delete query.keyword,最后 push 新对象。
怎么做:完整方法如下,笔记里保留了两种写法:
vue
<script>
import SearchSelector from "@/pages/Search/SearchSelector";
export default {
name: "Search",
components: {SearchSelector},
methods:{
moveKeyword(){
// 1
// this.$router.push({
// path:"/search",
// query:{
// keyword:undefined
// }
// })
// 2
const query = {...this.$route.query};
delete query.keyword;
this.$router.push({
path:"/search",
query
})
}
}
}
</script>调试过程要理解:转写稿里先尝试直接删除 this.$route.query.keyword,再 push 同一个 query,结果页面没有按预期跳转。原因是 query 对象的引用地址没变,路由系统认为地址没变化。后来改成先复制一份 query,再删除属性,再 push 新对象,才能稳定触发跳转。方案 1 的 keyword: undefined 也能达到去掉关键字的效果;方案 2 更直观,也方便保留其他 query 条件。
易错点:不要直接写一个空对象 query:{} 去 push。转写稿特别提醒:如果还有其他查询条件,直接清空会把它们一起丢掉。也不要只 delete 原对象后就 push 原对象,引用地址不变容易不跳转。
关闭后搜索框不同步:用事件总线清空
为什么需要:面包屑的“×”只改了路由,头部搜索框是另一个组件里的输入框,它不会自动清空。转写稿里发现:关键字标签删掉后,搜索框里还留着“黑”等内容,体验不一致。
怎么想:一开始容易想到在 header 里继续监听路由变化,但这样要监听的条件越来越多,程序会变麻烦。转写稿建议用全局事件总线:header 负责订阅“清空关键字”,Search 在合适时机触发通知。
怎么做:在入口文件给 Vue.prototype 增加一个 $bus(转写稿说“只要保证是同一个 Vue 实例即可”)。在 header 组件的 mounted 中订阅事件,收到后把输入框清空,例如 this.$refs.keyword.value = ''。在 Search 组件里有两个触发时机:一是 beforeDestroy 销毁之前,二是 moveKeyword 移除关键字之后,调用 this.$bus.$emit(...) 通知 header。
易错点:事件名要前后一致;$refs 名字要和模板里输入框的 ref 一致;不要只在“移除关键字”时触发,离开搜索页时也应清空;事件总线要挂在同一个 Vue 实例上,否则订阅和触发对不上。这样处理后,点“×”移除关键字,搜索框会同步清空;离开搜索页再回来,也不会残留旧关键字。
问题从哪来:路由清了,输入框为什么还在
搜索页里有两个“清空”场景:一是离开搜索页,二是点击关键字标签后面的叉。前者路由条件可能已经没了,后者 moveKeyword 里也会删掉 query.keyword 并跳转;但 Header 里的搜索框是另一个组件,它不会因为搜索页的 query 变了就自动清空。转写稿里说得很直白:再用路由监听也能做,但“太麻烦了”,要加监听、还要判断 keyword,越写越绕。所以本节改用事件总线:Header 负责“订阅清空消息”,Search 在合适时机“发布清空消息”。
怎么想:找一个所有组件都能访问的 Vue 实例作为总线;订阅者和发布者必须用同一个实例,否则 $emit 触发不了 $on。怎么做:先挂 $bus,再在 Header 订阅,最后在 Search 发布。易错点:不要把“清空路由 query”和“清空 input 的 value”当成同一件事,路由变了不等于输入框自动变。
建立全局事件总线:把 $bus 挂到 Vue 原型
为什么需要:Header 和 Search 不是父子关系,直接传 props 不方便;事件总线能让任意组件通信。怎么想:在根实例的 beforeCreate 里把当前 Vue 实例挂到 Vue.prototype.$bus,这样每个组件都能通过 this.$bus 拿到同一个实例。转写稿说“只要保证它是一个 Vue 实例,在同一个实例当中,就可以触发订阅者”。怎么做:修改 src/index.js:
js
import Vue from "vue";
import App from "@/App";
import router from "@/router";
import store from "@/store";
import filters from "@/filters";
import "@/mock";
import "@/assets/css/reset.css";
// 设置为 false 以阻止 vue 在启动时生成生产提示
Vue.config.productionTip = false;
new Vue({
el:"#app",
router,
store,
beforeCreate() {
// 全局过滤器
for(let key in filters){
Vue.filter(key,filters[key]);
}
// 全局事件总线1
Vue.prototype.$bus = this;
},
render:h=>h(App)
})易错点:不要在每个组件里 new Vue() 当总线,那样实例不统一;$bus 要在组件创建前挂好,beforeCreate 正合适;事件名后面必须前后一致。
Header 订阅:收到通知就清空输入框
为什么需要:输入框在 Header 里,只有 Header 能通过 this.$refs.keyword 改它的值。怎么想:在 mounted 后 DOM 已经可用,订阅 clearKeyword,回调里把输入框的值置空。怎么做:src/components/Header/index.vue:
js
mounted(){
this.$bus.$on("clearKeyword",()=>{
this.$refs.keyword.value = null;
})
}易错点:$refs.keyword 要确认绑定在原生 input 上;订阅事件名必须是 clearKeyword,不能写成 clearKeyWord;$on 只订阅一次即可,若组件反复创建要留意重复订阅问题。
Search 发布:两个时机都要通知
为什么需要:只监听离开页面不够,因为点击关键字标签的叉时页面没有离开,beforeDestroy 不会执行。怎么想:离开搜索页时在 beforeDestroy 发布;点击叉时在 moveKeyword 删除 query.keyword 并跳转后再发布。转写稿强调:点击叉后“条件没了,但输入框还在”,所以跳转后要再 $emit 一次。怎么做:src/pages/Search/index.vue:
js
// 1-销毁之前
beforeDestroy() {
this.$bus.$emit("clearKeyword");
}
// 2- 当清空关键字标签后
moveKeyword(){
const query = {...this.$route.query};
delete query.keyword;
this.$router.push({
path:"/search",
query
});
this.$bus.$emit("clearKeyword");
}易错点:不要直接改 this.$route.query,先 {...this.$route.query} 复制再 delete;$emit 的事件名要和 Header 的 $on 完全一致;如果只在 beforeDestroy 里发,点击叉不离开页面就不会清空,所以 moveKeyword 里必须单独发。
调试顺序:先看订阅,再看发布
为什么需要:事件总线出错时通常不是“没写”,而是实例、事件名、时机三者对不上。怎么想:先确认 $bus 是否挂上,再确认 Header 的 mounted 是否执行、$on 事件名是否一致,最后看 Search 的 beforeDestroy 或 moveKeyword 是否执行。转写稿里的验证过程是:搜索“你好”后离开页面,输入框清空;再搜索“黑”,点击叉,输入框也清空。怎么做:可以在 $on 回调和 $emit 前后临时打印,确认执行顺序。易错点:$emit 早于 $on 不会生效;事件名大小写不一致不会触发;组件销毁前触发 beforeDestroy,不要写到 destroyed 之后才依赖 DOM。
课堂画面





代码示例
代码示例一:src/pages/Search/index.vue —— 监听路由取数据 + 商品列表渲染 + 面包屑移除
vue
<template>
<div class="search">
<!-- 面包屑:类别标签 -->
<ul class="selector">
<li v-if="$route.query.categoryName" class="with-x">
{{ $route.query.categoryName }}<i @click="moveCategoryName">×</i>
</li>
<!-- 面包屑:关键字标签 -->
<li v-if="$route.query.keyword" class="with-x">
{{ $route.query.keyword }}<i @click="moveKeyword">×</i>
</li>
</ul>
<!-- 商品列表 -->
<ul class="yui3-g">
<li
v-for="item in $store.state.product.searchProductResult.goodsList"
class="yui3-u-1-5"
>
<div class="list-wrap">
<div class="p-img">
<a href="item.html" target="_blank">
<img :src="item.defaultImg" />
</a>
</div>
<div class="price">
<strong>
<em>¥</em>
<i>{{ item.price }}</i>
</strong>
</div>
<div class="attr">
<a target="_blank" href="item.html" :title="item.title">
{{ item.title }}
</a>
</div>
<div class="commit">
<i class="command">已有<span>{{ item.hotScore }}</span>人评价</i>
</div>
<div class="operate">
<a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
<a href="javascript:void(0);" class="sui-btn btn-bordered">收藏</a>
</div>
</div>
</li>
</ul>
</div>
</template>
<script>
export default {
name: "Search",
watch: {
"$route.query": {
handler(query) {
this.$store.dispatch("product/postProductListAsync", query);
},
immediate: true
}
},
methods: {
// 移除类别标签
moveCategoryName() {
// 方案二:只保留 keyword,类别相关的全部丢掉
const { keyword } = this.$route.query;
this.$router.push({
path: "/search",
query: {
keyword
}
});
},
// 移除关键字标签
moveKeyword() {
// 先浅拷贝再删,不能直接 delete this.$route.query.keyword
const query = { ...this.$route.query };
delete query.keyword;
this.$router.push({
path: "/search",
query
});
// 通知 Header 清空搜索框
this.$bus.$emit("clearKeyword");
}
},
// 离开搜索页(组件销毁前)清空搜索框
beforeDestroy() {
this.$bus.$emit("clearKeyword");
}
};
</script>关键行与易错点
watch监听"$route.query"时必须写immediate: true,否则首次进入搜索页不会发请求(只有路由再次变化才触发)。- 请求动作写在
handler(query)里,把 query 直接丢给product/postProductListAsync,页面本身不持有请求参数。 - 移除标签不能
delete this.$route.query.xxx后再把原对象传给$router.push:对象引用地址没变,path也没变,vue-router 认为路由没变化,不会跳转、页面不会刷新。必须先{ ...this.$route.query }浅拷贝,再删属性。 moveCategoryName用解构只取keyword,等价于把categoryName / category1Id / category2Id / category3Id一起删掉;如果只删categoryName,剩下的category3Id还会继续参与搜索。- 模板里
v-if="$route.query.categoryName"、v-if="$route.query.keyword"保证没有该条件时标签不渲染。 - 商品字段名注意:图片是
defaultImg、标题是title、评价数是hotScore,写错字段页面就是空白。
代码示例二:src/pages/Search/SearchSelector/index.vue —— 品牌与属性列表渲染
vue
<template>
<div class="clearfix selector">
<!-- 品牌 -->
<div class="type-wrap logo">
<div class="fl key brand">品牌</div>
<div class="value logos">
<ul class="logo-list">
<li v-for="item in trademarkList" :key="item.tmId">
{{ item.tmName }}
</li>
</ul>
</div>
</div>
<!-- 属性(网络、屏幕尺寸……二维数组:外层是行,内层是选项) -->
<div class="type-wrap" v-for="item in attrsList" :key="item.attrId">
<div class="fl key">{{ item.attrName }}</div>
<div class="fl value">
<ul class="type-list">
<li v-for="(info, index) in item.attrValueList" :key="index">
<a>{{ info }}</a>
</li>
</ul>
</div>
<div class="fl ext"></div>
</div>
</div>
</template>
<script>
import { mapState } from "vuex";
export default {
name: "SearchSelector",
computed: mapState("product", {
trademarkList(state) {
return state.searchProductResult.trademarkList.filter(item => item.tmName);
},
attrsList(state) {
return state.searchProductResult.attrsList;
}
})
};
</script>关键行与易错点
mapState("product", {...})的第一个参数是 Vuex 模块命名空间,漏写就会取到undefined。- 品牌用
.filter(item => item.tmName)过滤掉后端返回的空名字品牌(后台数据确实有空值),不做过滤会出现空白项。 - 高频报错:
searchProductResult初始是空对象{},此时trademarkList是undefined,.filter直接报 “Cannot read property 'filter' of undefined”。解决方式二选一:把 store 中该字段初始值设为空数组[],或写成(state.searchProductResult.trademarkList || []).filter(...)。 attrsList是二维结构:外层attrName(网络制式、屏幕尺寸…),内层attrValueList(4G、8G…)。渲染必须两层v-for,只写一层拿不到选项。- 组件本身不发请求、不接 props,数据全部从 Vuex 派生,所以父组件
Search只需import并注册即可使用。
代码示例三:TypeNav 的 goSearch 修复 + 全局事件总线清空搜索框
js
/* ---------- src/components/Header/TypeNav/index.vue ---------- */
// 修复点:不再 {...this.$route.query} 合并,只带 keyword
goSearch(e) {
const { id, level } = e.target.dataset;
if (id) {
this.$router.push({
path: "/search",
query: {
["category" + level + "Id"]: id,
categoryName: e.target.innerText.trim(),
keyword: this.$route.query.keyword
}
});
this.isShow = false;
}
}
/* ---------- src/main.js ---------- */
import Vue from "vue";
import App from "@/App";
import router from "@/router";
import store from "@/store";
import filters from "@/filters";
import "@/mock";
import "@/assets/css/reset.css";
Vue.config.productionTip = false;
new Vue({
el: "#app",
router,
store,
beforeCreate() {
// 全局过滤器
for (let key in filters) {
Vue.filter(key, filters[key]);
}
// 全局事件总线:$bus 就是当前 Vue 实例
Vue.prototype.$bus = this;
},
render: h => h(App)
});
/* ---------- src/components/Header/index.vue ---------- */
export default {
name: "Header",
mounted() {
// 订阅:收到 clearKeyword 就把输入框清空
this.$bus.$on("clearKeyword", () => {
this.$refs.keyword.value = null;
});
}
};关键行与易错点
goSearch里用计算属性名["category" + level + "Id"]拼出category1Id / category2Id / category3Id,level、id来自e.target.dataset。- 本次修复的核心:以前写
query: { ...this.$route.query, ... },会把上一次的category1Id一起合并进来,出现category1Id=100&category3Id=3这种“图书里找少儿”的矛盾条件,接口查不到数据。改成只显式带keyword: this.$route.query.keyword,其它类别条件自然被丢弃。 keyword为undefined时 vue-router 不会把它拼进 URL,所以不用额外判断。e.target.innerText.trim()取分类名,注意innerText而非innerHTML(否则会把标签带进来),并trim()去掉换行空格。Vue.prototype.$bus = this必须写在beforeCreate里,保证所有组件共用同一个实例,$emit才能触发到$on。- Header 里用
this.$refs.keyword.value = null直接操作 DOM,所以模板上必须给输入框加ref="keyword";$on放在mounted中,此时$refs才可用。 - 两个触发点缺一不可:
Search的beforeDestroy(离开搜索页时清空)和moveKeyword里push之后(点 × 但没离开页面时清空)。 - 该方案替代了“在 Header 里继续 watch
$route.query.keyword”,否则监听条件越加越多、逻辑越来越乱;若担心内存泄漏,可在 Header 的beforeDestroy里补this.$bus.$off("clearKeyword")。
常见错误
现象: 先点“手机”再点“抽油烟机”,搜索无数据或分类条件混乱。
原因:goSearch中把旧this.$route.query合并进新 query,导致旧的category1Id/category2Id/category3Id被保留。
修法: 构造全新 query,只显式带上当前分类键和keyword,不要无脑展开旧分类参数。现象: 点击面包屑类别“×”后页面不跳转,标签还在。
原因: 直接delete this.$route.query.xxx后继续传同一引用,路由 query 引用未变,push相同地址不触发更新。
修法: 先拷贝const query = {...this.$route.query},再删除categoryName和category1/2/3Id,最后push({path:'/search', query});或只保留keyword。现象:
SearchSelector报Cannot read property 'filter' of undefined或品牌列表空白。
原因:searchProductResult初始为空对象,trademarkList尚未返回时就执行filter。
修法: state 初始化为空数组;computed 中做兜底,如(state.searchProductResult.trademarkList || []).filter(...)。现象: 移除关键字标签后,Header 搜索框内容还在。
原因: 只改了路由 query,没有通知 Header 组件清空输入框。
修法: 在移除关键字后this.$bus.$emit('clearKeyword'),Header 在mounted订阅并清空this.$refs.keyword.value。现象:
this.$bus.$emit报错或不触发。
原因:$bus未挂载到 Vue 原型,或订阅/触发不在同一实例;组件销毁后未解绑。
修法: 在new Vue({ beforeCreate(){ Vue.prototype.$bus = this } })挂载;mounted订阅,beforeDestroy触发并$off解绑。现象: 商品列表渲染空白或图片 404。
原因:goodsList路径写错、初始数据无该字段,或静态图片相对路径层级不对。
修法: 用$store.state.product.searchProductResult.goodsList || []兜底,v-for加:key,图片路径按@/assets/...校正。
练习
- 给商品列表增加“无搜索结果”空态和加载态,要求切换
route.query时状态正确切换。 - 实现移除品牌/属性筛选条件,并把对应条件同步从路由 query 中删除。
- 重构
goSearch,确保连续点击不同层级分类时不会残留上一次的category1/2/3Id。 - 用事件总线实现:离开 Search 页或点击关键字“×”后,Header 搜索框都被清空;补上解绑逻辑。
- 给
SearchSelector的trademarkList、attrsList增加防御性默认值,保证接口未返回时不报错。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 渲染数据-商品列表 | 25:53 |
| 2 | SeachSelector组件完成渲染 | 21:45 |
| 3 | 面包屑导航-类别添加以及移除 | 15:23 |
| 4 | 面包屑导航-关键字添加以及移除 | 06:20 |
| 5 | 通过事件总线清空搜索内容 | 10:28 |
小结
- Search 页应以
route.query作为搜索条件入口,watch + immediate保证首次进入也发请求。 - 分类搜索要成组处理
categoryName与category1/2/3Id,构造新 query 时不要合并旧分类参数。 - 面包屑移除条件时优先“拷贝 query 再删除”,避免引用不变导致路由不跳转。
- 事件总线适合处理跨组件清空搜索框,订阅、触发、解绑三处要配套。
- 列表、品牌、属性渲染都要对 Vuex 初始空数据做兜底,避免接口未返回时模板报错。
- 商品列表要关注
v-for的key、图片静态路径和空结果提示。 - 分类组合可能本身无数据,排查时先看请求参数,再看渲染逻辑。