分離的思想結合單鏈表實現級聯組件:CascadeView


本文介紹自己最近做省市級聯的類似的級聯功能的實現思路,為了盡可能地做到職責分離跟表現與行為分離,這個功能拆分成了2個組件並用到了單鏈表來實現關鍵的級聯邏輯,下一段有演示效果的gif圖。雖然這是個很常見的功能,但是本文的實現邏輯清晰,代碼好理解,脫離了省市級聯這樣的語義,考慮了表現與行為的分離,希望本文的內容能夠為你的工作帶來一些參考的價值,歡迎閱讀和指正。

演示效果(代碼下載,注:該效果需要http才能運行,另外效果中的數據是模擬數據,並不是后台真實返回的,所以看到的省市縣的下拉數據都是一樣的):

22

:本文用到了前面幾篇相關博客的技術實現,如果有需要的話可以點擊下面的鏈接前去了解:
1)詳解Javascript的繼承實現:提供一個class.js,用來定義javascript的類和構建類的繼承關系;
2)jquery技巧之讓任何組件都支持類似DOM的事件管理:提供一個eventBase.js,用來給任意組件實例提供類似DOM的事件管理功能;
3)對jquery的ajax進行二次封裝以及ajax緩存代理組件:AjaxCache:提供ajax.js和ajaxCache.js,簡化jquery的ajax調用,以及對請求進行客戶端的緩存代理。

下面先來詳細了解下這個功能的要求。

1. 功能分析

以包含三個級聯項的級聯組件來說明這個功能:
1)每個級聯項可能需要一個用作輸入提示的option:
image
這種情況每個級聯項的數據列表中都能選擇一個空的option(就是輸入提示的那個):
image
也可能不需要用作輸入提示的option:
image
這種情況每個級聯項的數據列表中只能選數據option,選不到空的option:
image
2)如果當前這個頁面是從數據庫中查詢出來跟級聯組件對應的字段有值,那么就把查詢出來的值回顯到級聯組件上:
image
如果查詢出來的對應字段沒有值,那么就按第1)點需求描述的2種情況顯示。
3)各個級聯項在數據結構上構成單鏈表的關系,后一個級聯項的數據列表,跟前一個級聯項所選擇的數據有關聯的。
4)考慮到性能方面的問題,各個級聯項的數據列表都采用ajax異步加載顯示。
5)在級聯組件初始化完成以后,自動加載第一個級聯項的列表。
6)當前一個級聯項發生改變時,清空后面所有直接或間接關聯的級聯項的數據列表,同時如果前一個級聯項改變后的值不為空則自動加載跟它直接關聯的下一個級聯項的數據列表。清空級聯項的數據列表時要注意:如果級聯項需要顯示輸入提示的option,在清空的時候得保留該option。
7)要充分考慮性能問題,避免重復加載。
8)考慮到表單提交的問題,當級聯組件任意級聯項發生改變后,得把級聯組件所選的值體現到一個隱藏的文本域內,方便把級聯組件的值通過該文本域提交到后台。

功能大致如上。

2. 實現思路

1)數據結構

級聯組件跟別的組件不太一樣的是,它跟后台的數據有一些依賴,我考慮的比較好實現的數據結構是:

[
    {
        "id": 1,
        "text": "北京市",
        "code": 110000,
        "parentId": 0
    },
    {
        "id": 2,
        "text": "河北省",
        "code": 220000,
        "parentId": 0
    },
    {
        "id": 3,
        "text": "河南省",
        "code": 330000,
        "parentId": 0
    }
]

id是數據的唯一標識,數據之間的關聯關系通過parentId來構建,text,code這種都屬於普通的業務字段。如果按這個數據結構,我們查詢級聯項數據列表的接口就會變得很簡單:

//查第一個級聯項的列表
/api/cascade?parentId=0
//根據第一個級聯項選的值,查第二個級聯項的列表
/api/cascade?parentId=1
//根據第二個級聯項選的值,查第三個級聯項的列表
/api/cascade?parentId=4

這個結構對於后台來說也很好處理,雖然在結構上它們是一種樹形的表結構,但是查詢都是單層的,所以很好實現。

從前面的查詢演示也能夠看出,這個結構能夠很方便地幫我們把數據查詢的接口和參數統一成一個,這對於組件開發來說是一個很方便的事情。我們從后台拿到這個數據結構之后,把每一條數據解析成一個option,如<option value=”北京市” data-param-value=”1”>北京市</option>,這樣既能完成數據列表的下拉顯示,還能通過select這個表單元素的作用收集到當前級聯項所選中的值,最后當級聯項發生改變的時候,還能夠獲取到選中的option,把它上面存儲的data-param-value的值作為parentId這個參數,去加載下一個級聯項的列表。這也是級聯組件數據查詢和解析的思路。

但是這里面還需要考慮的是靈活性的問題,在實際的項目中,可能級聯組件的數據結構是按id parentId這種類似的關聯關系定義的,但是它們的字段不一定是叫id parentId text code,很有可能是別的字段。也就是說:在把數據解析成option的時候,option的text還有value到底用什么字段來解析,以及data-param-value這個屬性的用什么字段的值,都是不確定的;還有查詢數據時用的參數名稱parentId也不能是死的,有的時候如果后台人員先寫好了查詢接口,用了別的名稱,你不可能要求人家去改他的參數名稱,因為他那邊是需要編譯再部署的,相比前端更麻煩一些;還有parentId=0這個0值也是不能固定,因為實際項目中第一層的數據的parentid有可能是空,也有可能是-1。這些東西都得設計成option,一方面提供默認值,同時留給外部根據實際情況來設置,比如本文最終的實現中這個option都是這樣定義的:

textField: 'text', //返回的數據中要在<option>元素內顯示的字段名稱
valueField: 'text', //返回的數據中要設置在<option>元素的value上的字段名稱
paramField: 'id', //當調用數據查詢接口時,要傳遞給后台的數據對應的字段名稱
paramName: 'parentId', //當調用數據查詢接口時,跟在url后面傳遞數據的參數名
defaultParam: '', //當查詢第一個級聯項時,傳遞給后台的值,一般是0,'',或者-1等,表示要查詢第上層的數據

2)html結構

根據前面的功能分析的第1條,級聯組件的初始html結構有2種:

<ul id="licenseLocation-view" class="cascade-view clearfix">
    <li>
        <select class="form-control">
            <option value="">請選擇省份</option>
        </select>
    </li>
    <li>
        <select class="form-control">
            <option value="">請選擇城市</option>
        </select>
    </li>
    <li>
        <select class="form-control">
            <option value="">請選擇區縣</option>
        </select>
    </li>
</ul>
<ul id="companyLocation-view" class="cascade-view clearfix">
    <li>
        <select class="form-control">
        </select>
    </li>
    <li>
        <select class="form-control">
        </select>
    </li>
    <li>
        <select class="form-control">
        </select>
    </li>
</ul>

這兩個結構唯一的區別就在於是否配置了用作輸入提示的option。另外需要注意的是如果需要這個空的option,一定得把value屬性設置成空,否則這個空的option在表單提交的時候會把option的提示信息提交到后台。

這兩個結構最關鍵的是select元素,跟ul和li沒有任何關系,ul跟li是為了UI而用到的;select元素沒有任何語義,不用去標識哪個是省份,哪個是城市,哪個是區縣。從功能上來說,一個select代表一個級聯項,這些select在哪定義都不重要,我們只要告訴級聯組件,它的級聯項由哪些select元素構成就行了,唯一需要額外告訴組件的就是這些select元素的先后關系,但是這個通常都是用元素在html中的默認順序來控制的。這個結構能夠幫助我們把組件的功能盡可能地做到表現與行為分離。

3)職責分離和單鏈表的運用

從前面的部分也差不多能看出來了,這個級聯組件如果按職責划分,可以分成兩個核心的組件,一個負責整體功能和內部級聯項的管理(CascadeView),另一個負責級聯項的功能實現(CascadeItem)。另外為了更方便地實現級聯的邏輯,我們只需要把所有的級聯項通過鏈表連起來,通過發布-訂閱模式,后一個級聯項訂閱前一個級聯項發生改變的消息;當前面的級聯項發生改變的時候,發布消息,通知后面的級聯項去處理相關邏輯;通過鏈表的作用,這個消息可能可以一直傳遞到最后一個級聯項為止。用圖來描述的話,大致就是這個樣子:

image

我們需要做的就是控制好消息的發布跟傳遞。

4)表單提交

為了能夠方便地將級聯組件的值提交到后台,可以把整個級聯組件當成一個整體,對外提供一個onChanged事件,外部可通過這個事件獲取所有級聯項的值。由於存在多個級聯項,所以在發布onChanged這個事件時,只能在任意級聯項發生改變的時候,都去觸發這個事件。

5)ajax緩存

在這個組件里面得考慮兩個層級的ajax緩存,第一個是組件這一層級的,比如我把第一個級聯項切換到了北京,這個時候第二個級聯項就把北京的數據加載出來了,然后我把第一個級聯項從北京切換到河北再切換到北京,這個時候第二個級聯項要顯示的還是北京的關聯數據列表,如果我們在第一次加載這個列表的時候就把它的數據緩存下來了,那么這次就不用發起ajax請求了;第二個是ajax請求這一層級的,假如頁面上有多個級聯組件,我先把第一個級聯組件的第一個級聯項切換到北京,瀏覽器發起一個ajax請求加載數據,當我再把第二個級聯組件的第一個級聯項切換到北京的時候,瀏覽器還會再發一個請求去加載數據,如果我把第一個組件第一次ajax請求的返回的數據,先緩存起來,當第二個組件,用同樣的參數請求同樣的接口時,直接拿之前緩存覺得結果返回,這樣也能減少一次ajax請求。第二個層級的ajax緩存依賴上文《對jquery的ajax進行二次封裝以及ajax緩存代理組件:AjaxCache》,對於組件來說,它內部只實現了第一個層級的緩存,但是它不用考慮第二個層級的緩存,因為第二個層級的緩存實現對它來說是透明的,它不知道它用到的ajax組件有緩存的功能。

3. 實現細節

最終的實現包含了三個組件,CascadeView、CascadeItem、CascadePublicDefaults,前面兩個是組件的核心,最后一個只是用來定義一些option,它的作用在CascadeItem的注釋里面有詳細的描述。另外在下面的代碼中有非常詳細的注釋解釋了一些關鍵代碼的作用,結合着前面的需求來看代碼,應該還是比較容易理解的。我以前傾向於用文字來解釋一些實現細節,后來我慢慢覺得這種方式有點費力不討好,第一是細節層面的語言不好組織,有的時候言不達意,明明想把一件事情解釋清楚,結果反而弄得更加迷糊,至少我自己看自己寫的東西就會這樣的感觸;第二是本身開發人員都具有閱讀源碼的能力,而且大部分積極的開發人員都願意通過琢磨別人的代碼來理解實現思路;所以我改用注釋的方式來說明實現細節:)

CascadePublicDefaults:

define(function () {
    return {
        url: '',//數據查詢接口
        textField: 'text', //返回的數據中要在<option>元素內顯示的字段名稱
        valueField: 'text', //返回的數據中要設置在<option>元素的value上的字段名稱
        paramField: 'id', //當調用數據查詢接口時,要傳遞給后台的數據對應的字段名稱
        paramName: 'parentId', //當調用數據查詢接口時,跟在url后面傳遞數據的參數名
        defaultParam: '', //當查詢第一個級聯項時,傳遞給后台的值,一般是0,'',或者-1等,表示要查詢第上層的數據
        keepFirstOption: true, //是否保留第一個option(用作輸入提示,如:請選擇省份),如果為true,在重新加載級聯項時,不會清除默認的第一個option
        resolveAjax: function (res) {
            return res;
        }//因為級聯項在加載數據的時候會發異步請求,這個回調用來解析異步請求返回的響應
    }
});

CascadeView:

define(function (require, exports, module) {

    var $ = require('jquery');
    var Class = require('mod/class');
    var EventBase = require('mod/eventBase');
    var PublicDefaults = require('mod/cascadePublicDefaults');
    var CascadeItem = require('mod/cascadeItem');

    /**
     * PublicDefaults的作用見CascadeItem組件內的注釋
     */
    var DEFAULTS = $.extend({}, PublicDefaults, {
        $elements: undefined, //級聯項jq對象的數組,元素在數據中的順序代表級聯的先后順序
        valueSeparator: ',', //獲取所有級聯項的值時使用的分隔符,如果是英文逗號,返回的值形如 北京市,區,朝陽區
        values: '', //用valueSeparator分隔的字符串,表示初始時各個select的值
        onChanged: $.noop //當任意級聯項的值發生改變的時候會觸發這個事件
    });

    var CascadeView = Class({
        instanceMembers: {
            init: function (options) {
                //通過this.base調用父類EventBase的init方法
                this.base();

                var opts = this.options = this.getOptions(options),
                    items = this.items = [],
                    that = this,
                    $elements = opts.$elements,
                    values = opts.values.split(opts.valueSeparator);

                this.on('changed.cascadeView', $.proxy(opts.onChanged, this));

                $elements && $elements.each(function (i) {
                    var $el = $(this);

                    //實例化CascadeItem組件,並把每個實例的prevItem屬性指向前一個實例
                    //第一個prevItem屬性設置為undefined
                    var cascadeItem = new CascadeItem($el, $.extend(that.getItemOptions(), {
                        prevItem: i == 0 ? undefined : items[i - 1],
                        value: $.trim(values[i])
                    }));
                    items.push(cascadeItem);

                    //每個級聯項實例發生改變都會觸發CascadeView組件的changed事件
                    //外部可在這個回調內處理業務邏輯
                    //比如將所有級聯項的值設置到一個隱藏域里面,用於表單提交
                    cascadeItem.on('changed.cascadeItem', function () {
                        that.trigger('changed.cascadeView', that.getValue());
                    });
                });

                //初始化完成自動加載第一個級聯項
                items.length && items[0].load();
            },
            getOptions: function (options) {
                return $.extend({}, this.getDefaults(), options);
            },
            getDefaults: function () {
                return DEFAULTS;
            },
            getItemOptions: function () {
                var opts = {}, _options = this.options;
                for (var i in PublicDefaults) {
                    if (PublicDefaults.hasOwnProperty(i) && i in _options) {
                        opts[i] = _options[i];
                    }
                }
                return opts;
            },
            //獲取所有級聯項的值,是一個用valueSeparator分隔的字符串
            //為空的級聯項的值不會返回
            getValue: function () {
                var value = [];
                this.items.forEach(function (item) {
                    var val = $.trim(item.getValue());
                    val != '' && value.push(val);
                });
                return value.join(this.options.valueSeparator);
            }
        },
        extend: EventBase
    });

    return CascadeView;
});

CascadeItem:

define(function (require, exports, module) {

    var $ = require('jquery');
    var Class = require('mod/class');
    var EventBase = require('mod/eventBase');
    var PublicDefaults = require('mod/cascadePublicDefaults');
    var AjaxCache = require('mod/ajaxCache');

    //這是一個可緩存的Ajax組件
    var Ajax = new AjaxCache();

    /**
     * 有一部分option定義在PublicDefaults里面,因為CascadeItem組件不會被外部直接使用
     * 外部用的是CascadeView組件,所以有一部分的option必須變成公共的,在CascadeView組件也定義一次
     * 外部通過CascadeView組件傳遞所有的option
     * CascadeView內部實例化CascadeItem的時候,再把PublicDefaults內的option傳遞給CascadeItem
     */
    var DEFAULTS = $.extend({}, PublicDefaults, {
        prevItem: undefined, // 指向前一個級聯項
        value: '' //初始時顯示的value
    });

    var CascadeItem = Class({
        instanceMembers: {
            init: function ($el, options) {
                //通過this.base調用父類EventBase的init方法
                this.base($el);

                this.$el = $el;
                this.options = this.getOptions(options);
                this.prevItem = this.options.prevItem; //前一個級聯項
                this.hasContent = false;//這個變量用來控制是否需要重新加載數據
                this.cache = {};//用來緩存數據

                var that = this;

                //代理select元素的change事件
                $el.on('change', function () {
                    that.trigger('changed.cascadeItem');
                });

                //當前一個級聯項的值發生改變的時候,根據需要做清空和重新加載數據的處理
                this.prevItem && this.prevItem.on('changed.cascadeItem', function () {
                    //只要前一個的值發生改變並且自身有內容的時候,就得清空內容
                    that.hasContent && that.clear();

                    //如果不是第一個級聯項,同時前一個級聯項沒有選中有效的option時,就不處理
                    if (that.prevItem && $.trim(that.prevItem.getValue()) == '') return;

                    that.load();
                });

                var value = $.trim(this.options.value);
                value !== '' && this.one('render.cascadeItem', function () {
                    //設置初始值
                    that.$el.val(value.split(','));

                    //通知后面的級聯項做清空和重新加載數據的處理
                    that.trigger('changed.cascadeItem');
                });
            },
            getOptions: function (options) {
                return $.extend({}, this.getDefaults(), options);
            },
            getDefaults: function () {
                return DEFAULTS;
            },
            clear: function () {
                var $el = this.$el;
                $el.val('');
                if (this.options.keepFirstOption) {
                    //保留第一個option
                    $el.children().filter(':gt(0)').remove();
                } else {
                    //清空全部
                    $el.html('');
                }

                //通知后面的級聯項做清空和重新加載數據的處理
                this.trigger('changed.cascadeItem');

                this.hasContent = false;//表示內容為空
            },
            load: function () {
                var opts = this.options,
                    paramValue,
                    that = this,
                    dataKey;

                //dataKey是在cache緩存時用的鍵名
                //由於第一個級聯項的數據是頂層數據,所以在緩存的時候用的是固定且唯一的鍵:root
                //其它級聯項的數據緩存時用的鍵名跟前一個選擇的option有關
                if (!this.prevItem) {
                    paramValue = opts.defaultParam;
                    dataKey = 'root';
                } else {
                    paramValue = this.prevItem.getParamValue();
                    dataKey = paramValue;
                }

                //先看數據緩存中有沒有加載過的數據,有就直接顯示出來,避免Ajax
                if (dataKey in this.cache) {
                    this.render(this.cache[dataKey]);
                } else {
                    var params = {};
                    params[opts.paramName] = paramValue;
                    Ajax.get(opts.url, params).done(function (res) {
                        //resolveAjax這個回調用來在外部解析ajax返回的數據
                        //它需要返回一個data數組
                        var data = opts.resolveAjax(res);
                        if (data) {
                            that.cache[dataKey] = data;
                            that.render(data);
                        }
                    });
                }
            },
            render: function (data) {
                var html = [],
                    opts = this.options;
                data.forEach(function (item) {
                    html.push(['<option value="',
                        item[opts.valueField],
                        '" data-param-value="',//將paramField對應的值存放在option的data-param-value屬性上
                        item[opts.paramField],
                        '">',
                        item[opts.textField],
                        '</option>'].join(''));
                });

                //采用append的方式動態添加,避免影響第一個option
                //最后還要把value設置為空
                this.$el.append(html.join('')).val('');

                this.hasContent = true;//表示有內容
                this.trigger('render.cascadeItem');
            },
            getValue: function () {
                return this.$el.val();
            },
            getParamValue: function () {
                return this.$el.find('option:selected').data('paramValue');
            }
        },
        extend: EventBase
    });

    return CascadeItem;
});

4. demo說明

演示代碼的結構:

image

其中框起來的就是演示的相關部分。html/regist.html是演示效果的頁面,js/app/regist.js是演示效果的入口js:

define(function (require, exports, module) {

    var $ = require('jquery');
    var CascadeView = require('mod/cascadeView');

    function publicSetCascadeView(fieldName, opts) {
        this.cascadeView = new CascadeView({
            $elements: $('#' + fieldName + '-view').find('select'),
            url: '../api/cascade.json',
            onChanged: this.onChanged,
            values: opts.values,
            keepFirstOption: this.keepFirstOption,
            resolveAjax: function (res) {
                if (res.code == 200) {
                    return res.data;
                }
            }
        });
    }

    var LOCATION_VIEWS = {
        licenseLocation: {
            $input: $('input[name="licenseLocation"]'),
            keepFirstOption: true,
            setCascadeView: publicSetCascadeView,
            onChanged: function(e, value){
                LOCATION_VIEWS.licenseLocation.$input.val(value);
            }
        },
        companyLocation: {
            $input: $('input[name="companyLocation"]'),
            keepFirstOption: false,
            setCascadeView: publicSetCascadeView,
            onChanged: function(e, value){
                LOCATION_VIEWS.companyLocation.$input.val(value);
            }
        }
    };

    LOCATION_VIEWS.licenseLocation.setCascadeView('licenseLocation', {
        values: LOCATION_VIEWS.licenseLocation.$input.val()
    });

    LOCATION_VIEWS.companyLocation.setCascadeView('companyLocation', {
        values: LOCATION_VIEWS.companyLocation.$input.val()
    });
});

注意以上代碼中LOCATION_VIEWS這個變量的作用,因為頁面上有多個級聯組件,這個變量其實是通過策略模式,把各個組件的相關的東西都用一種類似的方式管理起來而已。如果不這么做的話,很容易產生重復代碼;這種形式也比較有利於在入口文件這種處理業務邏輯的地方,進行一些業務邏輯的分離與封裝。

5. others

這估計是在現在公司寫的最后一篇博客,過兩天就得去新單位去上班了,不確定還能否有這么多空余的時間來記錄平常的工作思路,但是好歹已經培養了寫博客的習慣,將來沒時間也會擠出時間來的。今年的目標主要是拓寬知識面,提高代碼質量,后續的博客更多還是在組件化開發這個類別上,希望以后能夠得到大家的繼續關注:)

謝謝閱讀:)


免責聲明!

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



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