某スクールにおいて、チーム開発で、フリーマーケットアプリを作成中であり、使用した技術について公開しています。
※初学者のため、ミスや認識違いが多々あると思いますがご了承ください。
ヘッダーから、ドロップダウンリストを用いてカテゴリーを選択できるように実装した。
動作イメージ
前提条件
ancestryにより、カテゴリーの親から孫まで紐づいている。
実装要件
- サーバー
1. クリックしたカテゴリーに紐づく商品を一覧表示する。
2. 親カテゴリー、もしくは子カテゴリーがクリックされたら、紐づく子カテゴリ、孫カテゴリーの商品も全て一覧表示する。
- マークアップ
1. ヘッダーのカテゴリーにマウスホバーすると親カテゴリー一覧が表示される。
2. リストのうちマウスホバーしているカテゴリーに紐づく、子カテゴリーが右側に表示される。(子カテゴリーは孫が)
3. マウスホバーしているカテゴリは赤くなる。
4. マウスホバーが外れても、赤く表示されたままである。
5. カテゴリーからマウスが外れたら全て非表示となる。
実装方針
本機能を実装するあたり、大まかに以下の2つの方法を検討しました。
(便宜上、一番左のリストを親、その右を子、さらにその左のリストを孫と表現します)
- 各々のリンク先を記載した全てのカテゴリーのhtmlを予め、重ねて作成し、display:noneしておき、親が選択された場合、jqueryにて、紐づく子カテゴリーをdisplay:blockし、表示させる。
- ホバーされた親に紐づく子カテゴリーを、非同期でデーターベースから引っ張ってきて、子カテゴリーを組み立てて表示する。
検討した結果、 「2」 を採用することにしました。おそらく、1の方がデーターベースを介する通信が不要であり、作成してあるhtmlの非表示を表示に切り替えるだけなため、安定性、速度において優位ではあると思うのですが、非同期通信の学習も兼ねて2を採用することにしました。
構想イメージ(動作の流れ)
- ヘッダーのカテゴリーにマウスホバーしたら、親カテゴリーを表示。(親カテゴリーについては、常に同じであるためhtmlで作成しておきdisplay:none から display:blockへの変更)
- 親カテゴリーにホバーしたら、紐づく子カテゴリーを全てデーターベースから取得し、子カテゴリーのボックスで、each do で回して、構築し表示する。
上記構想で、実装したところとある問題が発生しました。
このように、ゆっくりマウスを動かしたら問題なく動くのですが、マウスを素早く動かした場合、、、
、、、お気づきになりましたでしょうか?
マウスが停止しても、まだ子カテゴリーがシャコシャコ動いている!!しかも、停止したマウスのカテゴリーと無関係の子カテゴリーが表示されている!!
これを、便宜上**「おみくじ状態」**とさせていただきます。なぜこのような、おみくじ状態になるか調べた結果、、、
- マウスの素早い動きに非同期通信が追いついておらず、マウスが停止した後に遅れて、子カテゴリーが構築されている。
- 非同期通信は、発火した順番に対するレスポンスの順番は担保されていない。つまり、発火した順ではなく、通信が終わった順にレスポンスされている。よって、最後にマウスホバーした親カテゴリーに紐づく子カテゴリーが表示されるとは限らない。
ということが判明しました。
解決策
以上の事を踏まえて、とりあえず以下の2つの解決策が頭をよぎりました。
- **「マウスはゆっくり動かしてください」**と注意書きを入れる。(いやいや、それは流石に・・・と即、却下)
- 実装方針1のhtmlのみで実装する。
2を採用しようとしたのですが、なんとか非同期通信を用いて実装できないか、いろいろ調べた結果、以下の3つの方法に辿りつきました。
3.マウスがホバーして、非同期通信が発火するまでに、待機時間を設ける。(例:マウスホバー後0.5秒経過した後、非同期通信を開始)
4.非同期通信開始時と、発動とレスポンスの順番の同一性を担保する。
参考記事 http://www.koikikukan.com/archives/2012/10/11-000300.php
5.非同期通信中に、新たに非同期通通信が開始されたら、旧非同期通信は、中断される。
検討
3.については最もシンプルに実装できそうだと思ったのですが、マウスがホバーしてからしばらく待機時間があるのはユーザーインターフェイス上あまり好ましくないのでは、と却下。
4.については、順番が実行されるものの、マウスが停止後も子カテゴリーのシャコシャコ状態は改善されないのでは?と却下。
5.については、確かに順番は担保される。(最後にホバーしたカテゴリーに紐づく子がレスポンスされる。)かつ、シャコシャコ状態も回避できそう。
よって5を方針として選択しました。
参考記事 http://www.koikikukan.com/archives/2013/12/06-015555.php
参考記事 https://qiita.com/otsukayuhi/items/31ee9a761ce3b978c87a
自分なりに解釈した内容と端的に説明すると、
- 非同期通信開始時にajaxの返却値である jqxhrオブジェクト を変数に格納する。
(jqchrオブジェクトとは、XMLHttpRequestオブジェクトをjQueryがより使いやすい形に拡張したもの。XMLHttpRequestオブジェクトとは。。。イマイチ理解できませんでした。なんとなくですが、非同期通信を開始した際に、必要な各種データが返却値として格納されいるもの、、、みたいな感じですかね、、、検証から中身を確認したところ

- 通信中は、jqxhrオブジェクトにデーターが格納されているため、次回非同期通信開始時に、条件分岐 として
if (jqxhr) {
jqxhr.abort();
}
のように記述することにより、jqxhrが存在する時は、abortメソッドによりすでにより、実行中の通信を中断することができます。
参考 https://sutara-lumpur.hatenadiary.org/entry/20090131/1233365972
ちなみに、非通信中のjqxhtの中身はを、検証で確認したところ
となっており、何も格納されていません。
3 . よって結果的に、最後に通信を開始したレスポンスが、帰ってくるということです。
なんとかサーバーサイドについては実装完了しました。
マークアップについて
マークアップについては、わりかしスムーズに行き、後は選んだカテゴリーを赤くするだけの段階まできました。
「ふむふむ、cssでマウスホバーを当てて、カーソルが乗ってる部分を赤くすればいいだけね!」と思い、チームメンバーに対し、デキル男風に「あと、5分もあれば完成っスね!!」などと吹聴していたところ、とあることに気付き愕然としました。
上記の動作イメージは某メルカ○を参考にしたんですが、マウスが乗ったカテゴリーが部分が赤くなるのはそうなんですが、そこから子、孫カテゴリーに移動した時に、親カテゴリーは赤くなったままなんですね、、、
そのことに気づいて、顔面蒼白で冷や汗を垂らしながら震えていると、一つの解決策が突如閃きました!
「よし!!気づかなかったことにして、シラッと押し通そう!!」
と思ったのですが、チームメンバーが細かい事にこだわり一生懸命に思考錯誤している姿をみて、思いとどまり「、、、やっぱりもうちょっとかかります、、、」と訂正し、完コピすることにいたしました。
上記のとおり、cssでhover時にbackground-colorを与えると、カーソルが外れると、色が元どおりになります。そこで、考えた事は、**「カーソルが外れた時に、色は戻す。でも、右に外れた時は赤いままにする。」**とういう発想でした。でも、この考えだと、親から子に行って、さらに別の親に戻ると、前回の親は赤いままで、新しくカーソルが乗った親も赤くなるとうことになりそうです。
発想を転換して、カーソルが外れた時の挙動は特に指定せず、(カーソルが外れても赤いまま)、カーソルが乗った時に、兄弟要素を全て元に戻すという考えに切り替えました。これだと、目的の実装が可能そうです。
jqueryにはsiblings()という、同階層の要素を全て取得するメソッドがあるのでこれを用いて、カーソルが乗った後の挙動として以下のように記載しました。
$(this).siblings().removeClass("red"); // 兄弟要素のredクラスを削除。親から子に行った時は削除しない
$(this).addClass("red"); // 自分を赤くする。親から子に遷移したときに残すよう。
すると目的の挙動が達成しました。
これでようやく、目的の実装が全て完了しました。動作確認をしっかり確認した上で、チームメンバーに**「あと、5分もあれば完成っスね!!」と宣言し、きっちり5分後に「できました!!」**と報告しました。(何事も余裕を持った準備が大切ですね)
参考までに、jqueryのコードと、htmlを載せておきます。
$(function(){
let btn = $(".category-btn")
let category_downlists = $(".category-downlists")
valGlobal1 = "nextbox"; // メソッドを超えて使用するのでグローバル変数
btn.hover( // カテゴリーボタンに乗った時
function(){
category_downlists.removeClass("non-show-list").addClass("show-list");
}); // リストの型枠を表示
$(".header__inner__main").hover(
function(){
category_downlists.removeClass("show-list").addClass("non-show-list");
// 親リストを非常時にする。
$(".ones").eq(1).empty();
// 子を空にする
$(".ones").eq(2).empty();
// 孫を空にする
});
category_downlists.mouseleave( // カテゴリーダウンリストから外れた時
function(){
category_downlists.removeClass("show-list").addClass("non-show-list");
// 親リストを非常時にする。
$(".ones").eq(1).empty();
// 子を空にする
$(".ones").eq(2).empty();
// 孫を空にする
});
$(".category-downlist").on("mouseenter","#oya",(function () {
$(".ones").eq(2).empty();
// 親要素に乗った場合、孫を消去。孫から親に移動した時、孫が残り続けるのを防ぐ
}));
function childrenDownBuild(children){ // 一個一個整形用
$(".ones").eq(nextBox).empty(); // すでに表示しているものを空にする
$.each(children,
function(index,child) {
let html =
`<div class= "category-downlist__in__one" data-id = ${child.id}>
<a href= "/items/${child.id}/list_from_category">${child.name}</a>
</div>`
// json を取り出して表示。idはカテゴリーのid。nameはカテゴリの名前
$(".ones").eq(nextBox).append(html) // onesにhtmlを追加する
});
};
let parentIDs = [];
var jqxhr;
$(".ones").on("mouseenter",".category-downlist__in__one",(function () {// マウスが一つ一つのブロックに入ったら発動
// ※hoverメソッドは、動的なクラスに対し指定できないためonメソッドでmouseenterを使用する必要あり!!
nextBox = $(this).closest(".ones").data("next");
// マウスが入った親のカスタムデータ(親なら1、子なら2、孫ならnil)を取得
$(this).siblings().removeClass("red"); // 兄弟要素のredクラスを削除。親から子に行った時は削除しない
$(this).addClass("red");
// 自分を赤くする。親から子に遷移したときに残すよう。実際はcssのhoverで実装。(jqueryで実装すると動きがもたつくため)
if(jqxhr){ // jqxhrが存在したら、abortで中断する。連続送信を避けるため
jqxhr.abort();
}
let parentID = $(this).data('id'); //選択されたカテゴリーのカスタム属性data-id(つまり、そのカテゴリーのid)を格納
parentIDs.push(parentID); // hoverの順番を担保するため、取得順にidをparentIDsに格納
var nextParentID = parentIDs.shift();
// shiftメソッドで配列の最初の要素を削除。返り値は、削除した要素なので、nextnextParentIDに格納される。
jqxhr = $.ajax({ // ajax通信の中身をjqxhrに格納する。
url: "/items/category_children",
type: "GET", // サーバに送信する
data: { // nextParentID(parent_id)をparamsに格納しておくる。params[:parent_id]
parent_id: nextParentID
},
dataType: "json",
})
.done(function (children) {
childrenDownBuild(children);
})
.fail(function (jqxhr,textStatus) {
// abort(中断)した場合、failを処理する仕様なので、alertを出現させせないために、textStatus === 'abort'の場合は、何もしない処理をさせる。
if (textStatus === 'abort'){return;}
alert("カテゴリー取得に失敗しました");
});
}));
});
.category-downlists.non-show-list
.category-downlist
.category-downlist__in#oya
.ones{"data-next" => "1"}
- Category.roots.each do |category|
.category-downlist__in__one{"data-id" => "#{category.id}"}
-# カテゴリーのidをカスタム属性のdata-idに格納
= link_to category.name, list_from_category_item_path(category.id)
.category-downlist__in
.ones{"data-next" => "2"}
.category-downlist__in
.ones