实例详解AngularJS实现无限级联动菜单


Posted in Javascript onJanuary 15, 2016

多级联动菜单是常见的前端组件,比如省份-城市联动、高校-学院-专业联动等等。场景虽然常见,但仔细分析起来要实现一个通用的无限分级联动菜单却不一定像想象的那么简单。比如,我们需要考虑子菜单的加载是同步的还是异步的?对于初始值的回填发生在前端还是后端?如果异步加载,是否对于后端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
});
}
});
}
};
}]);
Javascript 相关文章推荐
escape、encodeURI 和 encodeURIComponent 的区别
Mar 02 Javascript
url 特殊字符 传递参数解决方法
Jan 01 Javascript
js 异步处理进度条
Apr 01 Javascript
js 获取元素下面所有li的两种方法
Apr 14 Javascript
Express作者TJ告别Node.js奔向Go
Jul 14 Javascript
使用jQuery.wechat构建微信WEB应用
Oct 09 Javascript
jQuery中even选择器的定义和用法
Dec 23 Javascript
Jquery 整理元素选取、常用方法一览表
Nov 26 Javascript
JS 中可以提升幸福度的小技巧(可以识别更多另类写法)
Jul 28 Javascript
小程序实现单选多选功能
Nov 04 Javascript
微信小程序实现圆形进度条动画
Nov 18 Javascript
element form 校验数组每一项实例代码
Oct 10 Javascript
利用CSS3在Angular中实现动画
Jan 15 #Javascript
JavaScript程序开发之JS代码放置的位置
Jan 15 #Javascript
探讨JavaScript标签位置的存放与功能有无关系
Jan 15 #Javascript
JavaScript知识点总结之如何提高性能
Jan 15 #Javascript
jQuery动态添加及删除表单上传元素的方法(附demo源码下载)
Jan 15 #Javascript
JavaScript焦点事件、鼠标事件和滚轮事件使用详解
Jan 15 #Javascript
JavaScript提高性能知识点汇总
Jan 15 #Javascript
You might like
php.ini中文版
2006/10/09 PHP
mysql5写入和读出乱码解决
2006/11/25 PHP
解析link_mysql的php版
2013/06/30 PHP
浅谈使用PHP开发微信支付的流程
2015/10/04 PHP
Symfony2获取web目录绝对路径、相对路径、网址的方法
2016/11/14 PHP
Laravel 实现密码重置功能
2018/02/23 PHP
关于js遍历表格的实例
2013/07/10 Javascript
深入分析JSON编码格式提交表单数据
2015/06/25 Javascript
老生常谈js动态添加事件--- 事件委托
2016/07/19 Javascript
Base64(二进制)图片编码解析及在各种浏览器的兼容性处理
2017/02/09 Javascript
vuejs通过filterBy、orderBy实现搜索筛选、降序排序数据
2020/10/26 Javascript
原生JS实现跑马灯效果
2017/02/20 Javascript
对vue v-if v-else-if v-else 的简单使用详解
2018/09/29 Javascript
使用jquery的cookie实现登录页记住用户名和密码的方法
2019/03/13 jQuery
[02:47]DOTA2亚洲邀请赛 HR战队出场宣传片
2015/02/07 DOTA
[12:29]2018国际邀请赛 开幕秀
2018/08/22 DOTA
Python中列表的一些基本操作知识汇总
2015/05/20 Python
Python Multiprocessing多进程 使用tqdm显示进度条的实现
2019/08/13 Python
python处理excel绘制雷达图
2019/10/18 Python
如何将你的应用迁移到Python3的三个步骤
2019/12/22 Python
python logging 日志的级别调整方式
2020/02/21 Python
解决keras,val_categorical_accuracy:,0.0000e+00问题
2020/07/02 Python
python3实现简单飞机大战
2020/11/29 Python
css3遮罩层镂空效果的多种实现方法
2020/05/11 HTML / CSS
css 如何让背景图片拉伸填充避免重复显示
2013/07/11 HTML / CSS
Hotter Shoes英国官网:英伦风格,舒适的鞋子
2017/12/28 全球购物
美国学校用品、教室和教学商店:Discount School Supply
2018/04/04 全球购物
新学期红领巾广播稿
2014/01/14 职场文书
企业管理标语
2014/06/10 职场文书
勤俭节约倡议书范文
2015/04/29 职场文书
元旦联欢晚会主持词
2015/07/01 职场文书
演讲稿:态度决定一切
2019/04/02 职场文书
基于Redis位图实现用户签到功能
2021/05/08 Redis
nginx的zabbix 5.0安装部署的方法步骤
2021/07/16 Servers
提高系统的吞吐量解决数据库重复写入问题
2022/04/23 MySQL
微信小程序实现轮播图指示器
2022/06/25 Javascript