Skip to content

按品牌进行搜索与如果没有搜索条件隐藏面包屑导航(Lesson17 1/3) ​

本页对应 Lesson17,共 6 段视频,约 1 小时 31 分。

学习目标 ​

  • 能够根据品牌参数完成商品搜索,并在面包屑导航中渲染品牌标签、执行移除品牌操作。
  • 能够判断当前是否存在搜索条件,并在无搜索条件时隐藏面包屑导航。
  • 能够使用查询参数传递品牌与属性搜索条件,并通过路由跳转或编程式导航完成参数合并与传递。
  • 能够根据商品属性参数完成属性搜索,并在面包屑导航中渲染属性标签、执行移除属性操作。
  • 能够处理品牌与属性搜索参数中的重复值、空值及数组合并问题,保证搜索条件正确更新。
  • 能够使用阿里图标库资源完善搜索与面包屑交互,并实现商品列表排序功能。

知识点 ​

本节要解决的问题 ​

这一节的目标是:在搜索页的品牌列表中点击某个品牌后,把品牌作为搜索条件带到搜索结果里,并且让地址栏、接口参数、面包屑标签保持一致。笔记给出的核心代码在 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 @click="$router.push({
                        path:'/search',
                        query:{
                            ...$route.query,
                            trademark:item.tmId+':'+item.tmName
                        }
                    })" v-for="item in trademarkList" :key="item.tmId">
                        {{item.tmName}}
                    </li>
                </ul>
            </div>
        </div>
        ....
    </div>
</template>

知识点一:点击品牌要变成 URL 查询条件 ​

为什么需要:品牌点击不能只改组件里的临时变量。转写稿里先讨论过“把品牌传给父组件”或“放进 store”,但前者刷新会丢,后者还要考虑什么时候清理缓存。最后选择把条件写进地址栏 query,因为刷新后条件还在,而且接口本来就需要这个参数。

怎么想:点击品牌时,本质是发起一次新的搜索。搜索条件应该体现在路由上,页面再根据 $route.query 发请求、渲染面包屑。品牌参数名按接口文档用 trademark,值不是单纯品牌名,而是 tmId:tmName 的形式。

怎么做:在品牌 li 上绑定点击事件,调用 $router.push 跳到 /search,在 query 中写入 trademark: item.tmId + ':' + item.tmName。笔记代码已经这样实现。

易错点:第一,值不能只写 tmName,接口要的是 tmId:tmName;第二,不要丢掉原来的搜索条件,所以代码里用了 ...$route.query;第三,模板里可以直接写 $router,但在方法里必须写 this.$router,转写稿调试时就是这里漏了 this 导致取不到路由对象。

知识点二:为什么必须合并原有 query ​

为什么需要:用户可能已经按分类搜索、按关键词搜索,再点品牌。如果只写 trademark,原来的分类和关键词就丢了,搜索结果会突然变成“只剩品牌条件”。

怎么想:品牌只是新增条件,不是重置搜索。地址栏 query 应该“旧条件 + 新品牌”。所以先展开 $route.query,再覆盖或新增 trademark。

怎么做:保持笔记代码中的 ...$route.query。点击小米后,地址栏会多出 trademark=2:小米 这类参数;页面里的请求参数再从这个 query 中取。

易错点:不要写成 query:{ trademark: ... } 而漏掉展开;也不要在点击品牌时把分类、关键词手动清空。转写稿还提到,如果再次点击同一个品牌,地址没变化,一般不需要额外判断,因为 query 相同不会产生新的条件。

知识点三:面包屑里渲染品牌和移除品牌 ​

为什么需要:用户点了品牌后,需要在“全部结果”下面看到已选品牌标签,否则不知道自己选了什么;标签上的关闭按钮要能撤销这个条件。

怎么想:品牌条件在 Search/index.vue 的面包屑区域渲染。因为 trademark 的值是 tmId:tmName,渲染时不能直接显示整串,要按冒号分割,取第二项作为品牌名。移除时不能把整个 query 清空,只移除 trademark。

怎么做:在面包屑组件中判断 $route.query.trademark 是否存在,存在则用 split(':')[1] 取品牌名渲染。关闭时用 $router.push 合并原 query,并把 trademark 设为 undefined;或者复制一份 query 后 delete query.trademark 再跳转。转写稿两种方案都演示过。

易错点:不要用固定截取位置,因为品牌 ID 可能是多位;移除品牌时不要写成只传 { path:'/search' },否则分类、关键词也被清掉;把字段设为 undefined 后,后续判断“有没有搜索条件”时要注意过滤空值,转写稿后面就用 Object.values(...).filter(v => v) 处理过类似问题。

知识点四:参数传递的补充与调试 ​

为什么需要:品牌搜索只是 query 参数的一种。后面属性搜索会传 props,同名参数在 query 中会变成数组,不理解这一点很容易把参数拼错。

怎么想:转写稿验证过:地址栏写 a=1&a=2&a=3,接收到的 a 是数组;router-link 和 this.$router.push 都可以传对象形式,数组要写成 a:[1,2,3],不能写成多个同名键的对象,否则后面的值会覆盖前面的。属性接口的 props 是数组,元素格式是“属性 ID:属性值:属性名”。

怎么做:品牌部分按笔记代码写;属性部分合并旧 props 时,用空数组兜底,再拼新元素,例如先保证 props 是数组,再用 includes 判断是否已存在,存在就停止,不存在再跳转。

易错点:undefined 上不能直接调 includes,转写稿就报过这个错,所以要先兜底空数组;移除品牌或属性时只删对应字段,不要整体重置 query;调试时先看地址栏 query,再看请求参数,最后看面包屑渲染,顺序不要乱。

需求背景:为什么没有搜索条件时要隐藏面包屑 ​

搜索页的面包屑导航不是固定装饰,它承担的是“当前搜索条件回显”的职责:用户按关键词、品牌、属性筛选后,面包屑里要出现对应标签,方便确认和移除条件。反过来,如果用户刚进入搜索页,$route.query 里没有任何有效条件,面包屑仍然显示“全部结果”和一片空标签区域,就会造成两个问题:一是页面上出现无意义的空占位,影响布局;二是用户会误以为当前已经附加了某个筛选条件。因此本节要做的判断是:有有效搜索条件才显示面包屑,没有就隐藏。

这里的“搜索条件”不要用组件里的临时变量判断,而要看路由 query。因为前面按品牌、按属性搜索时,条件都写进了地址栏参数,刷新后还能保留;面包屑是否显示也应跟随地址栏变化。转写稿里调试时遇到的关键情况是:关闭关键词或品牌后,query 里可能还留着一个键,只是值为 undefined,例如 keyword: undefined。这时如果只判断“有没有键”,就会误判为仍有搜索条件。

思路推导:判断 query 里有没有“有效值” ​

第一反应可能是:

js
Object.keys(this.$route.query).length > 0

但转写稿中实际调试发现,关闭条件后地址栏看起来没有条件,this.$route.query 里却仍可能保留 keyword: undefined 这样的键。Object.keys 只数键名,不管值是否有效,所以面包屑不会隐藏。也不能简单地用 JSON.stringify(query) 和 '{}' 比较,这种写法不直观,且遇到 undefined 等值时不稳。

更贴合本节需求的判断是:取出 query 的所有值,过滤掉 undefined、空字符串等假值,再看剩余有效值个数是否大于 0。也就是:

js
Object.values(this.$route.query).filter(v => v).length > 0

这样即使 query 里残留 keyword: undefined,过滤后长度为 0,面包屑就会隐藏。把这个判断抽成计算属性 isSelector,模板中用 v-show 控制面包屑即可。计算属性依赖 this.$route.query,路由变化时会重新求值。

具体实现:在 Search/index.vue 中加 v-show 和 computed ​

保留本节笔记中的代码结构,在 src/pages/Search/index.vue 中修改:

vue
<template>
.....
          <!--bread-->
            <div class="bread" v-show="isSelector">
                .....
            </div>
.....
</template>

<script>
export default {
    .....
    computed:{
        isSelector(){
            return Object.values(this.$route.query).filter(v=>v).length>0;
        }
    }
    ....
}
</script>

实现分两步理解:

  1. 模板层:给面包屑根节点加 v-show="isSelector"。这里用 v-show 而不是 v-if,是因为面包屑结构本身不复杂,显示/隐藏切换频繁,v-show 只切换 display,写法也直接。若业务更在意初始不渲染,也可以用 v-if,但本节按笔记保留 v-show。
  2. 逻辑层:isSelector 返回布尔值。Object.values(this.$route.query) 拿到所有 query 值,.filter(v=>v) 过滤掉假值,.length>0 表示至少有一个有效条件。

调试过程与易错点 ​

转写稿里的调试路径值得记住:先发现“关闭关键词后面包屑还在”,于是去控制台看 this.$route.query,看到类似 keyword: undefined 的残留键;再把判断从“键的数量”改成“值的有效数量”。这正是本节的核心。

易错点有三个。第一,computed 里不要漏写 this,应使用 this.$route.query。第二,不要用 Object.keys(query).length 判断有效条件,它会把值为 undefined 的键也算进去。第三,关闭条件时把参数设为 undefined 会留下键名,所以判断逻辑必须过滤值;如果后续 query 值可能是数组,还要按业务额外判断空数组,不能把空数组当成有效条件。最后注意:隐藏面包屑只是 UI 层控制,不等于清空路由参数;清空条件仍由关闭标签、跳转等逻辑负责。

为什么品牌要显示在面包屑导航里 ​

为什么需要:用户点击品牌搜索后,搜索结果已经按品牌过滤,但页面上还要让用户知道“当前选了哪个品牌”。面包屑导航就是承载这个已选条件的位置,同时提供一个“×”让用户能撤销品牌条件。转写稿里也反复确认:品牌参数写在地址栏里,刷新后标签还在;删掉参数,标签就消失。

怎么想:品牌搜索的参数不是单独存到组件里,而是放在 $route.query.trademark 中。转写稿里调试时看到地址栏类似 trademark=3:华为,也就是“品牌 ID:品牌名称”的格式。面包屑只需要显示冒号右边的品牌名,所以不能把整个 3:华为 直接渲染出来。

怎么做:在 Search/index.vue 的面包屑区域加一个 li,用 v-if="$route.query.trademark" 判断有品牌才渲染;内容用 $route.query.trademark.split(":")[1] 取品牌名;右侧的 i 绑定点击事件 moveTrademark。

易错点:不要在没有 trademark 时直接 .split(),否则会报错,所以 v-if 不能省;不要直接显示 trademark 原值,否则会带出 ID;品牌名一般不含冒号,所以取 [1] 是当前项目约定下的写法。

移除品牌:两种跳转写法 ​

为什么需要:用户点“×”表示不再按这个品牌过滤。此时不能只把页面上的标签隐藏,还要把地址栏里的 trademark 去掉,并重新跳转到 /search,让搜索结果按剩余条件重新请求。

怎么想:转写稿里给了两种思路。方案 1 是跳转时写 trademark: undefined,让这个参数失效;方案 2 是先复制一份 query,用 delete query.trademark 删掉品牌字段,再把新 query 传给 $router.push。方案 2 更直观,也不会在 query 对象里留下一个值为 undefined 的键。

怎么做:在 methods 中写 moveTrademark,复制 this.$route.query,删除 trademark,然后 this.$router.push({ path: "/search", query })。这样其他查询条件(比如关键词、分类等)会被保留,只移除品牌。

易错点:不要直接改 this.$route.query,它是路由带来的对象,应该复制后再改;path 要写 /search;删除后要重新 push,只改对象不会自动触发路由变化;如果直接写 trademark: undefined,虽然当前项目能跑,但不如 delete 干净。

调试过程:从点击品牌到删除标签 ​

转写稿里的调试路径很清楚:先点击品牌,地址栏出现 trademark=3:华为 这类参数;页面面包屑通过 split(":")[1] 渲染出“华为”;点击“×”后,地址栏中的 trademark 被移除,品牌标签消失,搜索结果恢复为不带品牌条件。刷新页面时,因为条件在地址栏里,所以标签仍然保留;删除参数后刷新,标签也不会再出现。

这里要特别区分“显示”和“移除”:显示靠模板读取 $route.query.trademark;移除靠路由跳转改 query。不要把移除写成只改本地 data,否则地址栏没变,刷新后品牌又回来了。

完整代码 ​

vue
<template>
    <!--  品牌 -->
	<li v-if="$route.query.trademark" class="with-x">
    {{$route.query.trademark.split(":")[1]}}<i @click="moveTrademark">×</i>
    </li>
</template>

<script>
export default {
    methods:{
        moveTrademark(){
            // 1
            // this.$router.push({
            //     path:"/search",
            //     query:{
            //         ...this.$route.query,
            //         trademark:undefined
            //     }
            // })

            // 2
            const query = {...this.$route.query};
            delete query.trademark;
            this.$router.push({
                path:"/search",
                query
            })
        }
    }
}
</script>

先弄清接口要什么 ​

为什么需要先看接口?因为“根据属性搜索”不是随便把属性值拼到地址上,而是要让搜索页拿到接口能识别的参数。转写稿里明确提到:接口需要一个 props 数组,数组元素是字符串,格式类似“属性ID:属性值:属性名”。所以点击属性时,不能只传 info,而要先拼成接口要求的字符串。

怎么想?先确定三件事:第一,点击属性后要跳到 /search;第二,跳转时不能丢掉原来的搜索条件,比如关键词、品牌、分类;第三,同一个属性不能重复添加,否则地址里会出现多个相同 props。

怎么做?在 SearchSelector/index.vue 里给属性项绑定点击事件,把 attrId、info、attrName 传进方法,拼成 attrId + ':' + info + ':' + attrName,再合并到 this.$route.query.props 中。易错点是:props 一开始可能是 undefined,不能直接 includes,所以先写 const props = this.$route.query.props || []。

补充:URL 中数组参数的两种写法 ​

为什么需要补充?因为搜索条件里经常要传数组,但 URL 查询字符串里“看起来像数组”不等于接收时就是数组。

第一种请求地址:

text
http://zhangpeiyue.com/search?a=[1,2,3,4]

接收到的 this.$route.query 是:

js
{ a: "[1,2,3,4]" }

这里 a 是字符串,不是数组。第二种请求地址:

text
http://zhangpeiyue.com/search?a=1&a=2&a=3&a=4

接收到的 this.$route.query 是:

js
{ a: ['1', '2', '3', '4'] }

怎么想?如果希望接收端拿到数组,就要用重复同名参数 a=1&a=2&a=3&a=4,而不是 a=[1,2,3,4]。易错点是:不要以为写了中括号就是数组;query 里的值最终会变成字符串或字符串数组。

为什么需要讲这两种?因为项目里既可能用 <router-link> 跳转,也可能在方法里用 $router.push 编程式导航。它们传数组参数的方式要一致,接收端才稳定。

笔记里的示例:

html
<router-link to="/search?a=1&a=2&a=3&a=4" class="register">搜索1</router-link>
<router-link :to="{
                  path:'/search',
                  query:{
                  a:[1,2,3,4]
                  }
                  }" class="register">搜索2</router-link>
<a @click.prevent="$router.push({
                   path:'/search',
                   query:{
                   a:[1,2,3,4]
                   }
                   })" href="">搜索3</a>

接收到的 this.$route.query 是:

js
{ a: ['1', '2', '3', '4'] }

怎么想?router-link 的 to 可以写字符串,也可以写对象;写对象时 query 里的数组会被展开成重复同名参数。$router.push 的对象写法和 :to 对象写法本质一样。

怎么做?跳转时用 path: '/search',把原查询条件合并进去,再把数组放进 query。易错点是:$router.push 如果只写 query: { props: [...] },会把原来的关键词、品牌等条件覆盖掉,所以要用 ...this.$route.query 合并。

在 SearchSelector 中拼属性并跳转 ​

为什么需要这个方法?因为属性搜索的核心是“拼出接口要的字符串,并追加到 props 数组里”。笔记代码如下:

vue
<template>
  	<a @click="addPropsGoSearch(item.attrId,info,item.attrName)">{{info}}</a>
</template>

<script>
export default {
    methods:{
        // attrId-->属性ID
        // info---->属性值
        // attrName--->属性名"
        addPropsGoSearch(attrId,info,attrName){
            // 添加的属性值
            const propsValue = attrId+':'+info+':'+attrName;
            // 判断props是否有值,如果无值,那么让其值为[]
            const props = this.$route.query.props || [];
            // 如果拥有该属性值,那么程序停止 。
            if(props.includes(propsValue)) return;
            this.$router.push({
                path:'/search',
                query:{
                    ...this.$route.query,
                    props:[...props,propsValue]
                }
            })
        }
    }
}
</script>

怎么想?第一步拼 propsValue,格式必须是“属性ID:属性值:属性名”,不能少冒号;第二步取原来的 props,没有就给空数组;第三步判重,已经存在就 return;第四步跳转,保留原 query,追加新 props。

怎么做?点击属性时调用 addPropsGoSearch(item.attrId, info, item.attrName)。跳转后地址里会出现类似 props=2:小米:品牌 的参数;多个属性时就是重复的 props,接收端会得到数组。

易错点有三个:第一,props 为 undefined 时直接 includes 会报错,所以必须先 || [];第二,重复点击同一属性会不断追加,必须判重;第三,拼接顺序和分隔符要和接口一致,不能写成 info:attrId:attrName。

调试与易错点 ​

转写稿里调试时出现过 includes 报错,原因是 this.$route.query.props 一开始是 undefined,而 undefined 没有 includes 方法。解决方式就是先给默认空数组。另一个常见问题是点击属性后原来的搜索条件丢了,原因是 $router.push 的 query 没有合并 ...this.$route.query。

在面包屑里渲染属性时,props 元素是“ID:值:名”,想只显示值,可以按冒号分割后取下标 1;移除属性时不要直接把整个 props 置空,而应 filter 掉当前项,保留其他属性。这样才符合“根据属性搜索”的完整流程:点击属性追加条件,页面按条件搜索,面包屑展示并可移除。

本节目标 ​

搜索页已经能按属性筛选商品。用户点过“麒麟990”“8GB”等属性后,地址栏里的 $route.query.props 会保存这些条件。本节要做的,是把这些已选属性渲染到面包屑导航里,并给每一项加一个“×”,点击后移除该属性,同时保留其他搜索条件。

为什么需要这样做?因为筛选条件不能只存在于商品列表接口里,用户必须能看见“我现在筛了什么”,并且能单独撤销某一项。怎么想?不要再造一份组件状态,直接以地址栏的 query 为数据源:渲染读 $route.query.props,移除就生成新数组再跳转。怎么做?用 v-for 渲染 props,用 split(":") 取出显示值,用 filter 排除当前项,再用 router.push 合并原 query。

渲染属性标签 ​

属性参数的结构不是单纯的值,而是类似“属性ID:属性值:属性名”的字符串。面包屑里只需要显示属性值,所以模板中写 item.split(":")[1]。例如 item 是 xxx:麒麟990:CPU型号,取下标 1 得到“麒麟990”。每个 li 加 with-x 类,内部放一个 i 作为删除按钮,并绑定 @click="moveProps(item)"。注意这里传的是完整 item,不是显示值,因为删除时要按完整字符串比较。

移除属性的推导 ​

移除时不能直接改 $route.query.props,它是路由参数,应该通过一次新的跳转来改变地址。目标地址仍是 /search,query 要写成:

js
{
  ...this.$route.query,
  props: this.$route.query.props.filter(v => v !== prop)
}

...this.$route.query 用来保留关键字、分类、品牌等其他条件;filter 返回一个新数组,只排除当前点击的 prop。转写稿里调试时提到:如果直接把 props 写成 undefined,会把所有属性都清掉;而用 filter(v => v !== prop) 才能只删一项。这里比较的必须是完整字符串,若拿 item.split(":")[1] 去比较,可能删不掉或误删。

本节代码 ​

vue
<template>
 <!-- 属性 -->
	<li v-for="item in $route.query.props" class="with-x">
    	{{item.split(":")[1]}}<i @click="moveProps(item)">×</i>
    </li>
</template>

<script>
export default {
    name: "Search",
    components: {SearchSelector},
    methods:{
        // 移除属性
        moveProps(prop){
            this.$router.push({
                path:"/search",
                query:{
                    ...this.$route.query,
                    props:this.$route.query.props.filter(v=>v !== prop)
                }
            })
        }
    }
    .....
}
</script>

易错点 ​

第一,v-for 不要和 v-if 随意写在同一层。转写稿解释过,v-for 优先级更高,会先循环再判断,效率不好;当前代码直接遍历 props,没有属性时自然不渲染。第二,filter 不会修改原数组,必须把返回值赋给新的 props。第三,跳转时不要漏掉 ...this.$route.query,否则品牌、关键字等条件会丢失。第四,删除按钮传参要传 item,不要传 item.split(":")[1]。第五,当前代码默认 $route.query.props 存在;如果后续要增强健壮性,可以在方法里先做空值兜底,但本节笔记的实现以路由参数为唯一数据源,思路是“渲染读地址栏,移除改地址栏”。

为什么搜索页要接图标库 ​

为什么需要:搜索页的“综合、价格”是排序入口,只有文字不够直观。价格后面配一个向上/向下的小箭头,用户能立刻知道当前排序方向。转写稿里搜索页是逐步完善的:先做类别、关键字搜索,再做品牌、属性筛选,最后补面包屑和参数拼接。图标属于展示层,先按最小步骤接进来,不影响后面的搜索参数逻辑。

怎么想:不要在每个页面手写 SVG 或图片,统一使用阿里图标库的字体图标。字体图标可以随文字颜色变化,类名复用方便。接入顺序是“放资源 → 全局引样式 → 页面用类名”。

怎么做:把阿里图标库项目下载后的 iconfont 目录整体放到 src/assets/iconfont。注意是整个目录,不要只拷 iconfont.css 而漏掉同目录字体文件。

易错点:目录名和大小写要一致;不要把资源放到 public 后又用 @/assets 引;后续如果图标不显示,先查字体文件是否 404,再查类名是否写错。

全局引入图标样式 ​

为什么需要:iconfont.css 里定义了 @font-face 和 .iconfont、.icon-icon_up 等类。只有引入一次,页面里的 <i class="iconfont icon-icon_up"></i> 才能生效。

怎么想:入口文件 src/index.js 是整个应用的入口,适合放全局样式。不要在 Search/index.vue 里重复 import,否则每个用图标的组件都要引一次,容易漏。

怎么做:在 src/index.js 中引入:

js
import "@/assets/iconfont/iconfont.css";

易错点:@ 别名要确认项目已配置;路径要写到 iconfont.css 文件本身;引入顺序放在应用挂载前。若控制台报路径错误,先看 src/assets/iconfont 下是否真的有该文件。

在 Search/index.vue 中使用图标 ​

为什么需要:搜索页导航栏要展示“综合”和“价格”的排序状态。综合用向上图标,价格用向下图标,和笔记代码一致。

怎么想:iconfont 是基础类,负责字体族;icon-icon_up、icon-icon_down 是阿里项目里生成的具体图标类名。两个类一起写,缺一不可。

怎么做:在 src/pages/Search/index.vue 中保留笔记里的结构:

vue
<div class="sui-navbar">
    <div class="navbar-inner filter">
        <ul class="sui-nav">
            <li class="active">
                <a href="#">综合<i class="iconfont icon-icon_up"></i></a>
            </li>
            <li>
                <a href="#">价格<i class="iconfont icon-icon_down"></i></a>
            </li>
        </ul>
    </div>
</div>

易错点:类名不要凭感觉写。打开 iconfont.css 搜索 icon-icon_up,确认当前项目生成的类名是否就是它;不同阿里项目的前缀可能不同。若图标显示成方框,通常是字体文件没加载或类名不存在。若图标大小、间距不合适,优先给 i 加样式,不要改图标库源文件。

和搜索逻辑的关系 ​

为什么需要:转写稿里搜索页一直在补业务:类别、关键字、品牌、属性、面包屑。图标只是导航栏展示,不要因为它打断搜索参数的主线。

怎么想:先保证页面结构能渲染,再接图标;后面做品牌、属性筛选时,排序图标仍留在导航栏。调试时按“资源路径 → 全局引入 → 类名 → 字体请求”的顺序排查,不要一上来改业务代码。

怎么做:完成本节三步后,回到搜索页确认“综合/价格”文字旁出现图标;再继续转写稿中的品牌、属性搜索。品牌参数要合并原有 query,属性参数要处理数组和重复值,这些是后续逻辑,不与图标库混在一起。

易错点:不要把图标库的 iconfont.css 和页面业务样式互相覆盖;不要为了显示图标改掉 li 的 active 状态;刷新后图标消失,优先看 Network 里字体文件是否被缓存或路径错误。

排序要解决什么问题 ​

搜索页已经有综合、价格两个排序入口。用户点击“价格”时,需要把结果按价格排序;再点一次,要在升序、降序之间切换。这里的关键不是只改页面上的箭头,而是把排序状态写进地址栏的 query.order。转写稿在讲品牌、属性搜索时反复强调:参数写到地址里,刷新后还能保留。排序也应遵循这个思路,否则刷新后排序状态会丢,页面高亮和接口参数也会对不上。

怎么想:排序状态由两部分组成——排序类型 type(1 综合、2 价格)和排序标识 flag(asc 升序、desc 降序),格式如 "1:desc"。
怎么做:初始化时从 this.$route.query.order 解析;点击时先判断“点的是不是当前类型”,再决定切换 flag 还是重置 type。
易错点:不要只改 data 不写路由,否则刷新丢失;也不要直接改 this.$route.query。

初始化:从地址栏恢复排序状态 ​

为什么需要:用户可能带着 ?order=2:asc 进入页面,也可能刷新页面。排序组件必须能恢复上次状态。
怎么想:order 是 "类型:标识" 的字符串,用 split(":") 拆成两个值;没有参数时给默认值 "1:desc"。
怎么做:笔记里给了两种写法,第二种更简洁:

js
const [type, flag] = (this.$route.query.order || "1:desc").split(":");

易错点:split 得到的是字符串,后面 this.type === type 比较时要注意类型一致;默认值不能漏,否则首次进入会得到 undefined。

模板:高亮与升降序箭头 ​

为什么需要:用户要知道当前按哪一项排序、是升序还是降序。
怎么想:用 type 控制 active,用 flag 控制箭头类名。
怎么做:orderSearch(1)、orderSearch(2) 分别代表综合、价格;v-show="type==1" 只显示当前项的箭头。
易错点:active 判断要用当前 type,不要写死;箭头类名由 upOrDown 提供,需保证它与 flag 对应。

点击排序:先切换状态,再回写路由 ​

为什么需要:点击同一类型要切换升降序;点击不同类型要切到新类型,并给一个合理默认排序。
怎么想:如果 this.type === type,说明点的是当前项,只翻转 flag;否则更新 type,并把 flag 重置为 "desc"。
怎么做:

js
orderSearch(type){
    if(this.type === type){
        this.flag = this.flag === "desc" ? "asc" : "desc";
    }else{
        this.type = type;
        this.flag = "desc";
    }
    this.$router.push({
        path:"/search",
        query:{
            ...this.$route.query,
            order:this.type + ":" + this.flag
        }
    })
}

这里用 ...this.$route.query 合并原有搜索条件,避免排序时把关键词、品牌、属性等参数冲掉。转写稿在讲品牌跳转时也强调过要合并原 query,否则之前条件会丢。
易错点:不要写成 query:{ order: ... } 而丢掉其他参数;order 的值必须是 "类型:标识" 字符串。

易错点与排查思路 ​

  • 刷新后排序丢失:检查是否只改了 data,没有 router.push 写 order。
  • 点击价格后综合还高亮:检查 :class="{active:type==1}" 是否用了当前 type。
  • 升降序不切换:检查 flag 是否在 "asc"、"desc" 之间翻转。
  • 搜索条件被清空:检查 query 是否合并了 ...this.$route.query。
  • 首次进入报错:检查 this.$route.query.order || "1:desc" 的默认值。

本节笔记没有给出排序报错截图,排查时按“地址栏 order 值 → data 解析结果 → 点击后 push 的 order 值”顺序看即可。

完整代码 ​

vue
<template>
	<ul class="sui-nav">
        <li @click.prevent="orderSearch(1)" :class="{active:type==1}">
            <a href="#">综合<i v-show="type==1" :class="upOrDown"></i></a>
        </li>
        <li @click.prevent="orderSearch(2)" :class="{active:type==2}">
            <a href="#">价格<i v-show="type==2" :class="upOrDown"></i></a>
        </li>
    </ul>
</template>

<script>
export default {
    data(){
        // console.log("order",this.$route.query.order);
        // 1
        // let type = 1;
        // let flag = "desc";
        // // 判断是否有排序的参数,如果有那么重置type,以及flag
        // if(this.$route.query.order){
        //     [type,flag] = this.$route.query.order.split(":");
        // }

        // 2
        const [type,flag]= (this.$route.query.order || "1:desc").split(":");

        return {
            // 排序方式
            // 排序类型(type)   1: 综合     2: 价格
            // 排序标识(flag)  asc: 升序  desc: 降序
            // 示例: "1:desc"
            type,// 综合
            flag
        }
    },
    methods:{
        orderSearch(type){
            // 当前的排序类别与选中的类别是否相同
            if(this.type === type){
                this.flag = this.flag==="desc"?"asc":"desc";
            }else{
                this.type = type;
                this.flag = "desc";
            }
            this.$router.push({
                path:"/search",
                query:{
                    ...this.$route.query,
                    order:this.type+":"+this.flag
                }
            })
        }
        .....
    },
}
</script>

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. 品牌搜索 + 面包屑渲染/移除品牌(完整 HTML)

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>品牌搜索</title>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-router.js"></script>
  <style>
    .selector { border: 1px solid #ddd; padding: 10px; margin: 10px 0; }
    .bread { margin: 10px 0; }
    .with-x { cursor: pointer; }
  </style>
</head>
<body>
<div id="app">
  <router-view></router-view>
</div>

<script>
Vue.use(VueRouter);

const SearchSelector = {
  name: 'SearchSelector',
  props: {
    trademarkList: { type: Array, default: () => [] }
  },
  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"
              @click="$router.push({
                path: '/search',
                query: {
                  ...$route.query,
                  trademark: item.tmId + ':' + item.tmName
                }
              })"
            >
              {{ item.tmName }}
            </li>
          </ul>
        </div>
      </div>
    </div>
  `
};

const Search = {
  name: 'Search',
  components: { SearchSelector },
  data() {
    return {
      trademarkList: [
        { tmId: 1, tmName: '小米' },
        { tmId: 2, tmName: '华为' },
        { tmId: 3, tmName: '苹果' }
      ]
    };
  },
  computed: {
    isSelector() {
      return Object.values(this.$route.query).filter(v => v).length > 0;
    }
  },
  methods: {
    moveTrademark() {
      const query = { ...this.$route.query };
      delete query.trademark;
      this.$router.push({
        path: '/search',
        query
      });
    }
  },
  template: `
    <div>
      <h3>Search</h3>

      <search-selector :trademark-list="trademarkList" />

      <div class="bread" v-show="isSelector">
        全部结果:
        <ul>
          <li v-if="$route.query.trademark" class="with-x">
            {{ $route.query.trademark.split(':')[1] }}
            <i @click="moveTrademark">×</i>
          </li>
        </ul>
      </div>

      <p>当前 query:{{ $route.query }}</p>
    </div>
  `
};

const router = new VueRouter({
  routes: [
    { path: '/', redirect: '/search' },
    { path: '/search', component: Search }
  ]
});

new Vue({ router }).$mount('#app');
</script>
</body>
</html>

关键行与易错点:

  • trademark: item.tmId + ':' + item.tmName:按笔记要求把品牌存成 ID:品牌名,面包屑渲染时再用 split(':')[1] 取品牌名。
  • ...$route.query:点击品牌时要保留原有搜索条件,不能只写 trademark。
  • isSelector:用 Object.values(this.$route.query).filter(v => v).length > 0 判断是否存在有效搜索条件,从而控制面包屑显隐。
  • 移除品牌时先复制 query 再 delete query.trademark,不要直接修改 this.$route.query。
  • 易错点:$route.query.trademark 是字符串,必须 split(':') 后再取名称;直接渲染会显示 2:华为。

2. 属性搜索 + 面包屑渲染/移除属性 + 排序(完整 HTML)

html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>属性搜索与排序</title>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue-router.js"></script>
  <style>
    .selector { border: 1px solid #ddd; padding: 10px; margin: 10px 0; }
    .bread { margin: 10px 0; }
    .sui-nav { list-style: none; display: flex; gap: 10px; padding: 0; }
    .sui-nav li { cursor: pointer; padding: 4px 8px; }
    .sui-nav li.active { background: #f60; color: #fff; }
    .with-x { cursor: pointer; margin-right: 8px; display: inline-block; }
  </style>
</head>
<body>
<div id="app">
  <router-view></router-view>
</div>

<script>
Vue.use(VueRouter);

const SearchSelector = {
  name: 'SearchSelector',
  props: {
    attrsList: { type: Array, default: () => [] }
  },
  methods: {
    // attrId -> 属性ID;info -> 属性值;attrName -> 属性名
    addPropsGoSearch(attrId, info, attrName) {
      const propsValue = attrId + ':' + info + ':' + attrName;

      // 注意:单个 props 时 $route.query.props 是字符串,这里统一转成数组
      const props = [].concat(this.$route.query.props || []);

      // 已存在该属性值则停止
      if (props.includes(propsValue)) return;

      this.$router.push({
        path: '/search',
        query: {
          ...this.$route.query,
          props: [...props, propsValue]
        }
      });
    }
  },
  template: `
    <div class="clearfix selector">
      <div v-for="item in attrsList" :key="item.attrId" class="type-wrap">
        <div class="key">{{ item.attrName }}</div>
        <div class="value">
          <a
            v-for="info in item.attrValues"
            :key="info"
            href="javascript:;"
            @click="addPropsGoSearch(item.attrId, info, item.attrName)"
          >
            {{ info }}
          </a>
        </div>
      </div>
    </div>
  `
};

const Search = {
  name: 'Search',
  components: { SearchSelector },
  data() {
    const [type, flag] = (this.$route.query.order || '1:desc').split(':');
    return {
      type,
      flag,
      attrsList: [
        { attrId: 1, attrName: '运行内存', attrValues: ['8G', '12G'] },
        { attrId: 2, attrName: '机身存储', attrValues: ['128G', '256G'] },
        { attrId: 3, attrName: '处理器', attrValues: ['麒麟990', '骁龙888'] }
      ]
    };
  },
  computed: {
    propsList() {
      return [].concat(this.$route.query.props || []);
    },
    upOrDown() {
      return this.flag === 'desc'
        ? 'iconfont icon-icon_down'
        : 'iconfont icon-icon_up';
    }
  },
  methods: {
    moveProps(prop) {
      this.$router.push({
        path: '/search',
        query

常见错误 ​

  1. 现象:点击品牌或属性后,原有搜索条件丢失。
    原因:跳转时只写了 trademark / props,没有合并 $route.query。
    修法:使用 query: { ...this.$route.query, trademark: ... } 或 query: { ...this.$route.query, props: ... }。

  2. 现象:删除品牌后,面包屑导航仍显示或显隐判断异常。
    原因:用 trademark: undefined 删除,URL 查询对象中仍可能保留该键;或用 Object.keys 判断时未过滤空值。
    修法:复制 query 后 delete query.trademark 再跳转;显隐判断用 Object.values(this.$route.query).filter(Boolean).length > 0。

  3. 现象:添加属性搜索时报 Cannot read properties of undefined (reading 'includes')。
    原因:首次没有属性条件时,this.$route.query.props 为 undefined,直接调用 includes。
    修法:先取默认值:const props = this.$route.query.props || [],再判断和合并。

  4. 现象:同一属性被重复添加到 URL 和面包屑中。
    原因:添加属性前未判断该属性值是否已存在。
    修法:拼接 propsValue 后先判断 props.includes(propsValue),存在则 return 或提示,不存在再跳转。

  5. 现象:刷新页面后品牌、属性等搜索条件丢失。
    原因:条件只保存在组件 data、父子传参或临时状态中,没有写入地址栏。
    修法:搜索条件统一放入路由 query,组件初始化时从 $route.query 读取。

  6. 现象:v-for 与 v-if 同级使用时,渲染结果或判断顺序不符合预期。
    原因:v-for 优先级高于 v-if,会先循环渲染再判断。
    修法:避免同级使用,改用计算属性先过滤,或在外层容器上做条件判断。

练习 ​

  1. 在 SearchSelector 中实现点击品牌后跳转搜索页,要求保留已有 keyword、props 等条件,并验证刷新后品牌条件仍存在。
  2. 在 Search 面包屑中实现品牌标签渲染与移除,要求移除后 URL 中不再保留 trademark 键。
  3. 实现属性 props 的添加、去重、移除:同一属性不能重复添加,点击叉号只移除当前属性。
  4. 实现排序切换:从 $route.query.order 初始化 type 和 flag,点击综合/价格后正确写入 order=type:flag。
  5. 分别用 router-link 和 $router.push 传递数组型 query,观察并记录 this.$route.query 的接收形态。

原视频对照 ​

#视频时长
1按品牌进行搜索23:46
2如果没有搜索条件隐藏面包屑导航20:20
3面包屑导航中渲染品牌-以及移除品牌07:29
4传递参数补充11:14
5根据属性进行搜索20:32
6面包屑导航中渲染属性以及移除属性08:18

小结 ​

  1. 搜索条件应优先放入路由 query,这是刷新后仍能保持筛选状态的关键。
  2. 跳转前必须合并 ...$route.query,否则容易覆盖已有搜索条件。
  3. 删除查询条件优先用 delete 删除键,不要依赖把值设为 undefined。
  4. 面包屑显隐应基于“有效查询值”判断,而不是简单判断查询对象是否有键。
  5. 品牌参数按 tmId:tmName 组织,属性参数按 attrId:value:attrName 组织。
  6. 属性数组添加前必须判重,移除时用 filter 精确过滤当前项。
  7. 排序状态从 order 初始化时要提供默认值,避免直接 split 报错。
  8. 子组件负责触发路由跳转,父组件从 $route.query 渲染面包屑,避免跨层直接改状态。