不败君

前端萌新&初级后端攻城狮

jQuery实现商品规格选择效果

jQuery实现商品规格选择效果

2020-10-23 10:06:00

围观(2600)

昨天博主去了一家公司面试,被面试官问到了了一个问题:“看你的简历,在上一家公司有参与商城项目的开发,那商品规格这个表是怎么设计的?”

博主想都没想,就说了:“可以设置两个表,一个是 product 商品信息表 存放一些例如商品标题,商品主图,之类的信息。另外设置一个 sku 商品规格表用于存放商品对应的规格 例如有 规格名称 规格键值对 库存 价格 等字段”。

面试官听完后一脸疑惑(面试官觉得一个规格表并不能完成规格功能的实现),继续追问“两个表就能实现这个规格了吗?”。

博主继续说也可以用四张表,sku 存商品ID和库存还有价格,还要一个 attr 表存商品ID和规格的键,例如 颜色 尺码 这两个。

有了规格键的表自然就要存规格值的表 attr_value 用于存 attr_id(也就是规格键的ID) 和规格值。

最后还要一个 sku_attr 表用于存放关联的规格键与值的ID。


例如:

sku 表:

+----+------------+-------+-------+
| id | product_id | stock | price |
+----+------------+-------+-------+
| 1  | 1          | 10    | 100   |
| 2  | 1          | 5     | 50    |
| 3  | 1          | 20    | 200   |
+----+------------+-------+-------+

attr 表:

+----+------------+------+
| id | product_id | name |
+----+------------+------+
| 1  | 1          | 颜色 |
| 2  | 1          | 尺码 |
+----+------------+------+

attr_value 表(attr_id 对应 attr 表):

+----+---------+-------+
| id | attr_id | value |
+----+---------+-------+
| 1  | 1       | 红色  |
| 2  | 1       | 黄色  |
| 3  | 2       | S     |
| 4  | 2       | M     |
+----+---------+-------+

sku_attr 表(sku_id 对应 sku 表。 attr_value_id 对应 attr_value 表。):

+----+--------+---------------+
| id | sku_id | attr_value_id |
+----+--------+---------------+
| 1  | 1      | 1             |
| 2  | 1      | 4             |
| 3  | 2      | 1             |
| 4  | 2      | 3             |
| 5  | 3      | 2             |
| 6  | 3      | 4             |
+----+--------+---------------+

获取商品对应的规格信息时,先根据商品的 id 查询 sku 表获取到商品对应的规格的库存与价格。

根据 sku 的 id 可以查询 sku_attr 表的 attr_value_id,根据 attr_value_id 查询 attr_value 表对应的 id 就能获取到规格值,再根据 attr_value 表的 attr_id 可以获取规格键信息。

也就是,商品 id 为 1 这个商品有三个规格,第一个规格是:颜色为红色 尺码为M。 第二个规格是:颜色为红色 尺码为S。 第三个规格是:颜色为黄色 尺码为M。


其实博主觉得用一个规格表更容易理解也更简单,例如博主之前写的这篇文章:使用Layui和Laravel开发商品多规格录入 (这里讲个小插曲,面试官问会不会使用 Layui 博主楞了一下 心里在想类ui是啥? 缓了几秒才反应过来... 平时真的不多说这个词...)


根据博主上面那篇文章写的,可以在数据库存储这样的数据:

+----+------------+----------------------------------+-----------+-------+-------+---------------------+---------------------+
| id | product_id | attrs_name                       | name      | stock | price | created_at          | updated_at          |
+----+------------+----------------------------------+-----------+-------+-------+---------------------+---------------------+
|  1 |          1 | {"颜色":"红色","尺码":"S"}        | 红色 S    |    10 |    10 | 2020-10-23 10:00:00 | 2020-10-23 10:00:00 |
|  2 |          1 | {"颜色":"黄色","尺码":"S"}        | 黄色 S    |    10 |    10 | 2020-10-23 10:00:00 | 2020-10-23 10:00:00 |
|  3 |          1 | {"颜色":"红色","尺码":"M"}        | 红色 M    |    10 |    10 | 2020-10-23 10:00:00 | 2020-10-23 10:00:00 |
|  4 |          1 | {"颜色":"黄色","尺码":"M"}        | 黄色 M    |    10 |    10 | 2020-10-23 10:00:00 | 2020-10-23 10:00:00 |
+----+------------+----------------------------------+-----------+-------+-------+---------------------+---------------------+

然后获取规格信息的时候就不用查询多个表了。

前端输入规格信息存储的时候其实可以使用 “笛卡尔积算法”。 

例如颜色 = [黄色 红色]。 尺码 = [S M]

使用笛卡尔积也就是 D = 颜色 × 尺码(2 × 2):

[
	[黄色 S]
	[黄色 M]
	[红色 S]
	[红色 M]
]

然后将这四个规格填写上规格与价格,没有的规格就将库存填为 0 即可:

[
	[黄色 S stock10 price100]
	[黄色 M stock5 price200]
	[红色 S stock20 price100]
	[红色 M stock10 price100]
]

这样存储即可,上面的 stock 和 price 只是容易理解实际并不需要只需要数字就好。


这样存储之后就剩下最后一个问题,就是前端要怎么渲染显示并选择规格?(这才是本文的重点)

博主对前端并不是非常熟悉,所以找到了一个开源的项目:商品多规格属性前后端实现 Laravel6.0 + Jquery

博主将这个开源项目的前端页面拿了出来:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Sku 多维属性状态判断</title>
    <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.min.js"></script>
    <style>
        body {
            font-size: 12px;
        }

        dt {
            width: 100px;
            text-align: right;
        }

        dl {
            clear: both;
            overflow: hidden;
        }

        dl.hl {
            background: #ddd;
        }

        dt, dd {
            float: left;
            height: 40px;
            line-height: 40px;
            margin-left: 10px;
        }

        button {
            font-size: 14px;
            font-weight: bold;
            padding: 4px 4px;
        }

        .disabled {
            color: #999;
            border: 1px dashed #666;
        }

        .active {
            color: red;
        }
    </style>
</head>
<body>

<p><textarea id="data_area" cols="100" rows="10">[{"颜色":"金色","内存":"16G","skuId":1},{"颜色":"金色","内存":"32G","skuId":2},{"颜色":"红色","内存":"16G","skuId":3}]</textarea></p>
<p><input onclick="updateData()" type="button" value="更新数据"></p>
<hr>
<div id="app"></div>
<hr>
<div id="msg"></div>

<script>
    // 接收数据
    var data = JSON.parse($('#data_area').val())
    // 所有子集
    var res = {}
    // 属性值分割符“⊙”
    var spliter = '\u2299'
    // 组合数据对象
    var r = {}
    // 所有的属性名
    var keys = []
    // 默认选中(第一个Sku对象)
    var selectedCache = []

    /**
     * 计算组合数据
     * 功能:
     * 1、按属性分组:将相同属性的不同属性值,放在一起,
     * 2、将属性值以“⊙”拼接,并同SKU ID绑定
     */
    function combineAttr(data, keys) {
        var allKeys = []
        var result = {}
        for (var i = 0; i < data.length; i++) {
            var item = data[i]
            var values = []

            // 循环属性值以“⊙”拼接
            for (var j = 0; j < keys.length; j++) {
                var key = keys[j]
                if (!result[key]) result[key] = []
                if (result[key].indexOf(item[key]) < 0) result[key].push(item[key]) // 按属性分组:将相同属性的不同属性值,放在一起
                values.push(item[key])
            }
            allKeys.push({
                path: values.join(spliter),
                sku: item['skuId']
            })
        }
        return {
            result: result, // 按属性名分组,eg:{颜色:["金色","红色"],"内存":["16G", "32G"],"保修期": ["首月", "半年"]}
            items: allKeys // 按SkuId把属性值拼接起来,eg:[{path: "金色⊙16G⊙首月", sku: 1},{path: "金色⊙32G⊙半年", sku: 2},{path: "红色⊙16G⊙半年", sku: 3}]
        }
    }

    /**
     * 渲染 DOM 结构(渲染规格)
     * 功能:
     * 1、渲染规格
     * 2、设置默认选中
     */
    function render(data) {
        var output = ''
        for (var i = 0; i < keys.length; i++) {
            var key = keys[i];
            var items = data[key]

            output += '<dl data-type="' + key + '" data-idx="' + i + '">'
            output += '<dt>' + key + ':</dt>'
            output += '<dd>'
            for (var j = 0; j < items.length; j++) {
                var item = items[j]
                var cName = j === 0 ? 'active' : ''
                if (j === 0) {
                    selectedCache.push(item)
                }
                output += '<button data-title="' + item + '" class="' + cName + '" value="' + item + '">' + item + '</button> '
            }

            output += '</dd>'
            output += '</dl>'
        }
        $('#app').html(output)
    }

    /**
     * 获取所有已拼接的属性值数组
     */
    function getAllKeys(arr) {
        var result = []
        for (var i = 0; i < arr.length; i++) {
            result.push(arr[i].path)
        }

        return result // ["金色⊙16G⊙首月", "金色⊙32G⊙半年", "红色⊙16G⊙半年"]
    }

    /**
     * 取得集合的所有子集「幂集」(所有可能性)
     */
    function powerset(arr) {
        var ps = [[]];
        for (var i = 0; i < arr.length; i++) {
            for (var j = 0, len = ps.length; j < len; j++) {
                ps.push(ps[j].concat(arr[i]));
            }
        }
        return ps;
    }

    /**
     * 生成所有子集是否可选、库存状态 map(核心)
     */
    function buildResult(items) {
        var allKeys = getAllKeys(items)

        for (var i = 0; i < allKeys.length; i++) {
            var curr = allKeys[i]
            var sku = items[i].sku
            var values = curr.split(spliter)
            var allSets = powerset(values)

            // 每个组合的子集
            for (var j = 0; j < allSets.length; j++) {
                var set = allSets[j]
                var key = set.join(spliter)

                if (res[key]) {
                    res[key].skus.push(sku)
                } else {
                    res[key] = {
                        skus: [sku]
                    }
                }
            }
        }
    }

    function trimSpliter(str, spliter) {
        var reLeft = new RegExp('^' + spliter + '+', 'g');
        var reRight = new RegExp(spliter + '+$', 'g');
        var reSpliter = new RegExp(spliter + '+', 'g');
        return str.replace(reLeft, '')
            .replace(reRight, '')
            .replace(reSpliter, spliter)
    }

    /**
     * 获取当前选中的属性
     */
    function getSelectedItem() {
        var result = []
        $('dl[data-type]').each(function () {
            var $selected = $(this).find('.active')
            if ($selected.length) {
                result.push($selected.val())
            } else {
                result.push('')
            }
        })

        return result
    }

    /**
     * 更新所有属性状态
     */
    function updateStatus(selected) {
        for (var i = 0; i < keys.length; i++) {
            var key = keys[i];
            var data = r.result[key]
            var hasActive = !!selected[i]
            var copy = selected.slice()

            for (var j = 0; j < data.length; j++) {
                var item = data[j]
                if (selected[i] === item) continue
                copy[i] = item

                var curr = trimSpliter(copy.join(spliter), spliter)
                var $item = $('dl').filter('[data-type="' + key + '"]').find('[value="' + item + '"]')

                var titleStr = '[' + copy.join('-') + ']'

                if (res[curr]) {
                    $item.removeClass('disabled')
                    setTitle($item.get(0))
                } else {
                    $item.addClass('disabled').attr('title', titleStr + ' 无此属性搭配')
                }
            }
        }
    }

    /**
     * 正常属性点击
     */
    function handleNormalClick($this) {
        $this.siblings().removeClass('active')
        $this.addClass('active')
    }

    /**
     * 无效属性点击
     */
    function handleDisableClick($this) {
        var $currAttr = $this.parents('dl').eq(0)
        var idx = $currAttr.data('idx')
        var type = $currAttr.data('type')
        var value = $this.val()

        $this.removeClass('disabled')
        selectedCache[idx] = value

        console.log(selectedCache)
        // 清空高亮行的已选属性状态(因为更新的时候默认会跳过已选状态)
        $('dl').not($currAttr).find('button').removeClass('active')
        updateStatus(getSelectedItem())

        /**
         * 恢复原来已选属性
         * 遍历所有非当前属性行
         *   1. 与 selectedCache 对比
         *   2. 如果要恢复的属性存在(非 disable)且 和当前*未高亮行*已选择属性的*可组合*),高亮原来已选择的属性且更新
         *   3. 否则什么也不做
         */
        for (var i = 0; i < keys.length; i++) {
            var item = keys[i]
            var $curr = $('dl[data-type="' + item + '"]')
            if (item == type) continue

            var $lastSelected = $curr.find('button[value="' + selectedCache[i] + '"]')

            // 缓存的已选属性没有 disabled (可以被选择)
            if (!$lastSelected.hasClass('disabled')) {
                $lastSelected.addClass('active')
                updateStatus(getSelectedItem())
            }
        }

    }

    /**
     * 高亮当前属性区
     */
    function highLighAttr() {
        for (var i = 0; i < keys.length; i++) {
            var key = keys[i]
            var $curr = $('dl[data-type="' + key + '"]')
            if ($curr.find('.active').length < 1) {
                $curr.addClass('hl')
            } else {
                $curr.removeClass('hl')
            }
        }
    }

    /**
     * 绑定规格按钮事件
     */
    function bindEvent() {
        $('#app').undelegate().delegate('button', 'click', function (e) {
            var $this = $(this)

            var isActive = $this.hasClass('.active')
            var isDisable = $this.hasClass('disabled')

            if (!isActive) {
                handleNormalClick($this)

                if (isDisable) {
                    handleDisableClick($this)
                } else {
                    selectedCache[$this.parents('dl').eq(0).data('idx')] = $this.val()
                }
                updateStatus(getSelectedItem())
                highLighAttr()
                showResult()
            }
        })

        $('button').each(function () {
            var value = $(this).val()

            if (!res[value] && !$(this).hasClass('active')) {
                $(this).addClass('disabled')
            }
        })
    }

    /**
     * 展示已选择结果
     */
    function showResult() {
        var result = getSelectedItem()
        var s = []

        for (var i = 0; i < result.length; i++) {
            var item = result[i];
            if (!!item) {
                s.push(item)
            }
        }

        if (s.length == keys.length) {
            var curr = res[s.join(spliter)]

            if (curr) {
                s = s.concat(curr.skus)
            }
            $('#msg').html('已选择:' + s.join('\u3000-\u3000'))
        }
    }

    /**
     * 更新初始化数据
     */
    function updateData() {
        data = JSON.parse($('#data_area').val())
        init(data)
    }

    function setTitle(el) {
        var title = $(el).data('title');
        if (title) $(el).attr('title', title);
    }

    function setAllTitle() {
        $('#app').find('button').each(setTitle)
    }

    // 初始化函数
    function init(data) {
        // 初始化将下列参数全部置为空
        res = {}
        r = {}
        keys = []
        selectedCache = []
        // 保存所有的key值
        for (var attr_key in data[0]) {
            if (!data[0].hasOwnProperty(attr_key)) continue; // 判断自身属性是否存在,即确定是否至少一个Sku对象
            if (attr_key !== 'skuId') keys.push(attr_key) // 将第一个Sku对象的Key(除了skuId)放置keyes数组中
        }
        
        // 组合数组:属性值与SKU ID绑定;属性分组
        r = combineAttr(data, keys)
        // 渲染规格
        render(r.result)
        // 生成所有可选子集
        buildResult(r.items)
        // 根据选中的Item更新所有Item状态
        updateStatus(getSelectedItem())
        // 展示已选择的结果
        showResult()
        // 绑定规格按钮事件
        bindEvent()
    }

    /**
     * 首次加载执行
     */
    init(data)
</script>
</body>
</html>

这个前端实现了选择规格的问题,那么就在 Laravel 中查询出博主存储在数据库中的规格信息,并放入到这个前端试试,直接在路由上这样写:

Route::get('get_sku', function () {
   $model_sku = new \App\Sku();
   $rows_sku = $model_sku->where('product_id', 1)->get();

   $lst_sku = [];

   foreach ($rows_sku as $row_sku) {
       $sku = json_decode($row_sku['attrs_name'], true);
       $sku['skuId'] = $row_sku['id'];
       $lst_sku[] = $sku;
   }

   return json_encode($lst_sku);
});

访问这个页面,获取到了这样的一段 JSON:

[
    {
        "颜色": "红色",
        "尺码": "S",
        "skuId": 1
    },
    {
        "颜色": "黄色",
        "尺码": "S",
        "skuId": 2
    },
    {
        "颜色": "红色",
        "尺码": "M",
        "skuId": 3
    },
    {
        "颜色": "黄色",
        "尺码": "M",
        "skuId": 4
    }
]

直接将这段 JSON 放入前端:

1.png

可以完美的实现规格功能。

博主之前参与开发的项目有像上面这样的多个表的设计,也有单独一个规格表的设计,两种设计都各有优势。

本文地址 : bubaijun.com/page.php?id=215

版权声明 : 未经允许禁止转载!

评论:我要评论
发布评论:
Copyright © 不败君 粤ICP备18102917号-1

不败君

首 页 作 品 微 语