关于同步、异步的加载方式,虽然大多数时候整个步骤是异步的,但是对于部分选项不多的联动菜单,也可以由一个api拉取所有数据,进行处理、缓存后供子级菜单渲染使用。因此同步、异步的渲染方式都应该支持。
至于api返回格式的问题,如果正在进行的是一个新的项目,或者后端程序员可以快速响应需求变动,或者前端同学本身就是全栈,这个问题可能不那么重要;但是很多时候,我们交互的api已经被项目的其他部分所使用,出于兼容性、稳定性的考虑,调整json的格式并非是一个可以轻松做出的决定;因此在本文中,对于子级菜单option数据的获取将从directive本身解耦出来,由具体业务逻辑处理。
那如何实现对灵活依赖关系的支持呢?除了最常见的线性依赖以外,也应支持树状依赖、倒金字塔依赖甚至复杂的网状依赖。由于这些业务场景的存在,将依赖关系硬编码到逻辑较为复杂。经过权衡,组件间将通过事件进行通信。
需求整理如下:
* 支持在前端完成初始值回填
* 支持子集菜单选项的同步、异步获取
* 支持菜单间灵活的依赖关系(比如线性依赖、树状依赖、倒金字塔依赖、网状依赖)
* 支持菜单空值选项(option[value=””])
* 子集菜单的获取逻辑从组件本身解耦
* 事件驱动,各级菜单在逻辑上相互独立互不影响
由于多级联动菜单对于AngularJS中select标签的原有行为侵入性较大,为了之后编程方便,减少潜在冲突,本文将采用{{item.text}}的朴素方式,而非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. 支持空值选项的细节问题
对于空值的支持本来觉得是一个很简单的问题,{{empty}}即可,但实际编码中发现,在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,原因上文已经提到+ '{{empty}}'// 使用朴素的ng-repeat+ '{{item.text}}',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 = 0) {onParentChange();}});// 对当前值进行监听,发生变化时对其进行广播parentScope.$watch(scope.modelName, function (newValue, oldValue) {if (newValue || '' !== oldValue || '') {scope.$root.$broadcast('selectUpdate', {// 将变动的菜单的name属性广播出去,便于依赖于它的菜单进行识别name: scope.name});}});}};}]);