实例详解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综合应用实例简单的表格统计
Sep 03 Javascript
JS自调用匿名函数具体实现
Feb 11 Javascript
当滚动条滚动到页面底部自动加载增加内容的js代码
May 13 Javascript
浅谈JavaScript中Date(日期对象),Math对象
Feb 05 Javascript
轻松学习jQuery插件EasyUI EasyUI实现树形网络基本操作(2)
Nov 30 Javascript
javascript简单写的判断电话号码实例
May 24 Javascript
js微信分享实现代码
Oct 11 Javascript
js实现鼠标跟随运动效果
Aug 02 Javascript
JS获取浏览器地址栏的多个参数值的任意值实例代码
Jul 24 Javascript
JS实现获取自定义属性data值的方法示例
Dec 19 Javascript
element-ui中Table表格省市区合并单元格的方法实现
Aug 07 Javascript
在vue中created、mounted等方法使用小结
Jul 21 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
Dedecms V3.1 生成HTML速度的优化办法
2007/03/18 PHP
php下通过curl抓取yahoo boss 搜索结果的实现代码
2011/06/10 PHP
php笔记之:初探PHPcms模块开发介绍
2013/04/26 PHP
php上传文件常见问题总结
2015/02/03 PHP
PHP实现在线阅读PDF文件的方法
2015/06/17 PHP
php数组生成html下拉列表的方法
2015/07/20 PHP
Zend Framework动作助手(Zend_Controller_Action_Helper)用法详解
2016/03/05 PHP
php版微信小店调用api示例代码
2016/11/12 PHP
tp5修改(实现即点即改)
2019/10/18 PHP
laravel返回统一格式错误码问题
2019/11/04 PHP
JQuery入门——移除绑定事件unbind方法概述及应用
2013/02/05 Javascript
jquery中的事件处理详细介绍
2013/06/24 Javascript
Visual Studio中js调试的方法图解
2014/06/30 Javascript
jQuery中is()方法用法实例
2015/01/06 Javascript
jQuery下拉友情链接美化效果代码分享
2015/08/26 Javascript
jQuery给元素添加样式的方法详解
2015/12/30 Javascript
JS中Eval解析JSON字符串的一个小问题
2016/02/21 Javascript
详述JavaScript实现继承的几种方式(推荐)
2016/03/22 Javascript
Javascript中常用的检测方法小结
2016/10/08 Javascript
JavaScript实现选中文字提示新浪微博分享效果
2017/06/15 Javascript
微信小程序之圆形进度条实现思路
2018/02/22 Javascript
详解JavaScript的BUG和错误
2018/05/07 Javascript
浅谈Vue.set实际上是什么
2019/10/17 Javascript
Vue基本指令实例图文讲解
2021/02/25 Vue.js
python读取json文件并将数据插入到mongodb的方法
2015/03/23 Python
Python RabbitMQ消息队列实现rpc
2018/05/30 Python
python 删除字符串中连续多个空格并保留一个的方法
2018/12/22 Python
python Scrapy爬虫框架的使用
2021/01/21 Python
html5定制表单_动力节点Java学院整理
2017/07/11 HTML / CSS
一站式跨境收款解决方案:Payoneer(派安盈)
2018/09/06 全球购物
自我评价范文
2013/12/22 职场文书
实习公司领导推荐函
2014/05/21 职场文书
小区门卫岗位职责范本
2014/08/24 职场文书
应收账款管理制度
2015/08/06 职场文书
小学三年级数学教学反思
2016/02/16 职场文书
教你漂亮打印Pandas DataFrames和Series
2021/05/29 Python