多級連動選單是常見的前端組件,例如省份-城市連結、大學-學院-專業連結等等。場景雖然常見,但仔細分析起來要實現一個通用的無限分級連動選單卻不一定像想像的那麼簡單。例如,我們需要考慮子選單的載入是同步的還是非同步的?對於初始值的回填發生在前端還是後端?如果非同步加載,是否對於後端API的返回格式有嚴格的定義?是否容易實現同步、非同步共存?是否可以靈活的支援各類依賴關係?選單中是否有空值選項? ……一系列的問題都需要精心處理。
帶著這些需求搜尋了一圈,不太出乎意料,並沒有能在AngularJS的生態中找到一個很適合的插件或指令。於是只好嘗試自己實現了一個。
本文的實作是基於AngularJS,但是思路通用,熟悉其他框架類別庫的同學也可以閱讀。
首先重新梳理了一下需求,由於AngularJS的渲染發生在前端,以前在後端根據已有值獲取各級菜單的option並在模板層進行渲染的方案並不是很適合,而且和很多同學一樣,我個人並不喜歡這樣實現方式:很多時候,即使在後端完成了第一次對option選項的拉取和對初始值的回填,但由於子級菜單的加載依賴於api,前端也需要監聽onchange事件並進行ajax交互,換言之,一個簡單的二級聯動菜單竟然需要把邏輯撕裂在前、後端,這樣的方式並不值得推崇。
關於同步、非同步的載入方式,雖然大多數時候整個步驟是異步的,但是對於部分選項不多的聯動選單,也可以由一個api拉取所有數據,進行處理、快取後供子級菜單渲染使用。因此同步、非同步的渲染方式都應該支援。
至於api返回格式的問題,如果正在進行的是一個新的項目,或者後端程式設計師可以快速響應需求變動,或者前端同學本身就是全棧,這個問題可能不那麼重要;但是很多時候,我們互動的api已經被專案的其他部分所使用,出於相容性、穩定性的考慮,調整json的格式並非是一個可以輕鬆做出的決定;因此在本文中,對於子級菜單option資料的獲取將從directive本身解耦出來,由具體業務邏輯處理。
那如何實現對靈活依賴關係的支持呢?除了最常見的線性依賴以外,也應支援樹狀依賴、倒金字塔依賴甚至複雜的網狀依賴。由於這些業務場景的存在,將依賴關係硬編碼到邏輯較為複雜。經過權衡,組件間將透過事件進行通訊。
需求整理如下:
* 支援在前端完成初始值回填
* 支援子集選單選項的同步、非同步取得
* 支援選單間靈活的依賴關係(如線性依賴、樹狀依賴、倒金字塔依賴、網狀依賴)
* 支援選單空值選項(option[value=""])
* 子集選單的取得邏輯從元件本身解耦
* 事件驅動,各級選單在邏輯上相互獨立不影響
由於多級連動選單對於AngularJS中select標籤的原有行為侵入性較大,為了之後編程方便,減少潛在衝突,本文將採用
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. 支持空值选项的细节问题
对于空值的支持本来觉得是一个很简单的问题,accd74be04ce4d5f8e4a628a199fee5e{{empty}}4afa15d3069109ac30911f04c56f3338即可,但实际编码中发现,在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 }); } }); } }; }]);