实例详解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 相关文章推荐
Js+XML 操作
Sep 20 Javascript
asp javascript 实现关闭窗口时保存数据的办法
Nov 24 Javascript
为jquery.ui.dialog 增加“自动记住关闭时的位置”的功能
Nov 24 Javascript
js获取控件位置以及不同浏览器中的差别介绍
Aug 08 Javascript
JS中的this变量的使用介绍
Oct 21 Javascript
AngularJs directive详解及示例代码
Sep 01 Javascript
bootstrap提示标签、提示框实现代码
Dec 28 Javascript
node.js发送邮件email的方法详解
Jan 06 Javascript
JS实现图片转换成base64的各种应用场景实例分析
Jun 22 Javascript
vscode 开发Vue项目的方法步骤
Nov 25 Javascript
vue-socket.io接收不到数据问题的解决方法
May 13 Javascript
vue中实现拖动调整左右两侧div的宽度的示例代码
Jul 22 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
弄了个检测传输的参数是否为数字的Function
2006/12/06 PHP
洪恩在线成语词典小偷程序php版
2012/04/20 PHP
PHP常用字符串操作函数实例总结(trim、nl2br、addcslashes、uudecode、md5等)
2016/01/09 PHP
通过实例解析PHP数据类型转换方法
2020/07/11 PHP
js 实现css风格选择器(压缩后2KB)
2012/01/12 Javascript
chrome下img加载对height()的影响示例探讨
2014/05/26 Javascript
jQuery插件slicebox实现3D动画图片轮播切换特效
2015/04/12 Javascript
详解AngularJS中自定义指令的使用
2015/06/17 Javascript
JavaScript实现添加及删除事件的方法小结
2015/08/04 Javascript
AngularJS模块学习之Anchor Scroll
2016/01/19 Javascript
jQuery弹出遮罩层效果完整示例
2016/09/13 Javascript
bootstrap下拉菜单使用方法解析
2017/01/13 Javascript
微信小程序图片宽100%显示并且不变形
2017/06/21 Javascript
浅谈FastClick 填坑及源码解析
2018/03/02 Javascript
JavaScript实现微信号随机切换代码
2018/03/09 Javascript
js实现按钮开关单机下拉菜单效果
2018/11/22 Javascript
Layer组件多个iframe弹出层打开与关闭及参数传递的方法
2019/09/25 Javascript
vue实现鼠标经过动画
2019/10/16 Javascript
原生JS实现汇率转换功能代码实例
2020/05/13 Javascript
2分钟实现一个Vue实时直播系统的示例代码
2020/06/05 Javascript
微信小程序实现滚动Tab选项卡
2020/11/16 Javascript
python读取Android permission文件
2013/11/01 Python
Django自定义分页与bootstrap分页结合
2021/02/22 Python
selenium+python 去除启动的黑色cmd窗口方法
2018/05/22 Python
python 统计一个列表当中的每一个元素出现了多少次的方法
2018/11/14 Python
详解Python 调用C# dll库最简方法
2019/06/20 Python
Django-silk性能测试工具安装及使用解析
2020/11/28 Python
电大毕业生自我鉴定
2013/11/10 职场文书
校园招聘策划书
2014/01/09 职场文书
社区工作者思想汇报
2014/01/13 职场文书
中学教师管理制度
2014/01/14 职场文书
农村改厕实施方案
2014/03/22 职场文书
计划生育个人总结
2015/03/02 职场文书
护理专业自我评价
2015/03/11 职场文书
公司副总经理岗位职责
2015/04/08 职场文书
面试必问:圣杯布局和双飞翼布局的区别
2021/05/13 HTML / CSS