主题
搭建SKU管理商品的静态与深度选择器(Day8 1/2)
本页对应 day8,共 6 段视频,约 1 小时 47 分。
学习目标
- 能够使用 Element Plus 的
el-card、el-table、el-table-column、el-pagination独立搭建 SKU 管理模块静态页面,并正确设置序号列、固定操作列及分页器布局。 - 能够封装 SKU 列表分页接口,在组件挂载和分页器页码、每页条数变化时发起请求,完成商品数据渲染、总条数展示与分页联动。
- 能够根据 SKU 的上下架状态字段控制操作图标与颜色,调用上架、下架接口完成业务操作,并在操作成功后给出提示且保留当前分页状态刷新数据。
- 能够使用
el-drawer、el-row、el-col、el-tag、el-carousel搭建 SKU 商品详情静态结构,并说明栅格系统 24 栏布局及列间距gutter的使用方式。 - 能够根据 SKU ID 请求商品详情接口,将商品名称、描述、价格、平台属性、销售属性和图片轮播等已有商品信息正确渲染到详情抽屉中。
- 能够说明
scoped生成data-v-哈希值并通过属性选择器实现局部样式的机制,并分别使用>>>、/deep/、:deep()穿透样式影响子组件非根节点元素。
知识点
先明确 SKU 列表页的目标
为什么需要:SKU 是库存里售卖商品的最小单位,后台必须能查看所有售卖商品,并控制其上下架。
怎么想:先把页面静态结构搭出来,再接接口渲染数据,最后补上下架业务。转写稿里明确:列表一挂载就要拿到所有售卖商品,共 122 条,并且分页展示。
怎么做:页面主体用 el-card 包 el-table,底部放 el-pagination;表格共 7 列:序号、名称、描述、默认图片、重量、价格、操作。
易错点:列宽加起来超过页面宽度时会出现横向滚动条,这是正常现象;操作列要固定在右侧,否则横向滚动时按钮会跑走。
表格静态与固定操作列
为什么需要:操作列里有上架/下架、更新、查看详情、删除等按钮,必须始终可见。
怎么想:先按普通列写,发现横向滚动后操作列看不到,再给最后一列加 fixed="right"。转写稿里就是先看到“只能看到一部分”,再查 Element Plus 文档找到固定列写法。
怎么做:序号列用 type="index",名称列宽度约 180,操作列宽度约 280 并固定右侧。代码按转写稿整理如下:
vue
<el-table :data="skuArr" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="名称" width="180" />
<el-table-column label="描述" />
<el-table-column label="默认图片" />
<el-table-column label="重量" />
<el-table-column label="价格" />
<el-table-column label="操作" width="280" fixed="right" />
</el-table>易错点:fixed 不是随便加的,一般只给操作列加;列宽不要凭感觉反复试,先保证内容能看清,再处理横向滚动。
分页器静态与接口参数
为什么需要:122 条商品不能一次性全铺开,必须分页请求。
怎么想:分页接口至少带两个参数:当前第几页、每页多少条。转写稿里反复强调“至少带两个参数”。
怎么做:定义 pageNo=ref(1)、pageSize=ref(10)、total=ref(0)、skuArr=ref([]);封装 getSkuList,组件挂载时调用一次;分页器绑定 current-page、page-size、total,并监听 current-change 和 size-change。
易错点:切换每页条数后要回到第一页;current-change 拿到当前页后要收集到 pageNo,再重新请求;接口返回一般是 result.records 和 result.total,不要直接拿整个对象当数组。
js
const pageNo = ref(1)
const pageSize = ref(10)
const total = ref(0)
const skuArr = ref([])
const getSkuList = async () => {
const result = await reqSkuList(pageNo.value, pageSize.value)
skuArr.value = result.records
total.value = result.total
}
onMounted(() => {
getSkuList()
})
const handleCurrentChange = (page) => {
pageNo.value = page
getSkuList()
}
const handleSizeChange = (size) => {
pageSize.value = size
pageNo.value = 1
getSkuList()
}易错点:size-change 里只改 pageSize 不够,还要重新请求;不要写成 pageNo.value = pageNo 这种没收集到参数的低级错误。
渲染 SKU 列表
为什么需要:静态表格只是壳,真正要展示商品名称、描述、图片、重量、价格和售卖状态。
怎么想:普通字段直接用 prop,图片和操作按钮需要用插槽。
怎么做:表格绑定 :data="skuArr";名称、描述、重量、价格分别用 skuName、skuDesc、weight、price;图片用 el-image 插槽,地址取商品默认图片字段;操作列插槽里放按钮。
易错点:插槽里 row 代表当前商品;图片地址字段较长,转写稿里是现场查数据确认的,不能凭记忆写;图片过大时给 el-image 设置宽高。
上架与下架业务
为什么需要:后台要控制前台商品是否售卖。转写稿明确:上架/下架对应前台商品能否展示。
怎么想:先看商品数据里的 isSale 字段。isSale === 1 表示当前在售,按钮应显示“下架”;否则显示“上架”。点击后根据状态发不同请求。
怎么做:上架、下架接口都要带商品 ID;请求成功后用 ElMessage.success 提示,并重新获取当前页数据,保持当前页。
易错点:isSale === 1 时点击是下架,不是上架;刷新时不要固定回第一页,否则用户在第 3 页操作后会跳走;这两个接口按转写稿说没有返回值,类型可定义为 void。
js
const handleSale = async (row) => {
if (row.isSale === 1) {
await reqSkuCancelSale(row.id)
ElMessage.success('下架成功')
} else {
await reqSkuSale(row.id)
ElMessage.success('上架成功')
}
getSkuList()
}易错点:参数是商品 ID,不是分类 ID;按钮图标要根据 isSale 切换上下箭头,颜色可自己选,但判断条件不能反。
详情抽屉与样式穿透补充
为什么需要:查看商品详情要展示名称、描述、价格、平台属性、销售属性、图片等,普通 dialog 在中间弹出,长内容不方便,转写稿改用 el-drawer。
怎么想:抽屉用 v-model 控制显示,内部用 el-row/el-col 栅格布局,图片用 el-carousel 轮播。
怎么做:点击“查看”时先打开抽屉,再根据商品 ID 请求详情;栅格用 :span="8" 和 :span="16" 分左右。
易错点:组件加了 scoped 后,父组件样式默认影响不到子组件内部;需要样式穿透时用 :deep(选择器)。转写稿强调:scoped 是给当前组件元素和子组件根节点加哈希属性,并不是“完全不能影响子组件”。
2.1 scoped 要解决什么问题
为什么需要:组件化开发里,每个组件都写样式,如果样式默认全局生效,很容易互相污染。比如父组件写了一个 h1 { color: red; },子组件里的 h1 也可能被染红。我们希望样式“只影响当前组件”,于是 Vue 提供了 <style scoped>。
怎么想:先别急着记结论,做一个最小实验:在 App 里引入一个 Test 子组件,父子结构里都放 h1。先不加 scoped,刷新页面,两个 h1 都会被样式匹配;再给父组件样式加 scoped,观察 DOM 和样式匹配结果。
怎么做:
vue
<!-- App.vue -->
<template>
<div>
<h1>我是父组件</h1>
<Test />
</div>
</template>
<script setup>
import Test from './components/Test.vue'
</script>
<style scoped>
h1 {
color: red;
}
</style>vue
<!-- Test.vue -->
<template>
<div>
<h1>我是子组件</h1>
</div>
</template>加 scoped 后,审查元素会看到当前组件 DOM 元素、子组件根节点上出现类似 data-v-xxxx 的自定义属性。样式最终不是简单的 h1,而是类似:
css
h1[data-v-xxxx] {
color: red;
}易错点:不要只说“scoped 是局部样式”就结束。它的底层是:scoped 生成 data-v-hash,再通过属性选择器提高匹配条件,从而实现局部匹配。
scoped 的底层推导:data-v-hash + 属性选择器
为什么需要理解底层:只背“局部样式”会遇到反例:为什么父组件的 scoped 有时还能影响子组件?为什么子组件非根节点又影响不到?必须看 hash 加在哪。
怎么想:转写稿里的调试过程是:父组件加 scoped 后,父组件 DOM 节点和子组件根节点都会带上相同的 data-v-hash;而子组件内部非根节点不会带父组件的这个 hash。于是 h1[data-v-父hash] 只能匹配父组件的 h1 和“恰好作为子组件根节点”的元素。
怎么做:把父组件样式写成 h1[data-v-xxxx] 来理解。若子组件根节点是 div,父组件的 h1 选择器匹配不到子组件内部的 h1;若子组件根节点本身就是 h1,它带父 hash,就可能被父样式命中。
易错点:说“scoped 只影响当前组件”是半句话。更准确:scoped 会让当前组件 DOM 元素以及子组件根节点带相同 hash,所以父样式可能影响子组件根节点;但子组件内部非根节点默认影响不到。
父子都 scoped 时,hash 为什么不一样
为什么需要:有同学会问:父组件加 scoped,子组件也加 scoped,那父样式能不能穿透到子组件内部?答案通常是不能。
怎么想:父组件生成父 hash,子组件生成子 hash。子组件根节点可能同时带父 hash 和子 hash,但子组件内部元素只带子 hash。父组件的 h1[data-v-父hash] 匹配不到只带子 hash 的内部 h1。
怎么做:在父子组件都加 scoped 后审查元素,对比 data-v- 值;再看编译后的选择器条件。这样就能解释“为什么父样式进不去子组件内部”。
易错点:不要靠删除 scoped 改成全局样式来解决穿透,这会污染其他组件;需要穿透时用深度选择器。
深度选择器:需要穿透时怎么做
为什么需要:实际开发中常要改子组件内部样式,比如轮播图指示器、表格某列等。父组件加了 scoped 又影响不到子组件内部,就需要“样式穿透”。
怎么想:深度选择器的本质是让编译后的选择器不再被当前 scoped 的 hash 条件卡住,从而匹配子组件内部元素。转写稿提到三种常见写法:原生 CSS 的 >>>;Less/Sass 等预处理器中的 ::v-deep;新版本 Vue 更推荐的 :deep(选择器)。
怎么做:
css
/* 原生 CSS 中可用 */
>>> h1 {
color: red;
}
/* Less/Sass 等预处理器中常见 */
::v-deep h1 {
color: red;
}
/* Vue 3 推荐写法 */
:deep(h1) {
color: red;
}易错点:>>> 在预处理器里可能失效;:deep() 要把选择器写进括号;深度选择器不要滥用,否则会破坏组件封装,优先考虑组件自身提供的 props、插槽或样式变量。
课堂画面






代码示例
示例一:scoped 局部样式的底层实现 + 三种深度选择器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>scoped 与深度选择器</title>
<style>
/* ===== 下面是父组件 <style scoped> 编译后的「真实产物」 ===== */
/* ① 源码写 h1 { color: red } → 编译成 h1[data-v-90] */
h1[data-v-90] { color: red; }
/* ② 源码写 .app :deep(.child h1) { color: green }
→ 编译成 .app[data-v-90] .child h1 (hash 只加在 :deep 前面那截) */
.app[data-v-90] .child h1 { color: green; }
/* ③ 不加 scoped 的就是全局样式:h2 { color: #999 } */
h2 { color: #999; }
body { font-family: system-ui, sans-serif; padding: 20px; }
</style>
</head>
<body>
<!-- 父组件模板:<div class="app"><h1>父</h1><Child /></div> -->
<div class="app" data-v-90>
<h1>父组件的 H1 —— 红色</h1>
<!-- 子组件根节点:同时带自己的 data-v-91 和父传下来的 data-v-90 -->
<div class="child" data-v-90 data-v-91>
<h1>子组件的 H1 —— 绿色(靠 :deep 穿透)</h1>
<h2>子组件里的 H2 —— 灰色(全局样式生效)</h2>
</div>
</div>
<script>
// 直观验证:哪些元素带上了 data-v-90 这个自定义属性
document.querySelectorAll('[data-v-90]').forEach((el) => {
console.log(el.tagName, '| class:', el.className, '| dataset:', el.dataset);
});
</script>
</body>
</html>关键行与易错点
h1[data-v-90]就是 scoped 的真相:scoped 会给当前组件的 DOM 元素打上data-v-哈希自定义属性,样式选择器末尾再拼一个属性选择器,于是只有带这个哈希的元素才匹配得上,这就是“局部样式”。- 子组件的根节点会同时拥有两个哈希(自己的
data-v-91和父组件传下来的data-v-90),但子组件内部的h1只有data-v-91。所以笔记里说“scoped 是局部样式”这句话只对一半——父组件的普通 scoped 样式会命中子组件的根节点。 - 想穿透到子组件内部(比如
el-carousel的指示器),必须用深度选择器。三种写法:原生 CSS / 预处理器用>>>,less 老版本用::v-deep,Vue3 推荐用:deep(选择器)。 - 易错点:
:deep()括号里写的才是“穿透”的部分,外面那截仍然会被加上 hash;写成:deep(.child h1)编译后是.app[data-v-90] .child h1,写成.child :deep(h1)编译结果是.child[data-v-90] h1,作用范围完全不同。
示例二:SKU 管理表格静态搭建 + 分页器
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SKU 管理 —— 静态搭建与分页</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
<style>
/* 表格与分页器之间留点空隙 */
.el-table { margin: 10px 0; }
</style>
</head>
<body>
<div id="app">
<el-card>
<el-table :data="skuArr" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="名称" width="180" prop="skuName" />
<el-table-column label="描述" prop="skuDesc" />
<el-table-column label="默认图片" width="180">
<template #default="{ row }">
<img :src="row.skuDefaultImg" style="width: 100px; height: 100px" />
</template>
</el-table-column>
<el-table-column label="重量" prop="weight" />
<el-table-column label="价格" prop="price" />
<!-- 最后一列固定到右侧 -->
<el-table-column label="操作" width="280" fixed="right" />
</el-table>
<el-pagination
v-model:current-page="pageNo"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 30, 40]"
layout="prev, pager, next, jumper, ->, total, sizes"
background
@current-change="getSkuList"
@size-change="handleSizeChange"
/>
</el-card>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script>
const { createApp, ref, onMounted } = Vue;
/* ===== 接口层:真实项目里换成 axios 二次封装 =====
GET /api/product/sku/{page}/{limit}
返回 { records, total, size, current, pages } */
const REQ_SKU_LIST = async (page, limit) => {
await new Promise((r) => setTimeout(r, 100));
const all = Array.from({ length: 122 }, (_, i) => ({
id: i + 1,
skuName: '商品名称' + (i + 1),
skuDesc: '商品描述' + (i + 1),
skuDefaultImg: 'https://via.placeholder.com/100',
weight: 100 + i,
price: 12.5 + i,
isSale: i % 3 === 0 ? 1 : 0
}));
return {
records: all.slice((page - 1) * limit, page * limit),
total: all.length,
size: limit,
current: page,
pages: Math.ceil(all.length / limit)
};
};
const app = createApp({
setup() {
const skuArr = ref([]); // 存储全部商品(当前页)
const total = ref(0); // 商品总个数
const pageNo = ref(1); // 当前第几页
const pageSize = ref(10); // 每页几条
// 挂载时发一次,翻页时再发 —— 所以必须封装成函数
const getSkuList = async () => {
const res = await REQ_SKU_LIST(pageNo.value, pageSize.value);
skuArr.value = res.records;
total.value = res.total;
};
// 每页条数变化:先回到第一页,再重新拉
const handleSizeChange = () => {
pageNo.value = 1;
getSkuList();
};
onMounted(() => { getSkuList(); });
return { skuArr, total, pageNo, pageSize, getSkuList, handleSizeChange };
}
});
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>关键行与易错点
type="index"生成序号列,不用自己写scope.$index + 1。- “默认图片”列必须用
#default="{ row }"插槽,因为要渲染<img>标签而不是纯文本。 - 7 列宽度加起来超过容器宽度 → 出现横向滚动条是正常现象,正因如此才需要
fixed="right"把操作列钉在右侧(fixed="left"也可以)。 - 分页器
layout里的->表示“把后面的项推到最右边”;background给页码加背景色。 - 易错点 1:
pageNo、pageSize是 ref,发请求时必须写.value,直接写pageNo会传一个对象给接口。 - 易错点 2:
@size-change里一定要先把pageNo重置为 1,否则从第 12 页切到“每页 40 条”可能停在空页。 - 易错点 3:分页接口至少带两个参数——当前第几页 + 每页多少条,这是通用套路。
示例三:商品的上架与下架业务
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>SKU 上架与下架</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css" />
</head>
<body>
<div id="app">
<el-card>
<el-table :data="skuArr" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="名称" width="180" prop="skuName" />
<el-table-column label="描述" prop="skuDesc" />
<el-table-column label="默认图片" width="180">
<template #default="{ row }">
<img :src="row.skuDefaultImg" style="width: 100px; height: 100px" />
</template>
</el-table-column>
<el-table-column label="重量" prop="weight" />
<el-table-column label="价格" prop="price" />
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<!-- isSale === 1 说明在售 → 显示「下架」图标;已下架 → 显示「上架」图标 -->
<el-button
size="small"
:type="row.isSale === 1 ? 'warning' : 'success'"
:icon="row.isSale === 1 ? 'Bottom' : 'Top'"
@click="updateSale(row)"
/>
<el-button size="small" type="primary" icon="Edit" @click="updateSku" />
<el-button size="small" type="info" icon="InfoFilled" @click="常见错误
1. 上下架图标不随商品状态变化
- 现象:列表里所有行的操作图标、颜色一模一样,明明下架的商品也显示“下架”按钮。
- 原因:模板里把图标名和 type 写死了,没有按
row.isSale做三元判断,只把接口请求写对了。 - 修法:图标与颜色都写成
row.isSale ? 'Bottom' : 'Top'这类三元表达式,颜色同理(在售用 warning、下架用 info),改完拿一条isSale=1和一条isSale=0的数据各点一次验证。
2. 上下架成功后列表“跳回第一页”
- 现象:在第 3 页点上下架,刷新后表格回到第 1 页,用户得重新翻。
- 原因:刷新逻辑里把页码参数写死成了
pageNo: 1(直接从初始值复制过来)。 - 修法:刷新时复用当前的
pageNo/pageSize,只在“切换每页条数”时才是pageNo = 1。
3. 分页器切条数后数据对不上
- 现象:一页 10 条切到 20 条,页码显示回到第 1 页,但表格内容还是旧的 10 条,或者请求参数与 UI 不一致。
- 原因:回调里只改了
pageSize的 ref,或者只改了pageNo却没重新发请求,参数收集与请求时机脱节。 - 修法:
size-change回调里三件事按顺序做——同步pageSize、把pageNo归 1、再调一次列表请求函数;current-change只同步页码再请求。列表请求一定抽成独立函数给两处复用,别在onMounted里内联写。
4. scoped 下改不动组件内部结构(抽屉、轮播图指示器)
- 现象:给
el-carousel的指示器、el-drawer内部元素写样式,怎么刷新都没效果。 - 原因:scoped 编译后给选择器加了属性选择器,只能命中本组件 DOM 与子组件根节点,进不到子组件内部深层节点。
- 修法:用深度选择器穿透,Vue3 推荐
:deep(选择器) { };不要图省事把<style scoped>直接改成全局样式,会污染其他页面。
5. 深度选择器写法过时导致不生效
- 现象:抄旧笔记写
::v-deep .xxx或/deep/ .xxx,控制台警告、样式不生效。 - 原因:
/deep/、::v-deep是旧版编译器写法,新版 Vue3 编译链要求函数式:deep(...)。 - 修法:统一改成
:deep(.el-carousel__indicator) { }这种形式,选择器写在括号内。
6. 查看详情传错 ID
- 现象:抽屉能弹出,但名称、价格全空。
- 原因:把
categoryId/spuId当成商品 ID 传给了详情接口,或者传了整个row对象。 - 修法:只传
row.id(当前 SKU 自身的主键),发请求前console.log一次入参,对照接口文档确认参数名。
7. 轮播图图片出不来
- 现象:抽屉里轮播区域是空白或只有 alt 文字。
- 原因:图片字段路径写错(不是
row.image而是skuInfo.skuDefaultImg之类),或el-carousel-item里img没设宽高。 - 修法:先
console.log拿到真实字段路径再绑定src;给img补width:100%; height:100%,并确认轮播容器有明确高度。
8. 栅格布局用错单位
- 现象:抽屉里左右两栏宽度不对,或一行内容被挤成上下两行。
- 原因:不熟 24 栅格,用固定像素宽度硬撑,或者
el-col忘了写span。 - 修法:一行的份额永远是 24,左 8 右 16 之类按
span分配;列间距用el-row的gutter,不要给el-col手写 margin。
练习
把 SKU 列表请求抽成
getSkuList(),在onMounted、current-change、size-change三处复用,并保证:翻页时保留当前每页条数,切条数时页码归 1。写完在页面上验证“第 3 页 → 切 20 条 → 再翻到第 2 页”参数是否正确。实现上下架:点击按钮时按
row.isSale决定调上架还是下架接口,成功后给ElMessage提示并刷新当前页数据。要求在第 2 页操作后仍停留在第 2 页。给表格操作列加
fixed="right",把列宽合计调到超出容器宽度,验证横向滚动时操作列固定在右侧、且不遮挡相邻列内容。用
el-drawer+el-row/el-col搭出商品详情静态:名称、描述、价格、平台属性(el-tag循环)、销售属性(el-tag循环)、商品图片(el-carousel循环)。要求左右两栏用 span 分配、用gutter控间距、每块之间用el-divider分隔。用
:deep()把轮播图指示器改成圆点样式(参考默认线性指示器的 DOM 结构),并在笔记里写清:为什么这里必须用深度选择器,以及scoped为什么单独写.el-carousel__indicator会失效。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | SKU模块静态的搭建 | 13:47 |
| 2 | SKU管理模块数据的渲染与分页的完成 | 20:59 |
| 3 | 完成商品的上架与下架的业务 | 16:48 |
| 4 | 完成展示SKU商品的静态的搭建--栅格系统 | 19:27 |
| 5 | 渲染已有的商品信息 | 15:32 |
| 6 | 三种深度选择器使用(scoped) | 20:57 |
小结
- 分页类接口的请求参数永远是“当前页 + 每页条数”,UI 上的页码/条数变化必须同步到参数并触发一次新请求,三者缺一不可。
- 列表请求一定要封装成函数:挂载、翻页、切条数、增删改后的刷新,全都是调同一个函数,写死参数是 bug 的高发点。
- 状态类按钮(上架/下架、启用/禁用)的图标、颜色、点击后调的接口,三者必须由同一个字段驱动,否则界面和业务必然脱节。
scoped的“局部”是半真半假:它能管住本组件 DOM 和子组件根节点,管不住子组件内部深层结构。- 深度选择器是穿透 scoped 的唯一正路,Vue3 统一用
:deep(选择器),旧写法别再用;能用组件自身 API(如 props)解决的样式问题,优先不用深度选择器。 - 布局优先用 24 栅格(
el-row/el-col+span+gutter),别用固定像素硬拼,屏幕变化时栅格能自动适配。 - 详情类功能的关键是“拿对 ID、拿对字段路径”:先把接口返回值打印出来看清结构,再写模板绑定,比反复猜字段名快得多。
- 后台模块的套路高度同质化:静态骨架 → 请求封装 → 数据渲染 → 分页 → 行内操作。把这一套练成肌肉记忆,新模块的边际成本会非常低。