多級聯動菜單是常見的前端組件,好比省份-城市聯動、高校-學院-專業聯動等等。場景雖然常見,但仔細分析起來要實現一個通用的無限分級聯動菜單卻不必定像想象的那麼簡單。好比,咱們須要考慮子菜單的加載是同步的仍是異步的?對於初始值的回填發生在前端仍是後端?若是異步加載,是否對於後端API的返回格式有嚴格的定義?是否容易實現同步、異步共存?是否能夠靈活的支持各種依賴關係?菜單中是否有空值選項?……一系列的問題都須要精心處理。
帶着這些需求搜索了一圈,不太出乎意料,並無能在AngularJS的生態中找到一個很適合的插件或者指令。因而只好嘗試本身實現了一個。
本文的實現基於AngularJS,可是思路通用,熟悉其餘框架類庫的同窗也能夠閱讀。javascript
首先從新梳理了一下需求,因爲AngularJS的渲染髮生在前端,之前在後端根據已有值獲取各級菜單的option並在模板層進行渲染的方案並非很適合,並且和不少同窗同樣,我我的並不喜歡這樣實現方式:不少時候,即便在後端完成了第一次對option選項的拉取和對初始值的回填,但因爲子級菜單的加載依賴於api,前端也須要監聽onchange事件並進行ajax交互,換言之,一個簡單的二級聯動菜單居然須要把邏輯撕裂在前、後端,這樣的方式並不值得推崇。html
關於同步、異步的加載方式,雖然大多數時候整個步驟是異步的,可是對於部分選項很少的聯動菜單,也能夠由一個api拉取全部數據,進行處理、緩存後供子級菜單渲染使用。所以同步、異步的渲染方式都應該支持。前端
至於api返回格式的問題,若是正在進行的是一個新的項目,或者後端程序員能夠快速響應需求變更,或者前端同窗自己就是全棧,這個問題可能不那麼重要;可是不少時候,咱們交互的api已經被項目的其餘部分所使用,出於兼容性、穩定性的考慮,調整json的格式並不是是一個能夠輕鬆作出的決定;所以在本文中,對於子級菜單option數據的獲取將從directive自己解耦出來,由具體業務邏輯處理。
那如何實現對靈活依賴關係的支持呢?除了最多見的線性依賴之外,也應支持樹狀依賴、倒金字塔依賴甚至複雜的網狀依賴。因爲這些業務場景的存在,將依賴關係硬編碼到邏輯較爲複雜。通過權衡,組件間將經過事件進行通訊。java
需求整理以下:
* 支持在前端完成初始值回填
* 支持子集菜單選項的同步、異步獲取
* 支持菜單間靈活的依賴關係(好比線性依賴、樹狀依賴、倒金字塔依賴、網狀依賴)
* 支持菜單空值選項(option[value=""])
* 子集菜單的獲取邏輯從組件自己解耦
* 事件驅動,各級菜單在邏輯上相互獨立互不影響程序員
因爲多級聯動菜單對於AngularJS中select標籤的原有行爲侵入性較大,爲了以後編程方便,減小潛在衝突,本文將採用<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</optoin>的樸素方式,而非ngOptions。ajax
1. 首先來思考第一個問題,如何在前端進行初始值的回填
多級聯動菜單最明顯的特色是,上一級菜單更改後,下一級菜單會被(同步或異步地)從新渲染。在回填值的過程當中,咱們須要逐級回填,沒法在頁面加載時(或路由加載或組件加載等等)時瞬間完成該過程。尤爲在AngularJS中,option的渲染過程應該發生在ngModel的渲染以前,不然即便option中有對應值,也會形成找不到匹配option的狀況。
解決方案是在指令的link階段,首先保存model的初始值,並將其賦爲空值(能夠調用$setViewValue),並在渲染完成後再異步地對其賦回原值。編程
2. 如何解耦子選項獲取的具體邏輯,並同時支持同步、異步的方式
可使用scope中的"="類屬性,將一個外部函數暴露到directive的link方法中。每次在執行該方法後,判斷其是否爲promise實例(或是否有then方法),根據判斷結果決定同步或異步渲染。經過這樣的解耦,使用者就能夠在傳入的外部函數中輕鬆地決定渲染方式了。爲了使回調函數不那麼難看,咱們還能夠將同步返回也封裝爲一個帶then方法的對象。以下所示:json
// 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上聲明依賴;因爲須要支持複雜的依賴關係,應該支持一個子集菜單同時有多個依賴。這樣在任何一個所依賴的菜單變化時,咱們均可以經過以下方式進行監聽:api
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 }); } }); } }; }]);