├── .gitignore ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── index.css ├── index.tsx ├── logo.svg ├── pages │ ├── spec-choose.tsx │ └── spec.css ├── react-app-env.d.ts ├── redux │ ├── index.ts │ └── reducer │ │ ├── root-reducer.ts │ │ └── spec-reducer.ts ├── serviceWorker.ts ├── setupTests.ts └── utils │ ├── adjoin-martix.ts │ └── spec-adjoin-martix.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 商品多规格选择-前端 sku 算法 3 | 4 | ![sku.png](https://i.loli.net/2020/06/21/xld2cADgjnuWF7J.png) 5 | 6 | 相信大家看到这张图片就知道我们这篇文章要讲什么了,没错就是-商品多规格选择的解法。 7 | 8 | 近来在掘金上面看见大家都在研究“商品多规格选择”的问题,例如`晨曦大佬`的[前端电商 sku 的全排列算法很难吗?学会这个套路,彻底掌握排列组合。](https://juejin.im/post/5ee6d9026fb9a047e60815f1) 在这篇文章里面,大佬写明了如何实现`sku`的全排列,思路非常的棒,但是并没有紧贴业务场景。真正的业务场景是,我们要根据用户每一次选择的规格,找出剩下可选的规格和不可选的规格,表现在前端页面上:就是将不可选的规格置灰,也就是如下效果(可以点击[这里](https://codesandbox.io/s/sku-algorithm-pionk?file=/src/redux/reducer/spec-reducer.ts)查看最终效果): 9 | 10 | ![sku.gif](https://i.loli.net/2020/06/21/hdQRZwjpvknKNru.gif) 11 | 12 | 那么今天我们就来讲讲这个问题的一个解决方法,要讲明白很难,但是我相信你看了这篇文章之后,`sku`就再也难不倒你了。 13 | 14 | ## 什么是 sku 15 | 16 | 在介绍具体解法之前,我们先来介绍一下什么是`sku`? `sku`是会计学中的一个名词,被称作`库存单元`。说人话?简单来讲就是,我们上图 👆 中每一个单规格选项,例如`深空灰色`、`64G`,都是一个规格(`sku`)。商品和`sku`属于一对多的关系,也就是我们可以选择多个`sku`来确定到某个具体的商品: 17 | 18 | ![商品.png](https://i.loli.net/2020/06/21/xpPuVWzkfFhD9t6.png) 19 | 20 | ## 业务场景 21 | 22 | 可以这么说,只要是做电商类相关的产品,比如购物 APP、购物网站等等,都会遇到这么一个场景,每个商品对应着多个规格,用户可以根据不同的规格组合,选择出自己想要的产品。我们自己在生活中也会经常用到这个功能,然而就是这样一个简单的功能,却难倒了很多小伙伴。 23 | 24 | 笔者也是一样,刚开始遇到这个场景,笔者觉得应该一个下午就能搞定,完美收工,奈何还是太过于年轻,搞了差不多两天,在网上查阅了很多相关的文章和资料,但是不得其解,最后没有办法,只能硬着头皮采用暴力求解(也就是不断循环)的方法来解决的,时间复杂度贼高,达到了`O(m*n)`也就是`O(n²)`,这种实现方法其实也不是不行(~~能跑就行~~),对吧。但是后来笔者发现,当一个商品的规格非常非常多、并且用户的设备性能不是那么好的情况下,那么这种实现方式就会导致运行时间过长,表现在页面上就是:当用户点击了一个规格,会有明显的卡顿,那怎么行,客户都流失了,老板还怎么买法拉利 🤔️?所以笔者又开始了研究。 25 | 26 | ## 图 27 | 28 | 一个偶然的机会,笔者在逛知乎的时候,看到了有人在讨论`图`,这个`数据结构`,突然灵光一现,貌似咱们的`多规格选择`也可以用图来作求解方法,后来一尝试,还真的可行。而且时间复杂度只有`O(n)`,简直完美。所以我们下面来介绍一下`图`,什么是`图`?相信大学学过`数据结构与算法`的同学都应该知道,不过应该已经忘得一干二净了。 29 | 30 | ### 什么是图 31 | 32 | `图`其实是数学的一个分支。它以图为研究对象。图论中的图是由若干给定的点及连接两点的线所构成的图形,这种图形通常用来描述某些事物之间的某种特定关系,用点代表事物,用连接两点的线表示相应两个事物间具有这种关系: 33 | 34 | ![图.jpg](https://i.loli.net/2020/06/21/aK6qZJw2Odm1Q9u.jpg) 35 | 36 | `图`通常有如下分类: 37 | 38 | - 分为有向图和无向图 39 | 40 | - 分为有权图和无权图 41 | 42 | 好了知道这两个概念就差不多了,当然如果想了解更多图多概念,请看[这里](https://zhuanlan.zhihu.com/p/25498681) 43 | 44 | 那么我们需要用到的是无向图,什么是无向图呢,就像这样: 45 | 46 | ![无向图.png](https://i.loli.net/2020/06/21/sMaedvPVSCfwFO9.png) 47 | 48 | 两个顶点之间如果有连线,则表示这两个顶点是互通的。小伙伴们看到这里可能会懵逼了,说了这么多,好像跟我们要解决的问题没关系啊。小伙伴们现在想一想:用户在选择规格的时候,肯定是没有先后顺序的,假设我们现在把每种规格看作是`无向图`的一个`顶点`的话,我们可以根据这些`单项规格`的组合规格,就可以画出一个像上图一样的`无向图`。 49 | 50 | ### 邻接矩阵 51 | 52 | 假设我们已经画出了如上 👆 的无向图,那么我们如何将这个图用咱们的代码来表示呢?这里就用到了`邻接矩阵` 53 | 54 | `邻接矩阵`其实是《线性代数》里面的概念,相信很多小伙伴都不会陌生,我们在代码中,表示它的方法是用一个`n x n`的二维数组来抽象邻接矩阵。让我们来把上面 👆 这个无向图用邻接矩阵(二维数组)表示出来: 55 | 56 | ![邻接矩阵.png](https://i.loli.net/2020/06/21/7h6IE2JgwcxqoXu.png) 57 | 58 | 很显然,如果两个顶点互通(有连线),那么它们对应下标的值则为 1,否则为 0。 59 | 60 | 好了,下面开始逐步都是高能,请小伙伴们认真观看。 61 | 62 | 假设现在我们有如下规格列表: 63 | 64 | ```js 65 | specList: [ 66 | { title: "颜色", list: ["红色", "紫色"] }, 67 | { title: "套餐", list: ["套餐一", "套餐二"] }, 68 | { title: "内存", list: ["64G", "128G", "256G"] }, 69 | ]; 70 | ``` 71 | 72 | 可供选择的规格组合有: 73 | 74 | ```js 75 | specCombinationList: [ 76 | { id: "1", specs: ["紫色", "套餐一", "64G"] }, 77 | { id: "2", specs: ["紫色", "套餐一", "128G"] }, 78 | { id: "3", specs: ["紫色", "套餐二", "128G"] }, 79 | { id: "4", specs: ["红色", "套餐二", "256G"] } 80 | ], 81 | ``` 82 | 83 | 首先,我们根据`specList`知道:我们有“`颜色`”、“`套餐`”、“`内存`”三种规格类别。分别有`红色`、`紫色`、`套餐一`、`套餐二`、`64G`、`128G`、`256G`这些单项规格。每个单项规格作为一个顶点,所以就有如下顶点: 84 | 85 | ![顶点.png](https://i.loli.net/2020/06/21/RhnGq17toK9xdj5.png) 86 | 87 | 然后我们根据`specCombinationList`,我们可以知道,哪些规格的组合是可选的。好了我们要开始画图了。 88 | 89 | 根据`{ id: "1", specs: ["紫色", "套餐一", "64G"] },`我们可以画出: 90 | 91 | ![开始画图.png](https://i.loli.net/2020/06/21/V9UXJQM1jC3sbAw.png) 92 | 93 | 接下来依葫芦画瓢:我们可以根据`specCombinationList`剩下的数据画出如下的图: 94 | 95 | ![规格组合.png](https://i.loli.net/2020/06/21/tQcJTjf6PXGnp3S.png) 96 | 97 | 好了,我们已经根据`specCombinationList`(也就是可选规格组合)将我们的规格无向图画完了。现在我们来模拟一下用户的选择: 98 | 99 | ```js 100 | specCombinationList: [ 101 | { id: "1", specs: ["紫色", "套餐一", "64G"] }, 102 | { id: "2", specs: ["紫色", "套餐一", "128G"] }, 103 | { id: "3", specs: ["紫色", "套餐二", "128G"] }, 104 | { id: "4", specs: ["红色", "套餐二", "256G"] } 105 | ], 106 | ``` 107 | 108 | 假设用户先选择了`紫色`、根据`data`,我们发现`套餐一`、`套餐二`、`64G`、`128G`是可选的,这个时候我们发现一个问题:显然跟`紫色`同级的`红色`其实也是可选的。所以这个图其实我们还没有画完。所以相同类型的规格,只要是在可选规格里面的,他们其实是应该连接起来的: 109 | 110 | ![全部规格.png](https://i.loli.net/2020/06/21/2huzUjsSYfT3mbt.png) 111 | 112 | 好了,无向图画好了,现在我们将它映射到`邻接矩阵`上面(这一步强烈建议小伙伴们拿出纸笔来一起画一画): 113 | 114 | ![顶点邻接矩阵.png](https://i.loli.net/2020/06/21/sz5d9k34wJnXmNM.png) 115 | 116 | 到了这一步,恭喜你,你已经懂了一大半了 👏。 117 | 118 | 好了,到这我们就可以公布最终结论了: 119 | 120 | - 当用户初次进入该页面时,所有的规格均可选: 121 | 122 | ![都可选.png](https://i.loli.net/2020/06/21/Jgy4dzTfwDEmMlR.png) 123 | 124 | - 当用户选择了某个顶点后,当前顶点所有可选项均被找出(即是当前顶点所在列值为 1 的顶点): 125 | 126 | ![选择一项.png](https://i.loli.net/2020/06/21/Gnkr3ZSwjcIRJqW.png) 127 | 128 | - 选取多个顶点时,可选项是各个顶点邻接点的交集:(即是选中顶点所在列的交集) 129 | 130 | ![多个顶点交集.png](https://i.loli.net/2020/06/21/xBTaGw9znPtVXED.png) 131 | 132 | ## 代码实现 133 | 134 | 说真的,我觉得小伙伴们看明白了我上面 👆 这些讲解,相信你已经完全懂了该如何实现“`多规格选择`”算法了。不过有句话叫做:光说不练假把式!那下面我们就一起来捋一捋,用代码如何实现吧,笔者这里用的前端框架是`react`,明白思路了,用什么框架都一样的哦。 135 | 136 | 这里先说下思路: 137 | 138 | 1、根据规格列表(`specList`)创建邻接矩阵(数组) 139 | 140 | 2、根据可选规格组合(`specCombinationList`)填写顶点的值 141 | 142 | 3、获得所有可选顶点,然后根据可选顶点填写同级顶点的值 143 | 144 | ### 创建邻接矩阵 145 | 146 | 首先,我们需要提供一个类来创建邻接矩阵。一个邻接矩阵,首先需要传入一个顶点数组:`vertex`,需要一个用来装邻接矩阵的数组:`adjoinArray`。刚刚我们上面说到了,这个类还必须提供计算`并集`和`交集`的方法: 147 | 148 | ```ts 149 | export type AdjoinType = Array; 150 | 151 | export default class AdjoinMatrix { 152 | vertex: AdjoinType; // 顶点数组 153 | quantity: number; // 矩阵长度 154 | adjoinArray: Array; // 矩阵数组 155 | 156 | constructor(vertx: AdjoinType) { 157 | this.vertex = vertx; 158 | this.quantity = this.vertex.length; 159 | this.adjoinArray = []; 160 | this.init(); 161 | } 162 | // 初始化数组 163 | init() { 164 | this.adjoinArray = Array(this.quantity * this.quantity).fill(0); 165 | } 166 | 167 | /* 168 | * @param id string 169 | * @param sides Array 170 | * 传入一个顶点,和当前顶点可达的顶点数组,将对应位置置为1 171 | */ 172 | setAdjoinVertexs(id: string, sides: AdjoinType) { 173 | const pIndex = this.vertex.indexOf(id); 174 | sides.forEach((item) => { 175 | const index = this.vertex.indexOf(item); 176 | this.adjoinArray[pIndex * this.quantity + index] = 1; 177 | }); 178 | } 179 | 180 | /* 181 | * @param id string 182 | * 传入顶点的值,获取该顶点的列 183 | */ 184 | getVertexCol(id: string) { 185 | const index = this.vertex.indexOf(id); 186 | const col: Array = []; 187 | this.vertex.forEach((item, pIndex) => { 188 | col.push(this.adjoinArray[index + this.quantity * pIndex]); 189 | }); 190 | return col; 191 | } 192 | 193 | /* 194 | * @param params Array 195 | * 传入一个顶点数组,求出该数组所有顶点的列的合 196 | */ 197 | getColSum(params: AdjoinType) { 198 | const paramsVertex = params.map((id) => this.getVertexCol(id)); 199 | const paramsVertexSum: Array = []; 200 | this.vertex.forEach((item, index) => { 201 | const rowtotal = paramsVertex 202 | .map((value) => value[index]) 203 | .reduce((total, current) => { 204 | total += current || 0; 205 | return total; 206 | }, 0); 207 | paramsVertexSum.push(rowtotal); 208 | }); 209 | return paramsVertexSum; 210 | } 211 | 212 | /* 213 | * @param params Array 214 | * 传入一个顶点数组,求出并集 215 | */ 216 | getCollection(params: AdjoinType) { 217 | const paramsColSum = this.getColSum(params); 218 | let collections: AdjoinType = []; 219 | paramsColSum.forEach((item, index) => { 220 | if (item && this.vertex[index]) collections.push(this.vertex[index]); 221 | }); 222 | return collections; 223 | } 224 | 225 | /* 226 | * @param params Array 227 | * 传入一个顶点数组,求出交集 228 | */ 229 | getUnions(params: AdjoinType) { 230 | const paramsColSum = this.getColSum(params); 231 | let unions: AdjoinType = []; 232 | paramsColSum.forEach((item, index) => { 233 | if (item >= params.length && this.vertex[index]) unions.push(this.vertex[index]); 234 | }); 235 | return unions; 236 | } 237 | } 238 | ``` 239 | 240 | 有了这个类,接下来可以创建一个专门用于生成`商品多规格选择`的类,它继承于`AdjoinMatrix`。 241 | 242 | ### 创建`多规格选择`邻接矩阵 243 | 244 | 我们这个多规格选择的邻接矩阵,需要提供一个查询可选顶点的方法:`getSpecscOptions` 245 | 246 | ```ts 247 | import AdjoinMatrix from "./adjoin-martix"; 248 | import { AdjoinType } from "./adjoin-martix"; 249 | import { SpecCategoryType, CommoditySpecsType } from "../redux/reducer/spec-reducer"; 250 | 251 | export default class SpecAdjoinMatrix extends AdjoinMatrix { 252 | specList: Array; 253 | specCombinationList: Array; 254 | 255 | constructor(specList: Array, specCombinationList: Array) { 256 | super(specList.reduce((total: AdjoinType, current) => [...total, ...current.list], [])); 257 | this.specList = specList; 258 | this.specCombinationList = specCombinationList; 259 | // 根据可选规格列表矩阵创建 260 | this.initSpec(); 261 | // 同级顶点创建 262 | this.initSameLevel(); 263 | } 264 | 265 | /** 266 | * 根据可选规格组合填写邻接矩阵的值 267 | */ 268 | initSpec() { 269 | this.specCombinationList.forEach((item) => { 270 | this.fillInSpec(item.specs); 271 | }); 272 | } 273 | // 填写同级点 274 | initSameLevel() { 275 | // 获得初始所有可选项 276 | const specsOption = this.getCollection(this.vertex); 277 | this.specList.forEach((item) => { 278 | const params: AdjoinType = []; 279 | // 获取同级别顶点 280 | item.list.forEach((value) => { 281 | if (specsOption.includes(value)) params.push(value); 282 | }); 283 | // 同级点位创建 284 | this.fillInSpec(params); 285 | }); 286 | } 287 | /* 288 | * 传入顶点数组,查询出可选规格 289 | * @param params 290 | */ 291 | getSpecscOptions(params: AdjoinType) { 292 | let specOptionCanchoose: AdjoinType = []; 293 | if (params.some(Boolean)) { 294 | // 过滤一下选项 295 | specOptionCanchoose = this.getUnions(params.filter(Boolean)); 296 | } else { 297 | // 所有可选项 298 | specOptionCanchoose = this.getCollection(this.vertex); 299 | } 300 | return specOptionCanchoose; 301 | } 302 | 303 | /** 304 | * 305 | * @param {*} params [key, key] 306 | */ 307 | fillInSpec(params: AdjoinType) { 308 | params.forEach((param) => { 309 | this.setAdjoinVertexs(param, params); 310 | }); 311 | } 312 | } 313 | ``` 314 | 315 | ### 页面渲染 316 | 317 | 好了到了这一步,我们已经可以在页面中使用这两个类了: 318 | 319 | ```tsx 320 | import React, { useState, useMemo } from "react"; 321 | import { useSelector } from "react-redux"; 322 | import { RootState } from "../redux/reducer/root-reducer"; 323 | import SpecAdjoinMatrix from "../utils/spec-adjoin-martix"; 324 | import "./spec.css"; 325 | const classNames = require("classnames"); 326 | 327 | const Spec: React.FC = () => { 328 | const { specList, specCombinationList } = useSelector((state: RootState) => state.spec); 329 | // 已选择的规格,长度为规格列表的长度 330 | const [specsS, setSpecsS] = useState(Array(specList.length).fill("")); 331 | 332 | // 创建一个规格矩阵 333 | const specAdjoinMatrix = useMemo(() => new SpecAdjoinMatrix(specList, specCombinationList), [specList, specCombinationList]); 334 | // 获得可选项表 335 | const optionSpecs = specAdjoinMatrix.getSpecscOptions(specsS); 336 | 337 | const handleClick = function(bool: boolean, text: string, index: number) { 338 | // 排除可选规格里面没有的规格 339 | if (specsS[index] !== text && !bool) return; 340 | // 根据text判断是否已经被选中了 341 | specsS[index] = specsS[index] === text ? "" : text; 342 | setSpecsS(specsS.slice()); 343 | }; 344 | 345 | return ( 346 |
347 | {specList.map(({ title, list }, index) => ( 348 |
349 |

{title}

350 |
351 | {list.map((value, i) => { 352 | const isOption = optionSpecs.includes(value); // 当前规格是否可选 353 | const isActive = specsS.includes(value); // 当前规格是否被选 354 | return ( 355 | handleClick(isOption, value, index)} 363 | > 364 | {value} 365 | 366 | ); 367 | })} 368 |
369 |
370 | ))} 371 |
372 | ); 373 | }; 374 | 375 | export default Spec; 376 | ``` 377 | 378 | 好了,打完收工了,如果有小伙伴想看实现效果,可以查看[这里](https://codesandbox.io/s/sku-algorithm-pionk?file=/src/redux/reducer/spec-reducer.ts),如果有小伙伴想把代码拉到本地看看,那么请点击[这里](https://github.com/xieyezi/sku-algorithm) 379 | 380 | ## 总结 381 | 382 | 实践证明:大学学的东西是真的有用的。我们通过`图`,解决了`商品多规格选择`的难题。在求解可选规格的时候,时间复杂度由原来的`O(n²)`变成了`O(n)`。不过值得一提的是,采用`邻接矩阵`来存储`图`,空间复杂度就变成了`O(n²)`了,同时也存在浪费空间的问题,但是`图`肯定不止有`邻接矩阵`这一种存储方法,我们还可以用`链表`来存储`图`,小伙伴们可以自己去试一试。另外如果用`链表`来存储图,空间复杂度会变低,但是时间复杂度会变高,具体如何选择,就看小伙伴们自己权衡了。 383 | 384 | 以后遇到这个需求,小伙伴们肯定是分分钟实现,提早下班。 385 | 386 | 我是觉非,码字不易,如果你觉得这篇文章对你有用的话,请给个赞吧!! 387 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sku-algorithm", 3 | "version": "0.1.0", 4 | "author": "xieyezi", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.3.2", 9 | "@testing-library/user-event": "^7.1.2", 10 | "@types/jest": "^24.0.0", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.0", 13 | "@types/react-dom": "^16.9.0", 14 | "@types/react-redux": "^7.1.9", 15 | "antd": "^4.3.4", 16 | "classnames": "^2.2.6", 17 | "react": "^16.13.1", 18 | "react-dom": "^16.13.1", 19 | "react-redux": "^7.2.0", 20 | "react-scripts": "3.4.1", 21 | "redux": "^4.0.5", 22 | "typescript": "~3.7.2" 23 | }, 24 | "scripts": { 25 | "start": "react-scripts start", 26 | "build": "react-scripts build", 27 | "test": "react-scripts test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "https://github.com/xieyezi/sku-algorithm.git" 33 | }, 34 | "eslintConfig": { 35 | "extends": "react-app" 36 | }, 37 | "browserslist": { 38 | "production": [ 39 | ">0.2%", 40 | "not dead", 41 | "not op_mini all" 42 | ], 43 | "development": [ 44 | "last 1 chrome version", 45 | "last 1 firefox version", 46 | "last 1 safari version" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xieyezi/sku-algorithm/349fc3591bc655aa2758d746101b20885426fd99/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xieyezi/sku-algorithm/349fc3591bc655aa2758d746101b20885426fd99/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xieyezi/sku-algorithm/349fc3591bc655aa2758d746101b20885426fd99/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Provider } from "react-redux"; 3 | import Spec from "./pages/spec-choose"; 4 | import Store from "./redux"; 5 | import 'antd/dist/antd.css'; 6 | 7 | const App: React.FC = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | 17 | export default App; 18 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/pages/spec-choose.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { RootState } from "../redux/reducer/root-reducer"; 4 | import SpecAdjoinMatrix from "../utils/spec-adjoin-martix"; 5 | import "./spec.css"; 6 | const classNames = require("classnames"); 7 | 8 | const Spec: React.FC = () => { 9 | const { specList, specCombinationList } = useSelector((state: RootState) => state.spec); 10 | // 已选择的规格,长度为规格列表的长度 11 | const [specsS, setSpecsS] = useState(Array(specList.length).fill("")); 12 | 13 | // 创建一个规格矩阵 14 | const specAdjoinMatrix = useMemo(() => new SpecAdjoinMatrix(specList, specCombinationList), [specList, specCombinationList]); 15 | // 获得可选项表 16 | const optionSpecs = specAdjoinMatrix.getSpecscOptions(specsS); 17 | 18 | const handleClick = function (bool: boolean, text: string, index: number) { 19 | // 排除可选规格里面没有的规格 20 | if (specsS[index] !== text && !bool) return; 21 | // 根据text判断是否已经被选中了 22 | specsS[index] = specsS[index] === text ? "" : text; 23 | setSpecsS(specsS.slice()); 24 | }; 25 | 26 | return ( 27 |
28 | {specList.map(({ title, list }, index) => ( 29 |
30 |

{title}

31 |
32 | {list.map((value, i) => { 33 | const isOption = optionSpecs.includes(value); // 当前规格是否可选 34 | const isActive = specsS.includes(value); // 当前规格是否被选 35 | return ( 36 | handleClick(isOption, value, index)} 44 | > 45 | {value} 46 | 47 | ); 48 | })} 49 |
50 |
51 | ))} 52 |
53 | ); 54 | }; 55 | 56 | export default Spec; 57 | -------------------------------------------------------------------------------- /src/pages/spec.css: -------------------------------------------------------------------------------- 1 | .container { 2 | width: 600px; 3 | height: 500px; 4 | border: 1px solid gray; 5 | position: absolute; 6 | margin: auto; 7 | left: 0; 8 | right: 0; 9 | top: 50px; 10 | padding: 20px; 11 | } 12 | .title { 13 | font-size: 16px; 14 | line-height: 24px; 15 | color: #262626; 16 | } 17 | .specBox { 18 | margin: 5px 0 5px 0; 19 | } 20 | .specOption { 21 | margin-left: 20px; 22 | background-color: #f3f3f3; 23 | padding: 5px 10px 5px 10px; 24 | color: #505257; 25 | } 26 | .specAction { 27 | margin-left: 20px; 28 | background-color: #fef6f4; 29 | padding: 5px 10px 5px 10px; 30 | color: #e34a40; 31 | border: 1px solid #e34a40; 32 | } 33 | .specDisabled { 34 | margin-left: 20px; 35 | background-color: #f3f3f3; 36 | padding: 5px 10px 5px 10px; 37 | color: #bebebe; 38 | } 39 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore } from "redux"; 2 | import { rootReducer } from "./reducer/root-reducer"; 3 | 4 | const Store = createStore(rootReducer); 5 | 6 | export default Store; 7 | -------------------------------------------------------------------------------- /src/redux/reducer/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux"; 2 | import specReducer from "./spec-reducer"; 3 | 4 | export const rootReducer = combineReducers({ 5 | spec: specReducer, 6 | }); 7 | 8 | export type RootState = ReturnType; 9 | -------------------------------------------------------------------------------- /src/redux/reducer/spec-reducer.ts: -------------------------------------------------------------------------------- 1 | export const TOGGLE = "spec"; 2 | 3 | export type CommoditySpecsType = { 4 | title: string; 5 | list: Array; 6 | }; 7 | 8 | export type SpecCategoryType = { 9 | id: string; 10 | specs: Array; 11 | }; 12 | 13 | export type SpecStateType = { 14 | specList: Array; 15 | specCombinationList: Array; 16 | }; 17 | 18 | const initialState: SpecStateType = { 19 | specList: [ 20 | {title: "颜色", list: ["白色", "粉色"]}, 21 | {title: "体重", list: ["G", "KG"]}, 22 | {title: "尺寸", list: ["1寸", "2寸", "3寸"]}, 23 | {title: "形状", list: ["圆", "正方", "三角"]}, 24 | ], 25 | specCombinationList: [ 26 | {id: "1", specs: ["G", "1寸", "白色", "正方"]}, 27 | {id: "2", specs: ["G", "1寸", "白色", "圆"]}, 28 | {id: "3", specs: ["G", "1寸", "粉色", "圆"]}, 29 | {id: "4", specs: ["G", "1寸", "粉色", "正方"]}, 30 | {id: "5", specs: ["KG", "3寸", "白色", "圆"]}, 31 | {id: "6", specs: ["KG", "2寸", "粉色", "正方"]}, 32 | ], 33 | // specList: [ 34 | // {title: "颜色", list: ["白色", "粉色"]}, 35 | // {title: "尺寸", list: ["1寸", "2寸"]}, 36 | // {title: "体重", list: ["G", "KG"]} 37 | // ], 38 | // specCombinationList: [ 39 | // {id: "1", specs: ["KG", "1寸", "白色"]}, 40 | // {id: "2", specs: ["G", "2寸", "白色"]}, 41 | // {id: "3", specs: ["G", "1寸", "粉色"]} 42 | // ], 43 | // specList: [ 44 | // { title: "颜色", list: ["红色", "紫色", "白色", "黑色"] }, 45 | // { title: "套餐", list: ["套餐一", "套餐二", "套餐三", "套餐四"] }, 46 | // { title: "内存", list: ["64G", "128G", "256G"] }, 47 | // ], 48 | // specCombinationList: [ 49 | // { id: "1", specs: ["紫色", "套餐一", "64G"] }, 50 | // { id: "2", specs: ["紫色", "套餐一", "128G"] }, 51 | // { id: "3", specs: ["紫色", "套餐二", "128G"] }, 52 | // { id: "4", specs: ["黑色", "套餐三", "256G"] }, 53 | // ], 54 | }; 55 | 56 | export default (state = initialState, action: any) => { 57 | switch (action.type) { 58 | case TOGGLE: { 59 | return { 60 | ...state, 61 | }; 62 | } 63 | 64 | default: { 65 | return { 66 | ...state, 67 | }; 68 | } 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | type Config = { 24 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 25 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 26 | }; 27 | 28 | export function register(config?: Config) { 29 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 30 | // The URL constructor is available in all browsers that support SW. 31 | const publicUrl = new URL( 32 | process.env.PUBLIC_URL, 33 | window.location.href 34 | ); 35 | if (publicUrl.origin !== window.location.origin) { 36 | // Our service worker won't work if PUBLIC_URL is on a different origin 37 | // from what our page is served on. This might happen if a CDN is used to 38 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 39 | return; 40 | } 41 | 42 | window.addEventListener('load', () => { 43 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 44 | 45 | if (isLocalhost) { 46 | // This is running on localhost. Let's check if a service worker still exists or not. 47 | checkValidServiceWorker(swUrl, config); 48 | 49 | // Add some additional logging to localhost, pointing developers to the 50 | // service worker/PWA documentation. 51 | navigator.serviceWorker.ready.then(() => { 52 | console.log( 53 | 'This web app is being served cache-first by a service ' + 54 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 55 | ); 56 | }); 57 | } else { 58 | // Is not localhost. Just register service worker 59 | registerValidSW(swUrl, config); 60 | } 61 | }); 62 | } 63 | } 64 | 65 | function registerValidSW(swUrl: string, config?: Config) { 66 | navigator.serviceWorker 67 | .register(swUrl) 68 | .then(registration => { 69 | registration.onupdatefound = () => { 70 | const installingWorker = registration.installing; 71 | if (installingWorker == null) { 72 | return; 73 | } 74 | installingWorker.onstatechange = () => { 75 | if (installingWorker.state === 'installed') { 76 | if (navigator.serviceWorker.controller) { 77 | // At this point, the updated precached content has been fetched, 78 | // but the previous service worker will still serve the older 79 | // content until all client tabs are closed. 80 | console.log( 81 | 'New content is available and will be used when all ' + 82 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 83 | ); 84 | 85 | // Execute callback 86 | if (config && config.onUpdate) { 87 | config.onUpdate(registration); 88 | } 89 | } else { 90 | // At this point, everything has been precached. 91 | // It's the perfect time to display a 92 | // "Content is cached for offline use." message. 93 | console.log('Content is cached for offline use.'); 94 | 95 | // Execute callback 96 | if (config && config.onSuccess) { 97 | config.onSuccess(registration); 98 | } 99 | } 100 | } 101 | }; 102 | }; 103 | }) 104 | .catch(error => { 105 | console.error('Error during service worker registration:', error); 106 | }); 107 | } 108 | 109 | function checkValidServiceWorker(swUrl: string, config?: Config) { 110 | // Check if the service worker can be found. If it can't reload the page. 111 | fetch(swUrl, { 112 | headers: { 'Service-Worker': 'script' } 113 | }) 114 | .then(response => { 115 | // Ensure service worker exists, and that we really are getting a JS file. 116 | const contentType = response.headers.get('content-type'); 117 | if ( 118 | response.status === 404 || 119 | (contentType != null && contentType.indexOf('javascript') === -1) 120 | ) { 121 | // No service worker found. Probably a different app. Reload the page. 122 | navigator.serviceWorker.ready.then(registration => { 123 | registration.unregister().then(() => { 124 | window.location.reload(); 125 | }); 126 | }); 127 | } else { 128 | // Service worker found. Proceed as normal. 129 | registerValidSW(swUrl, config); 130 | } 131 | }) 132 | .catch(() => { 133 | console.log( 134 | 'No internet connection found. App is running in offline mode.' 135 | ); 136 | }); 137 | } 138 | 139 | export function unregister() { 140 | if ('serviceWorker' in navigator) { 141 | navigator.serviceWorker.ready 142 | .then(registration => { 143 | registration.unregister(); 144 | }) 145 | .catch(error => { 146 | console.error(error.message); 147 | }); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /src/utils/adjoin-martix.ts: -------------------------------------------------------------------------------- 1 | export type AdjoinType = Array; 2 | 3 | export default class AdjoinMatrix { 4 | vertex: AdjoinType; // 顶点数组 5 | quantity: number; // 矩阵长度 6 | adjoinArray: Array; // 矩阵数组 7 | 8 | constructor(vertx: AdjoinType) { 9 | this.vertex = vertx; 10 | this.quantity = this.vertex.length; 11 | this.adjoinArray = []; 12 | this.init(); 13 | } 14 | // 初始化数组 15 | init() { 16 | this.adjoinArray = Array(this.quantity * this.quantity).fill(0); 17 | } 18 | 19 | /* 20 | * @param id string 21 | * @param sides Array 22 | * 传入一个顶点,和当前顶点可达的顶点数组,将对应位置设置权值 23 | */ 24 | setAdjoinVertexs(id: string, sides: AdjoinType, weight: number) { 25 | const pIndex = this.vertex.indexOf(id); 26 | sides.forEach((item) => { 27 | const index = this.vertex.indexOf(item); 28 | const cur = this.adjoinArray[pIndex * this.quantity + index]; 29 | if (typeof cur !== 'number') { // specList.length > 3时,存在单边多权的情况 30 | this.adjoinArray[pIndex * this.quantity + index].push(weight); 31 | } else if (cur > 1) { 32 | this.adjoinArray[pIndex * this.quantity + index] = [cur, weight]; 33 | } else { 34 | this.adjoinArray[pIndex * this.quantity + index] = weight; 35 | } 36 | }); 37 | } 38 | 39 | /* 40 | * @param id string 41 | * 传入顶点的值,获取该顶点的列 42 | */ 43 | getVertexCol(id: string) { 44 | const index = this.vertex.indexOf(id); 45 | const col: Array = []; 46 | this.vertex.forEach((item, pIndex) => { 47 | col.push(this.adjoinArray[index + this.quantity * pIndex]); 48 | }); 49 | return col; 50 | } 51 | 52 | /* 53 | * @param params Array 54 | * 传入一个顶点数组,求出并集 55 | */ 56 | getCollection(params: AdjoinType) { 57 | const paramsVertex = params.map((id) => this.getVertexCol(id)); 58 | let collections: AdjoinType = []; 59 | paramsVertex.forEach((col, index) => { 60 | if (col.some(item => item !== 0)) { 61 | collections.push(params[index]) 62 | } 63 | }) 64 | return collections; 65 | } 66 | 67 | /* 68 | * @param params Array 69 | * 传入一个顶点数组,求出交集 70 | */ 71 | getUnions(params: AdjoinType) { 72 | const paramsVertex = params.map((id) => this.getVertexCol(id)); 73 | let unions: AdjoinType = []; 74 | this.vertex.forEach((type, index) => { 75 | const row = paramsVertex.map(col => col[index]).filter(t => t !== 1) 76 | if (this.isItemEqual(row)) { 77 | unions.push(type) 78 | } 79 | }) 80 | return unions; 81 | } 82 | 83 | /* 84 | * @param params 85 | * 传入一个交集行,判断内部是否互相相等 86 | */ 87 | isItemEqual(params: Array) { 88 | if (params.includes(0)) return false; 89 | 90 | let weight: number = -1; 91 | 92 | // 找出权值 93 | if (params.length) { 94 | params.some(t => { 95 | if (typeof t === 'number') weight = t 96 | return typeof t === 'number' 97 | }) 98 | if (weight === -1) { // 都是多权边数组的情况 99 | return this.isArrayUnions(params) 100 | } 101 | } 102 | 103 | return params.every(t => { 104 | if (typeof t === 'number') { 105 | return t === weight 106 | } else { 107 | return t.includes(weight) 108 | } 109 | }) 110 | } 111 | 112 | /* 113 | * @param params 114 | * 传入多个数组,判断是否有交集 115 | */ 116 | isArrayUnions(params: Array>) { 117 | if (!params.length) return false; 118 | return params[0].some(t => { 119 | return params.every(_t => _t.includes(t)) 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/utils/spec-adjoin-martix.ts: -------------------------------------------------------------------------------- 1 | import AdjoinMatrix from "./adjoin-martix"; 2 | import { AdjoinType } from "./adjoin-martix"; 3 | import { SpecCategoryType, CommoditySpecsType } from "../redux/reducer/spec-reducer"; 4 | 5 | export default class SpecAdjoinMatrix extends AdjoinMatrix { 6 | specList: Array; 7 | specCombinationList: Array; 8 | 9 | constructor(specList: Array, specCombinationList: Array) { 10 | super(specList.reduce((total: AdjoinType, current) => [...total, ...current.list], [])); 11 | this.specList = specList; 12 | this.specCombinationList = specCombinationList; 13 | // 根据可选规格列表矩阵创建 14 | this.initSpec(); 15 | // 同级顶点创建 16 | this.initSameLevel(); 17 | } 18 | 19 | /** 20 | * 根据可选规格组合填写邻接矩阵的值 21 | */ 22 | initSpec() { 23 | this.specCombinationList.forEach((item, index) => { 24 | this.fillInSpec(item.specs, index + 2); // 0用于互不相连,1用于同级,权级就从2开始 25 | }); 26 | } 27 | // 填写同级点 28 | initSameLevel() { 29 | // 获得初始所有可选项 30 | const specsOption = this.getCollection(this.vertex); 31 | this.specList.forEach((item) => { 32 | const params: AdjoinType = []; 33 | // 获取同级别顶点 34 | item.list.forEach((value) => { 35 | if (specsOption.includes(value)) params.push(value); 36 | }); 37 | // 同级点位创建 38 | this.fillInSpec(params, 1); 39 | }); 40 | } 41 | /* 42 | * @params 43 | * 传入顶点数组,查询出可选规格 44 | */ 45 | getSpecscOptions(params: AdjoinType) { 46 | let specOptionCanchoose: AdjoinType = []; 47 | if (params.some(Boolean)) { 48 | // 获取可选项(交集) 49 | specOptionCanchoose = this.getUnions(params.filter(Boolean)); 50 | } else { 51 | // 所有可选项 52 | specOptionCanchoose = this.getCollection(this.vertex); 53 | } 54 | return specOptionCanchoose; 55 | } 56 | 57 | /* 58 | * @params 59 | * 填写邻接矩阵的值 60 | */ 61 | fillInSpec(params: AdjoinType, weight: number) { 62 | params.forEach((param) => { 63 | this.setAdjoinVertexs(param, params, weight); 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------