可爱又可恨的梅花雪
最近的一个项目中要对页面上的Filter中的菜单树进行改造,原因是之前所采用的TreeView控件树在页面初次加载的时候耗时过长,效率低下(想必用过微软TreeView树控件的用户都有过这样的感受)。改造的基本想法是用客户端树来代替从服务器端加载的TreeView树,客户端构造树只能用javascript了,要体现树的UI和可操控性,就必须提供动态的HTML用事件来进行加载(DHTML部分),这是一件非常繁琐的事情,于是我就想到了采用网络上非常著名的“梅花雪树控件”,不过期间遇到的问题很多,梅花雪的作者在需求方面考虑的问题还是不够全面,使得我在改造梅花雪树的过程中遇到了很多麻烦。
首先说明一下,这个项目是建立在Microsoft最新的企业级Portal Sharepoint 2007平台上,并且根据客户的要求,所有的Custom Code只能写在客户端,也就是说整个项目不能在服务器上部署dll(不过前面提到的那个Filter的TreeView是个例外,那个是采用C# Webpart编写的),UI的部分截图在下面。
在选择Filter中的Hierarchy之后
Hierarchy的下拉面板中有三棵树,通过RadioButton进行切换。
梅花雪网站上只能找到1.0版本的树,经过改良后的2.0版本效果不错,但是在梅花雪的网站上好像还没有提供下载,我在Google上搜索了一下,也还是找到很多可供下载的地址。
梅花雪树1.0
梅花雪树2.0
网络上有很多版本的梅花雪树,不过形态都大同小异,我所需要的是带复选框的树,并且绑定数据简单,最好不要通过递归绑定数据(因为当节点数过多时,通过递归绑定数据效率太低,在浏览器中加载树时通常都会“死机”)。其实需求很简单,于是我选择了上面的梅花雪2.0树。这个版本确实不错,除了可以通过不同的方式绑定数据外,还支持动态加载数据,也就是说在树的节点展开时才去动态加载节点。只要不在页面初始加载的时候全部展开树的节点,页面响应的时间会很短。我按照示例中的代码进行了测试,编写js脚本解析数据并绑定到树上,然后运行看效果,还不错,一切看起来都很正常,于是我很兴奋,看来大功快要告成!
进一步测试并添加相应的需求,问题马上就来了:
首先是我如何获取到已选择的节点?后来才知道这个其实是很容易的。设好debugger,调试js,跟一下node对象下有哪些属性或方法,找到一个bool属性checked,就是它了,通过遍历MzTreeView下的nodes,判断节点上的checked属性,然后就可以直接取到节点的id或text(这两个属性也是在调试js的过程中发现的,这比直接去读代码要来得方便)。
好,既然可以获取到已选择的节点,那么就允许设置哪些节点被选择了,如何做呢?从上面的过程来看,我们只需要设置这些节点的checked属性为true就行了,太容易了!那么果然如此吗?前面我说了,梅花雪2.0的树是动态加载节点的,也就是说,节点所在的分支如果没有打开,你是获取不到节点对象的,于是也就不能设置节点的checked属性了。这就是我所遇到的其中一个问题,看来梅花雪树的作者忽略了这个问题!问题的关键是我们首先要将那些你想设置属性的节点所在的分支打开,但因为树是层次结构,树可能会有很多层,我们需要逐级将这些分支打开,只要分支打开了,就能获取到节点对象,于是也就可以设置属性的值了。这就要求你在绑定树的数据时将这些节点的父节点从下向上依次保存起来,在设置时先按从上到下的顺序打开分支,然后遍历分支下的节点,最后设置这些节点的属性值。下面是我实现这一过程的部分代码。
2 var hashRegion = new Hashtable();
3 var hashCountry = new Hashtable();
4 var hashCompany = new Hashtable();
5 var hashSelectedCompany = new Hashtable();
6 var hashSelectedNodes = new Hashtable();
7
8 //该function用于解析绑定MzTreeView的数据
9 function FillData(arrAreaRegion, arrRegionCountry, arrCountryCompany, arrSelectedCompany) {
10 hashArea.clear();
11 hashRegion.clear();
12 hashCountry.clear();
13 hashCompany.clear();
14 hashSelectedCompany.clear();
15 hashSelectedNodes.clear();
16
17 var data = {};
18
19 for (var i = 0; i < arrSelectedCompany.length; i++) {
20 hashSelectedCompany.add(arrSelectedCompany[i], arrSelectedCompany[i]);
21 }
22
23 data["-1_Root"] = "text: Statuary;";
24
25 //fill Area and Region
26 for (var i = 0; i < arrAreaRegion.length - 1; i++) {
27 if (arrAreaRegion[i] != "") {
28 arrTmp = arrAreaRegion[i].split("`");
29 if (arrTmp.length > 1) {
30 var a = arrTmp[0].trim();
31 var b = arrTmp[1].trim();
32 if (!hashArea.contains(a)) {
33 hashArea.add(a, "Root");
34 data["Root_" + a] = "text:" + a + ";";//Area
35 }
36 if (!hashRegion.contains(b + "1")) {
37 hashRegion.add(b + "1", a);
38 data[a + "_" + b + "1"] = "text:" + b + ";";//Region
39 }
40 }
41 }
42 }
43
44 //fill Country
45 for (var j = 0; j < arrRegionCountry.length - 1; j++) {
46 if (arrRegionCountry[j] != "") {
47 arrTmp = arrRegionCountry[j].split("`");
48 if (arrTmp.length > 1) {
49 var a = arrTmp[0].trim();
50 var b = arrTmp[1].trim();
51 if (!hashCountry.contains(b + "11")) {
52 hashCountry.add(b + "11", a + "1");
53 data[a + "1_" + b + "11"] = "text:" + b + ";";//Country
54 }
55 }
56 }
57 }
58
59 //fill Company
60 var t;
61 for (var l = 0; l < arrCountryCompany.length - 1; l++) {
62 if (arrCountryCompany[l] != "") {
63 arrTmp = arrCountryCompany[l].split("`");
64 if (arrTmp.length > 1) {
65 t = arrTmp[1].split(";#");
66 if (t.length > 1) {
67 var a = arrTmp[0].trim();
68 var b = t[1].trim();
69 if (!hashCompany.contains(b)) {
70 hashCompany.add(b, a);
71 data[a + "11_" + t[0]] = "text:" + b + ";";
72 if (hashSelectedCompany.contains(b.split("-")[0])) {
73 data[a + "11_" + t[0]] += "checked:true;"; //CompandCode
74 hashSelectedNodes.add(a + "11", "");
75 }
76 }
77 }
78 }
79 }
80 }
81
82 return data;
83 }
84
85 //有选择性地打开树中的分支,只要分支打开了,该分支下设置为checked=true的节点就能自动被选择上
86 function expandNodes(a) {
87 if (hashSelectedNodes.count > hashCompany.count / 2) {
88 a.expandAll("Root");
89 return;
90 }
91 var _nodesCountry = new Hashtable();
92 var _nodesRegion = new Hashtable();
93
94 for (var i in hashSelectedNodes._hash) {
95 if (hashCountry.contains(i)) {
96 _nodesCountry.add(hashCountry.items(i), "");
97 }
98 }
99 for (var j in _nodesCountry._hash) {
100 if (hashRegion.contains(j)) {
101 _nodesRegion.add(hashRegion.items(j), "");
102 }
103 }
104 //expand region level
105 for (var i in _nodesRegion._hash) {
106 a.expand(i);
107 }
108 //expand country level
109 for (var j in _nodesCountry._hash) {
110 a.expand(j);
111 }
112 //expand company level
113 for (var k in hashSelectedNodes._hash) {
114 a.expand(k);
115 }
116 }
先说明一下,读者可能看得不是很明白。首先我要构造的树总共有四层,这是我预先知道的,Area-Region-Country-Company,我们要设置的节点位于叶子节点,也就是Company这一级,FillData函数中分三个for循环分别构造了这四层,在最后一层的时候我根据hashSelectedCompany中的值来判断是否要将该节点的checked设置为true,如果设置为true,就需要保存它的父节点的id到hashSelectedNodes中。在expandNodes函数中,我们需要根据这个hashSelectedNodes来找到相应的Country和Region,然后逐级打开Area、Region、Country,设置为checked=true的节点就会自动被选择上。当然,如果你初始设置时被选择的节点数目很多,这个函数执行的效率不会很高,所以我在函数开始的地方添加了一个判断,如果你所要设置的节点的数目超过所有节点数目的一半,那么我干脆就将树全部展开,而不用再去逐级打开节点了。还有一个前提条件,那就是MzTreeView在Render完后,我们要默认expand第一级,否则expandNodes函数在expand Region的时候便会报找不到对象的错误。
上面的代码中,读者可能会问,FillData的时候是用来给data数组按照MzTreeView所要求的格式填充数据的,为什么我会在里面加上1 和11呢?这也是我在使用MzTreeView时所遇到的另外一个问题,即节点id重复怎么办?
先分析MzTreeView中用于绑定数据时的结构,我用的不一定是数字,而是文本(因为后台传递过来的数据只有这些):
2 data["Root_APAC"] = "text: APAC";
3 data["APAC_Australia"] = "text: Australia";
4 data["Australia_Australia"] = "text: Australia";
5
继续测试,发现有些节点死活都没有出现在树中,为什么?调试,跟踪,再调试,再跟踪…后来终于发现问题所在,原来在我的数据源中含有小括号,又因为我是直接用节点的名称作为id来绑定数据的,MzTreeView的代码中使用了正则表达式来进行节点查找,而我后来在调试中发觉这个正则表达式没有对特殊字符进行特殊处理,例如我这里遇到的小括号(小括号在正则表达式中也是特殊字符),下面是MzTreeView中mzdata.js文件中的原始代码。
2 MzData.prototype.getNodeById = function(id)
3 {
4 if(id==this.rootId&&this.rootNode.virgin) return this.rootNode;
5 var _=this.get__(), d = this.dividerEncoding;
6 var reg=new RegExp("([^"+_+ d +"]+"+ d + id +")("+_+"|$)");
7 if(reg.test(this.indexes)){var s=RegExp.$1;
8 if(s=this.dataSource[s].getAttribute("index_"+ this.hashCode))
9 return this.nodes[s];
10 else{System._alert("The node isn't initialized!"); return null;}}
11 alert("sourceId="+ id +" is nonexistent!"); return null;
12 };
reg.test(this.indexes)判断的结果为false,也就是说reg的正则匹配失败,原因就是id的值中含有了小括号。我的想法是在进行正则表达式之前将id值中的小括号统一替换成别的字符,然后进行正则表达式匹配,匹配完后再替换回小括号,下面是我修改之后的代码。
2 {
3 if(id==this.rootId&&this.rootNode.virgin) return this.rootNode;
4 var _=this.get__(), d = this.dividerEncoding;
5 //----------------
6 var _id = id;
7 _id = _id.replace(/\(/g, "jackyxu").replace(/\)/g, "xujacky");
8 var _indexes = this.indexes;
9 _indexes = _indexes.replace(/\(/g, "jackyxu").replace(/\)/g, "xujacky");
10 var reg=new RegExp("([^"+_+ d +"]+"+ d + _id +")("+_+"|$)");
11 if (reg.test(_indexes)) { var s = RegExp.$1.replace(/(jackyxu)/g, "(").replace(/(xujacky)/g, ")");
12 //----------------
13 if(s=this.dataSource[s].getAttribute("index_"+ this.hashCode))
14 return this.nodes[s];
15 else{System._alert("The node isn't initialized!"); return null;}}
16 alert("sourceId="+ id +" is nonexistent!"); return null;
17 };
因为实在不知道用什么字符来替换小括号(很多键盘上的字符都被用作正则表达式的特殊字符了),我就用jackyxu来代替“(”,用xujacky来代替“)”,正则表达式匹配后再替换回来。在mzdata.js文件中还有一个地方也需要类似修改,下面是原始代码和修改后的代码。
2 {
3 var $=this.$$caller,r=$.dividerEncoding,_=$.get__(), i, cs;
4 var tcn=this.childNodes;tcn.length=0;if(this.sourceIndex){
5 if((i=this.get("JSData"))) $.loadJsData((/^\w+\.js(\s|\?|$)/i.test(i)?$.jsDataPath:"")+i);
6 if((i=this.get("ULData"))) $.loadUlData(i, this.id);
7 if((i=this.get("XMLData")))$.loadXmlData((/^\w+\.xml(\s|\?|$)/i.test(i)?$.xmlDataPath:"")+i,this.id);}
8 var reg=new RegExp(_ + this.id + r +"[^"+ _ + r +"]+", "g");
9 if((cs=$.indexes.match(reg))){for(i=0;i<cs.length;i++){
10 tcn[tcn.length]=this.DTO(DataNodeClass, cs[i].substr(_.length));}}
11 this.isLoaded = true;
12 };
2 {
3 var $=this.$$caller,r=$.dividerEncoding,_=$.get__(), i, cs;
4 var tcn=this.childNodes;tcn.length=0;if(this.sourceIndex){
5 if((i=this.get("JSData"))) $.loadJsData((/^\w+\.js(\s|\?|$)/i.test(i)?$.jsDataPath:"")+i);
6 if((i=this.get("ULData"))) $.loadUlData(i, this.id);
7 if((i=this.get("XMLData")))$.loadXmlData((/^\w+\.xml(\s|\?|$)/i.test(i)?$.xmlDataPath:"")+i,this.id);}
8 //----------------
9 var _id = this.id;
10 _id = _id.replace(/\(/g, "jackyxu").replace(/\)/g, "xujacky");
11 var _indexes = $.indexes;
12 _indexes = _indexes.replace(/\(/g, "jackyxu").replace(/\)/g, "xujacky");
13 var reg=new RegExp(_ + _id + r +"[^"+ _ + r +"]+", "g");
14 if((cs=_indexes.match(reg))){for(i=0;i<cs.length;i++){
15 tcn[tcn.length]=this.DTO(DataNodeClass, cs[i].substr(_.length).replace(/(jackyxu)/g, "(").replace(/(xujacky)/g, ")"));}}
16 //----------------
17 this.isLoaded = true;
18 };
当然,可能需要替换的字符不止是小括号,或者中括号、大括号等,但在我的项目中只会遇到使用小括号的情况,于是我也就只做了这样的修改,如果想一劳永逸,读者可以按照相似的方式专门编写一个function进行正则表达式中特殊字符的处理。经过修改后的代码可以使名称中带有小括号的的节点成功在树中显示,于是这个问题就解决了。
接下来是不是就没有问题了呢?不然!梅花雪的示例页面中树是在页面load的时候加载上去的,没有通过按钮事件进行加载,就是说在页面上没有反复加载的过程,而在我的需求中是需要这么做的,于是我把树的加载过程放在一个按钮的事件中,这样当我想重新加载树的时候只需要点击按钮即可,不过这个时候问题就来了,每次我点击按钮重新加载树的时候根节点前面都出现了莫名其妙的空白!看看下面的截图。
Statutory是根节点,可是前面却出现了空白,似乎梅花雪在树加载的时候将它作为一个子节点处理了。我试着点了点这个空白的地方,发现是一张图片,就是子节点前面的虚线。找到问题所在就好办了,看看代码吧!根节点只会在树Render的时候显示出来,那么问题应该出现在MzTreeView.js文件的Render方法中。
2 {
3 function loadImg(C){for(var i in C){if("string"==typeof C[i]){
4 var a=new Image(); a.src=me.iconPath + C[i]; C[i]=a;}}} var me=this;
5 loadImg(MzTreeView.icons.expand);loadImg(MzTreeView.icons.collapse);
6 loadImg(MzTreeView.icons.line); me.firstNode=null;
7 loadCssFile(this.iconPath +"mztreeview.css", "MzTreeView_CSS");
8
9 this.initialize(); var str="no data", i, root=this.rootNode;
10 if (root.hasChild){var a = [], c = root.childNodes; me.firstNode=c[0];
11 for(i=0;i<c.length;i++)a[i]=c[i].render(i==c.length-1);str=a.join("");}
12 setTimeout(function(){me.afterRender();}, 10);
13 return "<div class='mztreeview' id='MTV_root_"+ this.index +"' "+
14 "onclick='Instance(\""+ this.index +"\").clickHandle(event)' "+
15 "ondblclick='Instance(\""+ this.index +"\").dblClickHandle(event)' "+
16 ">"+ str +"</div>";
17 };
2 {
3 var $=this.$$caller, s=$.dataSource[this.sourceIndex],target,data,url;
4 var icon=s.getAttribute("icon");
5 if(!(target=s.getAttribute("target")))target=$.getDefaultTarget();
6 var hint=$.showToolTip ? s.getAttribute("hint") || this.text : "";
7 if(!(url=s.getAttribute("url"))) url = $.getDefaultUrl();
8 if(data=s.getAttribute("data"))url+=(url.indexOf("?")==-1?"?":"&")+data;
9
10 var id=this.index, s="";
11 var isRoot=this.parentNode==$.rootNode;
12 if( isRoot && $.convertRootIcon && !icon) icon = "root";
13 if(!isRoot)this.childPrefix=this.parentNode.childPrefix+(last?",ll":",l4");
14 if(!icon || typeof(MzTreeView.icons.collapse[icon])=="undefined")
15 this.icon = this.hasChild ? "folder" : "file"; else this.icon = icon;
16 this.line = this.hasChild ? (last ? "pm2" : "pm1") : (last ? "l2" : "l1");
17 if(!$.showLines) this.line = this.hasChild ? "pm3" : "ll";
18
19 s += "<div><table border='0' cellpadding='0' cellspacing='0'>"+
20 "<tr title='"+ hint +"'><td>"; if (MzTreeNode.htmlChildPrefix)
21 s += MzTreeNode.htmlChildPrefix +"</td><td>"; if(!isRoot)
22 s += "<img border='0' id='"+ $.index +"_expand_"+ id +"' src='"+
23 (this.hasChild ? MzTreeView.icons.collapse[this.line].src :
24 MzTreeView.icons.line[this.line].src)+"'>"; if($.showNodeIcon)
25 s += "<img border='0' id='"+ $.index +"_icon_"+ id +"' src='"+
26 MzTreeView.icons.collapse[this.icon].src +"'>"; if($.useCheckbox)
27 s += "<img border='0' id='"+$.index +"_checkbox_"+ id +"' src='"+
28 MzTreeView.icons.line["c"+ (this.checked?1:0)].src +"'>";
29 s += "</td><td style='padding-left: 3px' nowrap='true'><a href='"+ url +
30 "' target='"+ target +"' id='"+$.index +"_link_"+ id +
31 "' class='MzTreeView'>"+ this.text +"</a></td></tr></table><div ";
32 if(isRoot&&this.text=="") s="<div><div ";
33 s += "id='"+$.index+"_tree_"+id+"' style='display: none;'></div></div>";
34 return s;
35 };
2 {
3 var me=this;
4 if(document.getElementById("MTV_root_"+ me.index))
5 {
6 //if(me.firstNode)(me.currentNode=me.firstNode).focus();
7 this.dispatchEvent(new System.Event("onrender"));
8 if(me.useArrow) me.attachEventArrow();
9 }
10 else setTimeout(function(){me.afterRender();}, 100);
11 };
isend——判断树是否被加载完毕
lastNode——最后的节点
在树Render之前给lastNode赋值(最后一个节点的id),setInterval方法中不断判断树是否已被加载完毕,然后做相应的处理,读者可以参考我附件中的例子。需要修改MzTreeView.js文件中的MzTreeNode.prototype.expandLevel方法如下。
2 if (level <= 0) return;
3 level--; var me = this;
4 if (this.hasChild && !this.expanded) this.expand();
5 if (level == 1) {
6 if (this.hasChild) {
7 var $ = this.$$caller;
8 for (i = 0; i < this.childNodes.length; i++)
9 if ($.lastNode != "" && this.childNodes[i].text == $.lastNode) $.isend = true;
10 }
11 }
12 for (var x = this.$$caller.index, i = 0, n = this.childNodes.length; i < n; i++) {
13 var node = this.childNodes[i], d = node.index; if (node.hasChild)
14 setTimeout("Instance('" + x + "').nodes['" + d + "'].expandLevel(" + level + ")", 1);
15 }
16 };
不过有个小小的缺陷,那就是采用上述方式loading树的时候用于提示用户的gif图片不动了,直到整棵树加载完成。这个问题是由于js本身是单线程处理的语言造成的,当js在采用setInterval方法加载树的过程中一直会占用CPU的处理时间,这时便没有功夫去处理gif图片的动画了。感兴趣的读者可以改造附件中的代码,自己模拟多线程使gif图片动起来。不过我最终还是放弃了使用gif图片进行提示,改用文字了,反正达到预期的效果就好。
结论:总体来说,梅花雪树的效果还是不错的,尤其在动态加载树的节点,采用不同的方式绑定数据,并且还可以为树的节点提供右键菜单,功能很强大,也为采用客户端树的开发人员提供了很大的便利。个人认为,web开发中采用客户端树总比采用服务器端树效率要高,但复杂度高并且难以控制,适当并合理地借鉴前人的经验,将会省去很多不必要的麻烦。虽然梅花雪树经过了2个版本,但是在实际应用中还是暴露了不少的问题,作者给出的开发文档和注释也不全面,园子里有不少热心的朋友都对梅花雪树改造过,希望大家可以共同努力,打造一个功能更强大、更完美的梅花雪树!!
顺便给出梅花雪树2.0中重要文件的说明,其中标记为红色的是主要修改的文件或资源。为了适应我的项目,经过修改后的梅花雪树中删除了部分没有用的文件(如采用js和xml文件绑定数据等),修改了部分加载资源的路径等。
scripts\csdn\community\treedata\ | 该目录下的文件用于绑定树的数据源,包括js文件和xml文件。 |
scripts\jsframework.js | mztreeview主框架代码文件。 |
scripts\system\_resource\mztreeview\ | 主要资源文件,包括样式表和图片。 |
scripts\system\data\mzdata.js | 用于处理mztreeview节点数据。 |
scripts\system\net\mzcookie.js | mztreeview的状态可以保存到cookie中,该文件用于处理cookie的保存和获取。 |
scripts\system\plugins\ | 支持多浏览器版本数据加载的处理。 |
scripts\system\web\ui\webcontrols\mztreeview.js | 生成mztreeview主结构的代码文件。 |
scripts\system\web\forms\mzeffect.js | mztreeview特殊效果处理文件。 |
scripts\system\xml\mzxmldocument.js | 用于处理xml文档。 |
scripts\system\global.js | mztreeview全局配置文件。 |