web-dev-qa-db-fra.com

jQuery UI Autocomplete Combobox très lent avec de grandes listes de sélection

J'utilise une version modifiée de la combobox jQuery UI Autocomplete, comme on le voit ici: http://jqueryui.com/demos/autocomplete/#combobox

Pour cette question, disons que j'ai exactement ce code ^^^

Lors de l'ouverture de la zone de liste déroulante, en cliquant sur le bouton ou en se concentrant sur la saisie de texte des zones de liste déroulante, il y a un grand délai avant d'afficher la liste des éléments. Ce délai devient sensiblement plus grand lorsque la liste de sélection a plus d'options.

Ce retard ne se produit pas seulement la première fois non plus, il se produit à chaque fois.

Comme certaines des listes de sélection de ce projet sont très grandes (des centaines et des centaines d'articles), le retard/le gel du navigateur est inacceptable.

Quelqu'un peut-il m'orienter dans la bonne direction pour optimiser cela? Ou même là où le problème de performances peut être?

Je crois que le problème peut être lié à la façon dont le script affiche la liste complète des éléments (une recherche de saisie semi-automatique recherche-t-elle une chaîne vide), existe-t-il une autre façon d'afficher tous les éléments? Peut-être que je pourrais créer un cas unique pour afficher tous les éléments (car il est courant d'ouvrir la liste avant de commencer à taper) qui ne fait pas tout le regex correspondant?

Voici un jsfiddle avec lequel jouer: http://jsfiddle.net/9TaMu/

62
elwyn

Avec l'implémentation combobox actuelle, la liste complète est vidée et rendue à chaque fois que vous développez la liste déroulante. Vous devez également définir la valeur minLength sur 0, car il doit effectuer une recherche vide pour obtenir la liste complète.

Voici ma propre implémentation étendant le widget de saisie semi-automatique. Dans mes tests, il peut gérer des listes de 5000 éléments de manière assez fluide, même sur IE 7 et 8. Il affiche la liste complète une seule fois et la réutilise chaque fois que le bouton déroulant est cliqué. Cela supprime également le dépendance de l'option minLength = 0. Il fonctionne également avec les tableaux et ajax comme source de liste. De plus, si vous avez plusieurs grandes listes, l'initialisation du widget est ajoutée à une file d'attente pour pouvoir s'exécuter en arrière-plan et ne pas geler le navigateur.

<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>
78
gary

J'ai modifié la façon dont les résultats sont retournés (dans la fonction source) parce que la fonction map () me semblait lente. Il s'exécute plus rapidement pour les grandes listes de sélection (et plus petites aussi), mais les listes avec plusieurs milliers d'options sont encore très lentes. J'ai profilé (avec la fonction de profil de Firebug) l'original et mon code modifié, et le temps d'exécution se présente comme suit:

Original: profilage (372,578 ms, appels 42307)

Modifié: profilage (0,082 ms, 3 appels)

Voici le code modifié de la fonction source, vous pouvez voir le code original sur la démo jquery ui http://jqueryui.com/demos/autocomplete/#combobox =. Il peut certainement y avoir plus d'optimisation.

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 );
},

J'espère que cela t'aides.

19
Berro

J'aime la réponse de Berro. Mais parce qu'il était encore un peu lent (j'avais environ 3000 options dans select), je l'ai légèrement modifié pour que seuls les premiers résultats de correspondance N soient affichés. J'ai également ajouté un élément à la fin pour informer l'utilisateur que plus de résultats sont disponibles et annulé le focus et sélectionner les événements pour cet élément.

Voici le code modifié pour les fonctions source et select et ajouté un pour le focus:

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;
    }
},
15
Peja

Nous avons trouvé la même chose, mais au final notre solution était d'avoir des listes plus petites!

Quand je l'ai examiné, c'était une combinaison de plusieurs choses:

1) Le contenu de la zone de liste est effacé et reconstruit chaque fois que la zone de liste est affichée (ou l'utilisateur tape quelque chose et commence à filtrer la liste). Je pense que cela est principalement inévitable et assez essentiel au fonctionnement de la zone de liste (car vous devez supprimer des éléments de la liste pour que le filtrage fonctionne).

Vous pouvez essayer de le modifier pour qu'il affiche et masque les éléments de la liste plutôt que de le reconstruire complètement à nouveau, mais cela dépendra de la façon dont votre liste est construite.

L'alternative est d'essayer d'optimiser l'effacement/la construction de la liste (voir 2. et 3.).

2) Il y a un retard substantiel lors de l'effacement de la liste . Ma théorie est que cela est au moins dû à chaque élément de liste auquel des données sont attachées (par la fonction data() jQuery) - Je semble me souvenir que la suppression des données attachées à chaque élément a considérablement accéléré cette étape.

Vous voudrez peut-être rechercher des moyens plus efficaces de supprimer les éléments html enfants, par exemple Comment rendre jQuery.empty plus de 10 fois plus rapide . Faites attention à ne pas introduire de fuites de mémoire si vous jouez avec des fonctions alternatives de empty.

Vous pouvez également essayer de le modifier afin que les données ne soient pas attachées à chaque élément.

3) Le reste du retard est dû à la construction de la liste - plus précisément la liste est construite en utilisant une grande chaîne d'instructions jQuery, par exemple :

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

Cela semble joli, mais c'est une façon assez inefficace de construire du html - un moyen beaucoup plus rapide est de construire vous-même la chaîne html, par exemple:

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

Voir String Performance: an Analysis pour un article assez détaillé sur la manière la plus efficace de concaténer des chaînes (ce qui est essentiellement ce qui se passe ici).


C'est là que réside le problème, mais honnêtement, je ne sais pas quelle serait la meilleure façon de le résoudre - à la fin, nous avons raccourci notre liste d'articles, ce n'était donc plus un problème.

En abordant les points 2) et 3), vous constaterez peut-être que les performances de la liste s'améliorent à un niveau acceptable, mais sinon, vous devrez répondre 1) et essayer de trouver une alternative à l'effacement et à la reconstruction de la liste. chaque fois qu'il est affiché.

Étonnamment, la fonction de filtrage de la liste (qui impliquait des expressions régulières assez complexes) a eu très peu d'effet sur les performances de la liste déroulante - vous devriez vérifier que vous n'avez pas fait quelque chose de stupide, mais pour nous, ce n'était pas la performance bouteille.

11
Justin

Ce que j'ai fait je partage:

Dans le _renderMenu, J'ai écrit ceci:

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);
                            });
                        }
                    }

C'est principalement pour le service de requête côté serveur. Mais il peut être utilisé pour les données locales. Nous stockons la demande et nous vérifions si elle correspond à ** ce qui signifie que la recherche complète dans le menu est en cours. Vous pouvez remplacer "**" avec "" si vous recherchez un menu complet sans "chaîne de recherche". Veuillez me contacter pour tout type de requêtes. Il améliore les performances dans mon cas d'au moins 50%.

1
soham