多級聯動菜單是常見的前端組件,比如省份-城市聯動、高校-學院-專業聯動等等。場景雖然常見,但仔細分析起來要實現一個通用的無限分級聯動菜單卻不一定像想象的那么簡單。比如,我們需要考慮子菜單的加載是同步的還是異步的?對於初始值的回填發生在前端還是后端?如果異步加載,是否對於后端API的返回格式有嚴格的定義?是否容易實現同步、異步共存?是否可以靈活的支持各類依賴關系?菜單中是否有空值選項?……一系列的問題都需要精心處理。
帶着這些需求搜索了一圈,不太出乎意料,並沒有能在AngularJS的生態中找到一個很適合的插件或者指令。於是只好嘗試自己實現了一個。
本文的實現基於AngularJS,但是思路通用,熟悉其他框架類庫的同學也可以閱讀。
首先重新梳理了一下需求,由於AngularJS的渲染發生在前端,以前在后端根據已有值獲取各級菜單的option並在模板層進行渲染的方案並不是很適合,而且和很多同學一樣,我個人並不喜歡這樣實現方式:很多時候,即使在后端完成了第一次對option選項的拉取和對初始值的回填,但由於子級菜單的加載依賴於api,前端也需要監聽onchange事件並進行ajax交互,換言之,一個簡單的二級聯動菜單竟然需要把邏輯撕裂在前、后端,這樣的方式並不值得推崇。
關於同步、異步的加載方式,雖然大多數時候整個步驟是異步的,但是對於部分選項不多的聯動菜單,也可以由一個api拉取所有數據,進行處理、緩存后供子級菜單渲染使用。因此同步、異步的渲染方式都應該支持。
至於api返回格式的問題,如果正在進行的是一個新的項目,或者后端程序員可以快速響應需求變動,或者前端同學本身就是全棧,這個問題可能不那么重要;但是很多時候,我們交互的api已經被項目的其他部分所使用,出於兼容性、穩定性的考慮,調整json的格式並非是一個可以輕松做出的決定;因此在本文中,對於子級菜單option數據的獲取將從directive本身解耦出來,由具體業務邏輯處理。
那如何實現對靈活依賴關系的支持呢?除了最常見的線性依賴以外,也應支持樹狀依賴、倒金字塔依賴甚至復雜的網狀依賴。由於這些業務場景的存在,將依賴關系硬編碼到邏輯較為復雜。經過權衡,組件間將通過事件進行通信。
需求整理如下:
* 支持在前端完成初始值回填
* 支持子集菜單選項的同步、異步獲取
* 支持菜單間靈活的依賴關系(比如線性依賴、樹狀依賴、倒金字塔依賴、網狀依賴)
* 支持菜單空值選項(option[value=""])
* 子集菜單的獲取邏輯從組件本身解耦
* 事件驅動,各級菜單在邏輯上相互獨立互不影響
由於多級聯動菜單對於AngularJS中select標簽的原有行為侵入性較大,為了之后編程方便,減少潛在沖突,本文將采用<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</optoin>的朴素方式,而非ngOptions。
1. 首先來思考第一個問題,如何在前端進行初始值的回填
多級聯動菜單最明顯的特點是,上一級菜單更改后,下一級菜單會被(同步或異步地)重新渲染。在回填值的過程中,我們需要逐級回填,無法在頁面加載時(或路由加載或組件加載等等)時瞬間完成該過程。尤其在AngularJS中,option的渲染過程應該發生在ngModel的渲染之前,否則即使option中有對應值,也會造成找不到匹配option的情況。
解決方案是在指令的link階段,首先保存model的初始值,並將其賦為空值(可以調用$setViewValue),並在渲染完成后再異步地對其賦回原值。
2. 如何解耦子選項獲取的具體邏輯,並同時支持同步、異步的方式
可以使用scope中的"="類屬性,將一個外部函數暴露到directive的link方法中。每次在執行該方法后,判斷其是否為promise實例(或是否有then方法),根據判斷結果決定同步或異步渲染。通過這樣的解耦,使用者就可以在傳入的外部函數中輕松地決定渲染方式了。為了使回調函數不那么難看,我們還可以將同步返回也封裝為一個帶then方法的對象。如下所示:
// scope.source為外部函數 var returned = scope.source ? scope.source(values) : false; !returned || (returned = returned.then ? returned : { then: (function (data) { return function (callback) { callback.call(window, data); }; })(returned) }).then(function (items) { // 對同步或異步返回的數據進行統一處理 }
3. 如何實現菜單間基於事件的通信
大體上還是通過訂閱者模式實現,需要在directive上聲明依賴;由於需要支持復雜的依賴關系,應該支持一個子集菜單同時有多個依賴。這樣在任何一個所依賴的菜單變化時,我們都可以通過如下方式進行監聽:
scope.$on('selectUpdate', function (e, data) { // data.name是變化的菜單,dependents是當前菜單所聲明的依賴數組 if ($.inArray(data.name, dependents) >= 0) { onParentChange(); } }); // 並且為了方便上文提到的source函數對於變動值的調用,可以對所依賴的菜單進行遍歷並保存當前值 var values = {}; if (dependents) { $.each(dependents, function (index, dependent) { values[dependent] = selects[dependent].getValue(); }); }
4. 處理兩類過期問題
容易想到的是異步過期的問題:設想第一級菜單發生變化,觸發對第二級菜單內容的拉取,但網速較慢,該過程需要3秒。1秒后用戶再次改變第一級菜單,再次觸發對第二級菜單內容的拉取,此時網速較快,1秒后數據返回,第二級菜單重新渲染;但是1秒后,第一次請求的結果返回,第二級菜單再次被渲染,但事實上第一級菜單此后已經發生過變化,內容已經過期,此次渲染是錯誤的。我們可以用閉包進行數據過期校驗。
不容易想到的是同步過期(其實也是異步,只是未經io交互,都是緩沖時間為0的timeout函數)的問題,即由於事件隊列的存在,稍不謹慎就可能出現過期,代碼中會有相關注釋。
5. 支持空值選項的細節問題
對於空值的支持本來覺得是一個很簡單的問題,<option value="" ng-if="empty">{{empty}}</option>即可,但實際編碼中發現,在directive的link中,由於此option的link過程並未開始,option標簽被實際上移除,只剩下相關注釋占位。AngularJS認為該select不含有空值選項,於是報錯。解決方案是棄用ng-if,使用ng-show。這二者的關系極其微妙有意思,有興趣的同學可以自己研究~
以上就是編碼過程中遇到的主要問題,歡迎交流~
需要看demo的同學可以到:
http://www.cnblogs.com/front-end-ralph/p/5133122.html
directive('multiLevelSelect', ['$parse', '$timeout', function ($parse, $timeout) { // 利用閉包,保存父級scope中的所有多級聯動菜單,便於取值 var selects = {}; return { restrict: 'CA', scope: { // 用於依賴聲明時指定父級標簽 name: '@name', // 依賴數組,逗號分割 dependents: '@dependents', // 提供具體option值的函數,在父級change時被調用,允許同步/異步的返回結果 // 無論同步還是異步,數據應該是[{text: 'text', value: 'value'},]的結構 source: '=source', // 是否支持控制選項,如果是,空值的標簽是什么 empty: '@empty', // 用於parse解析獲取model值(而非viewValue值) modelName: '@ngModel' }, template: '' // 使用ng-show而非ng-if,原因上文已經提到 + '<option ng-show="empty" value="">{{empty}}</option>' // 使用朴素的ng-repeat + '<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</option>', require: 'ngModel', link: function (scope, elem, attr, model) { var dependents = scope.dependents ? scope.dependents.split(',') : false; var parentScope = scope.$parent; scope.name = scope.name || 'multi-select-' + Math.floor(Math.random() * 900000 + 100000); // 將當前菜單的getValue函數封裝起來,放在閉包中的selects對象中方便調用 selects[scope.name] = { getValue: function () { return $parse(scope.modelName)(parentScope); } }; // 保存初始值,原因上文已經提到 var initValue = selects[scope.name].getValue(); var inited = !initValue; model.$setViewValue(''); // 父級標簽變化時被調用的回調函數 function onParentChange() { var values = {}; // 獲取所有依賴的菜單的當前值 if (dependents) { $.each(dependents, function (index, dependent) { values[dependent] = selects[dependent].getValue(); }); } // 利用閉包判斷io造成的異步過期 (function (thenValues) { // 調用source函數,取新的option數據 var returned = scope.source ? scope.source(values) : false; // 利用多層閉包,將同步結果包裝為有then方法的對象 !returned || (returned = returned.then ? returned : { then: (function (data) { return function (callback) { callback.call(window, data); }; })(returned) }).then(function (items) { // 防止由異步造成的過期 for (var name in thenValues) { if (thenValues[name] !== selects[name].getValue()) { return; } } scope.items = items; $timeout(function () { // 防止由同步(嚴格的說也是異步,注意事件隊列)造成的過期 if (scope.items !== items) return; // 如果有空值,選擇空值,否則選擇第一個選項 if (scope.empty) { model.$setViewValue(''); } else { model.$setViewValue(scope.items[0].value); } // 判斷恢復初始值的條件是否成熟 var initValueIncluded = !inited && (function () { for (var i = 0; i < scope.items.length; i++) { if (scope.items[i].value === initValue) { return true; } } return false; })(); // 恢復初始值 if (initValueIncluded) { inited = true; model.$setViewValue(initValue); } model.$render(); }); }); })(values); } // 是否有依賴,如果沒有,直接觸發onParentChange以還原初始值 !dependents ? onParentChange() : scope.$on('selectUpdate', function (e, data) { if ($.inArray(data.name, dependents) >= 0) { onParentChange(); } }); // 對當前值進行監聽,發生變化時對其進行廣播 parentScope.$watch(scope.modelName, function (newValue, oldValue) { if (newValue || '' !== oldValue || '') { scope.$root.$broadcast('selectUpdate', { // 將變動的菜單的name屬性廣播出去,便於依賴於它的菜單進行識別 name: scope.name }); } }); } }; }]);