主题
收集搜索关键词与合并类别及关键字搜索(Lesson16 2/3)
本页对应 Lesson16,共 5 段视频,约 35 分钟。
学习目标
- 能说明合并类别与关键字搜索时参数合并的顺序,使用展开运算符将当前路由
query与搜索关键词合并,并验证搜索后再切换分类时关键字与分类参数均能保留。 - 能实现搜索优化:监听路由参数变化,在参数为空时清空搜索关键词;或使用事件总线在离开搜索页时清空关键词,并能说明两种方案的触发时机与差异。
- 能按接口文档在
productAPI 模块中封装搜索商品接口,正确设置 POST 请求方式、接口地址和请求体参数,并说明 JSON 与x-www-form-urlencoded请求体格式的差异。 - 能在
productstore 中定义搜索结果状态、mutation 和 action,调用封装好的搜索 API,并将响应中的data提交到 state。 - 能在搜索页通过
watch监听$route.query并设置immediate: true调用接口获取数据,验证切换分类参数会重新请求,且组件复用时不能依赖mounted重新挂载。 - 能通过浏览器 Network 面板检查请求方法、请求地址、请求体格式和响应结构,定位参数未传递或数据未更新等问题。
知识点
本节目标与代码位置
本节在 src/components/Header/index.vue 中完成搜索关键词收集,并让搜索与分类参数能够合并。头部结构里保留 Logo 图片:
vue
<img src="./images/logo.png" alt="尚品汇Logo">完整基础代码如下(保留原笔记代码,仅将图片 alt 改为中文):
vue
<template>
<div>
<!-- 头部 -->
<header class="header">
<!-- 头部的第一行 -->
<div class="top">
<div class="container">
<div class="loginList">
<p>尚品汇欢迎您!</p>
<p>
<span>请</span>
<router-link to="/login">登录</router-link>
<router-link to="/register" class="register">免费注册</router-link>
<router-link to="/search" class="register">搜索</router-link>
</p>
</div>
<div class="typeList">
<a>我的订单</a>
<a>我的购物车</a>
<a>我的尚品汇</a>
<a>尚品汇会员</a>
<a>企业采购</a>
<a>关注尚品汇</a>
<a>合作招商</a>
<a>商家后台</a>
</div>
</div>
</div>
<!--头部第二行 搜索区域-->
<div class="bottom">
<h1 class="logoArea">
<router-link to="/" class="logo" title="尚品汇">
<img src="./images/logo.png" alt="尚品汇Logo">
</router-link>
</h1>
<div class="searchArea">
<form autocomplete="off" class="searchForm">
<input placeholder="请输入搜索的关键词" type="text" ref="keyword" id="autocomplete" class="input-error input-xxlarge" />
<button @click="goSearch" class="sui-btn btn-xlarge btn-danger" type="button">搜索</button>
</form>
</div>
</div>
</header>
<TypeNav v-if="$route.meta.isTypeNav"></TypeNav>
</div>
</template>
<script>
import TypeNav from "@/components/Header/TypeNav";
export default {
name: "Header",
components: { TypeNav },
methods: {
goSearch() {
// 收集输入的关键词
const keyword = this.$refs.keyword.value.trim();
// 判断关键词是否有值,如果有值跳转至搜索界面,并将关键词进行传递
if (keyword)
this.$router.push({
path: "/search",
query: {
keyword
}
});
}
},
mounted() {
// 判断查询字符串中是否有keyword,如果有将其值设置为搜索框的内容。
if (this.$route.query.keyword)
this.$refs.keyword.value = this.$route.query.keyword;
}
}
</script>收集关键词的基本流程
为什么需要:搜索框输入的内容必须转成路由参数,搜索页才能根据参数请求数据。
怎么想:用 ref 拿到输入框 DOM,点击按钮时读取 value,去掉首尾空格;没有内容就不跳转。
怎么做:this.$refs.keyword.value.trim() 得到 keyword,再 this.$router.push({ path: "/search", query: { keyword } })。
易错点:按钮在 form 内要写 type="button",否则可能触发表单提交;空字符串不要跳转;回填时在 mounted 读取 this.$route.query.keyword。
合并分类与关键词搜索
为什么需要:只传 keyword 会覆盖当前分类参数,导致“有分类没关键词、有关键词没分类”。转写稿调试时点击分类后关键词消失,就是 query 被整体替换。
怎么想:新搜索应保留当前 $route.query,再把 keyword 合并进去。
怎么做:把 goSearch 改成:
js
goSearch() {
const keyword = this.$refs.keyword.value.trim();
if (keyword) {
this.$router.push({
path: "/search",
query: {
...this.$route.query,
keyword
}
});
}
}易错点:顺序必须是“先展开旧 query,再写 keyword”。如果写成 { keyword, ...this.$route.query },旧 keyword 会覆盖新输入,转写稿中“搜索手机没反应”就是这个原因。
路由变化时回填或清空
为什么需要:从搜索页切到注册页再回来,输入框可能还保留旧词。
怎么想:监听 $route,有 keyword 就回填,没有就清空。
怎么做:
js
watch: {
$route(newValue) {
if (!newValue.query.keyword) {
this.$refs.keyword.value = "";
}
}
}易错点:转写稿提到事件总线也能做,但每次路由变化都要通知,效率较低;监听 $route 更直接。
搜索接口与 store 封装
为什么需要:搜索页要拿 query 请求商品列表,不能把请求散落在组件里。
怎么想:按“请求方式、请求地址、发送参数、响应结果”四点封装 API,再在 store/product 中保存结果。
怎么做:API 用 post 发请求体;state 增加搜索结果对象,mutations 保存,actions 调接口后 commit。
易错点:axios.post 第二个参数传对象默认是 JSON,传字符串才是 x-www-form-urlencoded;若 state 初始为空对象,模板里直接写 goodsList[0] 可能报错。
调用接口与调试
为什么需要:搜索页挂载时要请求数据。
怎么想:在 Search 的 mounted 中 dispatch,参数用 this.$route.query。
怎么做:
js
mounted() {
this.$store.dispatch("product/reqSearchList", this.$route.query);
}易错点:只在 mounted 写一次,点击分类时组件复用、不会重新挂载,所以不会再次请求。应改为监听 $route.query 并加 immediate: true,让首次进入也执行。
合并搜索要解决什么问题
为什么需要:如果关键字搜索只传 keyword,类别搜索只传分类参数,那么用户先搜“读书”再点“手机”时,关键词会丢失;先点“手机”再输入关键词搜索时,分类又会丢失。本节要把两类参数合并到同一个 /search 路由的 query 中。
怎么想:每次跳转搜索页时,不要只带当前这一次的参数,而是先拿到当前路由已有的 query,再把自己新增的参数合并进去。新参数要放在展开对象后面,保证新值能覆盖旧值。
怎么做:在 Header 的关键字搜索里合并 keyword;在 TypeNav 的类别搜索里合并动态分类字段和 categoryName。
易错点:合并顺序写反。...this.$route.query 要放在前面,新参数放在后面;否则旧 query 会把本次参数覆盖掉,表现为“搜索没反应”。
关键字搜索保留已有分类参数
为什么需要:用户可能已经点了“手机”分类,再到 Header 输入关键词。此时跳转搜索页不能把分类参数丢掉。
怎么想:先从输入框拿到关键词并 trim(),有值才跳转;跳转时把当前 $route.query 展开,再追加 keyword。
怎么做:
js
goSearch(){
// 收集输入的关键词
const keyword = this.$refs.keyword.value.trim();
// 判断关键词是否有值,如果有值跳转至搜索界面,并将关键词进行传递
if(keyword)
this.$router.push({
path:"/search",
query:{
...this.$route.query,
keyword
}
});
}易错点:不要写成 query: { keyword },否则分类参数会丢;空字符串不要跳转。
类别搜索保留关键词
为什么需要:用户可能已经在搜索页带着 keyword,再点左侧分类。此时只改分类参数,不能把关键词清掉。
怎么想:从点击目标上取 id、level,拼出 category1Id/category2Id/category3Id 这类动态字段;同时保存分类名 categoryName。仍然先展开当前 query,再追加新分类参数。
怎么做:
js
goSearch(e){
const {id,level} = e.target.dataset;
if(id){
this.$router.push({
path:"/search",
query:{
...this.$route.query,
["category"+level+"Id"]:id,
categoryName:e.target.innerText.trim(),
}
});
this.isShow = false;
}
}易错点:["category"+level+"Id"] 是动态键,不要漏掉方括号;点击后要收起分类面板 isShow = false;dataset 取自实际点击目标,点击区域要确保带 data-id、data-level。
合并参数时的顺序陷阱
为什么需要:转写稿里专门调试过:如果把 ...this.$route.query 写在下面,当前新参数会被旧 query 覆盖。比如上次搜“电子书”,这次搜“手机”,结果仍是旧内容。
怎么想:对象展开类似“后面的覆盖前面的”。因此当前路由 query 放上面,本次新参数放下面。
怎么做:两个 goSearch 都保持“先展开旧 query,再写新字段”。
易错点:调试时看地址栏和请求参数;如果地址栏没变或请求参数仍旧,优先检查合并顺序。
搜索页参数变化与关键词清空
为什么需要:搜索页组件不会因为 query 改变而重新挂载。只在 mounted 里请求一次,后续点分类不会再次请求。另外离开搜索页后,Header 输入框里的关键词应清空。
怎么想:监听 $route.query,并设置 immediate: true 让首次进入也执行;监听 $route.path,离开 /search 时清空 this.$refs.keyword.value。转写稿也提到可用事件总线在组件离开时通知,但效率不如直接监听路由。
怎么做:在 Search 中 watch: { "$route.query": { handler(){ /* 调接口 */ }, immediate: true } };在 Header 中监听 $route.path 清空输入框。
易错点:只写 mounted 不写 watch,切换分类不重新请求;immediate 漏写,首次进入不请求。
封装搜索 API 与 store
为什么需要:搜索页需要按条件请求商品数据,接口、状态、页面调用要分层。
怎么想:按“API → store → Search 调用 → 渲染”推进。接口看四点:请求方式、地址、参数、响应结果。转写稿中搜索接口为 POST,地址在 API 列表中,参数是搜索条件对象。
怎么做:在 src/api/product.js 封装搜索商品函数,使用 axios.post,第二个参数传请求体;在 src/store/product.js 增加搜索结果状态和 action,action 接收请求体,调用接口后 commit 保存响应数据。转写稿建议把响应整体放进一个对象,避免定义过多状态;若采用该方案,初始值要给对象,渲染前注意判空,避免空对象直接取 goodsList[0] 报错。
易错点:POST 默认常见为 application/json;若接口要求 application/x-www-form-urlencoded,要按文档传字符串或设置格式;form-data 多用于上传图片。请求格式不对会直接导致接口异常。
搜索页调用接口获取数据
为什么需要:最终要把 $route.query 作为搜索条件发给后端,并拿到商品列表、品牌、属性、总数、页码等数据。
怎么想:在 Search 中通过 this.$store.dispatch 调用 action,把 this.$route.query 传过去;再用 watch 监听 query,参数一变就重新请求。
怎么做:先保证 action 能接收请求体并返回结果,再在 Search 的 watch 中调用。转写稿调试时看到请求已发出、响应里有商品列表、品牌列表、属性列表、总数、页码、每页条数等信息。
易错点:参数没传全时后端可能给默认页码;切换分类但路由组件未卸载,不能依赖 mounted 重新执行;保存响应后渲染要防初始空值。
先明确目标:搜索条件要能叠加
为什么需要:只按关键词搜索时,query 里只有 keyword;再点分类时,如果只传 categoryName,上一次的关键词就丢了;反过来先点分类再搜索,分类也会丢。这样搜索条件不能组合,用户体验不完整。
怎么想:把路由 query 当成“当前搜索条件包”。点击搜索按钮时,不要只传 keyword,而是把当前 query 和新 keyword 合并;点击分类时,也不要只传分类,而是把当前 query 和新分类合并。合并时要注意覆盖顺序:新条件要能覆盖旧条件。
怎么做:在 Header/index 的搜索方法里,按转写稿思路先合并再跳转:
js
// 点击搜索:保留已有分类等条件,用新 keyword 覆盖旧 keyword
this.$router.push({
path: '/search',
query: Object.assign({}, this.$route.query, {
keyword: this.keyword
})
})分类点击同理,把 categoryName 合并进去:
js
this.$router.push({
path: '/search',
query: Object.assign({}, this.$route.query, {
categoryName: item.categoryName
})
})转写稿特别强调 Object.assign 的位置:如果旧 query 放在后面,会把前面的新条件覆盖掉。调试时先搜“电子书”,再点“手机”,如果关键词还在、分类也变了,说明合并成功;如果点手机后关键词没了,说明分类方法没有合并 query。
易错点:不要直接改 this.$route.query 后以为什么都不做;合并时新条件应放在后面覆盖旧值;每次跳转前检查 query 是否带上历史条件。
搜索页输入框与路由同步
为什么需要:在搜索页输入关键词后,去注册页再回来,输入框可能还残留上次内容;但搜索条件已经变了,界面和 query 不一致。
怎么想:监听路由变化。转写稿中判断的是路由 path 是否发生改变;当新路由里没有 keyword 时,把输入框绑定的值清空。
怎么做:在 Search 组件里监听 $route:
js
watch: {
$route(newValue) {
if (!newValue.query.keyword) {
this.keyword = ''
}
}
}易错点:watch 不加 immediate 时首次进入不会执行;如果只监听 path 变化,要确认业务确实需要“path 改变才清空”。转写稿也提到事件总线 $on/$off/$emit 方案:离开 Search 时发布通知,入口文件 new 一个 bus 并定义事件。但这种方式不管路由怎么变都可能触发,效率相对低;监听 $route 更直接。
封装商品搜索 API
为什么需要:组件里直接写 axios 会散落请求细节,接口地址、请求方式、参数格式都难维护。
怎么想:记四件事——请求方式、请求地址、发送参数、响应结果。转写稿中接口是 POST,地址在 API 列表里,参数是请求体。
怎么做:在 src/api/product.js 里封装:
js
import request from '@/utils/request'
// 根据条件搜索商品
export const reqSearchProduct = (body) => {
return request.post('/api/list', body)
}转写稿调试时验证了 POST 第二个参数是请求体:传对象时默认 Content-Type: application/json;传字符串时是 application/x-www-form-urlencoded;form-data 常用于上传图片。调用接口时要按接口文档要求的格式发,否则后面出 bug 很难判断是前端还是接口问题。
易错点:POST 第二个参数不是 query,而是请求体;对象和字符串的编码格式不同;接口要求 form-data 时不能随便传 JSON。
封装 store 保存搜索结果
为什么需要:搜索页、列表渲染都要用搜索结果,放到 Vuex 里便于共享。
怎么想:转写稿给了两种方案:一是把整个响应对象存成 searchResult;二是把品牌、属性、商品列表分别定义状态。讲师建议用第一种,快捷。注意响应里有 goodsList、trademarkList、attrsList、total、pageNum、pageSize、pages 等。
怎么做:在 product 模块中:
js
state: {
searchResult: {}
},
mutations: {
SAVE_SEARCH_RESULT(state, result) {
state.searchResult = result
}
},
actions: {
async getSearchResult({ commit }, body) {
const res = await reqSearchProduct(body)
commit('SAVE_SEARCH_RESULT', res.data)
}
}易错点:初始值写成空对象时,模板里直接写 searchResult.goodsList[0] 会报错,因为首次渲染时 goodsList 还是 undefined。要么初始结构写完整,要么渲染时判空,不要一上来就取中括号第 0 项。
在 Search 组件调用并监听 query
为什么需要:只在 mounted 里调用接口,首次进入搜索页能请求;但点击分类只是改了路由 query,组件没有卸载,mounted 不会再次执行,所以不会重新请求。
怎么想:用 watch 监听 $route.query,并加 immediate: true 让首次也执行。
怎么做:
js
watch: {
'$route.query': {
immediate: true,
handler(query) {
this.$store.dispatch('product/getSearchResult', query)
}
}
}调试时刷新页面会发一次请求;点击“手机”后 query 变化,会再发请求;如果当前已经是手机再点手机,query 没变,不发请求是正常的。
易错点:只写 mounted 不写 watch,点击分类不会更新数据;watch 不加 immediate,首次进入不会请求;不要把请求写在不会重新执行的挂载阶段里。
先明确:为什么要单独封装搜索商品 API
为什么需要:搜索商品时,页面里会带着关键词、分类、页码等参数发请求。如果每个组件都自己写请求地址和请求方式,后面接口一改,就要到处找、到处改;而且请求格式不统一时,很难排查是页面问题还是接口调用问题。转写稿里反复强调“先把 API 写好,再设计 store,再调用接口”,就是把请求收口到 API 层。
怎么想:看接口文档只记四个点:请求方式、请求地址、发送参数、响应结果。搜索商品是 POST,地址是 /list,参数放在请求体里,所以封装函数要接收 body。
怎么做:在 src/api/product.js 中导出函数,保留笔记代码:
js
// 搜索商品,body请求体数据
export const postProductList = (body)=>sphRequest.post("/list",body);这里 sphRequest.post 的第一个参数是接口路径,第二个参数是请求体数据。转写稿提到 API 前缀会由请求实例自动拼接,所以这里只写 /list,不要再重复写 /api。
易错点:一是路径重复拼接;二是把参数拼到 URL 上而不是放进 body;三是没确认接口要求的请求格式。转写稿中专门用 Network 面板看请求头和载荷,确认 Content-Type 与发送数据格式是否一致。若接口要求 JSON,就传对象;若要求 x-www-form-urlencoded,就要按对应格式发送;form-data 常用于上传图片。格式不对时,后端可能收不到参数。
从参数合并理解 body 应该传什么
为什么需要:搜索页里关键词和分类搜索不能互相覆盖。转写稿里说,点搜索只传关键词,点分类关键词就没了;点分类后关键词又被干掉。原因是每次只拿当前动作的参数去请求。
怎么想:把当前路由里已有的 query 和本次新增的关键词合并,生成新的搜索条件。合并时要注意顺序:后写的同名字段会覆盖先写的字段。
怎么做:在触发搜索时,把当前 $route.query 展开,再放入本次关键词;分类搜索同理,也要在已有条件上合并。这样关键词和分类可以同时存在。
易错点:合并顺序写反,旧的 query 把当前关键词覆盖掉;或者只在一个入口做合并,另一个入口仍直接替换参数。转写稿中通过“先搜索电子书,再点手机”来验证关键词是否还在,就是检查合并逻辑。
封装后如何在 store 和页面中调用
为什么需要:API 层只负责“怎么请求”,页面还需要把响应结果保存下来并渲染。转写稿的顺序是:写 API → 设计 store → 调接口 → 渲染数据。
怎么想:store 的 product 模块里保存搜索结果;action 中调用 postProductList(body),拿到响应后取响应体中的 data,再通过 commit 保存。页面组件不直接关心请求细节。
怎么做:在 store 中定义搜索结果状态,action 接收请求体 body,调用封装好的 postProductList,把返回数据提交到 mutation。Search 组件把收集到的 query 作为参数传进去。
易错点:转写稿特别提醒,如果初始状态是空对象,渲染时直接写 result.goods[0] 可能出错,因为首次渲染时 result.goods 还不存在;要么先给合理初始结构,要么在模板中做空值保护。另一个易错点是只在 mounted 里调用接口:点击分类只改变路由参数,组件不会卸载重建,所以不会再次请求。应监听 $route.query,并设置首次立即执行。
调试过程:先验证请求,再排查渲染
为什么需要:接口调不通时,不能只看页面有没有数据,要先确认请求有没有发出去、发成了什么格式。
怎么想:转写稿中临时在入口文件用请求实例发 POST,打开 Network 看请求头 Content-Type 和载荷,确认参数格式;再回到业务接口中按同样方式发送。
怎么做:确认请求方式为 POST、地址为 /list、请求体为搜索条件;观察响应中是否包含商品列表、品牌、属性、页码、每页条数、总条数等字段,再决定 store 保存整个结果还是拆成多个状态。
易错点:请求发了但格式不对,误以为接口没返回;或者响应有数据但页面取值路径写错。先看 Network 的请求头和载荷,再看响应结构,是转写稿中比较稳妥的调试顺序。
为什么需要封装 product store
商品搜索页会同时用到关键词、分类、页码等条件,搜索结果又要在多个组件/多次交互中复用。如果把这些数据都写在组件里,分类一改、关键词一改,状态就容易散落,组件之间也不好共享。转写稿里反复强调“综合搜索”:点搜索时要把当前关键词和已有分类参数合并;点分类时也不能把关键词干掉。要让这些参数和结果稳定下来,就需要把商品搜索相关状态收进 Vuex 的 product 模块。
怎么想:接口返回的搜索结果是对象,不只是数组;状态里要保存“搜索结果”,mutation 只负责同步赋值,action 负责调接口并把结果提交给 mutation。
怎么做:在 src/store/product.js 中定义 state、mutations、actions,并导出 namespaced: true 的模块。
易错点:模块开启命名空间后,组件里 dispatch 要带模块名,例如 product/postProductListAsync,不能只写 action 名。
怎么设计 state 与 mutation
笔记里 state.searchProductResult 初始值是 {},不是数组。原因是转写稿中确认接口返回的 data 是一个对象,里面包含商品列表、品牌、属性、页码、每页条数、总条数等信息。把整个 data 保存下来,比在 state 里逐个定义品牌、属性、商品列表更省事,后续渲染时从 searchProductResult 里取对应字段即可。
为什么需要:搜索结果是异步回来的,必须先有初始状态,组件才能安全绑定。
怎么想:mutation 命名为 SAVE_SEARCH_PRODUCT_RESULT,语义明确:保存搜索结果。
怎么做:mutation 接收 state 和 result,执行 state.searchProductResult = result。
易错点:初始值是空对象时,首次渲染直接写 searchProductResult.goods[0] 可能出错,因为此时 goods 还不存在。转写稿提到两种方案:保存整个对象,或拆成品牌、属性、商品列表等多个状态;课程中建议先用整体对象,但渲染时要注意空对象取值问题,必要时加默认值或可选链。
怎么做:product.js 完整代码
js
import {
postProductList
} from "@/api/product";
const state = {
// 搜索的结果
searchProductResult:{}
}
const mutations = {
// 保存搜索结果
SAVE_SEARCH_PRODUCT_RESULT(state,result){
state.searchProductResult = result;
}
}
const actions = {
async postProductListAsync({commit},body){
const {data} = await postProductList(body);
commit("SAVE_SEARCH_PRODUCT_RESULT",data);
}
}
export default {
namespaced:true,
state,
mutations,
actions
}为什么需要 action:组件不直接调接口再 commit,而是把“请求 + 提交结果”封装进 action,组件只负责传参数。
怎么想:postProductList(body) 返回的响应里取 data,这个 data 就是搜索结果对象。
怎么做:async action 中 await 接口,解构 { data },再 commit("SAVE_SEARCH_PRODUCT_RESULT", data)。
易错点:不要忘记 await;不要直接改 state;mutation 要通过 commit 调用。
组件里怎么调用与调试
转写稿中调用接口时,参数来自当前路由 query:this.$route.query。组件里可 dispatch:
js
this.$store.dispatch("product/postProductListAsync", this.$route.query);但调试时会发现:如果只写在 mounted 里,点击分类只改变路由 query,组件并没有卸载,所以不会重新挂载,也就不会再次发请求。转写稿的解决思路是监听 $route.query,并设置 immediate: true 让进入页面立即执行一次。这样分类或关键词变化时都会重新请求。
为什么需要:搜索页是同一个组件,query 变化不等于组件重新创建。
怎么想:监听路由 query,而不是只依赖生命周期。
怎么做:在 watch 中拿到新 query,dispatch 到 product 模块;首次进入用 immediate 立即调用。
易错点:合并搜索参数时,转写稿特别提醒对象合并顺序:把 $route.query 放在上面,把当前关键词放在下面,这样新关键词才能覆盖旧 query 里的同名字段;如果顺序反了,旧 query 会把当前输入覆盖掉,出现“搜手机还是上一次结果”的问题。
易错点小结
namespaced: true后,dispatch 必须写product/postProductListAsync。- mutation 只做同步赋值,异步请求放 action。
searchProductResult初始是{},渲染商品列表时不要把它当数组直接取[0]。- 分类和关键词要合并,不要互相覆盖;合并时注意先后顺序。
- 只写
mounted不会在 query 改变时重新请求,应监听$route.query并立即执行一次。 - 转写稿提到事件总线也能做,但路由 query 变化监听更直接,效率更高。
这一节要解决什么问题
这一节的目标是:在 Search 页面里,根据路由上的查询条件调用接口获取商品数据。它不只是“发一次请求”,而是要把关键字搜索和分类搜索合并起来,并且让路由参数变化时自动重新请求。笔记中的核心代码如下:
js
<script>
export default {
name: "Search",
watch:{
"$route.query":{
handler(query){
this.$store.dispatch("product/postProductListAsync",query)
},
immediate:true
}
}
}
</script>为什么需要这样做?因为搜索页可能从不同入口进入:有时带关键字,有时带分类,有时两者都有。如果每次点击搜索都只传当前一个条件,就会把另一个条件覆盖掉。怎么想?把“当前路由 query”当成已有条件,再把新条件合并进去。怎么做?在搜索按钮和分类按钮的处理里,用展开运算符合并 this.$route.query。易错点是合并顺序:如果新条件写在 $route.query 前面,后面的旧 query 可能把新值覆盖;所以要让新条件放在后面,例如 { ...this.$route.query, keyword: ... }、{ ...this.$route.query, categoryName: ... }。转写里反复强调“把 query 放上面”,本质就是避免旧参数覆盖新参数。
为什么用 watch 监听 $route.query
为什么需要?如果只在 mounted 里调用接口,第一次进入搜索页能请求;但在搜索页里切换分类时,组件不会卸载,也不会重新挂载,所以 mounted 不会再执行,数据就不会更新。怎么想?真正变化的是路由 query,不是组件生命周期。怎么做?监听 "$route.query",并在 handler 里 dispatch,同时加 immediate: true 让首次进入也执行一次。易错点:不要以为“点击分类”一定会触发请求;如果 query 没变,watch 不会触发,这是正常的。调试时可在 Network 看请求是否发出,并检查请求参数是否包含分类、关键字、页码等。
先封装 API:明确请求四要素
为什么需要?组件里直接写请求地址和参数,会让页面和接口耦合。怎么想?先看接口文档的四个点:请求方式、请求地址、发送参数、响应结果。怎么做?在 src/api/product.js 里封装“搜索商品”的函数,例如按文档使用 POST,并把请求体作为第二个参数传给 axios。易错点:axios 的 post(url, data) 第二个参数是请求体;传对象通常按 application/json 发送,传字符串/URLSearchParams 常见为 application/x-www-form-urlencoded,上传图片才用 form-data。接口要求什么格式,就按什么格式发,否则容易出现“代码执行了但没结果”的 bug。
再封装 store:保存搜索结果
为什么需要?接口结果要在页面里渲染,最好统一放进 Vuex 模块。怎么想?在 store/module/product.js 里增加状态保存搜索结果,增加 mutation 保存数据,增加 action 调 API 后 commit。怎么做?action 接收页面传来的查询参数,调用封装好的 API,把响应体中的 data 提交到 mutation。易错点:如果初始值设成空对象,渲染时直接写 result.goods[0],首次渲染可能因为 goods 还不存在而报错;可先用空数组、条件渲染或可选链。转写里也提醒:保存整个结果对象后,模板里要写 result.goods,不能想当然按数组用。
在 Search 组件里调用接口
为什么需要?API 和 store 准备好后,页面要在合适的时机触发 action。怎么想?既然监听的是 $route.query,就把 query 直接作为 action 参数传过去。怎么做?就是笔记中的 this.$store.dispatch("product/postProductListAsync", query)。这样从首页带关键字进入、在搜索页切换分类、修改关键字后重新搜索,只要 query 变化,就会重新请求。易错点:不要在组件里重复写一套请求逻辑;也不要把“点击相同分类”误判为失败,因为参数没变时 watch 不会重复执行。调试时先看请求有没有发、载荷对不对,再看响应结构,最后看 store 是否更新、模板是否渲染。
课堂画面





代码示例
示例 1:src/components/Header/index.vue —— 收集搜索关键词 + 合并类别搜索参数
vue
<template>
<div>
<!-- 头部 -->
<header class="header">
<!-- 头部的第一行 -->
<div class="top">
<div class="container">
<div class="loginList">
<p>尚品汇欢迎您!</p>
<p>
<span>请</span>
<router-link to="/login">登录</router-link>
<router-link to="/register" class="register">免费注册</router-link>
<router-link to="/search" class="register">搜索</router-link>
</p>
</div>
<div class="typeList">
<a>我的订单</a>
<a>我的购物车</a>
<a>我的尚品汇</a>
<a>尚品汇会员</a>
<a>企业采购</a>
<a>关注尚品汇</a>
<a>合作招商</a>
<a>商家后台</a>
</div>
</div>
</div>
<!--头部第二行 搜索区域-->
<div class="bottom">
<h1 class="logoArea">
<router-link to="/" class="logo" title="尚品汇">
<img src="./images/logo.png" alt="" />
</router-link>
</h1>
<div class="searchArea">
<form autocomplete="off" class="searchForm">
<input
placeholder="请输入搜索的关键词"
type="text"
ref="keyword"
id="autocomplete"
class="input-error input-xxlarge"
/>
<button
@click="goSearch"
class="sui-btn btn-xlarge btn-danger"
type="button"
>
搜索
</button>
</form>
</div>
</div>
</header>
<TypeNav v-if="$route.meta.isTypeNav"></TypeNav>
</div>
</template>
<script>
import TypeNav from "@/components/Header/TypeNav";
export default {
name: "Header",
components: { TypeNav },
methods: {
goSearch() {
// 收集输入的关键词
const keyword = this.$refs.keyword.value.trim();
// 判断关键词是否有值,如果有值跳转至搜索界面,并将关键词进行传递
if (keyword)
this.$router.push({
path: "/search",
query: {
...this.$route.query,
keyword,
},
});
},
},
mounted() {
// 判断查询字符串中是否有keyword,如果有将其值设置为搜索框的内容。
if (this.$route.query.keyword)
this.$refs.keyword.value = this.$route.query.keyword;
},
};
</script>要点解释 / 易错点
ref="keyword"配合this.$refs.keyword.value拿到输入框真实值,.trim()去掉首尾空格,避免把空串当关键词传过去。if (keyword)是必要守卫:关键词为空时不跳转,防止/search?keyword=这种无意义请求。- 关键改动是
query里的...this.$route.query:必须写在keyword之前。对象展开顺序决定覆盖关系,写在后面会把已有参数(如category1Id)覆盖掉,且转写稿中明确指出:若写在下面,上一次搜索的query会把本次输入替换掉,导致当前搜索失效。 button上写了type="button",避免 form 默认提交导致页面刷新。mounted中回填关键词,保证从搜索页刷新或路由跳转回来时输入框仍显示当前关键词。- 这里用了
$route.query.keyword而不是$route.params,因为搜索条件都放在 query 上。
示例 2:src/components/Header/TypeNav/index.vue —— 类别搜索(合并关键词)
vue
<script>
export default {
name: "TypeNav",
data() {
return {
isShow: true,
};
},
methods: {
goSearch(e) {
const { id, level } = e.target.dataset;
if (id) {
this.$router.push({
path: "/search",
query: {
...this.$route.query,
["category" + level + "Id"]: id,
categoryName: e.target.innerText.trim(),
},
});
this.isShow = false;
}
},
},
};
</script>要点解释 / 易错点
const { id, level } = e.target.dataset;直接解构data-id、data-level,模板上写成data-id/data-level即可。if (id)守卫:点到非类别元素(如纯文字或外层容器)时id为undefined,不跳转,避免产生脏参数。["category" + level + "Id"]: id是计算属性名写法,一级类别得到category1Id,二级得到category2Id,与后端接口参数名对应。- 同样把
...this.$route.query放在最前面,这样点击类别时不会把已有keyword干掉,实现“类别 + 关键词”合并搜索;这也是本节的核心目的。 categoryName: e.target.innerText.trim()用于面包屑展示,取自元素文本,trim()防止多余空白。this.isShow = false;跳转后收起类别下拉面板。
示例 3:API 封装 + store 模块 + Search 页面调用
src/api/product.js
js
// 搜索商品,body请求体数据
export const postProductList = (body) => sphRequest.post("/list", body);src/store/product.js
js
import { postProductList } from "@/api/product";
const state = {
// 搜索的结果
searchProductResult: {},
};
const mutations = {
// 保存搜索结果
SAVE_SEARCH_PRODUCT_RESULT(state, result) {
state.searchProductResult = result;
},
};
const actions = {
async postProductListAsync({ commit }, body) {
const { data } = await postProductList(body);
commit("SAVE_SEARCH_PRODUCT_RESULT", data);
},
};
export default {
namespaced: true,
state,
mutations,
actions,
};src/pages/Search/index.vue
vue
<script>
export default {
name: "Search",
watch: {
"$route.query": {
handler(query) {
this.$store.dispatch("product/postProductListAsync", query);
},
immediate: true,
},
},
};
</script>要点解释 / 易错点
postProductList第二个参数直接传对象,axios 会以 JSON 形式作为请求体发出,正好对应文档要求的 body 参数;/list是相对路径,baseURL 由sphRequest统一拼接。- store 模块用
namespaced: true,所以 dispatch 必须写全"product/postProductListAsync",漏掉product/会报 action 不存在。 const { data } = await postProductList(body);解构的是响应体中的data字段,后端返回里还有trademarkList、attrsList、goodsList、total、pageNo、pageSize等,这里整体保存,渲染时记得写searchProductResult.goodsList。searchProductResult初始值给了{}(对象)而不是数组,转写稿特别提醒:若在模板里直接写[0]取值,初次渲染时{}.goodsList是undefined,undefined[0]会报错。- 为什么用
watch而不是mounted:点击类别只改变$route.query,路由路径仍是/search,组件不会卸载重建,mounted只执行一次,不会重新发请求。监听$route.query才能做到“参数变就请求”。 immediate: true保证首次进入搜索页也执行一次,等价于原来mounted里的那次调用,不需要两处都写。- 想省事可以直接监听
$route.query,比监听$route.path更准确——path不变时不会触发。
常见错误
现象: 点分类后关键词消失,或点搜索后分类条件丢失。
原因: 跳转时只传了当前新条件,没有合并$route.query中的旧条件。
修法:query中先展开旧条件,再写新字段,如{ ...this.$route.query, keyword }、{ ...this.$route.query, ["category"+level+"Id"]: id, categoryName }。现象: 搜索框已有旧关键词时,再输入新关键词点搜索却不生效。
原因: 合并顺序写反,...this.$route.query放在新keyword后面,旧keyword覆盖了新值。
修法: 旧条件放前面,新条件放后面;同名字段由后面的新值覆盖。现象: 进入
/search?keyword=手机搜索框不回填;离开搜索页后搜索框仍残留关键词。
原因: 只在mounted中处理,组件复用或路由变化时不会重新执行。
修法: 监听$route.query/$route.path,有keyword时回填,无keyword时清空this.$refs.keyword.value。现象: 在搜索页切换分类或改关键词,页面数据不更新、不重新请求。
原因: 路由仍是/search,组件被复用,mounted不会再次触发。
修法: 在Search中watch: { "$route.query": { handler(query){ this.$store.dispatch("product/postProductListAsync", query) }, immediate: true } }。现象: 调用
POST /list后端收不到参数或提示格式不对。
原因:axios.post第二参数传了字符串或格式不符合接口要求,导致Content-Type变成表单格式。
修法: 按接口要求传对象sphRequest.post("/list", body);用 Network 面板核对Content-Type与请求体。现象: 模板渲染
searchProductResult.goodsList[0]报undefined相关错误。
原因: store 初始状态是空对象或空数组,首次渲染时目标字段不存在。
修法: 初始状态按模板消费结构给默认值,或模板加v-if/ 安全取值。
练习
- 在
Header的goSearch中实现“关键词搜索保留已有分类条件”,并说明...this.$route.query为什么必须放在新keyword前面。 - 实现搜索框回填与清空:进入
/search?keyword=手机时回填;离开搜索页或query中无keyword时清空输入框。 - 在
Search组件中监听$route.query并立即请求商品列表,说明为什么不能只写在mounted中。 - 为
productstore 设计searchProductResult的初始状态,使模板能安全渲染goodsList、trademarkList、attrsList,并处理无数据场景。 - 封装
postProductList,用 Network 面板验证POST /list的请求体与Content-Type是否符合接口要求。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 合并类别及关键字搜索 | 06:08 |
| 2 | 搜索优化 | 04:02 |
| 3 | 搜索封装API | 09:42 |
| 4 | 封装store | 07:29 |
| 5 | 调用接口获取数据 | 07:39 |
小结
- 搜索条件应以路由
query为唯一事实来源,组件内部状态只负责输入展示与触发。 - 合并
query时,旧条件在前、新条件在后,覆盖顺序决定最终搜索条件。 - 搜索页取数不能依赖
mounted,组件复用场景下应监听$route.query并immediate执行。 - 搜索框回填和清空属于路由变化问题,不是单纯输入框赋值问题。
- API 层只负责请求方式、地址、参数和响应;store 负责保存结果;页面负责在正确时机触发。
- store 初始状态要按模板实际消费结构设计,避免首次渲染访问不存在的字段。
- 排查搜索链路按“Network 请求 → Vuex 状态 → 组件 watch → 模板渲染”顺序定位。