Skip to content

抽离为组件与使用第三方模块--vue-photo-zoom-(Lesson18 2/2) ​

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

学习目标 ​

  • 能够将详情页中的放大镜区域抽离为独立 Vue 组件,并在父组件中正确注册、引入和渲染。
  • 能够安装并配置 [email protected],完成样式引入、组件注册及图片地址、放大倍数、选择器尺寸、外置放大区域等属性设置。
  • 能够抽离缩略图列表组件,集成 Swiper 并配置显示数量、间距、导航按钮等选项,完成缩略图数据渲染与样式调整。
  • 能够根据 isDefault 字段为默认缩略图添加选中样式,并实现点击缩略图更新选中项。
  • 能够通过组件事件、方法或计算属性实现缩略图与大图联动切换,并对初始数据为空或非数组情况进行容错处理。
  • 能够定位并修复第三方放大镜插件在快速拖动、定位层级和版本兼容方面的问题,通过固定版本或覆盖样式完成调整。

知识点 ​

先抽离放大镜结构 ​

为什么需要:详情页里放大镜的结构、样式和交互如果全堆在页面里,页面会越来越难读;后续换插件、调样式也容易误伤其他区域。
怎么想:先把“预览图 + 遮罩 + 放大区域”这一块整体搬到一个独立组件里,详情页只负责引入和使用。
怎么做:在 src/Detail/Zoom/index.vue 新建组件,把笔记中的结构和样式原样放入;图片路径按项目层级替换,转写稿里就是先把 s1.png 的路径改到能正确加载。然后在详情页引入、注册并使用该组件。笔记代码如下:

vue
<template>
    <div class="preview">
        <div class="jqzoom">
            <img src="../../../assets/images/s1.png">
        </div>
    </div>
</template>

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

<style lang="less" scoped>
.preview {
    width: 400px;
    height: 400px;
    border: 1px solid #DFDFDF;

    .jqzoom {
        cursor: pointer;
        width: 400px;
        height: 400px;
        position: relative;

        img {
            width: 100%;
        }

        .mask {
            width: 200px;
            height: 200px;
            background: rgba(255, 255, 255, .5);
            position: absolute;
            left: 0;
            top: 0;
            border: 1px solid #ddd;
        }
    }

    .maxbox {
        width: 400px;
        height: 400px;
        position: absolute;
        left: 420px;
        top: 0px;
        overflow: hidden;
        z-index: 20;
        border: 1px solid #ddd;

        img {
            width: 800px;
            height: 800px;
            display: block;
        }
    }
}
</style>

易错点:图片路径最容易错,转写稿里组件抽出来后先出现图片加载问题,就是路径层级没对上;另外组件名、目录名要和引入路径一致,scoped 样式搬过去后要检查选择器是否还被外层影响。

用 vue-photo-zoom-pro 替代手写放大镜 ​

为什么需要:手写放大镜要处理遮罩移动、比例、边界、层级,成本高;第三方插件能快速得到稳定效果。
怎么想:先保留刚抽离的组件作为结构练习,再把内部预览图替换为插件组件。转写稿明确说不要再用旧 JQ 思路,改用 Vue 插件。
怎么做:在 npm 搜 vue-photo-zoom-pro,选 PC 端那个;Vue2 项目安装 2.x。转写稿先装到 2.5,发现快速拖动有 bug,换成 2.2.1:cnpm i [email protected]。按文档引入组件和样式,注册后使用;imageSrc 放商品图,大图也用同一张;需要放大区域显示在图片外时加 out;选择器宽高按 400 图设成 200;层级不够时覆盖 z-index: 999,选择器背景色可覆盖为 rgba(255,0,0,.3) 这类半透明色。2.2.1 还有定位小问题,转写稿通过补定位、left/top 微调覆盖解决。
易错点:版本不要混,2.5 和 2.2.1 的问题点不同;覆盖插件样式时不要随便加 scoped 导致盖不住;选择器尺寸要跟主图比例匹配,否则遮罩看起来别扭。

抽离缩略图并接入 swiper ​

为什么需要:缩略图列表也有自己的结构和轮播逻辑,继续堆在详情页会重复首页轮播的代码。
怎么想:把详情页下方缩略图区域剪成 ImageList/index.vue,复用首页已经用过的 swiper。
怎么做:新建图片列表组件,把缩略图结构和样式搬过去,图片路径批量替换;引入 swiper 组件和样式并注册。配置上按转写稿:不要循环、不要分页、不要自动播放,slidesPerView: 5,间距约 10;用 v-for 渲染 schoolImagelist,key 用 item.id,图片地址用 item.imgUrl;当前选中项通过 isDefault === 1 加 active 类。
易错点:active 类名写了但样式没写,点击不会飘红;字段名要以接口为准,转写稿口述的 schoolImagelist、imgUrl 要和项目数据核对;swiper 的宽度、左右按钮、图片尺寸要慢慢调,不能只复制结构不调样式。

点击缩略图切换大图 ​

为什么需要:缩略图只是展示还不够,用户点小图时上方放大图必须跟着换。
怎么想:这是子组件向父组件通信:子组件把被点击的 id 传出去,父组件改数据里的选中状态,再用计算属性把选中图片交给放大镜组件。
怎么做:在 ImageList 的图片上绑定点击事件,执行 $emit('change', item.id);详情页接收后分两步:先把原来 isDefault === 1 的项改成 0,再按传入 id 找到当前项改成 1。详情页写计算属性 imageUrl,在 productInfo.schoolImagelist 中 find(v => v.isDefault === 1),返回其 imgUrl,传给 Zoom 的 imageSrc。
易错点:转写稿里一开始直接 find 报错,是因为初始 productInfo 里还没有 schoolImagelist 数组;要在初始数据里给空数组,计算属性里先判空再 find。另外 isDefault 用数字 1/0 还是字符串要统一,否则 active 和查找都会失效;点击后上方不变,通常是只改了子组件选中态,没有把选中图片传给放大镜组件。

为什么用第三方模块做放大镜 ​

为什么需要: 商品详情页的放大镜如果手写,要处理鼠标位置、选择器、放大区域、边界和层级,成本高。转写稿里明确说“做放大镜可以借助第三方插件”,所以本节把放大镜单独抽成组件,再引入 vue-photo-zoom-pro。

怎么想: 先把详情页里和放大镜相关的结构、样式剪切到 src/pages/Detail/Zoom/index.vue,让 Zoom 只负责图片预览;再去 Vue2 的 awesome 列表和 npm 找适合 PC 端的插件。转写稿里对比后选择 vue-photo-zoom-pro,因为它偏 PC 端、能和 Vue2 配合。

怎么做: 新建 src/pages/Detail/Zoom/index.vue,模板中保留 .preview 容器,图片仍从 Vuex 的商品数据中取。抽离后先保证图片能显示,再接插件。

易错点: 抽离组件时不要写死图片地址,否则详情页切换商品不会更新;样式要跟着组件迁移,否则 .preview 尺寸不对,放大镜会显得很小。转写稿里的“组箱”就是“组件”,“方大镜”就是“放大镜”。

安装与版本选择 ​

为什么需要: 当前项目是 Vue2,不能随便装最新大版本;转写稿提到最新 2.5 在快速拖动时有小问题,而 2.2.1 相对稳定,所以笔记锁定 2.2.1。

怎么想: 先进入自己的项目目录,再执行安装;只写包名会装 2.x 的最新版,不一定是想要的小版本。转写稿里还出现过装完仍是 2.5、需要重装并重启 dev server 的调试过程。

怎么做:

shell
cnpm install [email protected]

易错点: 不要在项目外安装;不要把 Vue2 项目装成不兼容的版本;装完版本不对时先看 package.json,再重装并重启服务。转写稿中的“CNPM”“will photo pro”分别对应 cnpm 和 vue-photo-zoom-pro。

在 Zoom 组件中引入并使用 ​

为什么需要: 插件本身是组件,还要引入它自己的 CSS,否则放大镜样式不完整。转写稿按官网流程:先引入组件,再引入样式,最后注册为局部组件。

怎么想: 用 <vue-photo-zoom-pro> 包住 <img>,把商品默认图同时给 high-url 和 img 的 src。转写稿说明:high-url 是放大用的高清图 URL;但项目数据里只有一张图,所以高低清先用同一张。out-zoomer="true" 表示放大区域展示到图片外;width/height 控制相关尺寸。

怎么做:

vue
<template>
    <div class="preview">
        <vue-photo-zoom-pro
                :out-zoomer="true"
                :width="200"
                :height="200"
                :high-url="$store.state.product.productInfo.skuInfo.skuDefaultImg">
            <img :src="$store.state.product.productInfo.skuInfo.skuDefaultImg" />
        </vue-photo-zoom-pro>
    </div>
</template>

<script>
import VuePhotoZoomPro from 'vue-photo-zoom-pro'
import 'vue-photo-zoom-pro/dist/style/vue-photo-zoom-pro.css'
export default {
    name: "Zoom",
    components: {
        VuePhotoZoomPro,
    }
}
</script>

易错点: 忘记引入 CSS 是最常见问题;组件名注册要和模板标签一致;Vuex 路径要核对到 skuInfo.skuDefaultImg,不要少层级。转写稿里“嗨哦杠UL”实际是 high-url,“小除/大图”对应小图和高清放大图。

样式覆盖与调试 ​

为什么需要: 默认放大镜的位置、层级、选择器颜色不一定符合页面。转写稿调试时用开发者工具检查 .zoomer 和 .selector,发现放大区域被遮挡就提高 z-index;2.2.1 还有定位偏移,需要用 top/left 覆盖。选择器默认约 168,容器 400,按两倍关系把相关尺寸调到 200 更协调。

怎么想: 先跑默认效果,再用浏览器检查元素找到库生成的类名;能覆盖就覆盖,库样式优先级高时用 !important。选择器背景用半透明红便于观察,放大区域层级调到 999。

怎么做:

vue
<style lang="less">
.preview {
    position: relative;
    width: 400px;
    height: 400px;
    border: 1px solid #DFDFDF;
    img{
        width:100%;
        height:100%;
    }
    .zoomer{
        z-index: 999;
        top:0!important;
        left:10px!important;
    }
    .selector{
        background-color: rgba(255,0,0,0.3);
    }
}
</style>

易错点: 覆盖库样式时不要随便加 scoped,否则可能选不中库内部类;top/left 的 !important 是解决 2.2.1 定位偏移;z-index:999 解决放大层被遮挡;.selector 背景只是调试和视觉效果,不要写成不透明色遮住图片。转写稿里“快速拖动的问题”对应 2.5 的 bug,换 2.2.1 后再用样式修正定位,这就是本节版本与样式调试的完整过程。

先拆组件:放大镜与缩略图列表 ​

这一节的目标是把详情页右侧的图片区拆成两个可维护的组件:放大镜 Preview.vue 和缩略图列表 ImageList.vue。为什么需要拆?因为详情页里既有路径导航、配置信息,又有图片区和缩略图;如果全部堆在 Detail.vue 里,样式、事件和数据会互相干扰。怎么想?先把“大图/放大镜”和“小图列表”分成两个块,父组件只负责把数据传下去,以及计算当前选中的大图地址。怎么做?从 Detail.vue 中剪切放大镜相关结构和样式到 Preview.vue,再剪切缩略图相关结构和样式到 ImageList.vue,父组件用组件标签替换原位置。

转写里先做放大镜时出现过图片路径问题,替换图片后预览正常;这说明抽离组件后要先保证“结构、样式、图片路径”三件事都能独立跑通,再谈交互。

用 vue-photo-zoom-pro 做放大镜 ​

放大镜不需要自己手写复杂的鼠标跟随逻辑,转写中改用第三方插件 vue-photo-zoom-pro。为什么?手写放大镜要处理选择器位置、层级、边界、快速拖动等细节,成本高。怎么想?先安装与 Vue2 匹配的 2.x 版本,再按文档引入组件和样式,把图片地址通过属性传进去。怎么做?

bash
cnpm i [email protected]
vue
<!-- Preview.vue -->
<template>
  <vue-photo-zoom-pro
    :url="url"
    :high-url="highUrl"
    :scale="2"
    out
  />
</template>

<script>
import VuePhotoZoomPro from 'vue-photo-zoom-pro'
// 样式按插件文档引入
export default {
  name: 'Preview',
  components: { VuePhotoZoomPro },
  props: {
    url: { type: String, default: '' },
    highUrl: { type: String, default: '' }
  }
}
</script>

转写中 url 是当前展示图,highUrl 是放大图;项目数据里没有更高清图,所以两者先用同一张。out 表示把放大区域展示到图片外。易错点:插件 2.5 在快速拖动时有 bug,转写中换到 2.2.1;2.2.1 又出现选择器定位问题,需要通过样式覆盖 top/left 和层级。选择器默认约 168,转写中按两倍思路设为 200,并调过背景色和 z-index。这些样式要以实际插件生成的类名为准,在控制台选中元素后覆盖。

缩略图列表:先用 swiper 写死,再换成数据 ​

缩略图列表需要横向排列、可左右切换,转写中复用了首页轮播的 swiper。为什么先用写死图片?因为要先验证 swiper 的结构、样式和切换按钮是否正常,再把真实数据接进来。怎么想?先保留 3~5 张写死图片,确认布局后再用 v-for。怎么做?在 ImageList.vue 中引入 swiper、swiper-slide 和样式,配置 slidesPerView: 5、spaceBetween: 10,关闭分页、自动播放和循环。

vue
<!-- ImageList.vue -->
<template>
  <div class="image-list">
    <swiper :options="swiperOption">
      <swiper-slide v-for="item in skuImageList" :key="item.id">
        <img
          :src="item.imgUrl"
          :class="{ active: item.isDefault === 1 }"
          alt="商品缩略图"
          @click="changeImg(item.id)"
        />
      </swiper-slide>
    </swiper>
  </div>
</template>

<script>
import { swiper, swiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'

export default {
  name: 'ImageList',
  components: { swiper, swiperSlide },
  props: {
    skuImageList: { type: Array, default: () => [] }
  },
  data() {
    return {
      swiperOption: {
        slidesPerView: 5,
        spaceBetween: 10,
        loop: false,
        pagination: false,
        autoplay: false
      }
    }
  },
  methods: {
    changeImg(id) {
      this.skuImageList.forEach(item => {
        item.isDefault = 0
      })
      const current = this.skuImageList.find(item => item.id === id)
      if (current) current.isDefault = 1
    }
  }
}
</script>

<style scoped>
.image-list img {
  width: 100%;
  cursor: pointer;
}
.image-list img.active {
  border: 1px solid red;
}
</style>

转写中数据字段按同音可辨认为 skuImageList,每项有 id、imgUrl、isDefault。父组件传值:

vue
<ImageList :sku-image-list="detail.productInfo.skuInfo.skuImageList" />

易错点:isDefault 后端给的是数字 1/0,不要改成 true/false,否则 find(v => v.isDefault === 1) 找不到;初始数据可能不是数组,find 会报错,所以 props 默认给 () => [],或在计算属性里先判空。

点击缩略图切换大图:数据驱动 isDefault ​

为什么点击小图大图要变?因为两个组件要通讯。怎么想?不要让子组件直接操作放大镜 DOM,而是修改“选中项”这个数据,让父组件根据 isDefault 计算当前大图地址。怎么做?子组件点击时先全部置 0,再把当前项置 1;父组件写计算属性 imageUrl,从 skuImageList 中找 isDefault === 1 的项并返回 imgUrl。

js
computed: {
  imageUrl() {
    const list = this.detail?.productInfo?.skuInfo?.skuImageList
    if (!list || !list.length) return ''
    const current = list.find(v => v.isDefault === 1)
    return current ? current.imgUrl : ''
  }
}

模板中把 imageUrl 同时传给放大镜的 url 和 highUrl。转写中调试时先打印点击的 id,确认事件执行,再改数据;曾把 isDefault 设成 true 导致匹配失败,改回数字 1 后恢复。缩略图默认选中项由数据里的 isDefault 决定,所以页面初次渲染时无需额外写死 active。

易错点与调试记录 ​

  1. 初始数据不存在时直接 .find 会报错,先判空或给默认空数组。
  2. isDefault 类型要统一为数字 1/0,不要混用布尔值。
  3. swiper 先写死图片验证结构,再换成 v-for,否则布局和图片路径问题会混在一起。
  4. 放大镜图片太小,转写中是因为容器上残留 style="200",去掉后恢复;选择器大小再按需覆盖。
  5. 插件版本有取舍:2.5 快速拖动有问题,2.2.1 有定位问题,按项目实际用样式覆盖解决。
  6. 修改的是传入数组里的对象属性,不要直接替换整个 prop 数组;父组件计算属性依赖同一数组,数据变化后大图会同步更新。

为什么需要缩略图:先明确场景 ​

商品详情页里,一个 SKU 往往有多张图片。主图区域空间有限,不可能把所有图片平铺出来,所以需要一条“缩略图列表”:横向排列小图,用户通过左右按钮或滑动查看,后续再点击小图切换大图。

怎么想:不要一上来就在详情页里堆结构。转写稿里的做法是先把它抽成独立组件 ImageList,详情页只负责引用。这样缩略图的结构、样式、轮播逻辑都集中在一个文件里,后续做“点击小图切换大图”时也方便做组件通讯。

怎么做:在 src/pages/Detail 下新建 ImageList/index.vue,把详情页里缩略图相关结构和样式剪切过来。转写稿里先保留静态图片验证布局,再替换成真实数据;这是很实用的调试顺序——先确认结构和样式,再排查数据问题。

易错点:不要直接把详情页一大段 DOM 原样复制后不清理;无关的类名、旧图片路径、旧样式要一起处理,否则后面调间距时会互相干扰。

引入 Swiper:复用首页轮播的思路 ​

为什么需要:左右按钮 + 中间可滑动区域,本质是轮播。转写稿明确说“和之前首页轮播很相似”,所以复用 vue-awesome-swiper,不要自己手写一套拖拽逻辑。

怎么想:模板分三段——左按钮、中间 swiper、右按钮。脚本里引入 Swiper、SwiperSlide 和样式,注册组件;data 里配置 slidesPerView: 5、spaceBetween: 10、navigation 的 nextEl/prevEl。转写稿里的“所维法/死维法”就是 Swiper,配置项要写正确英文。

怎么做:本节最终组件代码如下,保留原笔记代码:

vue
<template>
    <div class="specScroll">
        <!--左按钮-->
        <a class="prev">&lt;</a>
        <!-- 中间可滑动区域 -->
        <swiper class="swiper" :options="swiperOption">
            <swiper-slide :key="item.id" v-for="item in $store.state.product.productInfo.skuInfo.skuImageList">
                <img :src="item.imgUrl" />
            </swiper-slide>
        </swiper>
        <!--右按钮-->
        <a class="next">&gt;</a>
    </div>
</template>

<script>
import { Swiper, SwiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'
export default {
    name: "ImageList",
    components: {
        // 将vue-awesome-swiper提供的组件注册为当前组件
        Swiper,
        SwiperSlide
    },
    data() {
        return {
            // 配置项
            swiperOption: {
                // 显示多少屏
                slidesPerView: 5,
                // 屏之间的间隔
                spaceBetween: 10,
                navigation: {
                    nextEl: '.next',
                    prevEl: '.prev'
                }
            }
        }
    }
}
</script>

<style lang="less" scoped>
.specScroll {
    margin-top: 5px;
    width: 400px;
    overflow: hidden;
    display: flex;
    .prev, .next {
        text-align: center;
        width: 10px;
        height: 54px;
        line-height: 54px;
        border: 1px solid #CCC;
        background: #EBEBEB;
        cursor: pointer;
    }
    .swiper{
        width:100%;
        margin:0 10px;
    }
    img {
        text-align: center;
        border: 1px solid #CCC;
        padding: 2px 6px;
        width: 50px;
        height: 50px;
        margin-right: 20px;
    }
}
</style>

易错点:navigation 里的选择器必须和模板类名一致;swiper/css/swiper.css 不能漏,否则布局和按钮行为会异常;scoped 下类名仍要保证组件内唯一,避免多个轮播互相选中。

接真实数据:从静态图切到 skuImageList ​

为什么需要:静态图只能看布局,真实缩略图必须来自商品数据。转写稿里先在网络面板找到 skuInfo.skuImageList,确认每项有 id 和 imgUrl,再用 v-for 渲染。

怎么想:key 用 item.id,图片地址用 item.imgUrl。模板里保留 swiper-slide 作为每一项的容器,不要把小图直接塞进 swiper 里。

怎么做:把静态的多个 swiper-slide 改成一个 v-for,数据路径按项目实际结构调整;本节笔记里写的是 $store.state.product.productInfo.skuInfo.skuImageList。如果图片不显示,先看 imgUrl 是否有值,再看路径是否拼错。

易错点:转写稿里反复调试过“先几张占位图,再换真实图片”的过程;不要一上来就怀疑 Swiper。常见问题是数据路径、字段名、图片地址写错,或者 slidesPerView 与图片宽度、spaceBetween、margin-right 同时叠加导致间距不自然。本节先把缩略图展示和左右切换做出来,点击小图切换大图属于后续组件通讯,不在本节代码里展开。

需求拆解:缩略图切换到底在改什么 ​

缩略图切换的交互是:点击下方小图,上方大图跟着变,同时当前小图有选中样式。这里有两个组件:ImageList 负责缩略图列表,Zoom 负责大图预览。它们不能各自维护一份“当前选中项”,否则两边会不同步。因此要想清楚:选中态是共享数据,应该放在 Vuex 的 product 模块里,通过 isDefault 标记哪一张图被选中。点击小图时只提交一个 mutation,让 store 改数据,ImageList 和 Zoom 都依赖同一份数据自动更新。

在 store 中维护选中态 ​

为什么需要:如果直接在 ImageList 里改 class,再想办法通知 Zoom,组件间通讯会变复杂。怎么做:在 src/store/product.js 的 state 里保留 skuImageList,并增加一个根据 id 修改选中项的 mutation。思路是“先移除旧选中,再设置新选中”:先把当前 isDefault === "1" 的项改成 "0",再根据传入 id 找到目标项改成 "1"。易错点:isDefault 是字符串,不要写成数字 1;必须先改旧项再改新项,否则可能找不到旧选中项;find 找不到会报错,数据要保证有默认项。

js
const state = {
	// 商品信息
	productInfo:{
		// 商品详情
		skuInfo:{
			skuImageList:[]
		}
	}
}
const mutations = {
	// 根据ID修改选中项
	CAHNGE_IMAGE_LIST_BY_ID(state,id){
		// 1- 将之前的选中项移除,修改为0
		state.productInfo.skuInfo.skuImageList.find(v=>v.isDefault==="1").isDefault = "0";
		// 2- 根据id,将isDefault修改为1
		state.productInfo.skuInfo.skuImageList.find(v=>v.id === id).isDefault = "1";
	}
}

缩略图列表组件 ImageList ​

为什么需要:缩略图区域要能横向滑动,还要有左右按钮,所以用 vue-awesome-swiper。怎么想:列表数据直接取 store 里的 skuImageList;每张图绑定点击事件,把 item.id 传给 changeImg;选中样式用 active 类控制。怎么做:配置 slidesPerView: 5、spaceBetween: 10,左右按钮通过 navigation 指定 .next 和 .prev。易错点:commit 的模块名要写 product/CAHNGE_IMAGE_LIST_BY_ID,要和 store 中的拼写一致;class 绑定可以用 item.isDefault/1===1,本质是把字符串转数字比较,也可以写成 item.isDefault === "1";点击事件要传 item.id,不要传整个 item。

vue
<template>
    <div class="specScroll">
        <!--左按钮-->
        <a class="prev">&lt;</a>
        <!-- 中间可滑动区域 -->
        <swiper class="swiper" :options="swiperOption">
            <swiper-slide :key="item.id" v-for="item in $store.state.product.productInfo.skuInfo.skuImageList">
                <img  @click="changeImg(item.id)"  :class="{active:item.isDefault/1===1}" :src="item.imgUrl" />
            </swiper-slide>
        </swiper>
        <!--右按钮-->
        <a class="next">&gt;</a>
    </div>
</template>

<script>
import { Swiper, SwiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'
export default {
    name: "ImageList",
    components: {
        // 将vue-awesome-swiper提供的组件注册为当前组件
        Swiper,
        SwiperSlide
    },
    data() {
        return {
            // 配置项
            swiperOption: {
                // 显示多少屏
                slidesPerView: 5,
                // 屏之间的间隔
                spaceBetween: 10,
                navigation: {
                    nextEl: '.next',
                    prevEl: '.prev'
                }
            }
        }
    },
    methods:{
        changeImg(id){
            this.$store.commit("product/CAHNGE_IMAGE_LIST_BY_ID",id);
        }
    }
}
</script>

大图联动:Zoom 用计算属性取选中图 ​

为什么需要:大图不能自己存一份图片地址,否则缩略图切换后它不会变。怎么想:Zoom 只需要从 store 里找到 isDefault === "1" 的那一项,取它的 imgUrl。用计算属性最合适,因为 store 数据变化后它会重新计算。怎么做:在 computed 里 find 默认项,找到就返回 info.imgUrl,找不到就不返回,避免模板拿到 undefined。易错点:find 之前要确认 skuImageList 是数组;不要忘记判断 info;high-url 和 img 的地址都来自同一个 imgUrl 时也能正常放大,示例数据没有单独高清图时先用它占位。

vue
<template>
    <div class="preview">
        <vue-photo-zoom-pro
                :out-zoomer="true"
                :width="200"
                :height="200"
                :high-url="imgUrl">
            <img :src="imgUrl" />
        </vue-photo-zoom-pro>
    </div>
</template>

<script>
import VuePhotoZoomPro from 'vue-photo-zoom-pro'
import 'vue-photo-zoom-pro/dist/style/vue-photo-zoom-pro.css'
export default {
    name: "Zoom",
    components: {
        VuePhotoZoomPro,
    },
    computed:{
        imgUrl(){
            const info = this.$store.state.product.productInfo.skuInfo.skuImageList.find(v=>v.isDefault==="1");
            if(info) return info.imgUrl;
        }
    }
}
</script>

调试顺序与易错点 ​

调试时先看点击有没有执行:在 changeImg 里打印 id,确认点击的是当前小图;再看 mutation 有没有把旧项改成 "0"、新项改成 "1";最后看 Zoom 的计算属性是否重新执行。转写稿里反复强调“先改旧再改新”,因为如果先把新项设为 "1",再找 isDefault === "1" 就可能找到两个或找错。另一个常见问题是初始 skuImageList 为空数组时 find 返回 undefined,所以 state 里先给空数组、Zoom 里判断 info。只要保证 store 是唯一数据源,缩略图切换和大图联动就会稳定。

作业目标:无结果时不能只留白 ​

本节作业是:如果搜索不出来结果,显示“搜索结果为空”。转写稿主要复盘的是商品详情页渲染、组件抽离、放大镜和缩略图切换,并没有直接给出搜索页空状态的完整代码;但里面反复出现的“数据没到位不能直接点”“初始值要给数组”“先判断再渲染”“看控制台和 Network 调试”正好可以迁移到本作业。下面只按作业要求梳理实现思路,不虚构转写稿里没有的搜索接口字段。

知识点一:先判断列表,再决定渲染什么 ​

为什么需要:搜索无结果时,如果模板还按有数据的方式渲染,页面会空白,用户不知道是没搜到、没加载,还是页面坏了。
怎么想:搜索页至少有三态:加载中、有结果、无结果。空状态不是“什么都不显示”,而是明确告诉用户“没有搜到”。
怎么做:列表数据初始化为空数组,请求回来后赋值;模板先判断 list.length,有长度渲染结果,否则渲染空状态。示意结构如下:

html
<div v-if="list.length">
  <div v-for="item in list" :key="item.id">
    {{ item.name }}
  </div>
</div>
<div v-else>
  搜索结果为空
</div>

易错点:初始值写成 null 或 undefined,模板里直接 list.length 会报错;转写稿视频1里就提到“刚开始是不是一个 undefined,你不能直接写点”,视频6里 find 报错也是因为数据初始不是数组。空状态判断一定要建立在“它已经是数组”的前提下。

知识点二:数据初始值要给安全默认值 ​

为什么需要:搜索请求是异步的,页面第一次渲染时结果还没回来。如果默认值不安全,控制台先报错,后面逻辑都跑不稳。
怎么想:把“没有数据”当成正常状态,而不是异常状态。列表默认 [],对象默认 {} 或 null 后先判空。
怎么做:data 里写 list: [];组件抽离后,props 里的列表也给它默认数组。转写稿视频6里处理 skuImageList 时,就是在 props 里补默认数组,并在计算属性里先取到值、判断有值再返回,避免一开始就 find。
易错点:只改模板不改初始值;父组件传值慢时子组件先渲染;请求失败也直接当空结果。空结果和请求失败要分开提示,不能混在一起。

知识点三:空状态最好抽成小组件 ​

为什么需要:详情页里放大镜、缩略图都被抽成组件,搜索页的空状态也一样:它可能在多个列表页复用。
怎么想:先保证页面能跑,再把重复结构抽离;抽离时只传必要文案或类型,不让空状态组件关心接口。
怎么做:先在本页写 v-else 空状态,稳定后再抽成 EmptyResult 之类组件,父页传“搜索结果为空”。转写稿视频3、视频5都在讲“先抽离组件,再把结构和样式拿过去”,这个顺序可以照搬。
易错点:一上来抽组件,结果样式、图片路径、数据判断全乱;抽离后忘记把原样式带过去;组件里又写死接口逻辑。

知识点四:调试先看控制台和 Network ​

为什么需要:空状态不出现,可能是请求没回来、返回不是数组、判断写反、样式被覆盖。
怎么想:按“请求→数据→判断→渲染→样式”顺序查,不要先猜模板。
怎么做:转写稿视频1里反复“刷新一下、看控制台、看有没有警告、少了方括号”;视频6里通过 Network 看 skuImageList 到底是不是数组。搜索页同样:先看 Network 的返回是不是空数组,再看 list.length 是否为 0,最后看 v-else 是否被别的 v-if 挡住。
易错点:把“加载中”误写成“空结果”;请求还没结束就显示空;v-if/v-else 中间夹了其他节点导致配对失效;空状态层级被图片或遮罩盖住。

收尾:作业检查清单 ​

  1. 搜索前列表默认 [];2. 请求成功后用数组赋值;3. 有结果走 v-for,无结果走 v-else;4. 加载中和空结果分开;5. 控制台无 undefined.length、find is not a function 一类报错;6. 空状态文案明确写“搜索结果为空”。这样就把转写稿里“先判空、给默认值、再看渲染”的经验落到了本节作业上。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

课堂画面 6

代码示例 ​

1. src/pages/Detail/Zoom/index.vue:使用 vue-photo-zoom-pro 实现放大镜 ​

vue
<template>
  <div class="preview">
    <vue-photo-zoom-pro
      :out-zoomer="true"
      :width="200"
      :height="200"
      :high-url="imgUrl"
    >
      <img :src="imgUrl" />
    </vue-photo-zoom-pro>
  </div>
</template>

<script>
import VuePhotoZoomPro from 'vue-photo-zoom-pro'
import 'vue-photo-zoom-pro/dist/style/vue-photo-zoom-pro.css'

export default {
  name: 'Zoom',
  components: {
    VuePhotoZoomPro
  },
  computed: {
    imgUrl() {
      const info = this.$store.state.product.productInfo.skuInfo.skuImageList.find(
        v => v.isDefault === '1'
      )
      if (info) return info.imgUrl
    }
  }
}
</script>

<style lang="less">
.preview {
  position: relative;
  width: 400px;
  height: 400px;
  border: 1px solid #dfdfdf;

  img {
    width: 100%;
    height: 100%;
  }

  .zoomer {
    z-index: 999;
    top: 0 !important;
    left: 10px !important;
  }

  .selector {
    background-color: rgba(255, 0, 0, 0.3);
  }
}
</style>

要点:

  • :out-zoomer="true" 表示放大区域展示到图片外部;:width、:height 控制选择器尺寸。
  • :high-url="imgUrl" 与 <img :src="imgUrl" /> 使用同一个计算属性,保证小图和大图来源一致。
  • computed.imgUrl 从 Vuex 的 skuImageList 中找到 isDefault === "1" 的项,isDefault 是字符串,不要写成数字 1。
  • 覆盖第三方组件内部类 .zoomer、.selector 时,<style> 不要加 scoped,否则覆盖不到子组件内部样式。
  • top/left 加 !important 是为了修正笔记中 2.2.1 版本快速拖动时的定位问题。

2. src/pages/Detail/ImageList/index.vue:缩略图列表与点击切换 ​

vue
<template>
  <div class="specScroll">
    <a class="prev">&lt;</a>

    <swiper class="swiper" :options="swiperOption">
      <swiper-slide
        :key="item.id"
        v-for="item in $store.state.product.productInfo.skuInfo.skuImageList"
      >
        <img
          @click="changeImg(item.id)"
          :class="{ active: item.isDefault / 1 === 1 }"
          :src="item.imgUrl"
        />
      </swiper-slide>
    </swiper>

    <a class="next">&gt;</a>
  </div>
</template>

<script>
import { Swiper, SwiperSlide } from 'vue-awesome-swiper'
import 'swiper/css/swiper.css'

export default {
  name: 'ImageList',
  components: {
    Swiper,
    SwiperSlide
  },
  data() {
    return {
      swiperOption: {
        slidesPerView: 5,
        spaceBetween: 10,
        navigation: {
          nextEl: '.next',
          prevEl: '.prev'
        }
      }
    }
  },
  methods: {
    changeImg(id) {
      this.$store.commit('product/CAHNGE_IMAGE_LIST_BY_ID', id)
    }
  }
}
</script>

<style lang="less" scoped>
.specScroll {
  margin-top: 5px;
  width: 400px;
  overflow: hidden;
  display: flex;

  .prev,
  .next {
    text-align: center;
    width: 10px;
    height: 54px;
    line-height: 54px;
    border: 1px solid #ccc;
    background: #ebebeb;
    cursor: pointer;
  }

  .swiper {
    width: 100%;
    margin: 0 10px;
  }

  img {
    text-align: center;
    border: 1px solid #ccc;
    padding: 2px 6px;
    width: 50px;
    height: 50px;
    margin-right: 20px;

    &.active {
      border-color: red;
    }
  }
}
</style>

要点:

  • vue-awesome-swiper 必须同时引入 swiper/css/swiper.css,否则左右切换和布局会异常。
  • navigation.nextEl/prevEl 使用 .next、.prev 类选择器,模板中必须存在对应元素。
  • :class="{ active: item.isDefault / 1 === 1 }" 中 item.isDefault 是字符串 "1" 或 "0",/1 将其转成数字再比较;也可以写成 item.isDefault === '1'。
  • changeImg 通过 commit('product/CAHNGE_IMAGE_LIST_BY_ID', id) 提交到带命名空间的 product 模块。
  • 注意笔记中 mutation 名是 CAHNGE_IMAGE_LIST_BY_ID,拼写不是 CHANGE,commit 与 mutation 必须保持一致。

3. src/store/product.js:缩略图选中项切换 mutation ​

js
const state = {
  // 商品信息
  productInfo: {
    // 商品详情
    skuInfo: {
      skuImageList: []
    }
  }
}

const mutations = {
  // 根据ID修改选中项
  CAHNGE_IMAGE_LIST_BY_ID(state, id) {
    // 1- 将之前的选中项移除,修改为0
    state.productInfo.skuInfo.skuImageList.find(v => v.isDefault === '1').isDefault = '0'

    // 2- 根据id,将isDefault修改为1
    state.productInfo.skuInfo.skuImageList.find(v => v.id === id).isDefault = '1'
  }
}

export default {
  namespaced: true,
  state,
  mutations
}

要点:

  • namespaced: true 与组件中的 commit('product/CAHNGE_IMAGE_LIST_BY_ID', id) 对应,命名空间不能漏。
  • isDefault 全程使用字符串 "0" / "1",不要混用数字 0 / 1,否则 find(v => v.isDefault === '1') 找不到。
  • mutation 分两步:先把旧选中项改成 "0",再按点击的 id 把新项改成 "1"。
  • 易错点:find(...) 可能返回 undefined,直接 .isDefault 会报错;实际项目里应先判断找到的项再赋值。

常见错误 ​

1. 进详情页直接白屏,控制台报 Cannot read properties of undefined (reading 'find')

  • 现象:页面首次渲染就崩,连面包屑都没出来。
  • 原因:store 里 skuInfo 的初值没给全,异步数据回来前 skuImageList 是 undefined,计算属性里直接 .find()。
  • 修法:state 里把字段写全(skuInfo: { skuImageList: [] });计算属性里先取数组再判空:const list = ... || []; const cur = list.find(...); return cur ? cur.imgUrl : ''。

2. 点击小图能高亮,但上一张一直不熄灭,出现两个红框

  • 现象:连续点几张,选中框越积越多。
  • 原因:mutation 只写了"把点中的置 1",漏了"把原来的置 0";或者原默认项是字符串 "1",用 === 1 找不到它。
  • 修法:严格两步走——先 find(v => v.isDefault === "1") 置 "0",再按 id 置 "1",两处都加空值保护。

3. commit("product/CAHNGE_IMAGE_LIST_BY_ID") 报 [vuex] unknown mutation type

  • 现象:点击缩略图无反应,控制台红字。
  • 原因:mutation 名拼写(CAHNGE / CHANGE)与 commit 不一致;或模块没开 namespaced: true 却带了 product/ 前缀。
  • 修法:两边名字逐字符对齐;要么开命名空间,要么去掉前缀。名字建议抽成常量。

4. 抽离组件后图片 404,图裂

  • 现象:../../../assets/images/s1.png 在浏览器里打不开。
  • 原因:Detail/Zoom/index.vue 比原来的文件深了一层,../ 数量对不上。
  • 修法:改用 @/assets/images/s1.png 别名,不再数层级。

5. 给 .zoomer / .selector 写的样式完全不生效

  • 现象:放大镜依然压在图里,选中框颜色没变。
  • 原因:组件 style 加了 scoped,而这些是第三方组件内部生成的节点,不带当前组件的 data-v 属性。
  • 修法:去掉 scoped,或用 ::v-deep .zoomer {},或单独放全局样式。覆盖定位时该用 !important 就用,别硬扛优先级。

6. Swiper 左右箭头点了没反应

  • 现象:小图不翻页,箭头样式却正常。
  • 原因:navigation.nextEl: '.next' 是全局查询,页面里存在同名 class,或按钮实际在 swiper 容器外没被命中。
  • 修法:按钮 class 加唯一前缀;或拿 ref 拿实例直接调 slideNext() / slidePrev()。

7. 快速左右划动时,选择框和右侧大图错位、抖动

  • 现象:慢速正常,快速滑动就飘。
  • 原因:第三方库版本 bug(2.5 快速拖动有问题,2.2.1 定位有偏移),叠加自己覆盖的定位样式。
  • 修法:锁定 [email protected],用 .zoomer{ top:0; left:10px } 修正偏移;重装后务必重启 dev server 确认版本真的换了。

练习 ​

  1. 把 Zoom 的图片地址抽成 props: { imgUrl: String },由父级传入,让 Zoom 不再直接读 store;不传时显示占位图且不报错。
  2. 把 find(isDefault === "1") 的逻辑搬到 store 的 getter currentImg,Zoom 改用 mapGetters,对比改动前后组件少了几行代码、耦合点从哪挪到了哪。
  3. 造两组数据:只有 1 张图的商品、skuImageList 为空数组的商品。检查面包屑 / 缩略图 / 放大镜三处是否都还能渲染,把崩掉的地方补上空值保护。
  4. 验证 scoped 能否命中 swiper 生成的 slide 内部节点:先在 scoped 里写 .swiper-slide img { border-color: red },若不生效,给出两种解决方案并说明各自代价。
  5. 把 vue-photo-zoom-pro 从 2.2.1 升到最新 2.x,分别记录"快速拖动"和"定位偏移"两种表现,写一句话结论:你选哪个版本、用什么样式兜底、为什么。

原视频对照 ​

#视频时长
1渲染二12:27
2渲染三04:11
3抽离为组件03:34
4vue-photo-zoom-pro21:39
5实现缩略图12:45
6实现缩略图切换17:20

小结 ​

  1. 组件抽离的边界应看数据依赖,而不是视觉区域:Zoom 只需要一张图,就不该让它知道 store 的结构。
  2. 第三方库能省掉绝大部分交互代码,代价是"版本即功能"——锁定版本号比追新更划算。
  3. mutation 里的"旧值置 0 + 新值置 1"是一个不可拆的整体,拆到组件里做必然出现双高亮。
  4. 凡是 find(...).xxx 连写的写法都是定时炸弹,异步数据的初值必须在 state 里给到位。
  5. scoped 是默认安全区,一旦要改第三方内部结构就得主动出圈,这个决定要在动手写样式之前想清楚。
  6. 缩略图与大图是共享状态关系,用 store 比父子传参省事,代价是两个组件都耦合到了 store。
  7. 排错顺序:先看控制台第一条报错 → 再看 Network 里的真实数据结构 → 最后才怀疑样式和版本。