主题
SPU与SKU理解(Day5 2/2)
本页对应 day5,共 6 段视频,约 1 小时 36 分。
学习目标
- 能够使用 lodash 的
cloneDeep对引用类型数据进行深拷贝,并说明深拷贝与浅拷贝在属性更新场景中的差异。 - 能够根据属性 ID 完成删除已有属性业务,包括确认弹窗、调用删除 API、成功提示及删除后重新获取属性列表。
- 能够结合电商场景解释 SPU 与 SKU 的含义,并举例区分标准化产品单元与库存量最小单位。
- 能够使用三级分类组件、卡片、表格、分页器等完成 SPU 管理模块静态页面搭建。
- 能够封装获取 SPU 列表 API,定义相关 TS 类型,并在三级分类变化时请求并渲染已有 SPU 数据。
- 能够处理分页器页码和每页条数变化事件,重新请求并更新 SPU 列表数据。
知识点
一、SPU与SKU:先建立业务概念
为什么需要:SPU、SKU是电商后台和面试里绕不开的术语。转写稿里明确提醒:简历写了SPU管理模块,面试官问“什么是SPU、什么是SKU”却答不上来,会很被动。
怎么想:不要把二者当成两个普通字段。SPU描述“这是哪款产品”,SKU描述“具体卖的是哪一个最小库存单元”。SPU是标准化产品单元,SKU是库存量最小单位。
怎么做:先记定义,再用商品举例。比如“苹果6手机”是SPU;颜色、内存、系统、价格等销售属性组合后,形成具体SKU,如“红色、256、苹果、6999”和“蓝色、128、塞班、1299”。转写稿还强调:销售属性最多三个,常见是颜色、版本、尺码。
易错点:不要把SPU说成“类”、SKU说成“实例”就结束,面试表达要回到电商语言;SPU不是具体库存,SKU才是售卖和库存的最小单位;属性组合要能唯一标识一个售卖单元,不能把不相关参数硬凑。
二、用苹果6手机把概念落到例子上
为什么需要:只背定义容易混。转写稿用“苹果6手机”反复推导,就是为了把“产品”与“具体售卖规格”分开。
怎么想:先问“这是哪家公司的哪款产品”,答案是SPU;再问“这款产品下到底卖哪一版”,答案是SKU。颜色红、紫、蓝,内存128、256,系统安卓、苹果、塞班,价格1299、6999、899,都是可选的销售属性值。
怎么做:把SPU写成“苹果6手机”,把SKU写成属性组合。例如SKU:红色、256、苹果、6999;SKU:蓝色、128、塞班、1299。后续SPU管理模块里,新增SPU要填品牌、名称、描述、图片、销售属性;新增SKU要选具体销售属性值和平台属性。
易错点:价格、系统是否作为销售属性要看业务设计,转写稿示例只是帮助理解;不要把所有参数都塞进SKU名称;SKU必须能对应真实可售、可库存的商品。
三、2.1 SPU静态搭建:先搭骨架
为什么需要:SPU模块数据量大、接口多。转写稿明确说这个模块难,所以先把静态结构搭好,再接数据,避免“结构、类型、请求、渲染”混在一起。
怎么想:静态页先复用三级分类全局组件,再放卡片、添加SPU按钮、表格和分页器。转写稿说“先把静态搭了,底部数据后面再分场景”。
怎么做:页面顶部引入三级分类组件,静态阶段传场景值并处理禁用/启用;卡片左上角放“添加SPU”按钮,使用primary类型和plus图标;表格先做四列:序号、SPU名称、SPU描述、SPU操作;操作列预留添加SKU、更新SPU、查看SKU、删除SPU等按钮;底部放分页器,配置当前页码和每页条数。
易错点:三级分类组件不传场景值或禁用状态,容易提前可操作;描述列过长要处理列宽和内容隐藏;分页器的“页码变化”和“每页条数变化”不是同一个事件,不能混用。
四、静态搭建后的调试与后续接数据注意
为什么需要:转写稿里反复出现“先刷新看骨架、再测接口、再看渲染”的过程,说明静态搭建不是写完就算,必须验证。
怎么想:先确认三级分类是否按预期禁用,按钮图标和文字是否正常,表格列是否齐全,分页器是否显示。后续接接口时,请求至少要带当前页、每页条数、三级分类ID。
怎么做:转写稿中的调试顺序是:先测接口能否拿到数据,再监听三级分类ID,有值才发请求;每次分类变化要清空上一次的SPU数组;页码变化用current-change收集当前页码,每页条数变化用size-change收集条数,再重新请求。
易错点:三级分类ID没有值时不要发请求;切换分类不清空旧数据会串数据;把页码事件和条数事件合并成一个回调,参数含义会混;没有数据时不要只显示空表格,可用空状态或分割线提示。
课堂画面






代码示例
代码一:SPU 与 SKU 的关系(纯 HTML + JS,直接双击运行)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SPU 与 SKU</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; padding: 20px; background: #f0f2f5; }
.spu { border: 1px solid #dcdfe6; border-radius: 6px; padding: 12px 16px; width: 560px; background: #fff; }
.spu h3 { margin: 0 0 8px; color: #303133; }
.spu p { margin: 6px 0; color: #606266; font-size: 14px; }
.sku-item { padding: 8px 12px; border-left: 3px solid #409eff; background: #f5f7fa; margin-bottom: 6px; font-size: 14px; }
</style>
</head>
<body>
<div id="app"></div>
<script>
// ===== 1. SPU:标准化产品单元(相当于“类”) =====
const spu = {
name: '苹果6手机',
// 销售属性(电商里最多 3 个:颜色、版本、尺码)
saleAttrs: {
color: ['红色', '紫色', '蓝色'],
memory: [128, 256],
system: ['安卓', '苹果', '塞班']
},
price: [1299, 6999, 899]
};
// ===== 2. SKU:库存量最小单位(相当于“类的实例”,即真正售卖的那一件) =====
const skus = [
{ color: '红色', memory: 256, system: '苹果', price: 6999 },
{ color: '蓝色', memory: 128, system: '塞班', price: 1299 }
];
// ===== 3. 用 SPU 的销售属性做笛卡尔积,推出它一共能拆出多少个 SKU =====
function buildSkus(spu) {
const keys = Object.keys(spu.saleAttrs);
return keys.reduce((acc, key) => {
const next = [];
acc.forEach(item => {
spu.saleAttrs[key].forEach(val => next.push({ ...item, [key]: val }));
});
return next;
}, [{}]);
}
const all = buildSkus(spu);
document.getElementById('app').innerHTML = `
<div class="spu">
<h3>SPU:${spu.name}</h3>
<p>颜色:${spu.saleAttrs.color.join('、')};
内存:${spu.saleAttrs.memory.join('、')}G;
系统:${spu.saleAttrs.system.join('、')}</p>
<p>该 SPU 按销售属性可拆出 <b>${all.length}</b> 个 SKU,其中已上架 ${skus.length} 个:</p>
${skus.map(s => `<div class="sku-item">
SKU:${s.color} / ${s.memory}G / ${s.system} / ¥${s.price}
</div>`).join('')}
</div>
`;
</script>
</body>
</html>要点解释:
spu对象里只放“这个产品有哪些可选项”,它本身不是一件能下单的商品;skus才是能加购物车、能扣库存的最小单位。buildSkus用reduce+ 展开运算符做笛卡尔积:3 色 × 2 内存 × 3 系统 = 18 个 SKU,这正是后台“添加 SKU”页面要一条条选出来的东西。- 易错点:面试被问“SPU 是什么”时,别答成“类”,要答“标准化产品单元”;SKU 答“库存量最小单位 / 售出的最小单位”。
- 易错点:销售属性最多 3 个(颜色、版本、尺码),不是想加几个加几个。
代码二:SPU 管理模块静态搭建(Vue3 + Element Plus,CDN 直接运行)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SPU 管理 - 静态搭建</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<style>
body { margin: 0; padding: 20px; background: #f0f2f5; }
.card-gap { margin-top: 20px; }
.table-gap { margin-top: 10px; }
</style>
</head>
<body>
<div id="app">
<!-- 上半部分:三级分类全局组件(真实项目换成 Category 组件,场景值 scene 禁用切换) -->
<el-card>
<el-select disabled placeholder="请选择三级分类" style="width: 240px;">
<el-option label="手机通讯 / 手机" value="61" />
</el-select>
</el-card>
<!-- 下半部分:SPU 列表卡片 -->
<el-card class="card-gap">
<el-button type="primary" icon="Plus">添加SPU</el-button>
<el-table border class="table-gap" :data="spuArr">
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="SPU名称" prop="spuName" />
<!-- 描述过长时省略号 + 悬浮提示,避免行高被撑得参差不齐 -->
<el-table-column label="SPU描述" prop="description" show-overflow-tooltip />
<el-table-column label="SPU操作" width="280">
<template #default="{ row }">
<el-button type="primary" size="small" icon="Plus" title="添加SKU" />
<el-button type="warning" size="small" icon="Edit" title="更新SPU" />
<el-button type="info" size="small" icon="InfoFilled" title="查看SKU列表" />
<el-button type="danger" size="small" icon="Delete" title="删除SPU" />
</template>
</el-table-column>
</el-table>
<!-- 空状态:有数据展示表格,没数据显示空状态 -->
<el-empty v-if="!spuArr.length" description="暂无数据" />
<el-pagination
style="margin-top: 20px;"
:current-page="pageNo"
:page-size="pageSize"
:page-sizes="[3, 5, 7, 9]"
layout="prev, pager, next, jumper, ->, sizes, total"
:total="total" />
</el-card>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref } = Vue;
const app = createApp({
setup() {
// 静态搭建阶段先用假数据把表格撑起来
const spuArr = ref([
{ id: 1, spuName: '苹果12', description: '苹果12,5G手机,性能不错' },
{ id: 2, spuName: '红米10X', description: '红米10X 性价比高' },
{ id: 3, spuName: '小米10', description: '小米10 旗舰机型' }
]);
const pageNo = ref(1); // 当前页码,默认第一页
const pageSize = ref(3); // 每页展示条数,默认 3 条
const total = ref(7); // 总条数
return { spuArr, pageNo, pageSize, total };
}
});
// 全局注册 Element Plus 与全部图标,模板里才能直接写 icon="Plus"
app.use(ElementPlus);
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
app.component(key, comp);
}
app.mount('#app');
</script>
</body>
</html>要点解释:
- 三级分类组件上先写
disabled,是因为此时还没选分类、也没拿到c3Id;真实项目里是:disabled="!c3Id"。 show-overflow-tooltip加在“SPU描述”这一列上,内容过长才省略并悬浮显示,不加的话表格行高会被撑得参差不齐。layout="prev, pager, next, jumper, ->, sizes, total"里的->是弹簧,把sizes和total推到最右边,这是分页器好看的常用写法。- 易错点:
el-table-column想禁止拖拽调宽,得在列上找属性(resizable),el-table本身没有这个开关。 - 易错点:
type="index"每页都从 1 开始;要跨页连续编号得写(pageNo - 1) * pageSize + index + 1。
代码三:监听三级分类 + 获取 SPU 列表 + 分页联动(Vue3 + Element Plus,CDN 直接运行)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>SPU 列表数据获取与分页联动</title>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<style> body { margin: 0; padding: 20px; background: #f0f2f5; } </style>
</head>
<body>
<div id="app">
<!-- 模拟三级分类全局组件:选中后仓库里的 c3Id 才有值 -->
<el-card>
<el-select v-model="c3Id" placeholder="请选择三级分类" style="width: 260px;">
<el-option label="手机通讯 / 手机" value="61" />
<el-option label="图书 / 电子书刊" value="62" />
</el-select>
</el-card>
<el-card style="margin-top: 20px;">
<el-table v-if="spuArr.length" border :data="spuArr">
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="SPU名称" prop="spuName" />
<el-table-column label="SPU描述" prop="description" show-overflow-tooltip />
</el-table>
<el-empty v-else description="暂无数据" />
<el-pagination
style="margin-top: 20px;"
:current-page="pageNo"
:page-size="pageSize"
:page-sizes="[3, 5, 7, 9]"
layout="prev, pager, next, jumper, ->, sizes, total"
:total="total"
@current-change="handleCurrentChange"
@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"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script>
const { createApp, ref, watch } = Vue;
// ===== 模拟后端数据 =====
const mockDB = {
'61': [
{ id: 1, spuName: '苹果12', description: '苹果12,5G手机' },
{ id: 2, spuName: '红米10X', description: '红米10X 性价比高' },
{ id: 3, spuName: '小米10', description: '小米10 旗舰机型' },
{ id: 4, spuName: '华为Mate20', description: '华为Mate20 5G手机' },
{ id: 5, spuName: 'OPPO 20', description: 'OPPO 20 Plus' },
{ id: 6, spuName: '苹果11', description: '苹果11' },
{ id: 7, spuName: '荣耀30', description: '荣耀30' }常见错误
- 编辑属性后取消,原列表数据也跟着变 → 原因:直接把已有属性对象赋给收集对象,引用类型仍指向同一数组 → 修法:对来源数据使用
cloneDeep深拷贝后再赋值。 - 点击删除属性“确定”没反应或没刷新列表 → 原因:
Popconfirm未绑定@confirm,或删除成功后未重新请求属性列表 → 修法:在confirm中按属性 ID 调删除接口,成功后提示并重新getAttr。 - 删除失败时出现重复提示 → 原因:业务层又写了一套失败提示,响应拦截器已统一处理 → 修法:业务层只处理成功分支,失败提示交给拦截器。
- SPU 页面未选三级分类就发请求,或切换分类后仍显示旧数据 → 原因:
watch中未判断三级分类 ID,且未清空上一次列表 → 修法:有C3ID才请求,变化时先清空SPUArr,默认页码传 1。 - 分页页码和每页条数共用一个回调,切换条数后请求参数错乱 → 原因:
current-change与size-change注入参数含义不同 → 修法:分开处理;size-change中先重置当前页为 1,再请求列表。 - SPU 表格描述列高度参差、列宽被随意拖拽、无数据时页面空白 → 原因:未配置列溢出提示、列可拖拽属性、空状态分支 → 修法:描述列加
show-overflow-tooltip,目标列设:resizable="false",列表长度用v-if切换表格与空状态。 - 系统内置属性被误删/误改 → 原因:未区分系统数据与自定义数据 → 修法:系统数据按只读处理,删除/编辑入口禁用或拦截。
练习
- 在“更新已有属性”场景中,指出哪一处赋值会引发引用共享问题,并用
lodash的cloneDeep给出改造方案。 - 完成“删除已有属性”链路:
Popconfirm确认事件、删除 API、成功提示、成功后刷新列表,并说明失败提示应放在哪里。 - 为 SPU 列表编写三级分类监听的正确流程:未选分类、切换分类、清空旧数据、默认页码、发起请求,列出关键判断。
- 分页器接入练习:分别处理
current-change和size-change,说明为什么不能合并;写出size-change中重置页码并重新请求的顺序。 - 排查 SPU 静态页体验问题:添加按钮未禁用、描述列高度不齐、列可拖拽、无数据空白,分别给出对应配置或指令。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 深拷贝使用lodash | 06:31 |
| 2 | 完成删除已有的属性的业务 | 08:35 |
| 3 | SPU模块业务的介绍 | 20:21 |
| 4 | 完成SPU模块静态的搭建 | 11:11 |
| 5 | 书写已有的SPU的API与数据的展示 | 38:03 |
| 6 | 渲染已有的SPU的数据 | 11:51 |
小结
- 属性更新模块最重要的风险点是引用类型共享,深拷贝必须掌握并能说清原因。
- 删除业务要形成闭环:确认、请求、成功提示、刷新列表;失败提示优先复用拦截器。
- SPU/SKU 要能用业务语言解释清楚:SPU 是标准化产品单元,SKU 是库存量最小单位。
- SPU 模块难点在数据量大、接口多、返回数据常不完整,应先看接口实际返回,再补 TS 类型。
- 三级分类 ID 是 SPU 列表请求的前置条件:未选不发请求,切换要清旧数据,按钮状态要联动。
- 分页两个事件参数语义不同,不能图省事合并;每页条数变化时应回到第一页。
- 表格体验细节要纳入验收:列宽拖拽、内容溢出、空状态、分割线都属于可交付质量。
- 系统数据只读、自定义数据可维护,这是属性管理模块必须守住的数据边界。