AngularJS无限级联动菜单实现:同步异步解耦与事件通信

作者:白露时间:2026-10-29 20:32:43浏览:115

本文详解如何在AngularJS中实现通用无限级联动菜单。针对前端回填、同步异步混合加载及复杂依赖关系,提出通过指令解耦获取逻辑,利用闭包处理异步过期,并采用事件驱动机制实现菜单间灵活通信,支持线性、树状及网状依赖。

AngularJS无限级联动菜单的需求与挑战

多级联动菜单是常见的前端组件,例如省份与城市的级联、高校与专业的级联等。尽管场景常见,但要实现一个通用的无限分级联动菜单并非易事。需要深入考虑子菜单加载是同步还是异步、初始值回填在前端还是后端进行、异步加载时后端API返回格式是否有严格定义、是否支持同步与异步共存、能否灵活支持各类依赖关系以及菜单中是否包含空值选项等问题。这些细节都需要精心处理。

基于上述需求进行搜索,并未在AngularJS生态中找到合适的现成插件或指令,因此决定自行实现该功能。

本方案基于AngularJS构建,但其设计思路具有通用性,熟悉其他框架的开发者亦可参考。

首先重新梳理需求。由于AngularJS的渲染在前端进行,以往在后端根据已有值获取各级菜单option并在模板层渲染的方案并不理想。此外,许多开发者也不推崇这种方式:即便后端完成了首次option选项拉取和初始值回填,由于子级菜单加载依赖API,前端仍需监听onchange事件并进行Ajax交互。这意味着简单的二级联动也需要将逻辑割裂在前后端,这种做法并不值得推荐。

关于加载方式,虽然多数情况下步骤是异步的,但对于选项较少的联动菜单,也可由一个API拉取所有数据,经处理和缓存后供子级菜单渲染。因此,方案需同时支持同步和异步渲染方式。

关于API返回格式,若为全新项目或后端能快速响应需求变更,或前端开发者具备全栈能力,此问题或许次要。但在多项目交互场景中,API已被其他部分使用,出于兼容性和稳定性考虑,调整JSON格式并非易事。因此,本文方案将子级菜单option数据的获取逻辑从指令本身解耦,交由具体业务逻辑处理。

如何支持灵活的依赖关系?除常见的线性依赖外,还需支持树状依赖、倒金字塔依赖甚至复杂的网状依赖。由于业务场景复杂,将依赖关系硬编码到逻辑中较为困难。经权衡,组件间将通过事件进行通信。

整理后的具体需求如下:

* 支持在前端完成初始值回填 * 支持子级菜单选项的同步、异步获取 * 支持菜单间灵活的依赖关系(如线性依赖、树状依赖、倒金字塔依赖、网状依赖) * 支持菜单空值选项(option[value=""]) * 子级菜单的获取逻辑从组件本身解耦 * 事件驱动,各级菜单在逻辑上相互独立且互不影响

由于多级联动菜单对AngularJS中select标签原有行为侵入性较大,为便于后续编程并减少潜在冲突,本文采用<option ng-repeat="item in items" value="{{item.value}}">{{item.text}}</option>的朴素方式,而非使用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
});
}
});
}
};
}]);

作者最新文章