前端如何展示商品屬性:SKU多維屬性狀態判斷算法的應用


 
作者:尾尾
鏈接:https://www.jianshu.com/p/7a17b4179225
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。
 

問題描述

這個問題來源於選擇商品屬性的場景。比如我們買衣服、鞋子這類物件,一般都需要我們選擇合適的顏色、尺碼等屬性

 
 

先了解一下 SKU 的學術概念吧

 

最小庫存管理單元(Stock Keeping Unit, SKU)是一個會計學名詞,定義為庫存管理中的最小可用單元,例如紡織品中一個SKU通常表示規格、顏色、款式,而在連鎖零售門店中有時稱單品為一個SKU。最小庫存管理單元可以區分不同商品銷售的最小單元,是科學管理商品的采購、銷售、物流和財務管理以及POS和MIS系統的數據統計的需求,通常對應一個管理信息系統的編碼。 —— form wikipedia 最小存貨單位

簡單的結合上面的實例來說: SKU 就是你上購物網站買到的最終商品,對應的上圖中已選擇的屬性是:顏色 黑色 - 尺碼 37

我先看看后端數據結構一般是這樣的,一個線性數組,每個元素是一個描述當前 SKU 的 map,比如:

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]

前端展示的時候顯然需要 group 一下,按不同的屬性分組,目的就是讓用戶按屬性的維度去選擇,group 后的數據大概是這樣的:

{
    "顏色": ["紅", "白", "藍"],
    "尺碼": ["大", "中", "小"],
    "型號": ["A", "B", "C"]
}

對應的在網頁上大概是這樣的 UI

 
 

這個時候,就會有一個問題,這些元子屬性能組成的集合(用戶的選擇路徑) 遠遠大於真正可以組成的集合,比如上面的屬性集合可以組合成一個 笛卡爾積,即。可以組合成以下序列:

[
    ["紅", "大", "A"],    //
    ["紅", "大", "B"],
    ["紅", "大", "C"],
    ["紅", "中", "A"],
    ["紅", "中", "B"],
    ["紅", "中", "C"],
    ["紅", "小", "A"],
    ["紅", "小", "B"],
    ["紅", "小", "C"],
    ["白", "大", "A"],
    ["白", "大", "B"],
    ["白", "大", "C"],
    ["白", "中", "A"],
    ["白", "中", "B"],    //
    ["白", "中", "C"],
    ["白", "小", "A"],
    ["白", "小", "B"],
    ["白", "小", "C"],
    ["藍", "大", "A"],
    ["藍", "大", "B"],
    ["藍", "大", "C"],
    ["藍", "中", "A"],
    ["藍", "中", "B"],
    ["藍", "中", "C"],
    ["藍", "小", "A"],
    ["藍", "小", "B"],
    ["藍", "小", "C"]     //
]

根據公式可以知道,一個由 3 個元素,每個元素是有 3 個元素的子集構成的集合,能組成的笛卡爾積一共有 3 的 3 次冪,也就是 27 種,然而源數據只可以形成 3 種組合

這種情況下最好能提前判斷出來不可選的路徑並置灰,告訴用戶,否則會造成誤解

確定規則

看下圖,如果我們定義紅色為當前選中的商品的屬性,即當前選中商品為 紅-大-A,這個時候如何確認其它非已選屬性是否可以組成可選路徑?

 
 

規則是這樣的: 假設當前用戶想選 白-大-A,剛好這個選擇路徑是不存在的,那么我們就把 置灰

 
 

以此類推,如果要確認 屬性是否可用,需要查找 藍-大-A 路徑是否存在

...

解決方法

根據上面的邏輯代碼實現思路就有了:

  1. 遍歷所有非已選元素:"白", "藍", "中", "小", "B", "C"
    1. 遍歷所有屬性行: "顏色", "尺碼", "型號"
      1. 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素,形成一個路徑
      2. 判斷此路徑是否存在,如果不存在將當前元素置灰

看來問題似乎已經解決了,然而 ...

我們忽略了一個非常重要的問題:上例中雖然 元素置灰,但是實際上 是可以被點擊的!因為用戶可以選擇 白-中-B 路徑

如果用戶點擊了 情況就變得復雜了很多,我們假設用戶 只選擇了一個元素 ,此時如何判斷其它未選元素是否可選?

 
 

即:如何確定 "大", "中", "小", "A", "B", "C" 需要置灰? 注意我們並不需要確認 "紅","藍" 是否可選,因為屬性里面的元素都是 單選,當前的屬性里任何元素都可選的

縮小問題規模

我們先 縮小問題范圍:當前情況下(只有一個 已選)如何確定尺碼 "大" 需要置灰? 你可能會想到根據我們之間的邏輯,需要分別查找:

  • 白 - 大 - A
  • 白 - 大 - B
  • 白 - 大 - C

他們都不存在的時候把尺碼 置灰,問題似乎也可以解決。其實這樣是不對的,因為 型號沒有被選擇過,所以只需要知道 白-大是否可選即可

同時還有一個問題,如果已選的個數不確定而且維度可以增加到不確定呢?

 
 

這種情況下如果還按之前的算法,即使實現也非常復雜。這時候就要考慮換一種思維方式

調整思路

之前我們都是反向思考,找出不可選應該置灰的元素。我們現在正向的考慮,如何確定屬性是否可選。而且多維的情況下用戶可以跳着選。比如:用戶選了兩個元素 白,B

 

 
 
圖1

 

我們再回過頭來看下 原始存在的數據

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]
//
[
   [ "紅", "大", "A" ],   // 存在
   [ "白", "中", "B" ],   // 存在
   [ "藍", "小", "C" ]    // 存在
]

顯然:如果第一條數據 "紅", "大", "A" 存在,那么下面這些子組合 肯定都存在

  • A
  • 紅 - 大
  • 紅 - A
  • 大 - A
  • 紅 - 大 - A

同理:如果第二條數據 "白", "中", "B" 存在,那么下面這些子組合 肯定都存在

  • B
  • 白 - 中
  • 白 - B
  • 中 - B
  • 白 - 中 - B

...

我們提前把 所有存在的路徑中的子組合 算出來,算法上叫取集合所有子集,數學上叫 冪集, 形成一個所有存在的路徑表,算法如下:


/**
 * 取得集合的所有子集「冪集」
 arr = [1,2,3]

     i = 0, ps = [[]]:
         j = 0; j < ps.length => j < 1:
             i=0, j=0 ps.push(ps[0].concat(arr[0])) => ps.push([].concat(1)) => [1]
                      ps = [[], [1]]

     i = 1, ps = [[], [1]] :
         j = 0; j < ps.length => j < 2
             i=1, j=0 ps.push(ps[0].concat(arr[1])) => ps.push([].concat(2))  => [2]
             i=1, j=1 ps.push(ps[1].concat(arr[1])) => ps.push([1].concat(2)) => [1,2]
                      ps = [[], [1], [2], [1,2]]

     i = 2, ps = [[], [1], [2], [1,2]]
         j = 0; j < ps.length => j < 4
             i=2, j=0 ps.push(ps[0].concat(arr[2])) => ps.push([3])    => [3]
             i=2, j=1 ps.push(ps[1].concat(arr[2])) => ps.push([1, 3]) => [1, 3]
             i=2, j=2 ps.push(ps[2].concat(arr[2])) => ps.push([2, 3]) => [2, 3]
             i=2, j=3 ps.push(ps[3].concat(arr[2])) => ps.push([2, 3]) => [1, 2, 3]
                      ps = [[], [1], [2], [1,2], [3], [1, 3], [2, 3], [1, 2, 3]]
 */
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;
}

有了這個存在的子集集合,再回頭看 圖1 舉例:

 
圖1
  • 如何確定 可選? 只需要確定 紅-B 可選
  • 如何確定 可選? 需要確定 白-中-B 可選
  • 如何確定 2G 可選? 需要確定 白-B-2G 可選

算法描述如下:

  1. 遍歷所有非已選元素
    1. 遍歷所有屬性行
      1. 取: a) 當前元素 b) 非當前元素所在的其它屬性已選元素(如果當前屬性中沒已選元素,則跳過),形成一個路徑
      2. 判斷此路徑是否存在(在所有存在的路徑表中查詢),如果不存在將當前元素置灰

以最開始的后端數據為例,生成的所有可選路徑表如下:
注意路徑用分割符號「-」分開是為了查找路徑時方便,不用遍歷

{
    "": {
        "skus": ["3158054", "3133859", "3516833"]
    },
    "紅": {
        "skus": ["3158054"]
    },
    "大": {
        "skus": ["3158054"]
    },
    "紅-大": {
        "skus": ["3158054"]
    },
    "A": {
        "skus": ["3158054"]
    },
    "紅-A": {
        "skus": ["3158054"]
    },
    "大-A": {
        "skus": ["3158054"]
    },
    "紅-大-A": {
        "skus": ["3158054"]
    },
    "白": {
        "skus": ["3133859"]
    },
    "中": {
        "skus": ["3133859"]
    },
    "白-中": {
        "skus": ["3133859"]
    },
    "B": {
        "skus": ["3133859"]
    },
    "白-B": {
        "skus": ["3133859"]
    },
    "中-B": {
        "skus": ["3133859"]
    },
    "白-中-B": {
        "skus": ["3133859"]
    },
    "藍": {
        "skus": ["3516833"]
    },
    "小": {
        "skus": ["3516833"]
    },
    "藍-小": {
        "skus": ["3516833"]
    },
    "C": {
        "skus": ["3516833"]
    },
    "藍-C": {
        "skus": ["3516833"]
    },
    "小-C": {
        "skus": ["3516833"]
    },
    "藍-小-C": {
        "skus": ["3516833"]
    }
}

為了更清楚的說明這個算法,再上一張圖來解釋下吧:

 

所以根據上面的邏輯得出,計算狀態后的界面應該是這樣的:

 

現在這種情況下如果用戶點擊 尺碼 應該怎么交互呢?

優化體驗

因為當前情況下路徑 紅-中-A 並不存在,如果點擊 ,那么除了尺碼 之外其它的屬性中 至少有一個 屬性和 的路徑搭配是不存在的

交互方面需求是:如果不存在就高亮當前屬性行,使用戶必須選擇到可以和 組合存在的屬性。而且用戶之間選擇過的屬性要做一次緩存

所以當點擊不存在的屬性時交互流程是這樣的:

  1. 無論當前屬性存不存在,先高亮(選中)當前屬性
  2. 清除其它所有已選屬性
  3. 更新當前狀態(只選當前屬性)下的其它屬性可選狀態
  4. 遍歷非當前屬性行的其它屬性查找對應的在緩存中的已選屬性
  5. 如果緩存中對應的屬性存在(可選),則默認選中緩存屬性並 再次更新 其它可選狀態。不存在,則高亮當前屬性行(深色背景)

這個過程的流程圖大概是這樣的,點進不存在的屬性就會進入「單選流程」

 

假設后端數據是這樣的:

[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "大", "型號": "A", "skuId": "3158054" }, // 多加了一條
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]

當前選中狀態是:白-大-A

如果用戶點擊 。這個時候 白-中 是存在的,但是 中-A 並不存在,所以保留顏色 ,高亮型號屬性行:

 

由此可見和 白-中 能搭配存在型號只有 B,而緩存的作用就是為了少讓用戶選一次顏色

到這里,基本上主要的功能就實現了。比如庫存邏輯處理方式也和不存屬性一樣,就不再贅述。唯一需要注意的地方是求冪集的復雜度問題

算法復雜度

冪集算法的時間復雜度是 O(2^n),也就是說每條數據上面的屬性(維度)越多,復雜度越高。SKU 數據的多少並不重要,因為是常數級的線性增長,而維度是指數級的增長


{1}       2^1 = 2
=> {},{1}
{1,2}     2^2 = 4
=> {},{1},{2},{1,2}
{1,2,3}   2^3 = 8
=> {},{1},{2},{3},{1,2},{1,3},{2,3},{1,2,3}
...
 
 

在 chrome 里面簡單跑了幾個用例,可見這個算法非常低效,如果要使用這個算法,必須控制維度在合理范圍內,而且不僅僅算法時間復雜度很高,生成最后的路徑表也會非常大,相應的占用內存也很高。

舉個例子:如果有一個 10 維的 SKU,那么最終生成的路徑表會有 2^10 個(1024) key/value

最終 demo 可以查看這個:
SKU 多維屬性狀態判斷

相關資料:
SKU組合查詢算法探索



demo
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Sku 多維屬性狀態判斷</title>
    <script src="http://misc.360buyimg.com/jdf/lib/jquery-1.6.4.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">
[
   { "顏色": "紅", "尺碼": "大", "型號": "A", "skuId": "3158055" },
   { "顏色": "白", "尺碼": "大", "型號": "A", "skuId": "3158054" },
   { "顏色": "白", "尺碼": "中", "型號": "B", "skuId": "3133859" },
   { "顏色": "藍", "尺碼": "小", "型號": "C", "skuId": "3516833" }
]
    </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 = []
var selectedCache = []

/**
 * 計算組合數據
 */
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,
        items: allKeys
    }
}

/**
 * 渲染 DOM 結構
 */
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>'
    }
    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
}

/**
 * 取得集合的所有子集「冪集」
 arr = [1,2,3]

     i = 0, ps = [[]]:
         j = 0; j < ps.length => j < 1:
             i=0, j=0 ps.push(ps[0].concat(arr[0])) => ps.push([].concat(1)) => [1]
                      ps = [[], [1]]

     i = 1, ps = [[], [1]] :
         j = 0; j < ps.length => j < 2
             i=1, j=0 ps.push(ps[0].concat(arr[1])) => ps.push([].concat(2))  => [2]
             i=1, j=1 ps.push(ps[1].concat(arr[1])) => ps.push([1].concat(2)) => [1,2]
                      ps = [[], [1], [2], [1,2]]

     i = 2, ps = [[], [1], [2], [1,2]]
         j = 0; j < ps.length => j < 4
             i=2, j=0 ps.push(ps[0].concat(arr[2])) => ps.push([3])    => [3]
             i=2, j=1 ps.push(ps[1].concat(arr[2])) => ps.push([1, 3]) => [1, 3]
             i=2, j=2 ps.push(ps[2].concat(arr[2])) => ps.push([2, 3]) => [2, 3]
             i=2, j=3 ps.push(ps[3].concat(arr[2])) => ps.push([2, 3]) => [1, 2, 3]
                      ps = [[], [1], [2], [1,2], [3], [1, 3], [2, 3], [1, 2, 3]]
 */
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 = getAllSets(values)
        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) {
    // ⊙abc⊙ => abc
    // ⊙a⊙⊙b⊙c⊙ => a⊙b⊙c
    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 = []

    for (var attr_key in data[0]) {
        if (!data[0].hasOwnProperty(attr_key)) continue;
        if (attr_key != 'skuId') keys.push(attr_key)
    }
    setAllTitle();

    r = combineAttr(data, keys)

    render(r.result)

    buildResult(r.items)

    updateStatus(getSelectedItem())
    showResult()

    bindEvent()
}

init(data)


</script>

</body>
</html>

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM