JavaScript 多級聯動浮動菜單 (第二版)

上一個版本(第一版請看這裏)基本實現了多級聯動和浮動菜單的功能,但效果不是太好,使用麻煩還有些bug,實用性不高。
這次除了修改已發現的問題外,還對程序做了大幅調整和改進,使程序實用性更高,功能更強大。

預覽圖

前臺效果預覽

下載完整實例

程序原理

程序最關鍵的地方是多級聯動,先大概說明一下:
首先第一級的菜單元素整理好後,從他們開始,當某個菜單元素觸發顯示下級菜單時,
準備好下一級的容器元素,並把下一級的菜單元素放進去,再定位並顯示容器元素。
裏面的菜單元素又可以觸發顯示下級菜單,然後按上面的步驟執行下去。
這樣一級一級的遞推下去,形成多級的聯動菜單。


程序說明

【容器對象】

在多級聯動中,每一級都需要一個容器元素來存放菜單元素。
程序中每個容器元素都對應一個容器對象,用來記錄該容器的相關信息。
容器對象的集合記錄在程序的_containers屬性中。

容器參數containers是程序實例化時的必要參數,它的結構如下:

[
    容器元素(id),
    { id: 容器元素(id), menu: 插入菜單元素(id) },
   
]


首先如果containers不是數組的話,程序會自動轉成單元素數組。
如果菜單插入的元素就是容器元素本身,可以直接用容器元素(id)作爲數組元素。
否則應該使用一個對象結構,它包括一個id屬性表示是容器元素(id)和一個menu屬性表示菜單插入的元素(id)。

containers會在程序初始化時這樣處理:

forEach(isArray(containers) ? containers : [containers], function(o, i){
   
var pos, menu;
   
if ( o.id ) {
        pos
= o.id; menu = o.menu ? o.menu : pos;
    }
else {
        pos
= menu = o;
    };
    pos
= $$(pos); menu = $$(menu);
    pos
&& menu && this.IniContainer( i, { Pos: pos, Menu: menu } );
},
this);


主要是生成一個容器對象,其中Pos屬性是容器元素,Menu屬性是插入菜單的元素。
然後傳遞索引和容器對象給IniContainer函數,對容器對象做初始化。

在IniContainer中,首先用ResetContainer重置容器對象可能在程序中設置過的屬性。
再給容器元素添加事件:

addEvent( oContainer, "mouseover", Bind( this, function(){ clearTimeout(this._timerContainer); } ) );
addEvent( oContainer,
"mouseout", BindAsEventListener( this, function(e){
   
var elem = e.relatedTarget,
        isOut
= Every( this._containers, function(o){ return !(Contains(o.Pos, elem) || o.Pos == elem); } );
   
if ( isOut ) {
        clearTimeout(
this._timerContainer); clearTimeout(this._timerMenu);
       
this._timerContainer = setTimeout( Bind( this, this.Hide ), this.Delay );
    };
}));


在mouseout時,先判斷是否容器內部或容器之間觸發,不是的話再用定時器執行Hide隱藏函數。
在Hide裏面,主要是隱藏容器:

this.forEachContainer(function(o, i){
   
if ( i === 0 ) {
       
this.ResetCss(o);
    }
else {
       
this.HideContainer(o);
    };
});


由於第一級容器一般是不自動隱藏的,只需要用ResetCss來重置樣式。
其他容器會用HideContainer函數來處理隱藏:

var css = container.Pos.style;
css.visibility
= "hidden"; css.left = css.top = "-9999px";
this._containers[container._index - 1]._active = null;


其中_active屬性是保存該容器觸發下級菜單的菜單對象,在隱藏容器同時重置上一級容器的_active。

在mouseover時清除容器定時器,其實就是取消Hide執行。

之後是設置樣式:

if ( index ) {
   
var css = container.Pos.style;
    css.position
= "absolute"; css.display = "block"; css.margin = 0;
    css.zIndex
= this._containers[index - 1].Pos.style.zIndex + 1;
};


除了第一級容器外,都設置浮動需要的樣式。

最後用_index屬性記錄索引,方便調用,並把容器對象插入到容器集合中:

container._index = index;
this._containers[index] = container;


這個索引很重要,它決定了容器是用在第幾級菜單。


【菜單對象】

容器元素插入了菜單元素纔算一個菜單。
程序中每個菜單元素都對應一個菜單對象,用來記錄該菜單的相關信息。

程序初始化前,應該先創建好自定義菜單集合,它的結構是這樣的:

[
    { id:
1, parent: 0, txt: 元素內容 },
    { id:
2, parent: 1, txt: 元素內容 },
   
]


其中id是菜單的唯一標識,parent是父級菜單的id。
除了這兩個關鍵屬性外,還可以包括以下屬性:
rank:排序屬性
elem:自定義元素
tag:生成標籤
css:默認樣式
hover:觸發菜單樣式
active: 顯示下級菜單時顯示樣式
txt:菜單內容
fixedmenu:是否相對菜單定位(否則相對容器)
fixed:定位對象
attribute:自定義Attribute屬性
property:自定義Property屬性

其中fixedmenu和fixed是用於下級容器定位的。
具體作用會在後面的分析中說明。

自定義菜單集合會保存在_custommenu屬性中。
在程序初始化時會執行BuildMenu程序,根據這個_custommenu生成程序需要的_menus菜單對象集合。
BuildMenu是比較關鍵的程序,菜單的層級結構就是在這裏確定,它由以下幾步組成:

第一步,清除舊菜單對象集合的dom元素。
這一步後面“內存泄漏”會詳細說明。

第二步,生成菜單對象集合。
爲了能更有效率地獲取指定id的菜單對象,_menus是以id作爲字典關鍵字的對象。
首先創建帶根菜單(id爲“0”)對象的_menus:

this._menus = { "0": { "_children": [] } };


然後整理_custommenu並插入到_menus中:

forEach(this._custommenu, function(o) {
   
var menu = DeepExtend( DeepExtend( {}, options ), o || {} );
   
if ( !!this._menus[ menu.id ] ) { return; };
    menu._children
= []; menu._index = -1;
   
this._menus[menu.id] = menu;
},
this);


其中菜單對象中包含對象屬性,要用DeepExtend深度擴展來複制屬性。
爲確保id是唯一標識,會排除相同id的菜單,間接排除了id爲“0”的菜單。
在重置_children(子菜單集合)和_index(聯級級數)之後,就可以插入到_menus中了。

第三步,建立樹形結構。
菜單之間的關係是一個樹形結構,程序通過id和parent來建立這個關係的(寫過數據庫分級結構的話應該很熟悉)。
而第一版是把子類直接菜單寫在菜單元素的menu屬性中,形成類似多維數組的結構。
比較這兩個方法,第一版的優勢在於定義菜單時就直接確立了關係,而新版還必須根據id和parent來判斷增加代碼複雜度。
新版的優勢是使用維護方便,靈活,級數越多就越體現出來,而第一版剛好相反。

能不能結合這兩個方法的優勢呢?
這裏採用了一個折中的方法,在寫自定義菜單對象時用的是新版的方法,然後程序初始化時把它轉換成類多維數組結構。
轉換過程是這樣的:
首先根據parent找到父菜單對象:

var parent = this._menus[o.parent];


如果找不到父菜單對象或父菜單對象就是菜單對象本身的,當成一級菜單處理:

if ( !parent || parent === o ) { parent = menus[o.parent = "0"]; };


最後把當前菜單對象放到父菜單對象的_children集合中:

parent._children.push(o);


這就把_menus變成了類多維數組結構,而且這個結構不會發生死循環。

第四步,整理菜單對象集合。
這步主要是整理_menus裏面的菜單對象。
首先,把自定義菜單元素放到碎片文檔中:

!!o.elem && ( o.elem = $$(o.elem) ) && this._frag.appendChild(o.elem);


菜單元素是需要顯示時纔會處理的,這樣可以防止在容器上出現未處理的菜單元素。

然後是修正樣式(詳細看樣式設置部分)。

最後,對菜單對象的_children集合進行排序:

o._children.sort(function( x, y ) { return x.rank - y.rank || x.id - y.id; });


先按rank再按id排序,跟菜單對象定義的順序是無關的。

執行完BuildMenu程序之後,_menus菜單對象集合就建立好了。
麻煩的是在每次修改_custommenu之後,都必須執行一次BuildMenu程序。


【多級聯動】

容器對象和菜單對象都準備好了,下面就是如何利用它們來做程序的核心——多級聯動效果了。

多級聯動包括以下步驟:

第一步,準備一級容器。
一級容器一般是顯示狀態的(也可以自己定義它的顯示隱藏,像仿右鍵菜單那樣)。

第二步,向容器插入菜單。
通過InsertMenu程序,可以向指定容器插入指定菜單,其中第一個參數是索引,第二個參數是父菜單id。

在InsertMenu程序裏面,先判斷是否同一個父級菜單,是的話就返回不用重複操作了:

var container = this._containers[index];
if ( container._parent === parent ) { return; };
container._parent
= parent;


接着把原有容器內菜單移到碎片對象中:

forEach( container._menus, function(o) { o._elem && this._frag.appendChild(o._elem); }, this );
container._menus
= [];


在第一版,菜單每次使用都會重新創建,新版改進後會把舊菜單元素保存到碎片對象中,要使用時再拿出來。

然後根據parent獲取父菜單對象,並把父菜單的_children子菜單集合的插入到容器中:

forEach(this._menus[parent]._children, function( menu, i ){
   
this.CheckMenu( menu, index );
    container._menus.push(menu);
    container.Menu.appendChild(menu._elem);
},
this);


這樣整個菜單就準備好了。

第三步,添加觸發下級菜單事件。
上面在把菜單插入到容器之前,會先用CheckMenu程序檢查菜單對象。

CheckMenu程序主要是檢測和處理菜單元素。
首先判斷沒有自定義元素,沒有的話就創建一個:

var elem = menu.elem;
if ( !elem ) { elem = document.createElement(menu.tag); elem.innerHTML = menu.txt; };


第一版並不能自定義元素,但考慮到seo、漸進增強等,在新版加入了這個功能。
但每次BuildMenu之後會把所有菜單元素包括自定義元素都清除,這個必須留意。

然後分別設置property、attribute和className屬性:

Extend( elem, menu.property );
var attribute = menu.attribute;
for (var att in attribute) { elem.setAttribute( att, attribute[att] ); };
elem.className
= menu.css;


ps:關於property和attribute的區別請看這裏的attribute/property部分

然後是關鍵的一步,添加HoverMenu觸發事件程序:

menu._event = BindAsEventListener( this, this.HoverMenu, menu );
addEvent( elem,
"mouseover", menu._event );


處理後的元素會保存在菜單對象的_elem屬性中。

第四步,觸發顯示下級菜單事件。
當觸發了顯示下級菜單事件,就會執行HoverMenu程序。
在HoverMenu程序裏面,主要是做一些樣式設置,詳細參考後面的樣式設置部分。
然後是用定時器準備執行ShowMenu顯示菜單程序。

第五步,整理菜單容器。
在ShowMenu程序中,首先是隱藏不需要的容器:

this.forEachContainer( function(o, i) { i > index && this.HideContainer(o); } );


然後判斷當前菜單是否有子菜單,當有子菜單時,先用CheckContainer程序檢查下級菜單容器。
CheckContainer程序主要是檢查容器是否存在,不存在的話就自動添加一個:

var pre = this._containers[index - 1].Pos
    ,container
= pre.parentNode.insertBefore( pre.cloneNode(false), pre );
container.id
= "";


其實就是用cloneNode複製前一個容器,注意要重置id防止衝突。
雖然程序能自動創建菜單,但也要求至少自定義一個容器。

第六步,顯示菜單容器。
在顯示之前,先按第二步向容器插入菜單,最後就是執行ShowContainer程序來定位和顯示容器了。

當下一個容器內的菜單觸發顯示下級菜單事件時,會顯示下下級的菜單容器。
程序就是這樣一級一級遞推下去,形成多級聯級效果。


【樣式設置】

樣式設置也是一個重要的部分,不是說要弄出多炫的界面,而是如何使程序能最大限度地靈活地實現那些界面。

菜單對象有三個樣式相關的屬性,分別是:
css:默認樣式
hover:鼠標進入菜單時使用樣式
active:顯示下級菜單時使用樣式

在BuildMenu程序中,會對這些樣式屬性進行整理:

if ( !!o.elem && o.elem.className ) {
    o.css
= o.elem.className;
}
else if ( o.css === undefined ) { o.css = ""; };
if ( o.hover === undefined ) { o.hover = o.css; };
if ( o.active === undefined ) { o.active = o.hover; };


可以看到,程序會優先使用自定義元素的class,避免被程序設置的默認樣式覆蓋。
空字符串也可能被用來清空樣式,所以要用undefined來判斷是否自定義了樣式。

程序中主要在兩個地方設置樣式:在鼠標移到菜單元素上時(HoverMenu)和顯示下級菜單時(ShowMenu)。

在HoverMenu程序中,先對每個顯示的容器設置一次樣式:

this.forEachContainer(function(o, i){
   
if ( o.Pos.visibility === "hidden" ) { return; };
   
this.ResetCss(o);
   
var menu = o._active;
   
if ( menu ) { menu._elem.className = menu.active; };
});


由於鼠標可能是在多個容器間移動,所以所有顯示的容器都需要設置。
用ResetCss重置容器樣式後再設置有下級菜單的菜單的樣式爲active。
爲了方便獲取,容器對象用一個_active屬性來保存當前容器觸發了下級菜單的菜單對象。

然後是設置鼠標所在菜單的樣式:

if ( this._containers[menu._index]._active !== menu ) { elem.className = menu.hover; };


爲了優先設置active樣式,在當前菜單不是容器的_active時才設置hover樣式。

在ShowMenu程序中,首先把顯示下級菜單的菜單對象保存到容器的_active屬性。
再用ResetCss重置當前容器樣式,這個在同級菜單中移動時會有用。
然後再根據當前菜單是否有下級菜單來設置樣式爲active或hover。


【內存泄漏】

上面“菜單對象”中說到清除舊菜單對象的dom元素,這個主要是爲了防止內存泄漏。
關於內存泄漏也有很多文章,這裏推薦看看Douglas Crockford的“JScript Memory Leaks”和winter的“瀏覽器中的內存泄露”。

下面說說我解決本程序內存泄漏的經過:
首先,通過調用程序的Add和Delete數千次來測試是否有內存泄漏。
怎麼看出來呢?可以找些相關的工具來檢測,或者直接看任務管理器的頁面文件(pf)使用記錄。
結果發現,雖然每個元素都用removeChild移出了dom,但隨着循環的次數增多,pf還是穩步上升。
於是按照Memory Leaks中說的“we must null out all of its event handlers to break the cycles”去掉事件:

removeEvent( elem, "mouseover", o._event );


效果是有了,但不太理想,然後再逐一排除,發現原來是_elem屬性還關聯着元素,結果經過一些操作後,又把元素append到dom上,還重新創建了一個元素。

於是在移除元素後,立即重置_elem和elem屬性:

o._elem = o.elem = null;


內存泄漏就沒有了,其實這裏也不算是內存泄露了,而是程序設計有問題了。
所以清除dom元素時必須注意:
1,按照Douglas Crockford的建議,移除所有dom元素相關的事件函數;
2,刪除/重置所有關聯dom元素的js對象/屬性。


【cloneNode的bug】

在上面多級聯動中說到,會用cloneNode複製容器,但cloneNode在ie中有一個bug:
在ie用attachEvent給dom元素綁定事件,在cloneNode之後會把事件也複製過去。
而用addEventListener添加的事件就不會,可以在ie和ff測試下面的代碼:
<!DOCTYPE html>
<html>
<body>
<div id="t">div</div>
<script>
var o = document.getElementById("t");
if(o.attachEvent){
o.attachEvent(
"onclick", function(){alert(2)});
}
else{
o.addEventListener(
"click", function(){alert(2)}, false);
}
document.body.appendChild(o.cloneNode(
true));
</script>
</body>
</html>


在ie和ff點擊第一個div都會觸發alert,關鍵是第二個div,在ff不會觸發,而ie就會。
當然這個是不是bug還不清楚,或許attachEvent本來就是這樣設計的也說不定。
但第一版就是由於這個bug,而沒有用cloneNode。

在找解決方法之前,再擴展這個問題,看看直接添加onclick事件會不會有同樣的bug。
首先測試在元素裏面添加onclick:

<!DOCTYPE html>
<html>
<body>
<div id="t" onclick="alert(1)">div</div>
<script>
var o = document.getElementById("t");
document.body.appendChild(o.cloneNode(
true));
</script>
</body>
</html>


結果在ie和ff都會複製事件。

再測試在js添加onclick:
<!DOCTYPE html>
<html>
<body>
<div id="t">div</div>
<script>
var o = document.getElementById("t");
o.onclick
= function(){alert(1)}
document.body.appendChild(o.cloneNode(
true));
</script>
</body>
</html>


結果在ie和ff都不會複製事件,看來只有attachEvent會引起這個bug。

下面是解決方法:
用John Resig在《精通JavaScript》推薦的Dean Edwards寫的addEvent和removeEvent方法來添加/移除事件。
它的好處就不用說了,而且它能在ie解決上面說到的cloneNode的bug。
因爲它的實現原理是在ie用onclick來綁定事件,而上面的測試也證明用onclick綁定的事件是不會被cloneNode複製的。

ps:我對原版的fixEvent做了些修改,方便獨立調用。


【浮動定位】

容器的浮動定位用的是浮動定位提示效果中的定位方法。
在該文章中已經詳細說明了如何獲取指定的浮動定位座標,這裏做一些補充。

一般來說用getBoundingClientRect配合scrollLeft/scrollTop就能獲得對象相對文檔的位置座標。
測試下面代碼:
<!DOCTYPE html>
<html>
<body style="padding:1000px 0;">
<div id="t1" style="border:1px solid; width:100px; height:100px;"></div>
<div id="t2"></div>
<script>
var $$ = function (id) {
   
return "string" == typeof id ? document.getElementById(id) : id;
};
var b = 0;
window.onscroll
=function(){
var t = $$("t1").getBoundingClientRect().top + document.documentElement.scrollTop;
if( t != b ){ b = t; $$("t2").innerHTML += t + "<br>"; }
}
</script>
</body>
</html>


在除ie8外的瀏覽器,t會保持在一個固定值,但在ie8卻會在1008和1009之間變換(用鼠標一格一格滾會比較明顯)。
雖然多數時候還是標準的1008,但原來的效果可能就會被這1px的差距破壞(例如仿京東和仿淘寶的菜單)。
ps:chrome和safari要把documentElement換成body。

爲了解決這個問題,只好在ie8的時候用回傳統的offset來取值了(詳細參考代碼)。
至於造成這個問題的原因還沒弄清楚,各位有什麼相關資料的記得告訴我哦。


使用技巧

在仿京東商城商品分類菜單中,實現了一個陰影效果。
原理是這樣的:
底部是一個灰色背景層(陰影),裏面放內容層,然後設置內容層相對定位(position:relative),並做適當的偏移(left:-3px;top:-3px;)。
由於相對定位會保留佔位空間,這樣就能巧妙地做出了一個可自適應大小的背景層(陰影)。
ps:博客園首頁也做了類似的效果,但貌似錯位有些嚴重哦。

仿右鍵菜單效果並不支持opera,因爲opera並沒有類似oncontextmenu這樣的事件,要實現的話會很麻煩。
ps:如果想兼容opera的話,可以看看這篇文章“Opera下自定義右鍵菜單的研究”。
注意,在oncontextmenu事件中要用阻止默認事件(preventDefault)來取消默認菜單的顯示。
這個效果還做了一個不能選擇的處理,就是拖動它的內容時不會被選擇。
在ff中把樣式-moz-user-select設爲none就可以了,而ie、chrome和safari通過在onselectstart返回false來實現相同的效果。
ps:css3有user-select樣式,但貌似還沒有瀏覽器支持。
當然,還有很多不完善的地方,這裏只是做個參考例子,就不深究了。

仿淘寶拼音索引菜單主要體現了active的用法和相對容器定位,難的地方還是在樣式(具體參考代碼)。

這幾個例子都只是二級菜單,其實是有點殺雞用牛刀了。


使用說明

實例化時,第一個必要參數是自定義容器對象:

new FixedMenu("idContainer");


第二個可選參數用來設置系統的默認屬性,包括
屬性:   默認值//說明
Menu:   [],//自定義菜單集合
Delay:   200,//延遲值(微秒)
Tag:   "div",//默認生成標籤
Css:   undefined,//默認樣式
Hover:   undefined,//觸發菜單樣式
Active:   undefined,//顯示下級菜單時顯示樣式
Txt:   "",//菜單內容
FixedMenu:  true,//是否相對菜單定位(否則相對容器)
Fixed:   { Align: "clientleft", vAlign: "bottom" },//定位對象
Attribute:  {},//自定義Attribute屬性
Property:  {},//自定義Property屬性
onBeforeShow: function(){}//菜單顯示時執行

其中包括菜單對象的默認屬性,因此菜單對象屬性名都是小寫以示區分。

還提供了以下方法:
Add:添加菜單,參數是菜單對象或菜單對象集合;
Edit:修改菜單,找出對應id的菜單對象修改屬性設置;
Delete:刪除菜單,參數是要刪除菜單對象的id。
這些方法都會執行Ini初始化程序,效率較低,一般來說盡量不要使用。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章