实例详解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 相关文章推荐
jQuery 名称冲突的解决方法
Apr 08 Javascript
jquery对dom的操作常用方法整理
Jun 25 Javascript
js加入收藏以及使用Jquery更改透明度
Jan 26 Javascript
Jquery代码实现图片轮播效果(一)
Aug 12 Javascript
javascript验证手机号和实现星号(*)代替实例
Aug 16 Javascript
理解AngularJs篇:30分钟快速掌握AngularJs
Dec 23 Javascript
JavaScript原生节点操作小结
Jan 17 Javascript
node操作mysql数据库实例详解
Mar 17 Javascript
React Native第三方平台分享的实例(Android,IOS双平台)
Aug 04 Javascript
利用nvm管理多个版本的node.js与npm详解
Nov 02 Javascript
JS document对象简单用法完整示例
Jan 14 Javascript
详解实现vue的数据响应式原理
Jan 20 Vue.js
利用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新手上路(十三)
2006/10/09 PHP
php 批量生成html,txt文件的实现代码
2013/06/26 PHP
PHP中include/require/include_once/require_once使用心得
2016/08/28 PHP
Javascript 函数中的参数使用分析
2010/03/27 Javascript
html组件不可输入(只读)同时任何组件都有效
2013/04/01 Javascript
jQuery之选项卡的简单实现
2014/02/28 Javascript
jquery中ajax函数执行顺序问题之如何设置同步
2014/02/28 Javascript
使用javascript实现json数据以csv格式下载
2015/01/09 Javascript
基于BootStrap Metronic开发框架经验小结【七】数据的导入、导出及附件的查看处理
2016/05/12 Javascript
Google 地图API Map()构造器详解
2016/08/06 Javascript
js判断价格,必须为数字且不能为负数的实现方法
2016/10/07 Javascript
js输入框使用正则表达式校验输入内容的实例
2017/02/12 Javascript
如何使node也支持从url加载一个module详解
2018/06/05 Javascript
详解vue.js根据不同环境(正式、测试)打包到不同目录
2018/07/13 Javascript
vue中的router-view组件的使用教程
2018/10/23 Javascript
vue input实现点击按钮文字增删功能示例
2019/01/29 Javascript
微信小程序云开发 生成带参小程序码流程
2019/05/18 Javascript
vue-loader中引入模板预处理器的实现
2019/09/04 Javascript
[01:00:04]DOTA2上海特级锦标赛B组小组赛#1 Alliance VS Spirit第二局
2016/02/26 DOTA
[33:42]LGD vs OG 2018国际邀请赛小组赛BO2 第一场 8.16
2018/08/17 DOTA
Django查找网站项目根目录和对正则表达式的支持
2015/07/15 Python
Python入门_浅谈for循环、while循环
2017/05/16 Python
python实现稀疏矩阵示例代码
2017/06/09 Python
Python实现获取nginx服务器ip及流量统计信息功能示例
2018/05/18 Python
儿童学习python的一些小技巧
2018/05/27 Python
python实现抠图给证件照换背景源码
2019/08/20 Python
pytorch的batch normalize使用详解
2020/01/15 Python
Python开发企业微信机器人每天定时发消息实例
2020/03/17 Python
阿迪达斯比利时官方商城:adidas比利时
2016/10/10 全球购物
婚庆公司的创业计划书
2014/01/22 职场文书
展会邀请函范文
2014/01/26 职场文书
大学班级干部的自我评价分享
2014/02/10 职场文书
先进教育工作者事迹材料
2014/12/23 职场文书
结婚典礼致辞
2015/07/28 职场文书
《彼得与狼》教学反思
2016/02/20 职场文书
关于企业的执行力标语大全
2020/01/06 职场文书