Skip to content

调用接口获取数据与渲染数据-商品列表(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。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

代码示例一: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")。

常见错误 ​

  1. 现象: 先点“手机”再点“抽油烟机”,搜索无数据或分类条件混乱。
    原因: goSearch 中把旧 this.$route.query 合并进新 query,导致旧的 category1Id/category2Id/category3Id 被保留。
    修法: 构造全新 query,只显式带上当前分类键和 keyword,不要无脑展开旧分类参数。

  2. 现象: 点击面包屑类别“×”后页面不跳转,标签还在。
    原因: 直接 delete this.$route.query.xxx 后继续传同一引用,路由 query 引用未变,push 相同地址不触发更新。
    修法: 先拷贝 const query = {...this.$route.query},再删除 categoryName 和 category1/2/3Id,最后 push({path:'/search', query});或只保留 keyword。

  3. 现象: SearchSelector 报 Cannot read property 'filter' of undefined 或品牌列表空白。
    原因: searchProductResult 初始为空对象,trademarkList 尚未返回时就执行 filter。
    修法: state 初始化为空数组;computed 中做兜底,如 (state.searchProductResult.trademarkList || []).filter(...)。

  4. 现象: 移除关键字标签后,Header 搜索框内容还在。
    原因: 只改了路由 query,没有通知 Header 组件清空输入框。
    修法: 在移除关键字后 this.$bus.$emit('clearKeyword'),Header 在 mounted 订阅并清空 this.$refs.keyword.value。

  5. 现象: this.$bus.$emit 报错或不触发。
    原因: $bus 未挂载到 Vue 原型,或订阅/触发不在同一实例;组件销毁后未解绑。
    修法: 在 new Vue({ beforeCreate(){ Vue.prototype.$bus = this } }) 挂载;mounted 订阅,beforeDestroy 触发并 $off 解绑。

  6. 现象: 商品列表渲染空白或图片 404。
    原因: goodsList 路径写错、初始数据无该字段,或静态图片相对路径层级不对。
    修法: 用 $store.state.product.searchProductResult.goodsList || [] 兜底,v-for 加 :key,图片路径按 @/assets/... 校正。

练习 ​

  1. 给商品列表增加“无搜索结果”空态和加载态,要求切换 route.query 时状态正确切换。
  2. 实现移除品牌/属性筛选条件,并把对应条件同步从路由 query 中删除。
  3. 重构 goSearch,确保连续点击不同层级分类时不会残留上一次的 category1/2/3Id。
  4. 用事件总线实现:离开 Search 页或点击关键字“×”后,Header 搜索框都被清空;补上解绑逻辑。
  5. 给 SearchSelector 的 trademarkList、attrsList 增加防御性默认值,保证接口未返回时不报错。

原视频对照 ​

#视频时长
1渲染数据-商品列表25:53
2SeachSelector组件完成渲染21:45
3面包屑导航-类别添加以及移除15:23
4面包屑导航-关键字添加以及移除06:20
5通过事件总线清空搜索内容10:28

小结 ​

  1. Search 页应以 route.query 作为搜索条件入口,watch + immediate 保证首次进入也发请求。
  2. 分类搜索要成组处理 categoryName 与 category1/2/3Id,构造新 query 时不要合并旧分类参数。
  3. 面包屑移除条件时优先“拷贝 query 再删除”,避免引用不变导致路由不跳转。
  4. 事件总线适合处理跨组件清空搜索框,订阅、触发、解绑三处要配套。
  5. 列表、品牌、属性渲染都要对 Vuex 初始空数据做兜底,避免接口未返回时模板报错。
  6. 商品列表要关注 v-for 的 key、图片静态路径和空结果提示。
  7. 分类组合可能本身无数据,排查时先看请求参数,再看渲染逻辑。