本文介紹自己最近做省市級聯的類似的級聯功能的實現思路,為了盡可能地做到職責分離跟表現與行為分離,這個功能拆分成了2個組件並用到了單鍊錶來實現關鍵的級聯邏輯,下一段有簡報效果的gif圖。雖然這是個很常見的功能,但是本文的實現邏輯清晰,代碼好理解,脫離了省市級聯這樣的語義,考慮了表現與行為的分離,希望本文的內容能夠為你的工作帶來一些參考的價值,歡迎閱讀和指正。
Cascade 級聯操作
CascadeType. PERSIST 級聯持久化 ( 儲存 ) 操作
CascadeType. MERGE 級聯更新 ( 合併 ) 操作
CascadeType. REFRESH 級聯刷新操作,只會查詢取得操作
CascadeType. REMOVE 級聯刪除操作
CascadeType. ALL 級聯以上全部操作
Fetch 抓取是否延遲加載,預設情況一的方為立即加載,多的一方為延遲加載
mappedBy 關係維護
mappedBy= "parentid" 表示在children 類別中的 parentid 屬性來維護關係,這個名稱必須和children 類別中的 parentid屬性名稱完全一致才行。
另外要注意,parent類別中的集合類型必須是List或Set,不能設定為ArrayList,否則會報錯
示範效果(程式碼下載,註:該效果需要http才能運行,另外效果中的數據是模擬數據,並不是後台真實返回的,所以看到的省市縣的下拉數據都是一樣的):
註:本文用到了前面幾篇相關部落格的技術實現,如果有需要的話可以點擊下面的連結前去了解:
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:
這種情況每個級聯項的資料清單中都能選擇一個空的option(就是輸入提示的那個):
也可能不需要用作輸入提示的option:
這種情況每個級聯項的資料清單中只能選取資料option,選不到空的option:
2)如果目前這個頁面是從資料庫中查詢出來跟級聯元件對應的欄位有值,那麼就把查詢出來的值回顯到級聯元件上:
如果查詢出來的對應欄位沒有值,那麼就依第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,如,這樣既能完成數據清單的下拉顯示,還能透過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', //傳回的資料中要在
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)。另外为了更方便地实现级联的逻辑,我们只需要把所有的级联项通过链表连起来,通过发布-订阅模式,后一个级联项订阅前一个级联项发生改变的消息;当前面的级联项发生改变的时候,发布消息,通知后面的级联项去处理相关逻辑;通过链表的作用,这个消息可能可以一直传递到最后一个级联项为止。用图来描述的话,大致就是这个样子:
我们需要做的就是控制好消息的发布跟传递。
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说明
演示代码的结构:
其中框起来的就是演示的相关部分。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
这估计是在现在公司写的最后一篇博客,过两天就得去新单位去上班了,不确定还能否有这么多空余的时间来记录平常的工作思路,但是好歹已经培养了写博客的习惯,将来没时间也会挤出时间来的。今年的目标主要是拓宽知识面,提高代码质量,后续的博客更多还是在组件化开发这个类别上,希望以后能够得到大家的继续关注脚本之家网站!