AngularJS中实现无限级联动菜单

多级联动菜单是常见的前端组件,好比省份-城市联动、高校-学院-专业联动等等。场景虽然常见,但仔细分析起来要实现一个通用的无限分级联动菜单却不必定像想象的那么简单。好比,咱们须要考虑子菜单的加载是同步的仍是异步的?对于初始值的回填发生在前端仍是后端?若是异步加载,是否对于后端API的返回格式有严格的定义?是否容易实现同步、异步共存?是否能够灵活的支持各种依赖关系?菜单中是否有空值选项?……一系列的问题都须要精心处理。
带着这些需求搜索了一圈,不太出乎意料,并无能在AngularJS的生态中找到一个很适合的插件或者指令。因而只好尝试本身实现了一个。
本文的实现基于AngularJS,可是思路通用,熟悉其余框架类库的同窗也能够阅读。javascript


首先从新梳理了一下需求,因为AngularJS的渲染发生在前端,之前在后端根据已有值获取各级菜单的option并在模板层进行渲染的方案并非很适合,并且和不少同窗同样,我我的并不喜欢这样实现方式:不少时候,即便在后端完成了第一次对option选项的拉取和对初始值的回填,但因为子级菜单的加载依赖于api,前端也须要监听onchange事件并进行ajax交互,换言之,一个简单的二级联动菜单居然须要把逻辑撕裂在前、后端,这样的方式并不值得推崇。html

关于同步、异步的加载方式,虽然大多数时候整个步骤是异步的,可是对于部分选项很少的联动菜单,也能够由一个api拉取全部数据,进行处理、缓存后供子级菜单渲染使用。所以同步、异步的渲染方式都应该支持。前端

至于api返回格式的问题,若是正在进行的是一个新的项目,或者后端程序员能够快速响应需求变更,或者前端同窗自己就是全栈,这个问题可能不那么重要;可是不少时候,咱们交互的api已经被项目的其余部分所使用,出于兼容性、稳定性的考虑,调整json的格式并不是是一个能够轻松作出的决定;所以在本文中,对于子级菜单option数据的获取将从directive自己解耦出来,由具体业务逻辑处理。
那如何实现对灵活依赖关系的支持呢?除了最多见的线性依赖之外,也应支持树状依赖、倒金字塔依赖甚至复杂的网状依赖。因为这些业务场景的存在,将依赖关系硬编码到逻辑较为复杂。通过权衡,组件间将经过事件进行通讯。java


需求整理以下:
* 支持在前端完成初始值回填
* 支持子集菜单选项的同步、异步获取
* 支持菜单间灵活的依赖关系(好比线性依赖、树状依赖、倒金字塔依赖、网状依赖)
* 支持菜单空值选项(option[value=""])
* 子集菜单的获取逻辑从组件自己解耦
* 事件驱动,各级菜单在逻辑上相互独立互不影响程序员

 

因为多级联动菜单对于AngularJS中select标签的原有行为侵入性较大,为了以后编程方便,减小潜在冲突,本文将采用<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</optoin>的朴素方式,而非ngOptions。ajax

 

1. 首先来思考第一个问题,如何在前端进行初始值的回填
多级联动菜单最明显的特色是,上一级菜单更改后,下一级菜单会被(同步或异步地)从新渲染。在回填值的过程当中,咱们须要逐级回填,没法在页面加载时(或路由加载或组件加载等等)时瞬间完成该过程。尤为在AngularJS中,option的渲染过程应该发生在ngModel的渲染以前,不然即便option中有对应值,也会形成找不到匹配option的状况。
解决方案是在指令的link阶段,首先保存model的初始值,并将其赋为空值(能够调用$setViewValue),并在渲染完成后再异步地对其赋回原值。编程

 

2. 如何解耦子选项获取的具体逻辑,并同时支持同步、异步的方式
可使用scope中的"="类属性,将一个外部函数暴露到directive的link方法中。每次在执行该方法后,判断其是否为promise实例(或是否有then方法),根据判断结果决定同步或异步渲染。经过这样的解耦,使用者就能够在传入的外部函数中轻松地决定渲染方式了。为了使回调函数不那么难看,咱们还能够将同步返回也封装为一个带then方法的对象。以下所示:json

// 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上声明依赖;因为须要支持复杂的依赖关系,应该支持一个子集菜单同时有多个依赖。这样在任何一个所依赖的菜单变化时,咱们均可以经过以下方式进行监听:api

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。这两者的关系极其微妙有意思,有兴趣的同窗能够本身研究~

以上就是编码过程当中遇到的主要问题,欢迎交流~

须要看demo的同窗能够到:

http://www.cnblogs.com/front-end-ralph/p/5133122.html

 

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
                    });
                }
            });

        }
    };
}]);
相关文章
相关标签/搜索