主题
抽离为组件与使用第三方模块--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。
易错点与调试记录
- 初始数据不存在时直接
.find会报错,先判空或给默认空数组。 isDefault类型要统一为数字1/0,不要混用布尔值。- swiper 先写死图片验证结构,再换成
v-for,否则布局和图片路径问题会混在一起。 - 放大镜图片太小,转写中是因为容器上残留
style="200",去掉后恢复;选择器大小再按需覆盖。 - 插件版本有取舍:2.5 快速拖动有问题,2.2.1 有定位问题,按项目实际用样式覆盖解决。
- 修改的是传入数组里的对象属性,不要直接替换整个 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"><</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">></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"><</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">></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 中间夹了其他节点导致配对失效;空状态层级被图片或遮罩盖住。
收尾:作业检查清单
- 搜索前列表默认
[];2. 请求成功后用数组赋值;3. 有结果走v-for,无结果走v-else;4. 加载中和空结果分开;5. 控制台无undefined.length、find is not a function一类报错;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"><</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">></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 确认版本真的换了。
练习
- 把 Zoom 的图片地址抽成
props: { imgUrl: String },由父级传入,让 Zoom 不再直接读 store;不传时显示占位图且不报错。 - 把
find(isDefault === "1")的逻辑搬到 store 的 gettercurrentImg,Zoom 改用mapGetters,对比改动前后组件少了几行代码、耦合点从哪挪到了哪。 - 造两组数据:只有 1 张图的商品、
skuImageList为空数组的商品。检查面包屑 / 缩略图 / 放大镜三处是否都还能渲染,把崩掉的地方补上空值保护。 - 验证 scoped 能否命中 swiper 生成的 slide 内部节点:先在 scoped 里写
.swiper-slide img { border-color: red },若不生效,给出两种解决方案并说明各自代价。 - 把
vue-photo-zoom-pro从 2.2.1 升到最新 2.x,分别记录"快速拖动"和"定位偏移"两种表现,写一句话结论:你选哪个版本、用什么样式兜底、为什么。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 渲染二 | 12:27 |
| 2 | 渲染三 | 04:11 |
| 3 | 抽离为组件 | 03:34 |
| 4 | vue-photo-zoom-pro | 21:39 |
| 5 | 实现缩略图 | 12:45 |
| 6 | 实现缩略图切换 | 17:20 |
小结
- 组件抽离的边界应看数据依赖,而不是视觉区域:Zoom 只需要一张图,就不该让它知道 store 的结构。
- 第三方库能省掉绝大部分交互代码,代价是"版本即功能"——锁定版本号比追新更划算。
- mutation 里的"旧值置 0 + 新值置 1"是一个不可拆的整体,拆到组件里做必然出现双高亮。
- 凡是
find(...).xxx连写的写法都是定时炸弹,异步数据的初值必须在 state 里给到位。 - scoped 是默认安全区,一旦要改第三方内部结构就得主动出圈,这个决定要在动手写样式之前想清楚。
- 缩略图与大图是共享状态关系,用 store 比父子传参省事,代价是两个组件都耦合到了 store。
- 排错顺序:先看控制台第一条报错 → 再看 Network 里的真实数据结构 → 最后才怀疑样式和版本。