Skip to content

收集搜索关键词与合并类别及关键字搜索(Lesson16 2/3) ​

本页对应 Lesson16,共 5 段视频,约 35 分钟。

学习目标 ​

  1. 能说明合并类别与关键字搜索时参数合并的顺序,使用展开运算符将当前路由 query 与搜索关键词合并,并验证搜索后再切换分类时关键字与分类参数均能保留。
  2. 能实现搜索优化:监听路由参数变化,在参数为空时清空搜索关键词;或使用事件总线在离开搜索页时清空关键词,并能说明两种方案的触发时机与差异。
  3. 能按接口文档在 product API 模块中封装搜索商品接口,正确设置 POST 请求方式、接口地址和请求体参数,并说明 JSON 与 x-www-form-urlencoded 请求体格式的差异。
  4. 能在 product store 中定义搜索结果状态、mutation 和 action,调用封装好的搜索 API,并将响应中的 data 提交到 state。
  5. 能在搜索页通过 watch 监听 $route.query 并设置 immediate: true 调用接口获取数据,验证切换分类参数会重新请求,且组件复用时不能依赖 mounted 重新挂载。
  6. 能通过浏览器 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

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例 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 不变时不会触发。

常见错误 ​

  1. 现象: 点分类后关键词消失,或点搜索后分类条件丢失。
    原因: 跳转时只传了当前新条件,没有合并 $route.query 中的旧条件。
    修法: query 中先展开旧条件,再写新字段,如 { ...this.$route.query, keyword }、{ ...this.$route.query, ["category"+level+"Id"]: id, categoryName }。

  2. 现象: 搜索框已有旧关键词时,再输入新关键词点搜索却不生效。
    原因: 合并顺序写反,...this.$route.query 放在新 keyword 后面,旧 keyword 覆盖了新值。
    修法: 旧条件放前面,新条件放后面;同名字段由后面的新值覆盖。

  3. 现象: 进入 /search?keyword=手机 搜索框不回填;离开搜索页后搜索框仍残留关键词。
    原因: 只在 mounted 中处理,组件复用或路由变化时不会重新执行。
    修法: 监听 $route.query / $route.path,有 keyword 时回填,无 keyword 时清空 this.$refs.keyword.value。

  4. 现象: 在搜索页切换分类或改关键词,页面数据不更新、不重新请求。
    原因: 路由仍是 /search,组件被复用,mounted 不会再次触发。
    修法: 在 Search 中 watch: { "$route.query": { handler(query){ this.$store.dispatch("product/postProductListAsync", query) }, immediate: true } }。

  5. 现象: 调用 POST /list 后端收不到参数或提示格式不对。
    原因: axios.post 第二参数传了字符串或格式不符合接口要求,导致 Content-Type 变成表单格式。
    修法: 按接口要求传对象 sphRequest.post("/list", body);用 Network 面板核对 Content-Type 与请求体。

  6. 现象: 模板渲染 searchProductResult.goodsList[0] 报 undefined 相关错误。
    原因: store 初始状态是空对象或空数组,首次渲染时目标字段不存在。
    修法: 初始状态按模板消费结构给默认值,或模板加 v-if / 安全取值。

练习 ​

  1. 在 Header 的 goSearch 中实现“关键词搜索保留已有分类条件”,并说明 ...this.$route.query 为什么必须放在新 keyword 前面。
  2. 实现搜索框回填与清空:进入 /search?keyword=手机 时回填;离开搜索页或 query 中无 keyword 时清空输入框。
  3. 在 Search 组件中监听 $route.query 并立即请求商品列表,说明为什么不能只写在 mounted 中。
  4. 为 product store 设计 searchProductResult 的初始状态,使模板能安全渲染 goodsList、trademarkList、attrsList,并处理无数据场景。
  5. 封装 postProductList,用 Network 面板验证 POST /list 的请求体与 Content-Type 是否符合接口要求。

原视频对照 ​

#视频时长
1合并类别及关键字搜索06:08
2搜索优化04:02
3搜索封装API09:42
4封装store07:29
5调用接口获取数据07:39

小结 ​

  1. 搜索条件应以路由 query 为唯一事实来源,组件内部状态只负责输入展示与触发。
  2. 合并 query 时,旧条件在前、新条件在后,覆盖顺序决定最终搜索条件。
  3. 搜索页取数不能依赖 mounted,组件复用场景下应监听 $route.query 并 immediate 执行。
  4. 搜索框回填和清空属于路由变化问题,不是单纯输入框赋值问题。
  5. API 层只负责请求方式、地址、参数和响应;store 负责保存结果;页面负责在正确时机触发。
  6. store 初始状态要按模板实际消费结构设计,避免首次渲染访问不存在的字段。
  7. 排查搜索链路按“Network 请求 → Vuex 状态 → 组件 watch → 模板渲染”顺序定位。