マルチレベルの連携メニューは、州と都市の連携、大学と大学と専攻の連携など、一般的なフロントエンド コンポーネントです。シナリオは共通していますが、注意深く分析すると、汎用の無限階層リンク メニューの実装は想像ほど簡単ではない可能性があります。たとえば、サブメニューが同期的に読み込まれるか、非同期的に読み込まれるかを考慮する必要があります。初期値の埋め戻しはフロントエンドで発生しますか?それともバックエンドで発生しますか?非同期でロードされる場合、バックエンド API の戻り形式の厳密な定義はありますか?同期と非同期の共存を実現するのは簡単ですか?さまざまな依存関係を柔軟にサポートできますか?メニューに null 値のオプションはありますか? …一連の問題には慎重に対処する必要がある。
これらの要件を調べた結果、当然のことながら、AngularJS エコシステムで非常に適切なプラグインや命令が見つかりませんでした。そのため、自分で実装してみなければなりませんでした。
この記事の実装は AngularJS に基づいていますが、アイデアは一般的なものであり、他のフレームワーク ライブラリに精通している学生も読むことができます。
まず、AngularJS のレンダリングはフロントエンドで行われるため、バックエンドで既存の値に基づいてすべてのレベルのメニューのオプションを取得してレンダリングするという以前のソリューションを再整理しました。テンプレート層はあまり適切ではありません。多くの学生と同様に、私も個人的にはこの実装が好きではありません。オプションの最初のプルと初期値のバックフィルがバックエンドで完了したとしても、読み込みが完了するため、何度も実行されます。サブメニューの内容は API に依存するため、フロントエンドも onchange イベントをリッスンして Ajax インタラクションを実行する必要があります。つまり、単純な第 2 レベルのリンケージ メニューでは、フロント エンドとバック エンドの間でロジックを分割する必要があります。この方法は価値がありません。賞賛の。
同期および非同期の読み込み方法に関しては、ほとんどの場合ステップ全体が非同期ですが、オプションが少ない一部のリンケージ メニューでは、API ですべてのデータを取得し、処理し、キャッシュしてサブサーバーに提供することもできます。 -menu レンダリングに使用されます。したがって、同期レンダリング方法と非同期レンダリング方法の両方をサポートする必要があります。
API の戻り形式の問題に関しては、新しいプロジェクトに取り組んでいる場合、またはバックエンド プログラマーが需要の変化にすぐに対応できる場合、またはフロントエンドの学生自身がフルスタックである場合、この問題は発生しない可能性があります。非常に重要ですが、多くの場合、私たちがやり取りする API はプロジェクトの他の部分で使用されているため、この記事では JSON 形式を調整するのは簡単ではありません。サブメニュー オプション データはディレクティブ自体から分離され、特定のビジネス ロジックによって処理されます。
柔軟な依存関係のサポートを実装するにはどうすればよいですか?最も一般的な線形依存関係に加えて、ツリー依存関係、逆ピラミッド依存関係、さらには複雑なネットワーク依存関係もサポートする必要があります。これらのビジネス シナリオが存在するため、依存関係をロジックにハードコーディングするのは複雑です。トレードオフの後、コンポーネントはイベントを通じて通信します。
要件は次のように要約されます:
* フロントエンドでの初期値バックフィルをサポート
* サブセット メニュー オプションの同期および非同期取得をサポート
* メニュー間の柔軟な依存関係 (線形依存関係、ツリー依存関係、逆ピラミッド依存関係、メッシュ依存関係など) をサポート
* メニューの空の値オプション (option[value=""])
をサポートします。
* サブセット メニューの取得ロジックはコンポーネント自体から切り離されています
* イベント駆動型の、すべてのレベルのメニューは論理的に互いに独立しており、相互に影響しません
マルチレベルのリンク メニューは、AngularJS の select タグの本来の動作にさらに影響を与えるため、その後のプログラミングを容易にし、潜在的な競合を減らすために、この記事では項目で
1. まず最初の質問、フロントエンドで初期値を埋め戻す方法について考えてみましょう
マルチレベル連携メニューの最も明白な特徴は、上位レベルのメニューが変更された後、下位レベルのメニューが (同期または非同期で) 再レンダリングされることです。値をバックフィルするプロセスでは、段階的にバックフィルする必要がありますが、ページがロードされたとき (またはルートがロードされたとき、コンポーネントがロードされたときなど)、このプロセスは即座に完了することはできません。特に AngularJS では、オプションのレンダリング処理が ngModel のレンダリングより前に行われる必要があります。そうしないと、たとえオプションに対応する値があっても、一致するオプションが見つかりません。
解決策は、まず命令のリンクフェーズでモデルの初期値を保存し、それを null 値に割り当て ($setViewValue を呼び出すことができます)、次にレンダリングの完了後に非同期的に元の値に割り当て直します。
2. サブオプション取得の特定のロジックを分離し、同期メソッドと非同期メソッドの両方をサポートする方法
スコープ内で「=」クラス属性を使用して、外部関数をディレクティブのリンク メソッドに公開できます。このメソッドを実行するたびに、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. 支持空值选项的细节问题
对于空值的支持本来觉得是一个很简单的问题,即可,但实际编码中发现,在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 }); } }); } }; }]);