多級聯動菜單是常見的前端組件,比如省份-城市聯動、高校-學院-專業聯動等等。情境雖然常見,但仔細分析起來要實現一個通用的無限分級聯動菜單卻不一定像想象的那麼簡單。比如,我們需要考慮子功能表的載入是同步的還是非同步?對於初始值的回填發生在前端還是後端?如果非同步載入,是否對於後端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。這二者的關係極其微妙有意思,有興趣的同學可以自己研究~
以上就是編碼過程中遇到的主要問題,歡迎交流~
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});}});}};}]);