jQuery UI 自动完成组合框在大型选择列表中非常缓慢。

64
我正在使用修改过的jQuery UI Autocomplete Combobox版本,如下所示: http://jqueryui.com/demos/autocomplete/#combobox 就这个问题而言,我们假设我有完全相同的代码^^^。
当打开组合框(通过单击按钮或聚焦于组合框文本输入)时,显示项目列表之前有很长的延迟。当选择列表拥有更多选项时,这种延迟变得更加明显。
这种延迟不仅发生在第一次,每次都会发生。
由于此项目中的某些选择列表非常大(数百个项目),因此延迟和浏览器冻结是无法接受的。
请问有没有人能指导我优化这个问题?甚至可以指出性能问题出在哪里?
我认为问题可能与脚本显示所有项目列表的方式有关(对空字符串进行自动完成搜索),是否有其他方法来显示所有项目?也许我可以构建一个一次性例子以显示所有项目(因为在开始输入之前打开列表是很常见的),而不执行所有正则表达式匹配?
以下是可供玩耍的jsfiddle: http://jsfiddle.net/9TaMu/

你可能会看到最大的速度提升,通过在创建小部件之前完成所有正则表达式和操作,这样当小部件被使用时只执行简单的数组/对象查找。 - Alec Gorge
5个回答

79

当前的组合框实现方式是每次展开下拉列表时都会清空并重新渲染完整列表。此外,您必须将minLength设置为0,因为它需要执行空搜索来获取完整列表。

以下是我自己扩展自动完成小部件的实现。在我的测试中,即使在IE 7和8上,它也可以轻松处理5000个项目的列表。它只会渲染完整列表一次,并在每次单击下拉按钮时重用它。这也消除了选项minLength = 0的依赖性。它还适用于数组和ajax作为列表源。此外,如果您有多个大型列表,则将小部件初始化添加到队列中,以便可以在后台运行而不会冻结浏览器。

<script>
(function($){
    $.widget( "ui.combobox", $.ui.autocomplete, 
        {
        options: { 
            /* override default values here */
            minLength: 2,
            /* the argument to pass to ajax to get the complete list */
            ajaxGetAll: {get: "all"}
        },

        _create: function(){
            if (this.element.is("SELECT")){
                this._selectInit();
                return;
            }

            $.ui.autocomplete.prototype._create.call(this);
            var input = this.element;
            input.addClass( "ui-widget ui-widget-content ui-corner-left" );

            this.button = $( "<button type='button'>&nbsp;</button>" )
            .attr( "tabIndex", -1 )
            .attr( "title", "Show All Items" )
            .insertAfter( input )
            .button({
                icons: { primary: "ui-icon-triangle-1-s" },
                text: false
            })
            .removeClass( "ui-corner-all" )
            .addClass( "ui-corner-right ui-button-icon" )
            .click(function(event) {
                // close if already visible
                if ( input.combobox( "widget" ).is( ":visible" ) ) {
                    input.combobox( "close" );
                    return;
                }
                // when user clicks the show all button, we display the cached full menu
                var data = input.data("combobox");
                clearTimeout( data.closing );
                if (!input.isFullMenu){
                    data._swapMenu();
                    input.isFullMenu = true;
                }
                /* input/select that are initially hidden (display=none, i.e. second level menus), 
                   will not have position cordinates until they are visible. */
                input.combobox( "widget" ).css( "display", "block" )
                .position($.extend({ of: input },
                    data.options.position
                    ));
                input.focus();
                data._trigger( "open" );
            });

            /* to better handle large lists, put in a queue and process sequentially */
            $(document).queue(function(){
                var data = input.data("combobox");
                if ($.isArray(data.options.source)){ 
                    $.ui.combobox.prototype._renderFullMenu.call(data, data.options.source);
                }else if (typeof data.options.source === "string") {
                    $.getJSON(data.options.source, data.options.ajaxGetAll , function(source){
                        $.ui.combobox.prototype._renderFullMenu.call(data, source);
                    });
                }else {
                    $.ui.combobox.prototype._renderFullMenu.call(data, data.source());
                }
            });
        },

        /* initialize the full list of items, this menu will be reused whenever the user clicks the show all button */
        _renderFullMenu: function(source){
            var self = this,
                input = this.element,
                ul = input.data( "combobox" ).menu.element,
                lis = [];
            source = this._normalize(source); 
            input.data( "combobox" ).menuAll = input.data( "combobox" ).menu.element.clone(true).appendTo("body");
            for(var i=0; i<source.length; i++){
                lis[i] = "<li class=\"ui-menu-item\" role=\"menuitem\"><a class=\"ui-corner-all\" tabindex=\"-1\">"+source[i].label+"</a></li>";
            }
            ul.append(lis.join(""));
            this._resizeMenu();
            // setup the rest of the data, and event stuff
            setTimeout(function(){
                self._setupMenuItem.call(self, ul.children("li"), source );
            }, 0);
            input.isFullMenu = true;
        },

        /* incrementally setup the menu items, so the browser can remains responsive when processing thousands of items */
        _setupMenuItem: function( items, source ){
            var self = this,
                itemsChunk = items.splice(0, 500),
                sourceChunk = source.splice(0, 500);
            for(var i=0; i<itemsChunk.length; i++){
                $(itemsChunk[i])
                .data( "item.autocomplete", sourceChunk[i])
                .mouseenter(function( event ) {
                    self.menu.activate( event, $(this));
                })
                .mouseleave(function() {
                    self.menu.deactivate();
                });
            }
            if (items.length > 0){
                setTimeout(function(){
                    self._setupMenuItem.call(self, items, source );
                }, 0);
            }else { // renderFullMenu for the next combobox.
                $(document).dequeue();
            }
        },

        /* overwrite. make the matching string bold */
        _renderItem: function( ul, item ) {
            var label = item.label.replace( new RegExp(
                "(?![^&;]+;)(?!<[^<>]*)(" + $.ui.autocomplete.escapeRegex(this.term) + 
                ")(?![^<>]*>)(?![^&;]+;)", "gi"), "<strong>$1</strong>" );
            return $( "<li></li>" )
                .data( "item.autocomplete", item )
                .append( "<a>" + label + "</a>" )
                .appendTo( ul );
        },

        /* overwrite. to cleanup additional stuff that was added */
        destroy: function() {
            if (this.element.is("SELECT")){
                this.input.remove();
                this.element.removeData().show();
                return;
            }
            // super()
            $.ui.autocomplete.prototype.destroy.call(this);
            // clean up new stuff
            this.element.removeClass( "ui-widget ui-widget-content ui-corner-left" );
            this.button.remove();
        },

        /* overwrite. to swap out and preserve the full menu */ 
        search: function( value, event){
            var input = this.element;
            if (input.isFullMenu){
                this._swapMenu();
                input.isFullMenu = false;
            }
            // super()
            $.ui.autocomplete.prototype.search.call(this, value, event);
        },

        _change: function( event ){
            abc = this;
            if ( !this.selectedItem ) {
                var matcher = new RegExp( "^" + $.ui.autocomplete.escapeRegex( this.element.val() ) + "$", "i" ),
                    match = $.grep( this.options.source, function(value) {
                        return matcher.test( value.label );
                    });
                if (match.length){
                    match[0].option.selected = true;
                }else {
                    // remove invalid value, as it didn't match anything
                    this.element.val( "" );
                    if (this.options.selectElement) {
                        this.options.selectElement.val( "" );
                    }
                }
            }                
            // super()
            $.ui.autocomplete.prototype._change.call(this, event);
        },

        _swapMenu: function(){
            var input = this.element, 
                data = input.data("combobox"),
                tmp = data.menuAll;
            data.menuAll = data.menu.element.hide();
            data.menu.element = tmp;
        },

        /* build the source array from the options of the select element */
        _selectInit: function(){
            var select = this.element.hide(),
            selected = select.children( ":selected" ),
            value = selected.val() ? selected.text() : "";
            this.options.source = select.children( "option[value!='']" ).map(function() {
                return { label: $.trim(this.text), option: this };
            }).toArray();
            var userSelectCallback = this.options.select;
            var userSelectedCallback = this.options.selected;
            this.options.select = function(event, ui){
                ui.item.option.selected = true;
                if (userSelectCallback) userSelectCallback(event, ui);
                // compatibility with jQuery UI's combobox.
                if (userSelectedCallback) userSelectedCallback(event, ui);
            };
            this.options.selectElement = select;
            this.input = $( "<input>" ).insertAfter( select )
                .val( value ).combobox(this.options);
        }
    }
);
})(jQuery);
</script>

太棒了!这真的加快了我的进度。谢谢! - Eric
我想使用您的实现,因为它很完美,但是当我尝试并点击按钮时,没有任何反应!没有菜单出现!尽管自动完成仍然有效。有什么想法吗?可能是因为 jQuery UI 的更新导致的吗? - dallin
7
上面的脚本依赖于jquery-ui 1.8.x,需要进行一些小的更改才能在1.9.x上运行。我已经很久没再处理它了,但我已在这里发布了代码。https://github.com/garyzhu/jquery.ui.combobox我没有对最新的jquery-ui进行全面测试,只是修复了明显的JavaScript错误。 - gary
感谢Gary提供的解决方案。然而,我们对它有几个问题。虽然不是很大的问题,但需要解决。你是否有更新的版本? - doekman
@gary或其他任何人可以给出上述解决方案的JSFiddle链接吗? - Balasubramani M
显示剩余3条评论

20

我已经修改了结果返回的方式(在 source 函数中),因为map()函数对我来说似乎很慢。它在大型选择列表(甚至是小型列表)中运行更快,但是选项有几千个的列表仍然非常缓慢。

  

原始代码:分析(372.578毫秒,42307次调用)

     

修改后的代码:分析(0.082毫秒,3次调用)

以下是 source 函数的修改代码,您可以在jquery ui演示中查看原始代码http://jqueryui.com/demos/autocomplete/#combobox。当然还可以进行更多优化。

source: function( request, response ) {
    var matcher = new RegExp( $.ui.autocomplete.escapeRegex(request.term), "i" );
    var select_el = this.element.get(0); // get dom element
    var rep = new Array(); // response array
    // simple loop for the options
    for (var i = 0; i < select_el.length; i++) {
        var text = select_el.options[i].text;
        if ( select_el.options[i].value && ( !request.term || matcher.test(text) ) )
            // add element to result array
            rep.push({
                label: text, // no more bold
                value: text,
                option: select_el.options[i]
            });
    }
    // send response
    response( rep );
},
希望这能帮到你。

当使用相同的实现方式用于多个下拉列表时,此解决方案始终返回相同的结果集。 - vml19
也许过去5年中jquery-ui的源代码已经发生了变化,但是为了使其正常工作,“select.get(0);”需要改为“this.element.get(0);”。 - mfoy_
好答案,但是for循环必须使用select_el.options.length而不是select_el.length。我已经编辑了代码。 - jscripter
我用这个替换了我的代码中的“source:”行,但是我的自动完成甚至没有显示出来。 - Heemanshu Bhalla

15

我喜欢Berro的答案。但是因为它还是有些慢(我的select中大约有3000个选项),所以我稍微修改了一下,只显示前N个匹配结果。

我还在最后添加了一个项目,告知用户还有更多的结果可用,并取消该项目的焦点和选择事件。

以下是修改后的源码、选择函数和新增的焦点函数:

source: function( request, response ) {
    var matcher = new RegExp( $.ui.autocomplete.escapeRegex(request.term), "i" );
    var select_el = select.get(0); // get dom element
    var rep = new Array(); // response array
    var maxRepSize = 10; // maximum response size  
    // simple loop for the options
    for (var i = 0; i < select_el.length; i++) {
        var text = select_el.options[i].text;
        if ( select_el.options[i].value && ( !request.term || matcher.test(text) ) )
            // add element to result array
            rep.push({
                label: text, // no more bold
                value: text,
                option: select_el.options[i]
            });
        if ( rep.length > maxRepSize ) {
            rep.push({
                label: "... more available",
                value: "maxRepSizeReached",
                option: ""
            });
            break;
        }
     }
     // send response
     response( rep );
},          
select: function( event, ui ) {
    if ( ui.item.value == "maxRepSizeReached") {
        return false;
    } else {
        ui.item.option.selected = true;
        self._trigger( "selected", event, {
            item: ui.item.option
        });
    }
},
focus: function( event, ui ) {
    if ( ui.item.value == "maxRepSizeReached") {
        return false;
    }
},

当然,给出的解决方案是不同的,但是你的表现最佳。谢谢! - Valentin Despa
2
这是一个很棒的解决方案。我继续扩展了自动完成控件的_renderMenu事件,因为在ASP.NET中使用AutoPostback下拉框时会发生postback。 - iMatoria
@MayankPathak - 感谢您的赞赏之词。 - iMatoria
我在这里使用tolower()时遇到了一个错误?还有其他人看到这种行为吗? - Chazt3n
1
嗨Peja,你的解决方案对我有用,但是在多次搜索和点击组合框后,它又让浏览器冻结了,你有什么想法吗? - Nikunj Chotaliya
显示剩余2条评论

11
我们也遇到了同样的问题,但最终我们的解决方案是缩小列表!当我深入研究时,发现它是几个因素的结合体:
1)每次显示列表框(或用户键入并开始过滤列表)时,列表框的内容会被清除并重新构建。我认为这在很大程度上是不可避免的,并且相当核心,因为您需要从列表中删除项目以使过滤起作用。
您可以尝试更改它,使其显示和隐藏列表中的项目,而不是完全重新构建它,但这将取决于您的列表是如何构建的。
另一种选择是尝试优化清除/构建列表(请参见2和3)。
2)清除列表时存在显着的延迟。我的理论是,这至少部分是由于每个列表项都附加了数据(通过jQuery函数 data()),我记得删除附加到每个元素的数据实际上可以大大加快此步骤。
您可能需要研究更有效的方法来删除子html元素,例如如何使jQuery.empty快10倍以上。如果尝试替代的empty函数,请注意潜在的内存泄漏。
或者您可能希望尝试调整它,使数据不附加到每个元素。

3) 剩余的延迟是由列表的构建造成的 - 具体来说,列表是使用大量的jQuery语句链构建的,例如:

$("#elm").append(
    $("option").class("sel-option").html(value)
);

这看起来很漂亮,但构建HTML的方式相对低效 - 更快的方法是自己构建HTML字符串,例如:

$("#elm").html("<option class='sel-option'>" + value + "</option>");

请参考《字符串性能分析》的深入文章,了解最有效的字符串拼接方式(这本质上就是本文中正在发生的事情)。


那就是问题所在,但我真的不知道修复它的最佳方法是什么 - 最终我们缩短了项目列表,所以不再是问题。
通过解决2)和3),您可能会发现列表的性能提高到可接受的水平,但如果没有,那么您需要解决1)并尝试想出一个替代方案,以便每次显示时都不必清除和重新构建列表。
令人惊讶的是,过滤列表的函数(涉及一些相当复杂的正则表达式)对下拉框的性能影响很小 - 您应该检查以确保您没有做愚蠢的事情,但对于我们来说,这不是性能瓶颈。

谢谢你的全面回答!这让我有明天的事情可做 :) 我很想缩短列表,但我不认为下拉列表完全适用于如此大的列表,然而我不确定这是否可能。 - elwyn
@elwyn - 让我知道进展如何 - 这是我真的想要解决的问题之一,但我们没有时间去做。 - Justin
1
有人优化了Berro以外的东西吗? :) - max4ever

1
我会尽力帮助你进行翻译。以下是您需要翻译的内容:

我所做的事情我正在分享:

_renderMenu中,我写了这个:

var isFullMenuAvl = false;
    _renderMenu: function (ul, items) {
                        if (requestedTerm == "**" && !isFullMenuAvl) {
                            var that = this;
                            $.each(items, function (index, item) {
                                that._renderItemData(ul, item);
                            });
                            fullMenu = $(ul).clone(true, true);
                            isFullMenuAvl = true;
                        }
                        else if (requestedTerm == "**") {
                            $(ul).append($(fullMenu[0].childNodes).clone(true, true));
                        }
                        else {
                            var that = this;
                            $.each(items, function (index, item) {
                                that._renderItemData(ul, item);
                            });
                        }
                    }

这主要是用于服务器端请求服务。但它也可以用于本地数据。我们正在存储requestedTerm并检查它是否与**匹配,这意味着正在进行完整菜单搜索。如果您正在使用“无搜索字符串”搜索完整菜单,可以将"**"替换为""。如果有任何疑问,请与我联系。在我的案例中,它至少提高了50%的性能。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接