主题
按品牌进行搜索与如果没有搜索条件隐藏面包屑导航(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>实现分两步理解:
- 模板层:给面包屑根节点加
v-show="isSelector"。这里用v-show而不是v-if,是因为面包屑结构本身不复杂,显示/隐藏切换频繁,v-show只切换display,写法也直接。若业务更在意初始不渲染,也可以用v-if,但本节按笔记保留v-show。 - 逻辑层:
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 的传参
为什么需要讲这两种?因为项目里既可能用 <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. 品牌搜索 + 面包屑渲染/移除品牌(完整 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常见错误
现象:点击品牌或属性后,原有搜索条件丢失。
原因:跳转时只写了trademark/props,没有合并$route.query。
修法:使用query: { ...this.$route.query, trademark: ... }或query: { ...this.$route.query, props: ... }。现象:删除品牌后,面包屑导航仍显示或显隐判断异常。
原因:用trademark: undefined删除,URL 查询对象中仍可能保留该键;或用Object.keys判断时未过滤空值。
修法:复制query后delete query.trademark再跳转;显隐判断用Object.values(this.$route.query).filter(Boolean).length > 0。现象:添加属性搜索时报
Cannot read properties of undefined (reading 'includes')。
原因:首次没有属性条件时,this.$route.query.props为undefined,直接调用includes。
修法:先取默认值:const props = this.$route.query.props || [],再判断和合并。现象:同一属性被重复添加到 URL 和面包屑中。
原因:添加属性前未判断该属性值是否已存在。
修法:拼接propsValue后先判断props.includes(propsValue),存在则return或提示,不存在再跳转。现象:刷新页面后品牌、属性等搜索条件丢失。
原因:条件只保存在组件data、父子传参或临时状态中,没有写入地址栏。
修法:搜索条件统一放入路由query,组件初始化时从$route.query读取。现象:
v-for与v-if同级使用时,渲染结果或判断顺序不符合预期。
原因:v-for优先级高于v-if,会先循环渲染再判断。
修法:避免同级使用,改用计算属性先过滤,或在外层容器上做条件判断。
练习
- 在
SearchSelector中实现点击品牌后跳转搜索页,要求保留已有keyword、props等条件,并验证刷新后品牌条件仍存在。 - 在
Search面包屑中实现品牌标签渲染与移除,要求移除后 URL 中不再保留trademark键。 - 实现属性
props的添加、去重、移除:同一属性不能重复添加,点击叉号只移除当前属性。 - 实现排序切换:从
$route.query.order初始化type和flag,点击综合/价格后正确写入order=type:flag。 - 分别用
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 |
小结
- 搜索条件应优先放入路由
query,这是刷新后仍能保持筛选状态的关键。 - 跳转前必须合并
...$route.query,否则容易覆盖已有搜索条件。 - 删除查询条件优先用
delete删除键,不要依赖把值设为undefined。 - 面包屑显隐应基于“有效查询值”判断,而不是简单判断查询对象是否有键。
- 品牌参数按
tmId:tmName组织,属性参数按attrId:value:attrName组织。 - 属性数组添加前必须判重,移除时用
filter精确过滤当前项。 - 排序状态从
order初始化时要提供默认值,避免直接split报错。 - 子组件负责触发路由跳转,父组件从
$route.query渲染面包屑,避免跨层直接改状态。