實(shí)例詳解AngularJS實(shí)現(xiàn)無限級(jí)聯(lián)動(dòng)菜單
多級(jí)聯(lián)動(dòng)菜單是常見的前端組件,比如省份-城市聯(lián)動(dòng)、高校-學(xué)院-專業(yè)聯(lián)動(dòng)等等。場景雖然常見,但仔細(xì)分析起來要實(shí)現(xiàn)一個(gè)通用的無限分級(jí)聯(lián)動(dòng)菜單卻不一定像想象的那么簡單。比如,我們需要考慮子菜單的加載是同步的還是異步的?對(duì)于初始值的回填發(fā)生在前端還是后端?如果異步加載,是否對(duì)于后端API的返回格式有嚴(yán)格的定義?是否容易實(shí)現(xiàn)同步、異步共存?是否可以靈活的支持各類依賴關(guān)系?菜單中是否有空值選項(xiàng)?……一系列的問題都需要精心處理。
帶著這些需求搜索了一圈,不太出乎意料,并沒有能在AngularJS的生態(tài)中找到一個(gè)很適合的插件或者指令。于是只好嘗試自己實(shí)現(xiàn)了一個(gè)。
本文的實(shí)現(xiàn)基于AngularJS,但是思路通用,熟悉其他框架類庫的同學(xué)也可以閱讀。
首先重新梳理了一下需求,由于AngularJS的渲染發(fā)生在前端,以前在后端根據(jù)已有值獲取各級(jí)菜單的option并在模板層進(jìn)行渲染的方案并不是很適合,而且和很多同學(xué)一樣,我個(gè)人并不喜歡這樣實(shí)現(xiàn)方式:很多時(shí)候,即使在后端完成了第一次對(duì)option選項(xiàng)的拉取和對(duì)初始值的回填,但由于子級(jí)菜單的加載依賴于api,前端也需要監(jiān)聽onchange事件并進(jìn)行ajax交互,換言之,一個(gè)簡單的二級(jí)聯(lián)動(dòng)菜單竟然需要把邏輯撕裂在前、后端,這樣的方式并不值得推崇。
關(guān)于同步、異步的加載方式,雖然大多數(shù)時(shí)候整個(gè)步驟是異步的,但是對(duì)于部分選項(xiàng)不多的聯(lián)動(dòng)菜單,也可以由一個(gè)api拉取所有數(shù)據(jù),進(jìn)行處理、緩存后供子級(jí)菜單渲染使用。因此同步、異步的渲染方式都應(yīng)該支持。
至于api返回格式的問題,如果正在進(jìn)行的是一個(gè)新的項(xiàng)目,或者后端程序員可以快速響應(yīng)需求變動(dòng),或者前端同學(xué)本身就是全棧,這個(gè)問題可能不那么重要;但是很多時(shí)候,我們交互的api已經(jīng)被項(xiàng)目的其他部分所使用,出于兼容性、穩(wěn)定性的考慮,調(diào)整json的格式并非是一個(gè)可以輕松做出的決定;因此在本文中,對(duì)于子級(jí)菜單option數(shù)據(jù)的獲取將從directive本身解耦出來,由具體業(yè)務(wù)邏輯處理。
那如何實(shí)現(xiàn)對(duì)靈活依賴關(guān)系的支持呢?除了最常見的線性依賴以外,也應(yīng)支持樹狀依賴、倒金字塔依賴甚至復(fù)雜的網(wǎng)狀依賴。由于這些業(yè)務(wù)場景的存在,將依賴關(guān)系硬編碼到邏輯較為復(fù)雜。經(jīng)過權(quán)衡,組件間將通過事件進(jìn)行通信。
需求整理如下:
* 支持在前端完成初始值回填
* 支持子集菜單選項(xiàng)的同步、異步獲取
* 支持菜單間靈活的依賴關(guān)系(比如線性依賴、樹狀依賴、倒金字塔依賴、網(wǎng)狀依賴)
* 支持菜單空值選項(xiàng)(option[value=""])
* 子集菜單的獲取邏輯從組件本身解耦
* 事件驅(qū)動(dòng),各級(jí)菜單在邏輯上相互獨(dú)立互不影響
由于多級(jí)聯(lián)動(dòng)菜單對(duì)于AngularJS中select標(biāo)簽的原有行為侵入性較大,為了之后編程方便,減少潛在沖突,本文將采用<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</optoin>的樸素方式,而非ngOptions。
1. 首先來思考第一個(gè)問題,如何在前端進(jìn)行初始值的回填
多級(jí)聯(lián)動(dòng)菜單最明顯的特點(diǎn)是,上一級(jí)菜單更改后,下一級(jí)菜單會(huì)被(同步或異步地)重新渲染。在回填值的過程中,我們需要逐級(jí)回填,無法在頁面加載時(shí)(或路由加載或組件加載等等)時(shí)瞬間完成該過程。尤其在AngularJS中,option的渲染過程應(yīng)該發(fā)生在ngModel的渲染之前,否則即使option中有對(duì)應(yīng)值,也會(huì)造成找不到匹配option的情況。
解決方案是在指令的link階段,首先保存model的初始值,并將其賦為空值(可以調(diào)用$setViewValue),并在渲染完成后再異步地對(duì)其賦回原值。
2. 如何解耦子選項(xiàng)獲取的具體邏輯,并同時(shí)支持同步、異步的方式
可以使用scope中的"="類屬性,將一個(gè)外部函數(shù)暴露到directive的link方法中。每次在執(zhí)行該方法后,判斷其是否為promise實(shí)例(或是否有then方法),根據(jù)判斷結(jié)果決定同步或異步渲染。通過這樣的解耦,使用者就可以在傳入的外部函數(shù)中輕松地決定渲染方式了。為了使回調(diào)函數(shù)不那么難看,我們還可以將同步返回也封裝為一個(gè)帶then方法的對(duì)象。如下所示:
// scope.source為外部函數(shù) 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) { // 對(duì)同步或異步返回的數(shù)據(jù)進(jìn)行統(tǒng)一處理 }
3. 如何實(shí)現(xiàn)菜單間基于事件的通信
大體上還是通過訂閱者模式實(shí)現(xiàn),需要在directive上聲明依賴;由于需要支持復(fù)雜的依賴關(guān)系,應(yīng)該支持一個(gè)子集菜單同時(shí)有多個(gè)依賴。這樣在任何一個(gè)所依賴的菜單變化時(shí),我們都可以通過如下方式進(jìn)行監(jiān)聽:
scope.$on('selectUpdate', function (e, data) { // data.name是變化的菜單,dependents是當(dāng)前菜單所聲明的依賴數(shù)組 if ($.inArray(data.name, dependents) >= 0) { onParentChange(); } }); // 并且為了方便上文提到的source函數(shù)對(duì)于變動(dòng)值的調(diào)用,可以對(duì)所依賴的菜單進(jìn)行遍歷并保存當(dāng)前值 var values = {}; if (dependents) { $.each(dependents, function (index, dependent) { values[dependent] = selects[dependent].getValue(); }); }
4. 處理兩類過期問題
容易想到的是異步過期的問題:設(shè)想第一級(jí)菜單發(fā)生變化,觸發(fā)對(duì)第二級(jí)菜單內(nèi)容的拉取,但網(wǎng)速較慢,該過程需要3秒。1秒后用戶再次改變第一級(jí)菜單,再次觸發(fā)對(duì)第二級(jí)菜單內(nèi)容的拉取,此時(shí)網(wǎng)速較快,1秒后數(shù)據(jù)返回,第二級(jí)菜單重新渲染;但是1秒后,第一次請(qǐng)求的結(jié)果返回,第二級(jí)菜單再次被渲染,但事實(shí)上第一級(jí)菜單此后已經(jīng)發(fā)生過變化,內(nèi)容已經(jīng)過期,此次渲染是錯(cuò)誤的。我們可以用閉包進(jìn)行數(shù)據(jù)過期校驗(yàn)。
不容易想到的是同步過期(其實(shí)也是異步,只是未經(jīng)io交互,都是緩沖時(shí)間為0的timeout函數(shù))的問題,即由于事件隊(duì)列的存在,稍不謹(jǐn)慎就可能出現(xiàn)過期,代碼中會(huì)有相關(guān)注釋。
5. 支持空值選項(xiàng)的細(xì)節(jié)問題
對(duì)于空值的支持本來覺得是一個(gè)很簡單的問題,<option value="" ng-if="empty">{{empty}}</option>即可,但實(shí)際編碼中發(fā)現(xiàn),在directive的link中,由于此option的link過程并未開始,option標(biāo)簽被實(shí)際上移除,只剩下相關(guān)注釋占位。AngularJS認(rèn)為該select不含有空值選項(xiàng),于是報(bào)錯(cuò)。解決方案是棄用ng-if,使用ng-show。這二者的關(guān)系極其微妙有意思,有興趣的同學(xué)可以自己研究~
以上就是編碼過程中遇到的主要問題,歡迎交流~
directive('multiLevelSelect', ['$parse', '$timeout', function ($parse, $timeout) { // 利用閉包,保存父級(jí)scope中的所有多級(jí)聯(lián)動(dòng)菜單,便于取值 var selects = {}; return { restrict: 'CA', scope: { // 用于依賴聲明時(shí)指定父級(jí)標(biāo)簽 name: '@name', // 依賴數(shù)組,逗號(hào)分割 dependents: '@dependents', // 提供具體option值的函數(shù),在父級(jí)change時(shí)被調(diào)用,允許同步/異步的返回結(jié)果 // 無論同步還是異步,數(shù)據(jù)應(yīng)該是[{text: 'text', value: 'value'},]的結(jié)構(gòu) source: '=source', // 是否支持控制選項(xiàng),如果是,空值的標(biāo)簽是什么 empty: '@empty', // 用于parse解析獲取model值(而非viewValue值) modelName: '@ngModel' }, template: '' // 使用ng-show而非ng-if,原因上文已經(jīng)提到 + '<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); // 將當(dāng)前菜單的getValue函數(shù)封裝起來,放在閉包中的selects對(duì)象中方便調(diào)用 selects[scope.name] = { getValue: function () { return $parse(scope.modelName)(parentScope); } }; // 保存初始值,原因上文已經(jīng)提到 var initValue = selects[scope.name].getValue(); var inited = !initValue; model.$setViewValue(''); // 父級(jí)標(biāo)簽變化時(shí)被調(diào)用的回調(diào)函數(shù) function onParentChange() { var values = {}; // 獲取所有依賴的菜單的當(dāng)前值 if (dependents) { $.each(dependents, function (index, dependent) { values[dependent] = selects[dependent].getValue(); }); } // 利用閉包判斷io造成的異步過期 (function (thenValues) { // 調(diào)用source函數(shù),取新的option數(shù)據(jù) var returned = scope.source ? scope.source(values) : false; // 利用多層閉包,將同步結(jié)果包裝為有then方法的對(duì)象 !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 () { // 防止由同步(嚴(yán)格的說也是異步,注意事件隊(duì)列)造成的過期 if (scope.items !== items) return; // 如果有空值,選擇空值,否則選擇第一個(gè)選項(xiàng) if (scope.empty) { model.$setViewValue(''); } else { model.$setViewValue(scope.items[0].value); } // 判斷恢復(fù)初始值的條件是否成熟 var initValueIncluded = !inited && (function () { for (var i = 0; i < scope.items.length; i++) { if (scope.items[i].value === initValue) { return true; } } return false; })(); // 恢復(fù)初始值 if (initValueIncluded) { inited = true; model.$setViewValue(initValue); } model.$render(); }); }); })(values); } // 是否有依賴,如果沒有,直接觸發(fā)onParentChange以還原初始值 !dependents ? onParentChange() : scope.$on('selectUpdate', function (e, data) { if ($.inArray(data.name, dependents) >= 0) { onParentChange(); } }); // 對(duì)當(dāng)前值進(jìn)行監(jiān)聽,發(fā)生變化時(shí)對(duì)其進(jìn)行廣播 parentScope.$watch(scope.modelName, function (newValue, oldValue) { if (newValue || '' !== oldValue || '') { scope.$root.$broadcast('selectUpdate', { // 將變動(dòng)的菜單的name屬性廣播出去,便于依賴于它的菜單進(jìn)行識(shí)別 name: scope.name }); } }); } }; }]);
- js實(shí)現(xiàn)的全國省市二級(jí)聯(lián)動(dòng)下拉選擇菜單完整實(shí)例
- JS實(shí)多級(jí)聯(lián)動(dòng)下拉菜單類,簡單實(shí)現(xiàn)省市區(qū)聯(lián)動(dòng)菜單!
- javascript實(shí)現(xiàn)省市區(qū)三級(jí)聯(lián)動(dòng)下拉框菜單
- jsp從數(shù)據(jù)庫獲取數(shù)據(jù)填充下拉框?qū)崿F(xiàn)二級(jí)聯(lián)動(dòng)菜單的方法
- 省市區(qū)三級(jí)聯(lián)動(dòng)下拉框菜單javascript版
- js實(shí)現(xiàn)簡單的聯(lián)動(dòng)菜單效果
- js實(shí)現(xiàn)select二級(jí)聯(lián)動(dòng)下拉菜單
- JS簡單實(shí)現(xiàn)多級(jí)Select聯(lián)動(dòng)菜單效果代碼
- 全國省市二級(jí)聯(lián)動(dòng)下拉菜單 js版
- JavaScript+node實(shí)現(xiàn)三級(jí)聯(lián)動(dòng)菜單
相關(guān)文章
Angular實(shí)現(xiàn)雙向折疊列表組件的示例代碼
本篇文章主要介紹了Angular實(shí)現(xiàn)雙向折疊列表組件的示例代碼,分為左右兩組,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-11-11Angular.js中上傳指令ng-upload的基本使用教程
這篇文章主要給大家介紹了關(guān)于Angular.js中上傳指令ng-upload的基本使用方法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面跟著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-07-07AngularJS基礎(chǔ) ng-include 指令簡單示例
本文主要介紹AngularJS ng-include 指令,這里對(duì)ng-include的基本知識(shí)做了整理,并附有代碼實(shí)例,有需要的朋友可以參考下2016-08-08AngularJS基于ngInfiniteScroll實(shí)現(xiàn)下拉滾動(dòng)加載的方法
這篇文章主要介紹了AngularJS基于ngInfiniteScroll實(shí)現(xiàn)下拉滾動(dòng)加載的方法,結(jié)合實(shí)例形式分析AngularJS下拉滾動(dòng)插件ngInfiniteScroll的下載、功能、屬性及相關(guān)使用方法,需要的朋友可以參考下2016-12-12angularjs創(chuàng)建彈出框?qū)崿F(xiàn)拖動(dòng)效果
這篇文章主要為大家詳細(xì)介紹了angularjs創(chuàng)建彈出框?qū)崿F(xiàn)拖動(dòng)效果的相關(guān)資料,angularjs modal模態(tài)框創(chuàng)建可拖動(dòng)的指令,感興趣的小伙伴們可以參考一下2016-01-01