2019年9月23日 星期一

使用RxJs 實作 Hover 的下拉選單 - RxJs

RxJs 是一個能幫助處理Javascript 各種事件(event)的好工具,
尤其是要偵測的事件是一組事件,而不是單一的事件時會非常的好用,

今天要來利用 RxJs 來實作 Hover 的下來選單,
成品如下:

首先在螢幕上方有一個 "Menu" 區塊,
在 "Menu" 區塊中有一個 "Menu Child" 區塊。

當滑鼠移到 "Menu" 區塊時,下方會滑出一塊 "Dropdown" 區塊,
裡面有一個 "Dropdown Child" 區塊。

當滑鼠移出 "Menu" 區塊及 "Dropdown" ,稍微等一下 (例如 100 毫秒),
如果此時滑鼠不在 "Menu" 區塊或 "Dropdown" 區塊,就將 "Dropdown" 區塊上來關掉,
不然就不做任何事 (即 "Dropdown" 區塊繼續維持下拉開著)。

在這裡我們有一個一組的事件需要處理,就是 :

滑鼠移出  "Menu" 區塊及 "Dropdown" --> 等一下 -->  滑鼠是否在 "Menu" 區塊或 "Dropdown" 區塊上

如果使用純 Javascript,我們可能會需要使用綁定 mouseenter, mouseleave 並配合 setTimeout 來處理,
也就是在 mouseenter 上做紀錄是否滑鼠移動進 "Menu" 或 "Dropdown" 區塊,
並在 mouseleave 後,用setTimeout 等一下讓 mouseenter 觸發上述紀錄的程式 (因為 mouseleave 會比 mouseenter先提早觸發) ,
等 mouseenter 紀錄完後在判斷要不要關掉 "Dropdown" 區塊。

但使用 RxJS 的話,將會使程式碼簡化許多。

先來看完整的程式碼:
使用的工具:
RxJS : 5.0.1 (下載)
jQuery : 1.9.1

HTML:
<div class="wrapper">
  <div class="menu">
    Menu
    <div class="menu_child">
      Menu Child
    </div>
  </div>
  <div class="dropdown" style="display:none;">
    Dropdown
    <div class="dropdown_child">
      Dropdown Child
    </div>
  </div>
</div>

CSS :
.wrapper {
  width: 500px;
  margin: 0 auto;
}

.menu {
  background-color: yellow;
}

.menu_child {
  background-color: #00ffd0;
  width: 300px;
  margin: 0 auto;
}

.dropdown {
  background-color: green;
  height: 300px;
}

.dropdown_child {
  background-color: #bc0eda;
  width: 300px;
  margin: 0 auto;
}

Javascript :
var $menu = $(".menu");
var $menu = $(".menu");
var $dropdown = $(".dropdown");

$menu.on("mouseenter", function() {
  $dropdown.slideDown();
});

var $menuMouseLeaveEvent = Rx.Observable.fromEvent($menu[0], "mouseleave");
var $dropdownMouseLeaveEvent = Rx.Observable.fromEvent($dropdown[0], "mouseleave");
var $menuMouseEnterEvent = Rx.Observable.fromEvent($menu[0], "mouseenter")
var $dropdownMouseEnterEvent = Rx.Observable.fromEvent($dropdown[0], "mouseenter")

var mouseLeaveEvent = Rx.Observable.merge($menuMouseLeaveEvent, $dropdownMouseLeaveEvent);
var mouseEnterEvent = Rx.Observable.merge($menuMouseEnterEvent, $dropdownMouseEnterEvent);

mouseLeaveEvent.map(function(e){
 return mouseEnterEvent.buffer(Rx.Observable.timer(100));
})
.concatAll()
.subscribe({
  next: function(value) {
   //if value.length > 0, it means one or more than one mouseEnterEvent 
    // was observed during 100 ms.
    if (value.length > 0) {
      $dropdown.slideDown();
    } else {
      $dropdown.slideUp();
    }
  }
});

說明:

  1. mouseEnterEvent.buffer(Rx.Observable.timer(100));
    的效果是,在一定時間後 (這裡是 100 毫秒),
    將這段時間中觀察到的 event 丟出,在此例中,就是 mouseEnterEvent。
  2. 使用 mouseenter 和 mouseleave 而不使用 mouseover 和 mouseout 事件,
    這樣才會不在滑鼠在"Menu", "Dropdown" 的子層元素 (Child DOM ) 進出時觸發了mouseover和mouseout事件。

--------------------------------------------------------------------------------------------------------------------------

改良版:
參考 Build dropdown list with RxJS 了這篇文章以後,
非常喜歡他的思考方式,
跟我之前單純用事件去想的方式不同,
他採用了狀態的概念,下面說明:

使用狀態來表示滑鼠目前是在 Menu 還是 Dropdown 上面,
這邊先用 isOnMenu 來表示是否在 Menu 上的狀態,
用 isOnDropdown 來表示是否在 Dropdown 上的狀態,
isOnMenu叨和 isOnDropdown 的值都為布林值 (boolean),即 true 或 false。

如果我們能正確的得到 isOnMenu 和 isOnDropdown 的值,
那我們就可以歸納出如下的結果:
isOnMenu = true isOnMenu = false
isOnDropdown = true Dropdown 要打開 Dropdown 要打開
isOnDropdown = false Dropdown 要打開 Dropdown 不要打開

可以從上表看出,只有在 isOnMenu 和 isOnDropdown 都為 false 的時候,
Dropdown 才不需要被打開。

接著就是如何正確地得到 isOnMenu 和 isOnDropdown 的值。
以 isOnMenu 為例 (isOnDropdown 同理),
方式就是用 RxJS 偵測滑鼠對於 Menu 的
mouseenter 和 mouseleave 事件,將事件 map 成我們要的資訊。

例如 map 成一個物件 Object,
其中存了 isOnMenu 的布林值
和一些其他資訊 (例如在有多個 Menu 的情況,滑鼠是 hover 了哪個 Menu 的訊息),
並且把兩個事件流結合 (Merge) 起來,取最後得到的事件 (即 Menu 的最後狀態)。

這樣我們就可以根據最後得到的 isOnMenu 和 isOnDropdown 狀態 (用 CombineLatest 來 combine isOnMenu 和 isOnDropdown 事件流) 來決定是否要開關 Dropdown 了。

以下直接以實作例子來舉例,
先看在 jsFiddle 上的成品如下:


情境為畫面上有兩個 (或多個) Menu 及各所屬的 Dropdown,

Html:
<div class="wrapper">
  <div class="menu" targetDropdown="dropdown1">
    Menu1
    <div class="menu_child">
      Menu1 Child
    </div>
  </div>
  <div class="menu" targetDropdown="dropdown2">
    Menu2
    <div class="menu_child">
      Menu2 Child
    </div>
  </div>
  
  <div class="dropdown" id="dropdown1">
    Dropdown1
    <div class="dropdown_child">
      Dropdown1 Child
    </div>
  </div>
  <div class="dropdown" id="dropdown2">
    Dropdown2
    <div class="dropdown_child">
      Dropdown2 Child
    </div>
  </div>
</div>


CSS:
.wrapper {
  width: 500px;
  margin: 0 auto;
}

.menu {
  background-color: yellow;
  display: inline-block;
  width: 45%;
  margin: 0;
}

.menu_child {
  background-color: #00ffd0;
  width: 30%;
  margin: 0 auto;
}

.dropdown {
  background-color: green;
  width: 80%;
  height: 0px;
  overflow: hidden;
  position: absolute;
}

.dropdown_child {
  background-color: #bc0eda;
  width: 300px;
  margin: 0 auto;
}

Javascript 程式碼如下,已將說明寫在程式碼之中:
//取得 Menu 及 Dropdown 的 DOM
var $menu = $(".menu");
var $dropdown = $(".dropdown");

// 偵測 Menu 及 Dropdown 的 mouseenter 和 mouseleave 事件
var $menuMouseLeaveEvent = Rx.Observable.fromEvent($menu, "mouseleave");
var $dropdownMouseLeaveEvent = Rx.Observable.fromEvent($dropdown, "mouseleave");
var $menuMouseEnterEvent = Rx.Observable.fromEvent($menu, "mouseenter");
var $dropdownMouseEnterEvent = Rx.Observable.fromEvent($dropdown, "mouseenter");

//將 Menu 的 mouseenter 和 mouseleave 事件轉成對應的資訊物件 
//(這裡儲存了滑鼠有無在 Menu 上面、滑鼠滑進 (或滑出) 了哪個 Menu DOM 的資訊),
//並將 mouseenter 和 mouseleave 事件 merge 起來,只要看最後的狀態就好
var isMouseOnMenuEvent = Rx.Observable.merge(
  $menuMouseEnterEvent.map(function(e) {
    return {
    	isHovered : true,
      hoveredDom : e.currentTarget
    };
  }),
  $menuMouseLeaveEvent.map(function(e) {
    return {
    	isHovered : false,
      hoveredDom : e.currentTarget
    };
  }));

//將 Dropdown 的 mouseenter 和 mouseleave 事件轉成對應的資訊物件 
//(這裡儲存了滑鼠有無在 Dropdown 上面、滑鼠滑進 (或滑出) 了哪個 Dropdown DOM 的資訊)
//並將 mouseenter 和 mouseleave 事件 merge 起來,只要看最後的狀態就好
var isMouseOnDropDownEvent = Rx.Observable.merge(
  $dropdownMouseEnterEvent.map(function() {
    return {
    	isHovered : true
    };
  }),
  $dropdownMouseLeaveEvent.map(function() {
    return {
    	isHovered :false
    };
  })).startWith({ 
    // 因為 RxJS 的 combineLatest() 不能 combine NULL 的事件 (會出錯),
    // 而一開始 Dropdown 沒有被打開時,不會有 Dropdown 的 mouseenter 和 mouseleave 事件,
    // 所以在這邊用 startWith 來手動增加一個 event 在最前面
  	isHovered :false
	});

// 將 isMouseOnMenuEvent 和 isMouseOnDropDownEvent 兩個事件流 Combine 在一起,
// 只看各自的最後一個狀態。
// 因為可能存在在短時間中,事件還沒有被觸發的情況,在這之中的狀態會不準而不對,
// 例如: 
// 滑鼠滑出了 Menu,要準備滑進 Dropdown 但還沒滑進的這一段時間中,
// 此時 isOnMenu = false,但 isDropdown 也等於 false,這時用這組狀態判斷 Dropdown 的開關就會不正確,
// 所以在這使用了 debounceTime() 來延長事件偵測的時間,例如滑鼠移出 Menu 後,等 100 毫秒看有沒有其他事件再決定送出事件
var mouseHoverStatus = Rx.Observable.combineLatest(isMouseOnMenuEvent, isMouseOnDropDownEvent)
                        .debounceTime(100);

// 在註冊事件 (subscribe) 裡判斷狀態,決定是否要開關 Dropdown、和要開關哪個 Dropdown
mouseHoverStatus.subscribe(function(e){
	if (e[0].isHovered || e[1].isHovered){
    var targetDropdown = $("#" + $(e[0].hoveredDom).attr("targetDropdown"));
    
    $dropdown.not(targetDropdown).stop(true).animate({
    	height: 0
    });
    
  	targetDropdown.stop(true)
    .animate({
    	height: 300
    });
  }else {
  	$dropdown.stop(true).animate({
    	height: 0
    });
  }
});



參考資料:

  1. 30 天精通 RxJS (01):認識 RxJS
  2. Element: mouseenter event
  3. Build dropdown list with RxJS