一、准备工作
本次实现采用前后端完全分离的最小依赖方案:前端用原生HTML/CSS/JS,无需框架;后端用Node.js+Express做快速演示接口,所有代码可直接复制运行。
1. 后端环境准备
2. 前端基础文件准备
在sku-demo文件夹下新建3个文件:index.html、server.js、style.css(样式仅做最小展示,可自行修改)
二、定义通用SKU数据结构
这是SKU联动的核心,大厂通用结构分为规格字典和SKU映射表两部分,避免冗余存储,也方便后续扩展规格。
完整演示用JSON数据
直接放在server.js里作为模拟数据,无需数据库:
```javascript
// server.js 顶部模拟数据
const mockData = {
// 规格字典:记录所有可选的规格分类和选项
specs: [
{ id: "color", name: "颜色", options: [{ id: "red", name: "中国红" }, { id: "blue", name: "星空蓝" }, { id: "black", name: "深邃黑" }] },
{ id: "size", name: "尺码", options: [{ id: "s", name: "S" }, { id: "m", name: "M" }, { id: "l", name: "L" }] },
{ id: "material", name: "材质", options: [{ id: "cotton", name: "纯棉" }, { id: "polyester", name: "涤纶" }] }
],
// SKU映射表:记录已选规格组合的唯一标识、库存、价格
skuMap: {
"color:red,size:s,material:cotton": { id: "sku1", stock: 5, price: 99 },
"color:red,size:m,material:cotton": { id: "sku2", stock: 0, price: 109 },
"color:red,size:l,material:polyester": { id: "sku3", stock: 2, price: 119 },
"color:blue,size:s,material:polyester": { id: "sku4", stock: 15, price: 89 },
"color:blue,size:m,material:cotton": { id: "sku5", stock: 8, price: 99 },
"color:black,size:l,material:cotton": { id: "sku6", stock: 3, price: 129 }
}
};
```
三、后端接口开发
只写一个接口,返回完整的SKU数据,方便前端调用:
```javascript
// 继续写在server.js里
const express = require('express');
const cors = require('cors');
const app = express();
const port = 3000;
// 允许跨域
app.use(cors());
// 统一返回SKU数据接口
app.get('/api/sku', (req, res) => {
res.json({ code: 200, data: mockData });
});
// 启动服务
app.listen(port, () => {
console.log(`后端服务已启动,访问地址:http://localhost:${port}/api/sku`);
});
```
终端执行node server.js,看到提示后接口即可正常访问。
四、前端SKU联动选择器开发
1. 基础HTML结构

在index.html中编写:
```html
电商SKU联动演示
已选:请选择规格
库存:--
价格:--
```
2. 最小展示样式CSS
在style.css中编写:
```css
.sku-container { width: 400px; margin: 50px auto; padding: 20px; border: 1px solid eee; border-radius: 8px; }
.spec-group { margin-bottom: 20px; }
.spec-title { font-size: 16px; margin-bottom: 10px; color: 333; }
.spec-options { display: flex; gap: 10px; flex-wrap: wrap; }
.spec-option { padding: 8px 16px; border: 1px solid ddd; border-radius: 4px; cursor: pointer; font-size: 14px; }
.spec-option.selected { border-color: ff4400; color: ff4400; background-color: fff5f0; }
.spec-option.disabled { border-color: eee; color: ccc; cursor: not-allowed; background-color: f5f5f5; }
.selected-info p { margin: 10px 0; font-size: 14px; }
buy-btn { width: 100%; padding: 12px; background-color: ff4400; color: fff; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; margin-top: 10px; }
buy-btn:disabled { background-color: ccc; cursor: not-allowed; }
```
3. 核心JS逻辑(可直接复制)
在sku-demo下新建index.js,编写联动逻辑(逻辑拆分为3步:获取数据渲染→处理点击选择→计算禁用状态):
```javascript
// 全局变量存储
let globalSpecs = [];
let globalSkuMap = {};
let currentSelected = {}; // key是规格id,value是选项id
// 入口函数
document.addEventListener('DOMContentLoaded', async () => {
await fetchSkuData();
renderSpecs();
});
// 1. 获取后端SKU数据
async function fetchSkuData() {
try {
const res = await fetch('http://localhost:3000/api/sku');
const { code, data } = await res.json();
if (code === 200) {
globalSpecs = data.specs;
globalSkuMap = data.skuMap;
}
} catch (err) {
alert('获取SKU数据失败,请检查后端服务是否启动');
console.error(err);
}
}
// 2. 渲染规格选择区域
function renderSpecs() {
const container = document.getElementById('specs-container');
container.innerHTML = '';
globalSpecs.forEach(spec => {
const groupDiv = document.createElement('div');
groupDiv.className = 'spec-group';
// 规格标题
const titleP = document.createElement('p');
titleP.className = 'spec-title';
titleP.textContent = spec.name;
// 规格选项
const optionsDiv = document.createElement('div');
optionsDiv.className = 'spec-options';
optionsDiv.dataset.specId = spec.id;
spec.options.forEach(option => {
const optionBtn = document.createElement('button');
optionBtn.className = 'spec-option';
optionBtn.textContent = option.name;
optionBtn.dataset.specId = spec.id;
optionBtn.dataset.optionId = option.id;
// 绑定点击事件
optionBtn.addEventListener('click', handleOptionClick);
optionsDiv.appendChild(optionBtn);
});
groupDiv.appendChild(titleP);
groupDiv.appendChild(optionsDiv);
container.appendChild(groupDiv);
});
// 初始渲染后计算禁用状态
updateDisabledOptions();
}
// 3. 处理规格选项点击
function handleOptionClick(e) {
const specId = e.target.dataset.specId;
const optionId = e.target.dataset.optionId;
// 已选同一规格同一选项则取消选择
if (currentSelected[specId] === optionId) {
delete currentSelected[specId];
} else {
// 否则更新选择
currentSelected[specId] = optionId;
}
// 更新UI
updateSelectedUI();
updateDisabledOptions();
updateSelectedInfo();
}
// 4. 更新已选选项的UI高亮
function updateSelectedUI() {
// 先清除所有高亮
document.querySelectorAll('.spec-option.selected').forEach(btn => btn.classList.remove('selected'));
// 再给当前已选的加高亮
Object.entries(currentSelected).forEach(([specId, optionId]) => {
const btn = document.querySelector(`.spec-option[data-spec-id="${specId}"][data-option-id="${optionId}"]`);
if (btn) btn.classList.add('selected');
});
}
// 5. 核心逻辑:计算哪些选项应该禁用
function updateDisabledOptions() {
// 生成所有可能的候选SKU组合
let candidateKeys = Object.keys(globalSkuMap);
// 先清除所有禁用
document.querySelectorAll('.spec-option.disabled').forEach(btn => btn.classList.remove('disabled'));
// 遍历所有规格,逐个判断选项是否可选
globalSpecs.forEach(spec => {
const currentSpecId = spec.id;
const otherSpecSelected = {};
// 收集除当前规格外的已选规格
Object.entries(currentSelected).forEach(([sId, oId]) => {
if (sId !== currentSpecId) otherSpecSelected[sId] = oId;
});
// 过滤出包含其他已选规格的候选SKU
let filteredCandidateKeys = candidateKeys.filter(key => {
const keyObj = key.split(',').reduce((obj, item) => {
const [s, o] = item.split(':');
obj[s] = o;
return obj;
}, {});
// 检查候选SKU是否包含所有其他已选规格
return Object.entries(otherSpecSelected).every(([sId, oId]) => keyObj[sId] === oId);
});
// 收集当前规格在候选SKU中的所有可选选项ID
const allowedOptionIds = new Set();
filteredCandidateKeys.forEach(key => {
const keyObj = key.split(',').reduce((obj, item) => {
const [s, o] = item.split(':');
obj[s] = o;
return obj;
}, {});
allowedOptionIds.add(keyObj[currentSpecId]);
});
// 禁用不在allowedOptionIds里的选项
spec.options.forEach(option => {
const btn = document.querySelector(`.spec-option[data-spec-id="${currentSpecId}"][data-option-id="${option.id}"]`);
if (btn && !allowedOptionIds.has(option.id) && currentSelected[currentSpecId] !== option.id) {
btn.classList.add('disabled');
}
});
});
}
// 6. 更新已选信息、库存、价格、购买按钮状态
function updateSelectedInfo() {
const selectedText = document.getElementById('selected-text');
const stockText = document.getElementById('stock-text');
const priceText = document.getElementById('price-text');
const buyBtn = document.getElementById('buy-btn');
// 检查是否选全所有规格
const isAllSelected = globalSpecs.every(spec => currentSelected[spec.id]);
if (!isAllSelected) {
selectedText.textContent = '请选择规格';
stockText.textContent = '--';
priceText.textContent = '--';
buyBtn.disabled = true;
return;
}
// 生成完整SKU key
const fullKey = globalSpecs.map(spec => `${spec.id}:${currentSelected[spec.id]}`).join(',');
const skuInfo = globalSkuMap[fullKey];
// 更新信息
const selectedNames = globalSpecs.map(spec => {
const option = spec.options.find(o => o.id === currentSelected[spec.id]);
return option.name;
});
selectedText.textContent = selectedNames.join(' / ');
stockText.textContent = skuInfo.stock;
priceText.textContent = `¥${skuInfo.price}`;
// 库存为0禁用购买
buyBtn.disabled = skuInfo.stock <= 0;
}
```
五、测试与优化
1. 测试步骤
- 确保终端执行
node server.js后后端服务正常运行
- 直接双击打开
index.html
- 测试:依次选中国红→S→纯棉(全选有库存);选中国红→M(库存0禁用购买);取消选择后再选蓝色→S→涤纶(库存多);选中可选规格后看其他规格的禁用项是否正确(比如选中中国红后,材质只有纯棉和涤纶可选?不对看映射表,深邃黑只有L纯棉可选,选中中国红S后,材质不能选深邃黑关联的?哦看逻辑是对的,因为候选SKU只包含其他已选,所以选中红色S后,不会出现深邃黑的选项以外的过滤?哦等下看我们的规格字典是所有可能的,禁用逻辑是在现有字典里过滤当前规格的不可选项:对,选中红色S后,材质的涤纶是否在候选里?看skuMap,红色S只有纯棉,所以涤纶会被禁用,没错!
2. 快速优化建议
- 价格可以展示划线价(在skuMap里加originalPrice字段)
- 禁用状态可以改成点击时弹出“该规格无货”的提示(在handleOptionClick里加判断disabled的逻辑,return掉并alert)
- 库存为0的SKU可以直接从skuMap里过滤(但初始保留更方便管理)