Skip to content

优化搜索与创建产品详情路由(Lesson18 1/2) ​

本页对应 Lesson18,共 7 段视频,约 2 小时 30 分。

学习目标 ​

  • 能够封装统一的 goSearch 路由跳转方法,合并当前 query 与传入参数,并用它替换分页、排序、属性、品牌、关键词移除等场景中的重复跳转代码。
  • 能够定位并修复搜索页分页与查询参数相关 BUG,说明自定义事件传参、this 指向、字符串/数字类型转换及 query.props 数组处理问题的原因与修复方式。
  • 能够为商品详情配置带参数的动态路由,使用 router-link 从商品列表的图片和标题跳转到详情页并正确传递商品 ID。
  • 能够基于静态页面创建商品详情静态组件,迁移页面结构与样式、修正图片路径,并清理接口无数据对应的冗余区域。
  • 能够使用 scrollBehavior 配置路由切换后的滚动位置,结合 to/from 与 meta 控制详情页、排序等场景是否复位到页面顶部。
  • 能够封装商品详情 API,通过 Vuex 的 action/mutation 获取详情数据,并渲染标题、描述、价格、SPU 属性等基本数据,处理异步渲染初始值问题并完成放大镜功能。

知识点 ​

为什么要封装 goSearch ​

搜索页的筛选、排序、分页都会改地址栏 query。若每个操作都写 this.$router.push({ path:"/search", query:{...} }),就要反复合并当前 query,容易漏参数。转写稿里明确说:这些方法“都有共同特点”,可以集中处理。怎么想:把“跳到搜索页并合并 query”抽成路由原型方法,页面只传本次要改的字段。怎么做:在 src/router/index.js 给 VueRouter.prototype 增加 goSearch。易错点:pageNo:1 要放在 ...query 前面,让调用方可以覆盖;否则分页永远回第一页。

js
// 接收参数query
VueRouter.prototype.goSearch=function(query){
  this.push({
    path:"/search",
    query:{
      ...this.history.current.query,
      pageNo:1,
      ...query
    }
  })
}

用 goSearch 统一改筛选条件 ​

为什么需要:移除类别、关键词、品牌、属性时,如果各自拼 query,代码重复且容易清不干净。怎么想:goSearch 会先保留当前 query,所以清除某项就传 undefined 覆盖它。怎么做:类别要同时清 categoryName 和三级 id;关键词清除后还要通知搜索框清空;属性用 filter 去掉当前项。易错点:类别不只一个字段,只清 categoryName 会留下 id;属性是数组,不能直接赋空字符串。

js
moveCategoryName(){
  this.$router.goSearch({
    categoryName:undefined,
    category3Id:undefined,
    category2Id:undefined,
    category1Id:undefined
  });
},
moveKeyword(){
  this.$router.goSearch({ keyword:undefined });
  this.$bus.$emit("clearKeyword");
},
moveTrademark(){
  this.$router.goSearch({ trademark:undefined });
}

模板中属性标签可写成:

vue
<li v-for="item in $route.query.props">
  {{item.split(":")[1]}}
  <i @click="$router.goSearch({props:$route.query.props.filter(v=>v !== item)})">×</i>
</li>

排序状态初始化与切换 ​

为什么需要:综合、价格排序要显示上下箭头,并写入 order=type:flag。怎么想:从 $route.query.order 拆出 type 和 flag,没有就默认 1:desc。怎么做:data 中 type:type/1 转数字;点击同类排序翻转 flag,不同类则重置为 desc。易错点:query 里的 type 是字符串,若 this.type === type 时一边数字一边字符串,会导致第一次点击不翻转。转写稿中就是通过 type/1 或 == 解决。

js
data(){
  const [type,flag]= (this.$route.query.order || "1:desc").split(":");
  return { type:type/1, flag };
},
methods:{
  orderSearch(type){
    if(this.type === type){
      this.flag = this.flag==="desc"?"asc":"desc";
    }else{
      this.type = type;
      this.flag = "desc";
    }
    this.$router.goSearch({order:this.type+":"+this.flag});
  }
}

分页事件的参数坑 ​

为什么需要:分页组件要把新页码交给 goSearch。笔记原写法 @change-page-no="$router.goSearch({pageNo})" 中 pageNo 在模板作用域没有定义,会报“属性或方法不存在”。怎么想:自定义事件触发时要把组件传出的页码接住。怎么做:改成箭头函数接收参数。易错点:不要写成普通函数后丢 this;转写稿也强调要弄清程序执行流程再调 bug。

vue
<Pagination :continue="5"
  @change-page-no="pageNo => $router.goSearch({pageNo})"
  :total="$store.state.product.searchProductResult.total"
  :pageNo="$store.state.product.searchProductResult.pageNo"
  :pageSize="$store.state.product.searchProductResult.pageSize">
</Pagination>

监听 $route.query 发请求 ​

为什么需要:筛选、排序、分页最终都体现为 query 变化,统一监听即可请求列表。怎么想:immediate:true 让首次进入也请求;请求参数取当前页码、页大小,再展开 query。怎么做:若 query.props 是字符串,先转成数组。易错点:不要直接依赖单值 props;否则属性筛选时后端收到的形态不一致。

js
watch:{
  "$route.query":{
    handler(query){
      const {pageNo,pageSize} = this.$store.state.product.searchProductResult;
      if(query.props && (typeof query.props === "string")){
        query.props = [query.props];
      }
      this.$store.dispatch("product/postProductListAsync",{
        pageNo, pageSize, ...query
      })
    },
    immediate:true
  }
}

isSelector 控制面包屑显示 ​

为什么需要:没有筛选条件时不应显示“全部结果”那一栏。怎么想:复制 query,排除 pageNo,空 props 也排除,再看是否还有有效值。怎么做:Object.values(query).filter(v=>v).length>0。易错点:[] 是 truthy,空数组必须先删;pageNo 不是筛选条件,不能算进去。

js
computed:{
  isSelector(){
    const query = {...this.$route.query};
    delete query.pageNo;
    if(query.props && query.props.length===0) delete query.props;
    return Object.values(query).filter(v=>v).length>0;
  }
}

收尾与易错点 ​

beforeDestroy 中 $bus.$emit("clearKeyword"),避免离开搜索页后搜索框残留关键词。整体优化后,页面里大量重复 push 被压成一行 goSearch 调用;排序、分页、清标签都复用它。检查时重点看:pageNo 是否被默认值覆盖、type 是否数字、props 是否数组、空筛选是否隐藏面包屑。转写稿的调试过程说明:先看报错指向哪个变量,再回到模板事件和 query 形态,不要盲目改代码。

先创建详情页组件 ​

为什么需要:路由必须有组件承载,否则访问详情地址时没有内容可渲染。转写稿里的顺序也是“先建组件,再配路由,再做列表跳转”。

怎么想:在 src/pages/Detail/index.vue 放一个最小页面,先验证路由能通,再考虑详情数据渲染。此时只写“详情”两个字即可。

怎么做:

vue
<template>
    <div>
        <h3>详情</h3>
    </div>
</template>

<script>
export default {
    name: "Detail"
}
</script>

<style scoped>

</style>

易错点:组件名和文件名要能对应;template 最好保留单一根元素;先别急着写复杂结构,否则路由不通时不好判断是组件问题还是路由问题。

配置动态详情路由 ​

为什么需要:详情页要按商品 id 显示不同商品,所以路径不能写死,要用动态段 :id。转写稿中明确用了 /detail/:id.html,并加 .html 后缀,目的是让地址更像静态页,便于搜索引擎收录。

怎么想:在 src/router/index.js 的路由数组里增加一条:路径带 :id.html,组件指向 Detail;同时加 meta.isTypeNav: true,让详情页也显示商品分类导航。

怎么做:

js
{
    path:"/detail/:id.html",
    component:Detail,
    meta:{
        isTypeNav: true
    }
}

易错点:要记得在路由文件里引入 Detail;path 的写法和列表跳转拼接必须一致;:id 是参数名,后面可通过 $route.params.id 取;.html 是静态后缀,不要误以为 id 包含 .html;meta 的字段名要和页面判断逻辑一致。

从搜索列表跳到详情 ​

为什么需要:用户搜索出商品后,需要点击商品图片或标题进入详情。转写稿特别提醒:不要给整个 li 加跳转,因为 li 里还有“加入购物车”“收藏”等操作,整块跳转会导致误触。

怎么想:只给商品图片和标题加 router-link。图片用 '/detail/' + item.id + '.html' 拼接;v-for 要有 :key,笔记里用的是 item.id。

怎么做:

vue
<li :key="item.id" v-for="item in $store.state.product.searchProductResult.goodsList" class="yui3-u-1-5">
    <div class="list-wrap">
        <div class="p-img">
            <router-link :to="'/detail/'+item.id+'.html'"><img :src="item.defaultImg" /></router-link>
        </div>
        <div class="price">
            <strong>
                <em>¥</em>
                <i>{{item.price}}</i>
            </strong>
        </div>
        <div class="attr">
            <a target="_blank" href="item.html"  :title="item.title" v-html="item.title"></a>
        </div>
        <div class="commit">
            <i class="command">已有<span>{{item.hotScore}}</span>人评价</i>
        </div>
        <div class="operate">
            <a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
            <a href="javascript:void(0);" class="sui-btn btn-bordered">收藏</a>
        </div>
    </div>
</li>

易错点:router-link 的 to 拼接要带 .html;不要用普通 a href 跳详情,否则会整页刷新;笔记里标题仍是 a 标签,转写稿说标题也应发生跳转,实际应同样改成 router-link;不要漏 :key。

调试与验证 ​

为什么需要:路由配置完不一定马上正确,要验证“地址能进、组件能出、参数能带”。转写稿中手动访问 /detail/1.html,能看到“详情”说明路由通了;点击列表图片后地址带上 id,说明拼接正确。

怎么想:先手动输入带 id 的地址测试,再从搜索列表点击图片测试。转写稿还发现单页面切换会保留上一次滚动位置:列表往下拉后进详情,页面仍停在下方,因此要在路由层处理滚动位置,而不是在详情组件里硬改。

怎么做:转写稿通过 scrollBehavior 返回 {x:0,y:0} 让切换路由回到顶部,并可结合 meta 判断某些页面是否恢复滚动。核心是:路由切换时统一控制滚动,不要每个组件各写一套。

易错点:滚动行为属于路由配置问题;scrollBehavior 接收 to/from,返回值决定滚动位置;meta 字段命名要统一;先保证路由和跳转正确,再处理滚动细节。

先明确:静态详情页的目标 ​

转写稿里说的“抵碳/低炭”就是 Detail,“配出所”就是 pages,“银袋”就是 index。这一节先不做接口,只把商品详情页的静态结构、图片和样式搬进 Vue 组件,保证点击商品能进入详情页且页面“长得像”。

  • 为什么需要:详情页结构复杂,先搭静态骨架,后续接数据时只替换绑定,不会一边调样式一边调接口。
  • 怎么想:先建组件 → 配路由 → 从静态页复制主体 → 替换图片路径 → 复制样式 → 验收。
  • 怎么做:新建 src/pages/Detail/index.vue,只保留一个根元素。
  • 易错点:Vue 模板只能有一个根节点;图片相对路径要按组件位置写;不要复制静态页里的头部、底部和旧 JS。

新建 Detail 组件:先保证单根和命名 ​

vue
<template>
  <div>
    <!-- 详情页主体 -->
  </div>
</template>

<script>
export default {
  name: "Detail"
};
</script>
  • 为什么需要:name 便于调试,单根节点是模板编译要求。
  • 怎么想:先把空组件跑通,再往里塞结构。
  • 怎么做:在 src/pages/Detail/index.vue 写上面的骨架。
  • 易错点:复制静态页时容易带出多个并列根节点,必须用一个 div 包住。

复制主体结构:面包屑、主内容、详情、猜你喜欢 ​

从静态页只复制“主要内容区域”,不要头部、底部、商品分类。图片路径统一指向 ../../assets/images/...,并补中文 alt。

vue
<template>
  <div>
    <section class="con">
      <div class="conPoin">
        <div class="conPoin">
          <a href="###">手机、数码、通讯</a>
          <a href="###">手机</a>
          <a href="###">Apple苹果</a>
          <a>iphone 6S系类</a>
        </div>
      </div>

      <div class="mainCon">
        <div class="previewWrap">
          <div class="preview">
            <div class="jqzoom">
              <img src="../../assets/images/s1.png" alt="主商品图1">
            </div>
          </div>
          <div class="specScroll">
            <a class="prev">&lt;</a>
            <div class="items">
              <div class="itemsCon" style="transition: all 0.5s ease 0s;">
                <img src="../../assets/images/s1.png" alt="缩略图1">
                <img src="../../assets/images/s2.png" alt="缩略图2">
                <img src="../../assets/images/s3.png" alt="缩略图3">
                <img src="../../assets/images/s1.png" alt="缩略图4">
                <img src="../../assets/images/s2.png" alt="缩略图5">
                <img src="../../assets/images/s3.png" alt="缩略图6">
              </div>
            </div>
            <a class="next">&gt;</a>
          </div>
        </div>

        <div class="InfoWrap">
          <div class="goodsDetail">
            <h3 class="InfoName">Apple iPhone 6s(A1700)64G玫瑰金色 移动通信电信4G手机</h3>
            <p class="news">推荐选择下方[移动优惠购],手机套餐齐搞定,不用换号,每月还有花费返</p>
            <div class="priceArea">
              <div class="priceArea1">
                <div class="title">价&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;格</div>
                <div class="price"><i>¥</i><em>5299</em></div>
              </div>
            </div>
          </div>

          <div class="choose">
            <div class="chooseArea">
              <div class="choosed"></div>
              <dl><dt class="title">选择颜色</dt><dd changepirce="0">金色</dd><dd changepirce="40">银色</dd><dd changepirce="90">黑色</dd></dl>
              <dl><dt class="title">内存容量</dt><dd changepirce="0">16G</dd><dd changepirce="300">64G</dd><dd changepirce="900">128G</dd><dd changepirce="1300">256G</dd></dl>
              <dl><dt class="title">选择版本</dt><dd changepirce="0">公开版</dd><dd changepirce="-1000">移动版</dd></dl>
              <dl><dt class="title">购买方式</dt><dd changepirce="0">官方标配</dd><dd changepirce="-240">优惠移动版</dd><dd changepirce="-390">电信优惠版</dd></dl>
            </div>

            <div class="cartWrap">
              <div class="controls">
                <input autocomplete="off" value="1" class="itxt">
                <a href="###" class="plus">+</a>
                <a href="###" class="mins">-</a>
              </div>
              <div class="add"><a href="###" target="_blank">加入购物车</a></div>
            </div>
          </div>
        </div>
      </div>
    </section>

    <section class="productDetail">
      <aside class="aside">
        <div class="tabWraped">
          <h4 class="active">相关分类</h4>
          <h4>推荐品牌</h4>
        </div>
        <div class="tabContent">
          <div class="tab-pane active">
            <ul class="partList">
              <li>手机</li><li>手机壳</li><li>内存卡</li><li>Iphone配件</li>
              <li>贴膜</li><li>手机耳机</li><li>移动电源</li><li>平板电脑</li>
            </ul>
            <ul class="goodsList">
              <li><div class="list-wrap">
                <div class="p-img"><img src="../../assets/images/part01.png" alt="相关商品1"></div>
                <div class="attr">Apple苹果iPhone 6s (A1699)</div>
                <div class="price"><em>¥</em><i>6088.00</i></div>
                <div class="operate"><a href="javascript:void(0);">加入购物车</a></div>
              </div></li>
              <li><div class="list-wrap">
                <div class="p-img"><img src="../../assets/images/part02.png" alt="相关商品2"></div>
                <div class="attr"><em>Apple苹果iPhone 6s (A1699)</em></div>
                <div class="price"><strong><em>¥</em><i>6088.00</i></strong></div>
                <div class="operate"><a href="javascript:void(0);">加入购物车</a></div>
              </div></li>
              <li><div class="list-wrap">
                <div class="p-img"><img src="../../assets/images/part03.png" alt="相关商品3"></div>
                <div class="attr"><em>Apple苹果iPhone 6s (A1699)</em></div>
                <div class="price"><strong><em>¥</em><i>6088.00</i></strong></div>
                <div class="operate"><a href="javascript:void(0);">加入购物车</a></div>
              </div></li>
            </ul>
          </div>
          <div class="tab-pane"><p>推荐品牌</p></div>
        </div>
      </aside>

      <div class="detail">
        <div class="fitting">
          <h4 class="kt">选择搭配</h4>
          <div class="good-suits">
            <div class="master">
              <img src="../../assets/images/l-m01.png" alt="主商品小图">
              <p>¥5299</p><i>+</i>
            </div>
            <ul class="suits">
              <li class="suitsItem"><img src="../../assets/images/dp01.png" alt="搭配商品1"><p>Feless费勒斯VR</p><label><input type="checkbox" value="39"><span>39</span></label></li>
              <li class="suitsItem"><img src="../../assets/images/dp02.png" alt="搭配商品2"><p>Feless费勒斯VR</p><label><input type="checkbox" value="50"><span>50</span></label></li>
              <li class="suitsItem"><img src="../../assets/images/dp03.png" alt="搭配商品3"><p>Feless费勒斯VR</p><label><input type="checkbox" value="59"><span>59</span></label></li>
              <li class="suitsItem"><img src="../../assets/images/dp04.png" alt="搭配商品4"><p>Feless费勒斯VR</p><label><input type="checkbox" value="99"><span>99</span></label></li>
            </ul>
            <div class="result">
              <div class="num">已选购0件商品</div>
              <div class="price-tit">套餐价</div>
              <div class="price">¥5299</div>
              <button class="addshopcar">加入购物车</button>
            </div>
          </div>
        </div>

        <div class="intro">
          <ul class="tab-wraped">
            <li class="active"><a href="###">商品介绍</a></li>
            <li><a href="###">规格与包装</a></li>
            <li><a href="###">售后保障</a></li>
            <li><a href="###">商品评价</a></li>
            <li><a href="###">手机社区</a></li>
          </ul>
          <div class="tab-content">
            <div id="one" class="tab-pane active">
              <ul class="goods-intro">
                <li>分辨率:1920*1080(FHD)</li><li>后置摄像头:1200万像素</li>
                <li>前置摄像头:500万像素</li><li>核 数:其他</li>
                <li>频 率:以官网信息为准</li><li>品牌: Apple</li>
                <li>商品名称:APPLEiPhone 6s Plus</li><li>商品编号:1861098</li>
                <li>商品毛重:0.51kg</li><li>商品产地:中国大陆</li>
                <li>热点:指纹识别,Apple Pay,金属机身,拍照神器</li>
                <li>系统:苹果(IOS)</li><li>像素:1000-1600万</li><li>机身内存:64GB</li>
              </ul>
              <div class="intro-detail">
                <img src="../../assets/images/intro01.png" alt="商品介绍图1">
                <img src="../../assets/images/intro02.png" alt="商品介绍图2">
                <img src="../../assets/images/intro03.png" alt="商品介绍图3">
              </div>
            </div>
            <div id="two" class="tab-pane"><p>规格与包装</p></div>
            <div id="three" class="tab-pane"><p>售后保障</p></div>
            <div id="four" class="tab-pane"><p>商品评价</p></div>
            <div id="five" class="tab-pane"><p>手机社区</p></div>
          </div>
        </div>
      </div>
    </section>

    <div class="like">
      <h4 class="kt">猜你喜欢</h4>
      <ul class="like-list">
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike01.png" alt="猜你喜欢商品1"></div><div class="attr"><em>DELL戴尔Ins 15MR-7528SS 15英寸 银色 笔记本</em></div><div class="price"><em>¥</em><i>3699.00</i></div><div class="commit">已有6人评价</div></li>
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike02.png" alt="猜你喜欢商品2"></div><div class="attr">Apple苹果iPhone 6s/6s Plus 16G 64G 128G</div><div class="price"><em>¥</em><i>4388.00</i></div><div class="commit">已有700人评价</div></li>
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike03.png" alt="猜你喜欢商品3"></div><div class="attr">DELL戴尔Ins 15MR-7528SS 15英寸 银色 笔记本</div><div class="price"><em>¥</em><i>4088.00</i></div><div class="commit">已有700人评价</div></li>
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike04.png" alt="猜你喜欢商品4"></div><div class="attr">DELL戴尔Ins 15MR-7528SS 15英寸 银色 笔记本</div><div class="price"><em>¥</em><i>4088.00</i></div><div class="commit">已有700人评价</div></li>
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike05.png" alt="猜你喜欢商品5"></div><div class="attr">DELL戴尔Ins 15MR-7528SS 15英寸 银色 笔记本</div><div class="price"><em>¥</em><i>4088.00</i></div><div class="commit">已有700人评价</div></li>
        <li class="likeItem"><div class="p-img"><img src="../../assets/images/itemlike06.png" alt="猜你喜欢商品6"></div><div class="attr">DELL戴尔Ins 15MR-7528SS 15英寸 银色 笔记本</div><div class="price"><em>¥</em><i>4088.00</i></div><div class="commit">已有700人评价</div></li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  name: "Detail"
};
</script>
  • 为什么需要:详情页由面包屑、放大镜区、选择区、详情区、猜你喜欢组成,先复制结构最省时间。
  • 怎么想:静态页里有些内容是旧 JS 渲染的,直接“查看源码”看不到,要看“元素”面板复制渲染后的 DOM。
  • 怎么做:只复制主体三段,删掉头部、底部、商品分类;图片路径统一换成 assets。
  • 易错点:复制后类名不能改乱,否则样式对不上;没有数据的“配送、促销、累计评价”等块先删掉。

复制样式:scoped less 和类名对应 ​

less
<style lang="less" scoped>
.con {
  width: 1200px;
  margin: 15px auto 0;
  .conPoin {
    padding: 9px 15px 9px 0;
    & > a + a:before {
      content: "/\00a0";
      padding: 0 5px;
      color: #ccc;
    }
  }
  .mainCon {
    overflow: hidden;
    margin: 5px 0 15px;
    .previewWrap { float: left; width: 400px; position: relative; }
    .InfoWrap { width: 700px; float: right; }
  }
}
.productDetail { width: 1200px; margin: 30px auto 0; overflow: hidden; }
.like { width: 1198px; border: 1px solid #ddd; margin: 15px auto; }
</style>
  • 为什么需要:scoped 防止详情页样式污染搜索页。
  • 怎么想:先复制完整样式,再按页面验收删多余规则。
  • 怎么做:把静态页的 detail 样式复制到 scoped less 中,保留 .con/.productDetail/.like 等主类。
  • 易错点:漏写 lang="less" 会编译失败;类名层级写错会导致布局塌。

配置路由并让列表跳过来 ​

js
// src/router/index.js
{
  path: "/detail/:id",
  name: "Detail",
  component: () => import("@/pages/Detail/index.vue")
}

列表页图片或标题加 router-link,如 :to="'/detail/' + item.id"。

  • 为什么需要:详情页必须能被访问。
  • 怎么想:先配路由,再在列表点图片/标题跳转。
  • 怎么做:路由带 :id,详情页后续可通过 this.$route.params.id 取商品 ID。
  • 易错点:不要给整个 li 都加跳转,否则点加减、按钮也会跳。

切换路由滚动位置 ​

js
scrollBehavior(to, from, savedPosition) {
  return { x: 0, y: 0 };
}
  • 为什么需要:单页面切换不会自动回顶部,用户从列表底部进详情会停在中间。
  • 怎么想:路由切换时统一回到左上角。
  • 怎么做:在 vue-router 配置里加 scrollBehavior。
  • 易错点:若某些页面需要保留位置,可用 meta 条件判断,不要一刀切。

静态组件验收与后续动态渲染的坑 ​

验收时看图片是否加载、面包屑/主内容/详情/猜你喜欢是否对齐、控制台是否报错。后续接数据时注意:视图先渲染,数据后到;若模板里直接写 productInfo.spuSale...,初始空对象会报 undefined。转写稿中的处理是给 productInfo 一个带 spuSale: {} 的初始值,或用 v-if 判断。静态阶段先把结构和样式做稳,动态阶段再补这个初始值。

问题从哪里来:单页面切换会保留滚动位置 ​

为什么需要:在搜索页往下拉,看到某件商品后点击进入详情页,页面往往还停在原来的滚动位置。因为这是单页面应用,路由切换时页面没有整体刷新,浏览器会保留上一次的滚动条位置。用户进入新页面后还得自己往上找内容,体验不自然。

怎么想:这类问题和路由有关,不应该在商品列表组件里零散处理,而应放到路由配置层统一处理。Vue Router 提供了 scrollBehavior,专门决定切换路由后滚动条去哪。

怎么做:在 src/router/index.js 创建 VueRouter 实例时加入 scrollBehavior。本节笔记代码如下:

js
const router = new VueRouter({
	mode:"history",
	routes,
	// 1- 什么时候执行?答:切换路由时执行。
	// 2- 接收什么参数?答:to:去哪个路由,from:来自哪个路由
	// 3- 返回的结果是什么?答:一个对象,对象可以决定滚动条的位置。
	scrollBehavior(to,from){
		if(!to.meta.noScroll){
			// console.log("scrollBehavior->to",to);
			// console.log("scrollBehavior->from",from);
			return {
				x:0,// 横向
				y:0// 纵向
			}
		}
	
	}
});

易错点:不要把它写成普通方法随手调用;它由路由切换触发。返回的 x 是横向滚动,y 是纵向滚动,别把两者理解反。

先弄清 scrollBehavior 的三个问题 ​

为什么需要:遇到函数先别急着背 API,要先知道它什么时候执行、收到什么、返回什么,否则调试时不知道从哪里下手。

怎么想:转写稿里明确用了“函数三问”:什么时候执行?接收什么参数?返回结果是什么?scrollBehavior 的答案是:切换路由时执行;接收 to(去哪个路由)和 from(来自哪个路由);返回一个对象,对象可以决定滚动条位置。

怎么做:调试时先加 console.log,在控制台观察。进入页面、点击“首页”“搜索”等路由,看是否触发;再打印 to、from,确认 to 是目标路由信息,from 是来源路由信息。

易错点:点击同一个地址可能不会产生真正的路由变化,所以看不到执行;要切换到不同路由验证。to、from 是路由对象,不是普通字符串。

用返回对象控制滚动位置 ​

为什么需要:知道函数能返回对象后,还要验证 x、y 的实际含义,才能准确控制页面。

怎么想:先做明显实验:把 x、y 设成 100,观察页面是否横向、纵向偏移;再改回 0,观察是否回到左上角。转写稿中就是通过 x:100、y:100 验证“横向、纵向”的含义,再改成 x:0、y:0 解决进入详情页位置不对的问题。

怎么做:在需要“切换后回到顶部”的场景返回:

js
return {
	x:0,
	y:0
}

这样从搜索页进入详情页时,滚动条会回到顶部,而不是保留列表页拉下去的位置。

易错点:x:0,y:0 表示回到页面左上角,不是“保持当前位置”。如果某类页面不希望回顶部,就不能无条件返回这个对象。

用 to.meta.noScroll 做页面级控制 ​

为什么需要:不是所有路由切换都希望回到顶部。例如在商品列表里点价格排序、综合排序时,用户可能希望留在当前浏览位置;如果一律 y:0,页面会突然跳回顶部。

怎么想:scrollBehavior 能拿到 to,就可以根据目标路由的 meta 做判断。笔记里用 to.meta.noScroll 作为开关:没有这个标记时回到顶部;有这个标记时不返回滚动对象,保持原位置。

怎么做:路由配置里给需要“不滚动”的页面加 meta,例如 meta: { noScroll: true };scrollBehavior 中写:

js
if(!to.meta.noScroll){
	return { x:0, y:0 }
}

易错点:判断条件容易写反。笔记的逻辑是“没有 noScroll 才回顶部”;如果写成 if(to.meta.noScroll),效果就相反。meta 名称可以自定义,但路由配置和判断处必须一致。转写稿里也反复调试过“希望它不往上走”的场景,本质就是让目标路由带上标记,使函数不返回滚动位置。

调试与易错点小结 ​

为什么需要:滚动行为看不见代码执行过程,必须靠控制台和实际点击验证。

怎么想:先验证“是否执行”,再验证“参数是谁”,最后验证“返回值造成什么效果”。转写稿的调试顺序正是:打印执行时机 → 看 to/from → 设置 x/y 为 100 观察偏移 → 改成 0 观察回顶 → 用 meta 控制个别页面不滚动。

怎么做:保留必要注释,按“切换路由时执行、接收 to/from、返回对象决定滚动位置”三点理解代码;实际点击不同路由观察效果。

易错点:不要把“页面数据先渲染、后请求”造成的报错和本节滚动问题混在一起;本节只解决路由切换后的滚动位置。scrollBehavior 返回 undefined 时不强制改变滚动位置,这正是 noScroll 分支能保持原位置的关键。

先封装商品详情接口 ​

为什么需要:详情页要展示标题、价格、图片、属性标签等信息,这些数据不能写死在组件里,必须按商品 ID 从接口获取。转写稿里先查接口文档,确认商品详情接口是 /api/item/{skuId},其中 skuId 是商品 ID,属于必填参数。把请求封装到 src/api/product.js,是为了让组件和 store 不直接关心请求路径,后续接口调整时只改一处。

怎么想:详情页路由里已经带了商品 ID,获取详情时只需要把这个 ID 传给接口。API 层只负责“发请求并返回结果”,不负责保存状态;状态保存交给 Vuex。

怎么做:在 src/api/product.js 中导出方法,使用项目已有的 sphRequest 发 GET 请求:

js
// 根据商品ID获取商品详情: /api/item/{ skuId }
export const getProductInfoById = skuId =>sphRequest.get(`/item/${skuId}`);

易错点:接口路径里的 skuId 是变量,必须用模板字符串拼接;不要把商品 ID 写死。转写稿里也强调先看接口文档的返回结构,再决定 store 里保存什么,不要凭感觉造字段。

在 Vuex 中维护商品详情 ​

为什么需要:详情页里价格、图片、属性选择等区域都可能用到同一份商品数据。如果每个组件各自请求,会重复发请求,也不方便统一更新。放到 src/store/product.js 中,用 state 保存商品信息,用 mutation 同步修改,用 action 处理异步请求,符合 Vuex 的单向数据流。

怎么想:state.productInfo 初始给一个空对象;请求成功后,把接口返回的商品数据整体保存进去。mutation 只做同步赋值;action 里 await 接口结果,再 commit。因为模块开启了 namespaced: true,页面调用时要带模块名。

怎么做:在 src/store/product.js 中写:

js
import {
	getProductInfoById
} from "@/api/product";

const state = {
	// 商品信息
	productInfo:{
	
	}
}
const mutations = {
	// 保存商品信息
	SAVE_PRODUCT_INFO(state,payload){
		state.productInfo = payload;
	}
}
const actions = {
	async getProductInfoByIdAsync ({commit},id){
		const {data} = await getProductInfoById(id);
		commit("SAVE_PRODUCT_INFO",data);
	}
}
export default {
	namespaced:true,
	state,
	mutations,
	actions
}

易错点:第一,action 里必须 await,否则 data 还没回来就执行 commit。第二,要解构 { data }。转写稿调试时提到,接口返回的是响应实例,真正商品数据在响应体的 data 里;如果直接 commit 整个响应实例,store 里保存的就不是商品详情。第三,mutation 名要和 commit 里的字符串一致。第四,模块有命名空间,页面 dispatch 要写 "product/getProductInfoByIdAsync",不能只写方法名。

在详情页触发请求 ​

为什么需要:接口和 store 都准备好后,还需要在详情页组件进入时触发一次请求,把当前路由参数中的商品 ID 传进去。这样页面一挂载就能拿到对应商品的数据。

怎么想:详情页路由通常配置为 /detail/:id 这类形式,转写稿里也确认通过 this.$route.params.id 能取到路由参数。组件挂载阶段 mounted 适合发起“进入页面后立即获取数据”的请求。

怎么做:在 src/pages/Detail/index.vue 的 mounted 中派发 action:

js
mounted(){
    this.$store.dispatch("product/getProductInfoByIdAsync",this.$route.params.id)
}

易错点:参数不要传成 this.$route.query.id,这里用的是路由参数 params.id;如果路由配置里参数名不是 id,要按实际配置取。转写稿里还调试过“少写 await”导致数据不对的问题,虽然问题在 store 的 action 里,但排查时要从“页面 dispatch → action 请求 → commit → state 更新”整条链路看。另一个常见问题是刷新后 productInfo 还是初始空对象,模板深层读取如 productInfo.skuInfo.skuName 会报错,因为视图先渲染、请求后返回;解决思路是给初始值补上模板需要的空对象,或在模板里用 v-if 做兜底。

本节目标:把详情页从静态结构接到真实数据 ​

为什么需要:详情页模板已经搭好,但标题、价格、面包屑、销售属性还都是静态内容。真实商品不同,数据必须来自接口。
怎么想:详情数据放进 Vuex 的 product 模块,组件只负责“映射数据 + 按路由 id 发请求”。
怎么做:mapState 取 skuInfo、spuSaleAttrList、categoryView;mounted 里 dispatch 请求详情。
易错点:页面先渲染、请求后回来,初始值不完整会读 undefined;路由参数要用 params.id,不要和 query 混。

模板中哪些位置要动态渲染 ​

为什么需要:面包屑要能点回搜索页;标题、描述、价格、销售属性要随商品变化。
怎么想:面包屑用 router-link 带 query 跳 /search;商品信息直接绑定 skuInfo;销售属性用双层 v-for。
怎么做:保留笔记中的结构,图片补中文 alt:

vue
<template>
  <div>
    <section class="con">
      <div class="conPoin">
        <router-link :to="{path:'/search',query:{category1Id:categoryView.category1Id,categoryName:categoryView.category1Name}}">
          {{ categoryView.category1Name }}
        </router-link>
        <router-link :to="{path:'/search',query:{category2Id:categoryView.category2Id,categoryName:categoryView.category2Name}}">
          {{ categoryView.category2Name }}
        </router-link>
        <router-link :to="{path:'/search',query:{category3Id:categoryView.category3Id,categoryName:categoryView.category3Name}}">
          {{ categoryView.category3Name }}
        </router-link>

        <!-- 方案二:循环生成三级面包屑 -->
        <router-link v-for="c in 3" :key="c" :to="{
          path:'/search',
          query:{
            [`category${c}Id`]: categoryView[`category${c}Id`],
            categoryName: categoryView[`category${c}Name`]
          }
        }">{{ categoryView[`category${c}Name`] }}</router-link>
      </div>

      <div class="mainCon">
        <div class="previewWrap">
          <div class="preview">
            <div class="jqzoom">
              <img src="../../assets/images/s1.png" alt="商品主图">
            </div>
          </div>
          <div class="specScroll">
            <a class="prev">&lt;</a>
            <div class="items">
              <div class="itemsCon" style="transition: all 0.5s ease 0s;">
                <img src="../../assets/images/s1.png" alt="商品缩略图1">
                <img src="../../assets/images/s2.png" alt="商品缩略图2">
                <img src="../../assets/images/s3.png" alt="商品缩略图3">
                <img src="../../assets/images/s1.png" alt="商品缩略图4">
                <img src="../../assets/images/s2.png" alt="商品缩略图5">
                <img src="../../assets/images/s3.png" alt="商品缩略图6">
                <img src="../../assets/images/s1.png" alt="商品缩略图7">
                <img src="../../assets/images/s2.png" alt="商品缩略图8">
                <img src="../../assets/images/s3.png" alt="商品缩略图9">
                <img src="../../assets/images/s1.png" alt="商品缩略图10">
                <img src="../../assets/images/s2.png" alt="商品缩略图11">
                <img src="../../assets/images/s3.png" alt="商品缩略图12">
                <img src="../../assets/images/s1.png" alt="商品缩略图13">
                <img src="../../assets/images/s2.png" alt="商品缩略图14">
                <img src="../../assets/images/s3.png" alt="商品缩略图15">
              </div>
            </div>
            <a class="next">&gt;</a>
          </div>
        </div>

        <div class="InfoWrap">
          <div class="goodsDetail">
            <h3 class="InfoName">{{ skuInfo.skuName }}</h3>
            <p class="news">{{ skuInfo.skuDesc }}</p>
            <div class="priceArea">
              <div class="priceArea1">
                <div class="title">价&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;格</div>
                <div class="price">
                  <i>¥</i><em>{{ skuInfo.price }}</em>
                </div>
              </div>
            </div>
          </div>

          <div class="choose">
            <div class="chooseArea">
              <div class="choosed"></div>
              <dl v-for="item in spuSaleAttrList" :key="item.id">
                <dt class="title">选择{{ item.saleAttrName }}</dt>
                <dd
                  :class="{active: info.isChecked/1===1}"
                  v-for="info in item.spuSaleAttrValueList"
                  :key="info.id"
                  changepirce="0"
                >{{ info.saleAttrValueName }}</dd>
              </dl>
            </div>
            <div class="cartWrap">
              <div class="controls">
                <input autocomplete="off" value="1" class="itxt">
                <a href="###" class="plus">+</a>
                <a href="###" class="mins">-</a>
              </div>
              <div class="add"><a href="###" target="_blank">加入购物车</a></div>
            </div>
          </div>
        </div>
      </div>
    </section>
  </div>
</template>

易错点:categoryView 初始可能为空,模板直接读 category1Name 会报错,所以计算属性里要 || {} 兜底;isChecked 可能是字符串 "1",用 /1===1 转成数字再比较。

用 mapState 映射 product 模块数据 ​

为什么需要:模板里反复写 this.$store.state.product... 很啰嗦,也容易写错层级。
怎么想:详情数据都在 product 模块的 productInfo 下,用带命名空间的 mapState 映射成组件计算属性。
怎么做:

vue
<script>
import { mapState } from "vuex";

export default {
  name: "Detail",
  computed: mapState("product", {
    // 详情
    skuInfo(state) {
      return state.productInfo.skuInfo;
    },
    // 销售属性配置
    spuSaleAttrList(state) {
      return state.productInfo.spuSaleAttrList;
    },
    // 类别,面包屑导航
    categoryView(state) {
      return state.productInfo.categoryView || {};
    }
  }),
  mounted() {
    this.$store.dispatch("product/getProductInfoByIdAsync", this.$route.params.id);
  }
};
</script>

易错点:mapState("product", {...}) 要求 Vuex 模块开启命名空间;categoryView 不给默认值,首次渲染可能报 Cannot read properties of undefined。

mounted 里按路由 id 请求详情 ​

为什么需要:详情页地址里带着商品 id,进入页面必须拿这个 id 请求接口。
怎么想:路由配置里是 :id,所以组件里取 this.$route.params.id;请求动作放在 mounted,组件挂载后立即发。
怎么做:调用 product/getProductInfoByIdAsync,把 id 作为参数传给 action。
易错点:不要把 params.id 写成 query.id;刷新详情页时 params 仍能取到;action 名要和 store 中定义一致。

调试过程:先渲染后请求导致 undefined ​

转写稿里反复调这个错:页面先渲染,Vuex 里 productInfo 还是空对象,模板读 skuInfo.skuName 或 categoryView.category1Name 就会报错。解决思路是给初始结构:productInfo 里准备 skuInfo: {}、spuSaleAttrList: [],组件里 categoryView 再用 || {} 兜底。这样请求回来前不报错,回来后数据替换,页面自然更新。易错点:只加 v-if 而不处理初始值,或把 spuSaleAttrList 初始成 undefined 后直接 v-for。

小结 ​

本节完成了详情页最基本的数据链路:路由 id → dispatch → Vuex product.productInfo → mapState → 模板渲染。重点不是写多少模板,而是理解“先有初始值,再有异步数据”的渲染顺序;面包屑、价格、销售属性都只是这条链路上的展示。

本节目标与边界 ​

本节笔记标题是“完成放大镜”,但转写稿实际完成的是商品详情页的基础链路:优化搜索跳转、修分页报错、创建详情路由与静态组件、处理路由滚动位置、获取详情数据并渲染。放大镜部分,转写稿明确说“这种动态效果一般很少使用原生,使用现成插件更稳定”,但没有给出插件名和完整实现代码。因此本文只按转写稿讲清选型结论,不补造插件 API。

为什么需要:详情页是搜索列表的下一跳,先把路由、静态结构、数据渲染打通,放大镜才有挂载位置。怎么想:先让页面能进、能显示,再谈交互增强。怎么做:按“路由→静态组件→数据→放大镜选型”推进。易错点:不要一上来手写放大镜,转写稿已明确不建议原生硬写。

优化搜索:把重复跳转收口到 goSearch ​

为什么需要:分页、排序、移除品牌/属性/关键词都要改 query 并 push,每处都写 { ...this.$route.query, ... } 很重复,也容易漏合并。怎么想:在 Vue Router 原型上增加统一方法,调用方只传“要去哪”和“要合并的 query”。怎么做:转写稿在路由入口给 VueRouter.prototype 增加 goSearch,内部再调用 this.$router.push:

js
VueRouter.prototype.goSearch = function (path, query) {
  this.push({
    path,
    query: { ...this.$route.query, ...query }
  })
}

调用处从“自己拼 query”变成一行,例如分页、排序、移除标签都走 this.$router.goSearch(...)。易错点:goSearch 内部的 this 必须指向 router 实例;query 合并顺序要让新参数覆盖旧参数;排序后要回第一页时,必须显式传 pageName: 1,否则会保留当前页。

分页与移除条件:pageName 和 query 的覆盖关系 ​

为什么需要:分页要保留筛选条件,排序要回第一页,移除属性/品牌/关键词要清掉对应 query。怎么想:所有“跳回搜索页”的动作统一传 pageName,需要清空的条件传空值或删除字段。怎么做:分页处理函数要接收分页组件传来的 pageName。转写稿里曾因处理函数内部直接引用 pageName 而报“属性或方法未定义”,改成接收参数的箭头函数后正常:

js
@change-page-no="(pageName) => goSearch('/search', { pageName })"

排序时传 { order, pageName: 1 };移除属性时注意 prop 是数组,空数组要删除或判断长度。易错点:pageName 默认值若放在合并前,会被旧 query 覆盖;移除属性后数组为空还继续传,接口可能报错,转写稿中通过判断 query.prop 是否存在且长度大于 0 再删除。

路由滚动位置:scrollBehavior ​

为什么需要:单页应用切换路由不刷新页面,用户从列表底部点详情,详情页会停在下方。怎么想:在路由配置里用 scrollBehavior(to, from, savedPosition) 控制滚动,返回 { x: 0, y: 0 } 即回到顶部。怎么做:

js
scrollBehavior(to, from, savedPosition) {
  return { x: 0, y: 0 }
}

转写稿还讨论了按 to.meta 判断是否允许滚动,但最终核心是回顶部。易错点:scrollBehavior 是 router 配置项,不是组件生命周期;x 是横向、y 是纵向;不要把它写成普通方法。

创建详情路由与静态组件 ​

为什么需要:列表点击商品要进入详情。怎么想:先建路由和空组件,再把静态页结构搬进来。怎么做:在 src/router/index.js 增加 /detail/:id,在 src/pages/detail/index.vue 写占位;列表图片和标题用 router-link 跳到详情,li 上补 :key。易错点:不要给整个 li 加跳转,否则点空白也跳;只给图片、标题等需要跳转的区域加;key 不能漏,转写稿中就用 item.id 作为唯一标识。

获取详情数据与渲染 ​

为什么需要:静态结构只是壳,标题、描述、价格、属性、图片要来自接口。怎么想:API 封装 getProductInfoById(id),store 里放 productInfo,详情页 mounted 调 action。怎么做:action 请求后 commit,组件里取 this.$route.params.id:

js
const { data } = await getProductInfoById(id)
commit('SET_PRODUCT_INFO', data)

渲染时注意初始值。转写稿报错“读取 skuInfo 下的 skuName 为 undefined”,原因是先渲染视图、后请求数据,初始空对象里没有 skuInfo。解决是给初始值补 skuInfo: {},或用 v-if 判断。易错点:解构要取响应体的 data;属性列表用 v-for 时 isChecked 为 1 才高亮,不要写死第一项红色。

放大镜的选型结论 ​

为什么需要:详情大图需要放大查看。怎么想:转写稿明确“市面上有很多放大镜方案,经过项目积累更稳定,自己写原生要测试,不建议”。怎么做:接入现成放大镜插件,把小图列表与大图容器对接;转写稿没有给出插件名和完整代码,因此不编造具体 API。易错点:不要手写原生放大镜;先保证小图切换大图、容器尺寸正确,再叠加放大效果。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

示例一:路由增强 goSearch + 详情路由 + scrollBehavior ​

js
// src/router/index.js
import Vue from "vue";
import VueRouter from "vue-router";
import Detail from "@/pages/Detail/index.vue";

Vue.use(VueRouter);

/* 对路由原型进行增强:任何组件里都能 this.$router.goSearch({...}) */
VueRouter.prototype.goSearch = function (query) {
  this.push({
    path: "/search",
    query: {
      ...this.history.current.query, // 先带上当前的查询条件
      pageNo: 1,                     // 默认回到第一页
      ...query                       // 调用时传入的条件,可覆盖上面的
    }
  });
};

const routes = [
  // ... Home / Search / Register 等路由
  {
    path: "/detail/:id.html",
    component: Detail,
    meta: {
      isTypeNav: true
    }
  }
];

const router = new VueRouter({
  mode: "history",
  routes,
  // 1- 什么时候执行?答:切换路由时执行
  // 2- 接收什么参数?答:to:去哪个路由,from:来自哪个路由
  // 3- 返回的结果是什么?答:一个对象,决定滚动条的位置
  scrollBehavior(to, from) {
    if (!to.meta.noScroll) {
      return {
        x: 0, // 横向
        y: 0  // 纵向
      };
    }
  }
});

export default router;

关键行与易错点

  • VueRouter.prototype.goSearch 是给路由实例挂方法,所以组件里必须写 this.$router.goSearch(...);写成 this.goSearch 找不到。
  • 函数体内 this 就是路由实例,因此要用 this.history.current.query 取当前查询串,不能再写 this.$router.push(实例上没有 $router)。
  • 展开顺序决定覆盖关系:...当前query → pageNo:1 → ...query。把 pageNo:1 放中间,点击分页传 {pageNo:3} 才能覆盖成 3;顺序写反则永远停在第 1 页。
  • ...this.history.current.query 是“合并”的关键,否则点分页、点排序会把 keyword / category3Id / trademark / props 全丢掉。
  • 路由是 /detail/:id.html,跳转必须写 '/detail/' + item.id + '.html',少了 .html 匹配不到。
  • scrollBehavior 三个点:切换路由时执行、接收 to/from、返回 {x, y} 决定滚动位置;返回 undefined(meta.noScroll 为真时)表示保持原位置,常用于排序时不希望页面回顶的场景。

示例二:搜索页 src/pages/Search/index.vue(用 goSearch 统一改 query) ​

vue
<template>
  <div class="main">
    <div class="py-container">
      <!-- 面包屑:已选条件 -->
      <div class="bread" v-show="isSelector">
        <ul class="fl sui-breadcrumb">
          <li><a href="#">全部结果</a></li>
        </ul>
        <ul class="fl sui-tag">
          <!-- 类别名称 -->
          <li v-if="$route.query.categoryName" class="with-x">
            {{$route.query.categoryName}}<i @click="moveCategoryName">×</i>
          </li>
          <!-- 关键词 -->
          <li v-if="$route.query.keyword" class="with-x">
            {{$route.query.keyword}}<i @click="moveKeyword">×</i>
          </li>
          <!-- 品牌 -->
          <li v-if="$route.query.trademark" class="with-x">
            {{$route.query.trademark.split(":")[1]}}<i @click="moveTrademark">×</i>
          </li>
          <!-- 属性 -->
          <li v-for="item in $route.query.props" :key="item" class="with-x">
            {{item.split(":")[1]}}
            <i @click="$router.goSearch({props:$route.query.props.filter(v => v !== item)})">×</i>
          </li>
        </ul>
      </div>

      <SearchSelector></SearchSelector>

      <div class="details clearfix">
        <div class="sui-navbar">
          <div class="navbar-inner filter">
            <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>
          </div>
        </div>

        <div class="goods-list">
          <ul class="yui3-g">
            <li v-for="item in $store.state.product.searchProductResult.goodsList"
                :key="item.id" class="yui3-u-1-5">
              <div class="list-wrap">
                <div class="p-img">
                  <router-link :to="'/detail/' + item.id + '.html'">
                    <img :src="item.defaultImg" />
                  </router-link>
                </div>
                <div class="price">
                  <strong><em>¥</em><i>{{item.price}}</i></strong>
                </div>
                <div class="attr">
                  <a target="_blank" href="item.html" :title="item.title" v-html="item.title"></a>
                </div>
                <div class="commit">
                  <i class="command">已有<span>{{item.hotScore}}</span>人评价</i>
                </div>
                <div class="operate">
                  <a href="success-cart.html" target="_blank" class="sui-btn btn-bordered btn-danger">加入购物车</a>
                  <a href="javascript:void(0);" class="sui-btn btn-bordered">收藏</a>
                </div>
              </div>
            </li>
          </ul>
        </div>

        <!-- 分页组件:事件回调需要接收页码参数 -->
        <Pagination :continue="5"
                    @change-page-no="pageNo => $router.goSearch({pageNo})"
                    :total="$store.state.product.searchProductResult.total"
                    :pageNo="$store.state.product.searchProductResult.pageNo"
                    :pageSize="$store.state.product.searchProductResult.pageSize"></Pagination>
      </div>
    </div>
  </div>
</template>

<script>
import SearchSelector from "@/pages/Search/SearchSelector";

export default {
  name: "Search",
  components: { SearchSelector },
  data() {
    const [type, flag] = (this.$route.query.order || "1:desc").split(":");
    return {
      type: type / 1, // 综合(转成数字)
      flag
    };
  },
  methods: {
    orderSearch(type) {
      // 当前的排序类别与选中的类别是否相同
      if (this.type === type) {
        this.flag = this.flag === "desc" ? "asc" : "desc";
      } else {
        this.type = type;
        this.flag = "desc";
      }
      this.$router.goSearch({ order: this.type + ":" + this.flag });
    },
    // 移除类别标签
    moveCategoryName() {
      this.$router.goSearch({
        categoryName: undefined,
        category3Id: undefined,
        category2Id: undefined,
        category1Id: undefined
      });
    },
    // 移除关键词
    moveKeyword() {
      this.$router.goSearch({ keyword: undefined });
      this.$bus.$emit("clearKeyword");
    },
    // 移除品牌
    moveTrademark() {
      this.$router.goSearch({ trademark: undefined });
    }
  },
  watch: {
    "$route.query": {
      handler(query) {
        const { pageNo, pageSize } = this.$store.state.product.searchProductResult;
        if (query.props && typeof query.props === "string") {
          query.props = [query.props];
        }
        this.$store.dispatch("product/postProductListAsync", {
          pageNo,
          pageSize,
          ...query
        });
      },
      immediate: true
    }
  },
  beforeDestroy() {
    this.$bus.$emit("clearKeyword");
  },
  computed: {
    upOrDown() {
      return this.flag === "desc" ? "iconfont icon-icon_down" : "iconfont icon-icon_up";
    },
    isSelector() {
      const query = { ...this.$route.query };
      delete query.pageNo;
      if (query.props && query.props.length === 0) delete query.props;
      return Object.values(query).filter(v => v).length > 0;
    }
  }
};
</script>

关键行与易错点

  • data 里 type: type / 1 必须转数字:split 出来是字符串 "1",若用 this.type === type 严格相等,"1" === 1 恒为 false,导致反复点击“综合”时箭头永远不翻转。
  • 排序时 goSearch 内部默认 pageNo:1,所以换排序自动回第一页,不用再手写 pageNo:1。
  • 移除条件用“传 undefined”:vue-router 会把值为 undefined 的字段从 query 中剔除;类别要一次把 category1Id/2Id/3Id + categoryName 全传 undefined。
  • props 是数组,移除单个属性要用 filter 生成新数组,不能 splice 原数组。
  • 分页事件:笔记原文写的是 @change-page-no="$router.goSearch({pageNo})",会报 pageNo is not defined(模板里没有这个变量),必须改成箭头函数 pageNo => ... 或单独写一个方法接收参数。
  • watch "$route.query" 加 immediate: true,首次进入页面也会发请求;query.props 可能是字符串(单个属性),先 typeof === "string" 转成数组再发请求,否则后端拼接出错。
  • isSelector 要复制一份 query 再删字段,别直接 delete this.$route.query.pageNo;props 空数组也要当作“没条件”,否则空标签栏会一直显示。

示例三:详情页数据链路(api + store + Detail 组件) ​

js
// src/api/product.js
import sphRequest from "@/utils/sphRequest";

// 根据商品ID获取商品详情: /api/item/{ skuId }
export const getProductInfoById = skuId => sphRequest.get(`/item/${skuId}`);
js
// src/store/product.js
import { getProductInfoById } from "@/api/product";

const state = {
  // 商品信息(给 skuInfo 一个初始空对象,避免首屏渲染读 undefined 报错)
  productInfo: {
    skuInfo: {}
  }
};

const mutations = {
  // 保存商品信息

常见错误 ​

  • 现象:点击分页后分类、关键词、品牌、属性条件丢失;原因:跳转时只传 pageNo,没有合并当前 query,或 goSearch 内 this 指向错误;修法:统一在 VueRouter.prototype.goSearch 中用 this.history.current.query 合并,调用处只传增量参数。
  • 现象:@change-page-no="$router.goSearch({pageNo})" 报 pageNo is not defined 或页码为 undefined;原因:自定义事件参数未接收,模板作用域中没有 pageNo 变量;修法:改为 @change-page-no="pageNo => $router.goSearch({pageNo})",或在 methods 中定义接收参数的方法。
  • 现象:点击“综合/价格”排序不切换升降序,或一直保持 desc;原因:data 中 type 初始为字符串,点击传入数字,this.type === type 严格比较失败;修法:初始化时统一 type: Number(type),或比较前统一类型,并确保 order 拼装为 type:flag。
  • 现象:详情页刷新报 Cannot read properties of undefined;原因:productInfo 初始为空对象,视图先渲染,模板直接读取 skuInfo.skuName 等深层属性;修法:给 productInfo 初始结构兜底,或 computed/模板中做空值保护。
  • 现象:详情接口请求的 id 为 undefined;原因:路由定义为 /detail/:id.html,参数在 params 中,却从 query.id 获取;修法:使用 this.$route.params.id,并在请求 action 中确认传入值。
  • 现象:属性标签渲染或删除异常,props 有时是字符串、有时是数组;原因:单值 query.props 为字符串,模板按数组 v-for/filter 处理;修法:在 watch $route.query 中统一将 props 转为数组,删除后再通过 goSearch 回写。

练习 ​

  • 将搜索页所有 this.$router.push 跳转替换为 goSearch,验证分页时保留 category*、keyword、trademark、props,排序时自动回到第 1 页。
  • 修复 orderSearch 的类型比较问题,完成“综合/价格”升降序切换,并检查 URL 中 order 与图标状态一致。
  • 为详情页实现 params.id 获取、Vuex action 请求详情、productInfo 初始结构兜底,确保刷新不报错。
  • 配置 scrollBehavior:普通路由切换回到顶部,搜索页排序/分页等操作通过 meta.noScroll 控制不回顶,并验证详情页返回搜索页的位置。
  • 完成详情页基础渲染:面包屑、skuName、skuDesc、price、spuSaleAttrList,并用 isChecked 控制属性选中高亮。

原视频对照 ​

#视频时长
1优化搜索1:04:03
2解决BUG05:40
3创建产品详情路由17:14
4创建详情的静态组件mp412:24
5切换路由并指定位置12:10
6获取详情数据16:34
7渲染一22:35

小结 ​

  • goSearch 应作为搜索相关跳转的统一出口,减少重复 query 合并代码。
  • 调用处只传增量参数,默认页码和显式覆盖要依赖合并顺序保证。
  • 路由参数 params 与查询参数 query 要分清,详情 ID 不应从 query 取。
  • 初始 state 必须有稳定结构,避免视图先渲染导致深层读取报错。
  • 排序、分页这类状态比较要统一类型,严格比较前先做类型转换。
  • scrollBehavior 要按业务决定是否回顶,不能一刀切。
  • 调试时先看路由执行流程、请求参数和 Vuex 状态,再定位模板渲染问题。