本文讨论了treefilter实现tree的查询,和后台返回路径的方式,来实现对extjs tree的搜索。
介绍部分内容来源网络,先谢谢该作者。
在任何一个Tree树中,提供查找功能无疑会大大方便用户。不用睁大眼睛一级一级去展开,只要输入关键字,回车就能自动定位到节点,岂不快哉?然后再次回车查找下一个。这样的用户体验是相当完美的 ( 见《用户体验这点事儿》)。但在动态异步加载的Tree树中,客户端实现这样的功能就有点困难,因为节点是异步动态加载的。默认是没有全部从服务器端取回的,通常的做法是默认加载第一级,其他级的节点都是惰性按需加载的,用户点了才会展开。而对于这个没有完全加载的树,用户希望搜索节点,怎么实现?笨办法是先展开树的所有节点,然后再在树中搜索。这样的话在服务器数据量大的情况下会非常慢。这里的实现方法是在服务器端的WebService查找,通过AJAX返回第一个匹配节点的路径Path,然后展开这个路径,选中这个搜索到节点。查找下一个(FindNext)的实现方法是…
(提供查找功能的树Tree无疑会大大方便用户)
在任何一个Tree树中,提供查找功能无疑会大大方便用户。不用睁大眼睛一级一级去展开,只要输入关键字,回车就能自动定位到节点,岂不快哉?然后再次回车查找下一个。这样的用户体验是相当完美的。
但在动态异步加载的Tree树中,客户端实现这样的功能就有点困难,因为节点是异步动态加载的。默认是没有全部从服务器端取回的,通常的做法是默认加载第一级,其他级的节点都是惰性按需加载的,用户点了才会展开。而对于这个没有完全加载的树,用户希望搜索节点,怎么实现?笨办法是先展开树的所有节点,然后再在树中搜索。这样的话在服务器数据量大的情况下会非常慢。
这里的实现方法是在服务器端的WebService查找,通过AJAX返回第一个匹配节点的路径Path,然后展开这个路径,选中这个搜索到节点。
在ExtJS中,AsyncTreeNode是异步节点,TreeLoader实现对树结点的异步加载,即使服务器取到大量的数据,也没有问题,异步加载能保证性能和节点的按需加载。服务端需要生成指定格式的Json字符串。
首先这儿有一个extjs的动态树,默认加载第一级,其他的级按需加载。(由于工程文件太大,s2sh+oracle,后台代码不提供。)下面给出利用treefilter和返回展开路径的web端代码:
dymicTree是一个treePanel,动态树。
var timeOutId = null; /** * 定义一个treefilter,采用第一种方式对动态树进行过滤,当然首先要展开树 * @author centre */ var treeFilter = new Ext.tree.TreeFilter(dymicTree, { clearBlank : true, autoClear : true }); // 保存上次隐藏的空节点 var hiddenPkgs = []; /** * 通过关键字查找树的节点,并定位,这儿有2种实现方式, 1.通过treeFilter,当数据量小的时候是可行的 2.通过返回tree路径,对应数据量大的时候 * * @param {} * node * @param {} * event * @author centre */ var findByKeyWordFiler = function(node, event) { // 清除timeOutId clearTimeout(timeOutId); // 展开树节点 dymicTree.expandAll(); // 为了避免重复的访问后台,给服务器造成的压力,采用timeOutId进行控制,如果采用treeFilter也可以造成重复的keyup timeOutId = setTimeout(function() { // 获取输入框的值。 var text = node.getValue(); // 根据输入制作一个正则表达式,'i'代表不区分大小写 var re = new RegExp(Ext.escapeRe(text), 'i'); // 先要显示上次隐藏掉的节点 Ext.each(hiddenPkgs, function(n) { n.ui.show(); }); hiddenPkgs = []; if (text != "") { treeFilter.filterBy(function(n) { // 只过滤叶子节点,这样省去枝干被过滤的时候,底下的叶子都无法显示 return !n.isLeaf() || re.test(n.text); }); // 如果这个节点不是叶子,而且下面没有子节点,就应该隐藏掉 dymicTree.root.cascade(function(n) { if(n.id!='0'){ if(!n.isLeaf() &&judge(n,re)==false&& !re.test(n.text)){ hiddenPkgs.push(n); n.ui.hide(); } } }); } else { treeFilter.clear(); return; } }, 500); } //过滤不匹配的非叶子节点或者是叶子节点 /** * * @param {Object} n * @param {Object} re * @return {TypeName} * @author yang */ var judge =function(n,re){ var str=false; n.cascade(function(n1){ if(n1.isLeaf()) { if(re.test(n1.text)){str=true;return;} } else{ if(re.test(n1.text)){str=true;return;} } }); return str; }; /** * 根据路径展开节点后,选中该节点 * @param {} bSuccess * @param {} oLastNode */ function onExpandPathComplete(bSuccess, oLastNode) { if (!bSuccess) return; // focus 节点,并选中节点! selectNode(oLastNode); } /** * 选中传入的节点,并单击此节点 * * @param {} * node 传入节点对象 * @author centre */ var selectNode = function(node) { node.ensureVisible(); node.select(); node.fireEvent('click', node); } /** * 通过关键字查找树的节点,并定位,这儿有2种实现方式, 1.通过treeFilter,当数据量小的时候是可行的 2.通过返回tree路径,对应数据量大的时候 * 支持模糊查询 * * @param {} * node * @param {} * event * @author centre */ var findByKeyWordPath = function(node, event) { // 清除timeOutId clearTimeout(timeOutId); timeOutId = setTimeout(function() { // 获取输入框的值。 var text = node.getValue().trim(); // 采用ajax获得需要展开的路径 if (text != "") { Ext.Ajax.request({ url : 'jsps/servlet/findByKeyword.jsp', success : function(response, opts) { var obj = Ext.decode(response.responseText); var length = obj.record.length; dymicTree.root.reload(); for (var i = 0; i < length; i++) { var path = obj.record[i].path; dymicTree.expandPath(path,'id',onExpandPathComplete); } }, failure : function(response, opts) { Ext.Msg.alert("错误提示", "请求失败,请与开发人员联系。") .setIcon(Ext.MessageBox.ERROR); }, params : { keyWord : text } }); } else { // Ext.Msg.alert("信息", // "请输入关键字").setIcon(Ext.MessageBox.INFO); // return; } }, 500); } /** * 给输入框绑定keyup事件,需要加上enableKeyEvents:true才能让extjs的textfield代理 鼠标事件 * * @author centre */ searchBox.findById("keyWord").on("keyup", function(node, event) { findByKeyWordFiler(node, event); });
以下是extjs官方帮助文档的代码,可以参考:
Ext.BLANK_IMAGE_URL = 'resources/s.gif'; Docs = {}; ApiPanel = function() { ApiPanel.superclass.constructor.call(this, { id:'api-tree', region:'west', split:true, header: false, width: 280, minSize: 175, maxSize: 500, collapsible: true, margins:'0 0 5 5', cmargins:'0 0 0 0', rootVisible:false, lines:false, autoScroll:true, animCollapse:false, animate: false, collapseMode:'mini', loader: new Ext.tree.TreeLoader({ preloadChildren: true, clearOnLoad: false }), root: new Ext.tree.AsyncTreeNode({ text:'Ext JS', id:'root', expanded:true, children:[Docs.classData] }), collapseFirst:false }); // no longer needed! //new Ext.tree.TreeSorter(this, {folderSort:true,leafAttr:'isClass'}); this.getSelectionModel().on('beforeselect', function(sm, node){ return node.isLeaf(); }); }; Ext.extend(ApiPanel, Ext.tree.TreePanel, { initComponent: function(){ this.hiddenPkgs = []; Ext.apply(this, { tbar:[ ' ', new Ext.form.TextField({ width: 200, emptyText:'Find a Class', enableKeyEvents: true, listeners:{ render: function(f){ this.filter = new Ext.tree.TreeFilter(this, { clearBlank: true, autoClear: true }); }, keydown: { fn: this.filterTree, buffer: 350, scope: this }, scope: this } }), ' ', ' ', { iconCls: 'icon-expand-all', tooltip: 'Expand All', handler: function(){ this.root.expand(true); }, scope: this }, '-', { iconCls: 'icon-collapse-all', tooltip: 'Collapse All', handler: function(){ this.root.collapse(true); }, scope: this }] }) ApiPanel.superclass.initComponent.call(this); }, filterTree: function(t, e){ var text = t.getValue(); Ext.each(this.hiddenPkgs, function(n){ n.ui.show(); }); if(!text){ this.filter.clear(); return; } this.expandAll(); var re = new RegExp('^' + Ext.escapeRe(text), 'i'); this.filter.filterBy(function(n){ return !n.attributes.isClass || re.test(n.text); }); // hide empty packages that weren't filtered this.hiddenPkgs = []; var me = this; this.root.cascade(function(n){ if(!n.attributes.isClass && n.ui.ctNode.offsetHeight < 3){ n.ui.hide(); me.hiddenPkgs.push(n); } }); }, selectClass : function(cls){ if(cls){ var parts = cls.split('.'); var last = parts.length-1; var res = []; var pkg = []; for(var i = 0; i < last; i++){ // things get nasty - static classes can have . var p = parts[i]; var fc = p.charAt(0); var staticCls = fc.toUpperCase() == fc; if(p == 'Ext' || !staticCls){ pkg.push(p); res[i] = 'pkg-'+pkg.join('.'); }else if(staticCls){ --last; res.splice(i, 1); } } res[last] = cls; this.selectPath('/root/apidocs/'+res.join('/')); } } }); DocPanel = Ext.extend(Ext.Panel, { closable: true, autoScroll:true, initComponent : function(){ var ps = this.cclass.split('.'); this.title = ps[ps.length-1]; Ext.apply(this,{ tbar: ['->',{ text: 'Config Options', handler: this.scrollToMember.createDelegate(this, ['configs']), iconCls: 'icon-config' },'-',{ text: 'Properties', handler: this.scrollToMember.createDelegate(this, ['props']), iconCls: 'icon-prop' }, '-',{ text: 'Methods', handler: this.scrollToMember.createDelegate(this, ['methods']), iconCls: 'icon-method' }, '-',{ text: 'Events', handler: this.scrollToMember.createDelegate(this, ['events']), iconCls: 'icon-event' }, '-',{ text: 'Direct Link', handler: this.directLink, scope: this, iconCls: 'icon-fav' }, '-',{ tooltip:'Hide Inherited Members', iconCls: 'icon-hide-inherited', enableToggle: true, scope: this, toggleHandler : function(b, pressed){ this.body[pressed ? 'addClass' : 'removeClass']('hide-inherited'); } }, '-', { tooltip:'Expand All Members', iconCls: 'icon-expand-members', enableToggle: true, scope: this, toggleHandler : function(b, pressed){ this.body[pressed ? 'addClass' : 'removeClass']('full-details'); } }] }); DocPanel.superclass.initComponent.call(this); }, directLink : function(){ var link = String.format( "<a href=\"{0}\" target=\"_blank\">{0}</a>", document.location.href+'?class='+this.cclass ); Ext.Msg.alert('Direct Link to ' + this.cclass,link); }, scrollToMember : function(member){ var el = Ext.fly(this.cclass + '-' + member); if(el){ var top = (el.getOffsetsTo(this.body)[1]) + this.body.dom.scrollTop; this.body.scrollTo('top', top-25, {duration:0.75, callback: this.hlMember.createDelegate(this, [member])}); } }, scrollToSection : function(id){ var el = Ext.getDom(id); if(el){ var top = (Ext.fly(el).getOffsetsTo(this.body)[1]) + this.body.dom.scrollTop; this.body.scrollTo('top', top-25, {duration:0.5, callback: function(){ Ext.fly(el).next('h2').pause(0.2).highlight('#8DB2E3', {attr:'color'}); }}); } }, hlMember : function(member){ var el = Ext.fly(this.cclass + '-' + member); if(el){ if (tr = el.up('tr')) { tr.highlight('#cadaf9'); } } } }); MainPanel = function(){ this.searchStore = new Ext.data.Store({ proxy: new Ext.data.ScriptTagProxy({ url: 'http://extjs.com/playpen/api.php' }), reader: new Ext.data.JsonReader({ root: 'data' }, ['cls', 'member', 'type', 'doc'] ), baseParams: {}, listeners: { 'beforeload' : function(){ this.baseParams.qt = Ext.getCmp('search-type').getValue(); } } }); MainPanel.superclass.constructor.call(this, { id:'doc-body', region:'center', margins:'0 5 5 0', resizeTabs: true, minTabWidth: 135, tabWidth: 135, plugins: new Ext.ux.TabCloseMenu(), enableTabScroll: true, activeTab: 0, items: { id:'welcome-panel', title: 'API Home', autoLoad: {url: 'welcome.html', callback: this.initSearch, scope: this}, iconCls:'icon-docs', autoScroll: true, tbar: [ 'Search: ', ' ', new Ext.ux.SelectBox({ listClass:'x-combo-list-small', width:90, value:'Starts with', id:'search-type', store: new Ext.data.SimpleStore({ fields: ['text'], expandData: true, data : ['Starts with', 'Ends with', 'Any match'] }), displayField: 'text' }), ' ', new Ext.app.SearchField({ width:240, store: this.searchStore, paramName: 'q' }) ] } }); }; Ext.extend(MainPanel, Ext.TabPanel, { initEvents : function(){ MainPanel.superclass.initEvents.call(this); this.body.on('click', this.onClick, this); }, onClick: function(e, target){ if(target = e.getTarget('a:not(.exi)', 3)){ var cls = Ext.fly(target).getAttributeNS('ext', 'cls'); e.stopEvent(); if(cls){ var member = Ext.fly(target).getAttributeNS('ext', 'member'); this.loadClass(target.href, cls, member); }else if(target.className == 'inner-link'){ this.getActiveTab().scrollToSection(target.href.split('#')[1]); }else{ window.open(target.href); } }else if(target = e.getTarget('.micon', 2)){ e.stopEvent(); var tr = Ext.fly(target.parentNode); if(tr.hasClass('expandable')){ tr.toggleClass('expanded'); } } }, loadClass : function(href, cls, member){ var id = 'docs-' + cls; var tab = this.getComponent(id); if(tab){ this.setActiveTab(tab); if(member){ tab.scrollToMember(member); } }else{ var autoLoad = {url: href}; if(member){ autoLoad.callback = function(){ Ext.getCmp(id).scrollToMember(member); } } var p = this.add(new DocPanel({ id: id, cclass : cls, autoLoad: autoLoad, iconCls: Docs.icons[cls] })); this.setActiveTab(p); } }, initSearch : function(){ // Custom rendering Template for the View var resultTpl = new Ext.XTemplate( '<tpl for=".">', '<div class="search-item">', '<a class="member" ext:cls="{cls}" ext:member="{member}" href="output/{cls}.html">', '<img src="../resources/images/default/s.gif" class="item-icon icon-{type}"/>{member}', '</a> ', '<a class="cls" ext:cls="{cls}" href="output/{cls}.html">{cls}</a>', '<p>{doc}</p>', '</div></tpl>' ); var p = new Ext.DataView({ applyTo: 'search', tpl: resultTpl, loadingText:'Searching...', store: this.searchStore, itemSelector: 'div.search-item', emptyText: '<h3>Use the search field above to search the Ext API for classes, properties, config options, methods and events.</h3>' }); }, doSearch : function(e){ var k = e.getKey(); if(!e.isSpecialKey()){ var text = e.target.value; if(!text){ this.searchStore.baseParams.q = ''; this.searchStore.removeAll(); }else{ this.searchStore.baseParams.q = text; this.searchStore.reload(); } } } }); Ext.onReady(function(){ Ext.QuickTips.init(); var api = new ApiPanel(); var mainPanel = new MainPanel(); api.on('click', function(node, e){ if(node.isLeaf()){ e.stopEvent(); mainPanel.loadClass(node.attributes.href, node.id); } }); mainPanel.on('tabchange', function(tp, tab){ api.selectClass(tab.cclass); }); var viewport = new Ext.Viewport({ layout:'border', items:[ { cls: 'docs-header', height: 44, region:'north', xtype:'box', el:'header', border:false, margins: '0 0 5 0' }, api, mainPanel ] }); api.expandPath('/root/apidocs'); // allow for link in var page = window.location.href.split('?')[1]; if(page){ var ps = Ext.urlDecode(page); var cls = ps['class']; mainPanel.loadClass('output/' + cls + '.html', cls, ps.member); } viewport.doLayout(); setTimeout(function(){ Ext.get('loading').remove(); Ext.get('loading-mask').fadeOut({remove:true}); }, 250); }); Ext.app.SearchField = Ext.extend(Ext.form.TwinTriggerField, { initComponent : function(){ if(!this.store.baseParams){ this.store.baseParams = {}; } Ext.app.SearchField.superclass.initComponent.call(this); this.on('specialkey', function(f, e){ if(e.getKey() == e.ENTER){ this.onTrigger2Click(); } }, this); }, validationEvent:false, validateOnBlur:false, trigger1Class:'x-form-clear-trigger', trigger2Class:'x-form-search-trigger', hideTrigger1:true, width:180, hasSearch : false, paramName : 'query', onTrigger1Click : function(){ if(this.hasSearch){ this.store.baseParams[this.paramName] = ''; this.store.removeAll(); this.el.dom.value = ''; this.triggers[0].hide(); this.hasSearch = false; this.focus(); } }, onTrigger2Click : function(){ var v = this.getRawValue(); if(v.length < 1){ this.onTrigger1Click(); return; } if(v.length < 2){ Ext.Msg.alert('Invalid Search', 'You must enter a minimum of 2 characters to search the API'); return; } this.store.baseParams[this.paramName] = v; var o = {start: 0}; this.store.reload({params:o}); this.hasSearch = true; this.triggers[0].show(); this.focus(); } }); /** * Makes a ComboBox more closely mimic an HTML SELECT. Supports clicking and dragging * through the list, with item selection occurring when the mouse button is released. * When used will automatically set {@link #editable} to false and call {@link Ext.Element#unselectable} * on inner elements. Re-enabling editable after calling this will NOT work. * * @author Corey Gilmore * http://extjs.com/forum/showthread.php?t=6392 * * @history 2007-07-08 jvs * Slight mods for Ext 2.0 */ Ext.ux.SelectBox = function(config){ this.searchResetDelay = 1000; config = config || {}; config = Ext.apply(config || {}, { editable: false, forceSelection: true, rowHeight: false, lastSearchTerm: false, triggerAction: 'all', mode: 'local' }); Ext.ux.SelectBox.superclass.constructor.apply(this, arguments); this.lastSelectedIndex = this.selectedIndex || 0; }; Ext.extend(Ext.ux.SelectBox, Ext.form.ComboBox, { lazyInit: false, initEvents : function(){ Ext.ux.SelectBox.superclass.initEvents.apply(this, arguments); // you need to use keypress to capture upper/lower case and shift+key, but it doesn't work in IE this.el.on('keydown', this.keySearch, this, true); this.cshTask = new Ext.util.DelayedTask(this.clearSearchHistory, this); }, keySearch : function(e, target, options) { var raw = e.getKey(); var key = String.fromCharCode(raw); var startIndex = 0; if( !this.store.getCount() ) { return; } switch(raw) { case Ext.EventObject.HOME: e.stopEvent(); this.selectFirst(); return; case Ext.EventObject.END: e.stopEvent(); this.selectLast(); return; case Ext.EventObject.PAGEDOWN: this.selectNextPage(); e.stopEvent(); return; case Ext.EventObject.PAGEUP: this.selectPrevPage(); e.stopEvent(); return; } // skip special keys other than the shift key if( (e.hasModifier() && !e.shiftKey) || e.isNavKeyPress() || e.isSpecialKey() ) { return; } if( this.lastSearchTerm == key ) { startIndex = this.lastSelectedIndex; } this.search(this.displayField, key, startIndex); this.cshTask.delay(this.searchResetDelay); }, onRender : function(ct, position) { this.store.on('load', this.calcRowsPerPage, this); Ext.ux.SelectBox.superclass.onRender.apply(this, arguments); if( this.mode == 'local' ) { this.calcRowsPerPage(); } }, onSelect : function(record, index, skipCollapse){ if(this.fireEvent('beforeselect', this, record, index) !== false){ this.setValue(record.data[this.valueField || this.displayField]); if( !skipCollapse ) { this.collapse(); } this.lastSelectedIndex = index + 1; this.fireEvent('select', this, record, index); } }, render : function(ct) { Ext.ux.SelectBox.superclass.render.apply(this, arguments); if( Ext.isSafari ) { this.el.swallowEvent('mousedown', true); } this.el.unselectable(); this.innerList.unselectable(); this.trigger.unselectable(); this.innerList.on('mouseup', function(e, target, options) { if( target.id && target.id == this.innerList.id ) { return; } this.onViewClick(); }, this); this.innerList.on('mouseover', function(e, target, options) { if( target.id && target.id == this.innerList.id ) { return; } this.lastSelectedIndex = this.view.getSelectedIndexes()[0] + 1; this.cshTask.delay(this.searchResetDelay); }, this); this.trigger.un('click', this.onTriggerClick, this); this.trigger.on('mousedown', function(e, target, options) { e.preventDefault(); this.onTriggerClick(); }, this); this.on('collapse', function(e, target, options) { Ext.getDoc().un('mouseup', this.collapseIf, this); }, this, true); this.on('expand', function(e, target, options) { Ext.getDoc().on('mouseup', this.collapseIf, this); }, this, true); }, clearSearchHistory : function() { this.lastSelectedIndex = 0; this.lastSearchTerm = false; }, selectFirst : function() { this.focusAndSelect(this.store.data.first()); }, selectLast : function() { this.focusAndSelect(this.store.data.last()); }, selectPrevPage : function() { if( !this.rowHeight ) { return; } var index = Math.max(this.selectedIndex-this.rowsPerPage, 0); this.focusAndSelect(this.store.getAt(index)); }, selectNextPage : function() { if( !this.rowHeight ) { return; } var index = Math.min(this.selectedIndex+this.rowsPerPage, this.store.getCount() - 1); this.focusAndSelect(this.store.getAt(index)); }, search : function(field, value, startIndex) { field = field || this.displayField; this.lastSearchTerm = value; var index = this.store.find.apply(this.store, arguments); if( index !== -1 ) { this.focusAndSelect(index); } }, focusAndSelect : function(record) { var index = typeof record === 'number' ? record : this.store.indexOf(record); this.select(index, this.isExpanded()); this.onSelect(this.store.getAt(record), index, this.isExpanded()); }, calcRowsPerPage : function() { if( this.store.getCount() ) { this.rowHeight = Ext.fly(this.view.getNode(0)).getHeight(); this.rowsPerPage = this.maxHeight / this.rowHeight; } else { this.rowHeight = false; } } }); Ext.Ajax.on('requestcomplete', function(ajax, xhr, o){ if(typeof urchinTracker == 'function' && o && o.url){ urchinTracker(o.url); } });