Objectif
J'ai un DOM avec environ 70 éléments (divs avec du contenu). J'ai besoin de déplacer et de basculer l'affichage de ces divs assez souvent et assez rapidement. La vitesse est l'une des choses les plus importantes. Le déclencheur du déplacement et du basculement de ces divs est une requête de recherche, un peu comme Google Instant sauf que tous les éléments du DOM que je déplace et que je fais basculer sont chargés la première fois (il n'y a donc plus d'appels au serveur).
Mise en œuvre
J'ai mis cela en œuvre de la manière suivante : à côté du DOM, je passe un tableau JavaScript d'objets représentant les divs avec leurs attributs tels que la position, le contenu, etc. Ce tableau agit comme un miroir du DOM. Lorsque l'utilisateur commence à taper, je commence à parcourir le tableau et à calculer, pour chaque div/objet, ce qui doit être fait. En fait, je boucle sur ce tableau plusieurs fois : Je vérifie d'abord si j'ai besoin de regarder une div/un objet, puis je regarde l'objet, puis je vérifie si j'ai besoin de regarder le contenu, puis je regarde le contenu.
L'une des choses que je fais dans ces boucles est la mise en place de drapeaux pour la manipulation du DOM. Si j'ai bien compris, la lecture et la manipulation du DOM est l'une des opérations les plus lentes en JavaScript, par rapport aux autres choses que je fais (boucles, lecture et écriture d'attributs d'objets, etc.). J'ai également fait du profilage, ce qui confirme cette hypothèse. J'ai donc essayé à tout prix d'éviter de "toucher" au DOM pour améliorer les performances. À la fin de mon algorithme, je boucle une fois de plus, j'exécute toutes les actions DOM nécessaires et je réinitialise les drapeaux pour signaler qu'ils ont été lus. Pour des raisons de compatibilité entre les navigateurs, j'utilise jQuery pour exécuter les actions du DOM (sélection, déplacement, basculement). Je fais no utiliser jQuery pour faire une boucle sur mon tableau.
Problème
Mon problème est que je penser mon code et ma structure de données sont un peu laids. J'ai ce tableau multidimensionnel assez grand tableau multidimensionnel avec beaucoup d'attributs et de drapeaux. Je le parcours en boucle avec des fonctions appelant des fonctions appelant des fonctions. Lorsque je rencontre des problèmes, je peux (encore) déboguer assez facilement, mais ce n'est pas le cas. sentir droite.
Question
Existe-t-il un modèle de conception ou une solution commune à ce type de problème ? Je pense que je pourrais implémenter une sorte de couplage intelligent entre le tableau et le DOM où je n'aurais pas à définir explicitement des drapeaux et exécuter des actions DOM, mais je n'ai aucune idée de la manière dont un tel couplage devrait fonctionner ou si c'est même une bonne idée ou si cela ne fait que compliquer les choses.
Y a-t-il d'autres principes algorithmiques ou de structure des données que j'ai négligés en résolvant ce problème ?
Merci de votre attention !
Mise à jour Comme demandé, j'ai ajouté mon code, il fait environ 700 lignes. Note : Je ne pollue pas l'espace de noms global, ces fonctions sont définies et utilisées à l'intérieur d'une fermeture.
/**
* Applies the filter (defined by the currentQuery and to the cats array)
*
* -checks whether matching is needed
* -if needed does the matching
* -checks whether DOM action is needed
* -if needed executes DOM action
*
* cats is an array of objects representing categories
* which themselves contain an array of objects representing links
* with some attributes
*
* cats = (array) array of categories through which to search
* currentQuery = (string) with which to find matches within the cats
* previousQuery = (string) with previously-typed-in query
*
* no return values, results in DOM action and manipulation of cats array
*/
function applyFilter(cats,currentQuery, previousQuery) {
cats = flagIfMatchingIsNeededForCats(cats,currentQuery,previousQuery);
cats = matchCats(cats,currentQuery);
cats = flagIfMatchingIsNeededForLinks(cats,currentQuery,previousQuery);
cats = matchLinks(cats,currentQuery);
cats = flagIfDisplayToggleNeeded(cats);
if ( currentQuery.length > 0 ) {
cats = flagIfMoveNeeded(cats);
} else {
// move everything back to its original position
cats = flagMoveToOriginalPosition(cats);
}
// take action on the items that need a DOM action
cats = executeDomActions(cats);
}
/**
* Sets a flag on a category if it needs matching, parses and returns cats
*
* Loops through all categories and sets a boolean to signal whether they
* need matching.
*
* cats = (array) an array with all the category-objects in it
* currentQuery = (string) the currently typed-in query
* previousQuery = (string) the query that was previously typed in
*
* returns (array) cats, possibly in a different state
*/
function flagIfMatchingIsNeededForCats(cats,currentQuery,previousQuery) {
var newQueryIsLonger = isNewQueryLonger(currentQuery, previousQuery);
// check if matching is necessary for categories
for (var i = 0; i < cats.length; i++) {
cats[i].matchingNeeded = isMatchingNeededForCat(
cats[i].matches
,newQueryIsLonger
,currentQuery.length
,cats[i].noMatchFoundAtNumChars
);
}
return cats;
}
/**
* Whether the new query is longer than the previous one
*
* currentQuery = (string) the currently typed-in query
* previousQuery = (string) the query that was previously typed in
*
* returns (boolean) true/false
*/
function isNewQueryLonger(currentQuery, previousQuery) {
if (previousQuery == false) {
return true;
}
return currentQuery.length > previousQuery.length
}
/**
* Deduces if a category needs to be matched to the current query
*
* This function helps in improving performance. Matching is done using
* indexOf() which isn't slow of itself but preventing even fast processes
* is a good thing (most of the time). The function looks at the category,
* the current and previous query, then decides whether
* matching is needed.
*
* currentlyMatched = (boolean) on whether the boolean was matched to the previous query
* newQueryIsLonger = (boolean) whether the new query is longer
* queryLength = (int) the length of the current query
* noMatchFoundAtNumChars = (int) this variable gets set (to an int) for a
* category when it switches from being matched to being not-matched. The
* number indicates the number of characters in the first query that did
* not match the category. This helps in performance because we don't need
* to recheck the categoryname if it doesn't match now and the new query is
* even longer.
*
* returns (boolean) true/false
*/
function isMatchingNeededForCat(currentlyMatched, newQueryIsLonger ,queryLength ,noMatchFoundAtNumChars) {
if (typeof(currentlyMatched) == 'undefined') {
// this happens the first time we look at a category, for all
// categories this happens with an empty query and that matches with
// everything
currentlyMatched = true;
}
if (currentlyMatched && newQueryIsLonger) {
return true;
}
if (!currentlyMatched && !newQueryIsLonger) {
// if currentlyMatched == false, we always have a value for
// noMatchFoundAtNumChars
// matching is needed if the first "no-match" state was found
// at a number of characters equal to or bigger than
// queryLength
if ( queryLength < noMatchFoundAtNumChars ) {
return true;
}
}
return false;
}
/**
* Does matching on categories for all categories that need it.
*
* Sets noMatchFoundAtNumChars to a number if the category does not match.
* Sets noMatchFoundAtNumChars to false if the category matches once again.
*
* cats = (array) an array with all the category-objects in it
* currentQuery = (string) the currently typed-in query
*
* returns (array) cats, possibly in a different state
*/
function matchCats(cats,currentQuery) {
for (var i = 0; i < cats.length; i++) {
if (cats[i].matchingNeeded) {
cats[i].matches = categoryMatches(cats[i],currentQuery);
// set noMatchFoundAtNumChars
if (cats[i].matches) {
cats[i].noMatchFoundAtNumChars = false;
} else {
cats[i].noMatchFoundAtNumChars = currentQuery.length;
}
}
}
return cats;
}
/**
* Check if the category name matches the query
*
* A simple indexOf call to the string category_name
*
* category = (object) a category object
* query = (string) the query
*
* return (boolean) true/false
*/
function categoryMatches(category,query) {
catName = category.category_name.toLowerCase();
if (catName.indexOf(query) !== -1 ) {
return true;
}
return false;
}
/**
* Checks links to see whether they need matching
*
* Loops through all cats, selects the non-matching, for every link decides
* whether it needs matching
*
* cats = (array) an array with all the category-objects in it
* currentQuery = the currently typed-in query
* previousQuery = the query that was previously typed in
*
* returns (array) cats, possibly in a different state
*/
function flagIfMatchingIsNeededForLinks(cats,currentQuery,previousQuery) {
var newQueryIsLonger = isNewQueryLonger(currentQuery, previousQuery);
for (var i = 0; i < cats.length; i++) {
if (!cats[i].matches) { // only necessary when cat does not match
for (var k = 0; k < cats[i].links.length; k++) {
cats[i].links[k].matchingNeeded = isMatchingNeededForLink(
cats[i].links[k].matches
,newQueryIsLonger
,currentQuery.length
,cats[i].links[k].noMatchFoundAtNumChars
);
}
}
}
return cats;
}
/**
* Checks whether matching is needed for a specific link
*
* This function helps in improving performance. Matching is done using
* indexOf() for every (relevant) link property, this function helps decide
* whether that *needs* to be done. The function looks at some link
* properties, the current and previous query, then decides whether
* matching is needed for the link.
*
* currentlyMatched = (boolean) on whether the boolean was matched to the previous query
* newQueryIsLonger = (boolean) whether the new query is longer
* queryLength = (int) the length of the current query
* noMatchFoundAtNumChars = (int) this variable gets set (to an int) for a
* link when it switches from being matched to being not-matched. The
* number indicates the number of characters in the first query that did
* not match the link. This helps in performance because we don't need
* to recheck the link properties in certain circumstances.
*
* return (boolean) true/false
*/
function isMatchingNeededForLink(currentlyMatched, newQueryIsLonger ,queryLength ,noMatchFoundAtNumChars) {
if (typeof(currentlyMatched) == 'undefined') {
// this happens to a link the first time a cat does not match and
// we want to scan the links for matching
return true;
}
if (currentlyMatched && newQueryIsLonger) {
return true;
}
if (!currentlyMatched && !newQueryIsLonger) {
// if currentlyMatched == false, we always have a value for
// noMatchFoundAtNumChars
// matching is needed if the first "no-match" state was found
// at a number of characters equal to or bigger than
// queryLength
if ( queryLength < noMatchFoundAtNumChars ) {
return true;
}
}
return false;
}
/**
* Does matching on links for all links that need it.
*
* Sets noMatchFoundAtNumChars to a number if the link does not match.
* Sets noMatchFoundAtNumChars to false if the link matches once again.
*
* cats = (array) an array with all the category-objects in it
* currentQuery = (string) the currently typed-in query
*
* returns (array) cats, possibly in a different state
*/
function matchLinks(cats,currentQuery) {
for (var i = 0; i < cats.length; i++) {
// category does not match, check if links in the category match
if (!cats[i].matches) {
for (var k = 0; k < cats[i].links.length; k++) {
if (cats[i].links[k].matchingNeeded) {
cats[i].links[k].matches = linkMatches(cats[i].links[k],currentQuery);
}
// set noMatchFoundAtNumChars
if (cats[i].links[k].matches) {
cats[i].links[k].noMatchFoundAtNumChars = false;
} else {
cats[i].links[k].noMatchFoundAtNumChars = currentQuery.length;
}
}
}
}
return cats;
}
/**
* Check if any of the link attributes match the query
*
* Loops through all link properties, skips the irrelevant ones we use for filtering
*
* category = (object) a category object
* query = (string) the query
*
* return (boolean) true/false
*/
function linkMatches(link,query) {
for (var property in link) {
// just try to match certain properties
if (
!( // if it's *not* one of the following
property == 'title'
|| property == 'label'
|| property == 'url'
|| property == 'keywords'
|| property == 'col'
|| property == 'row'
)
){
continue;
}
// if it's an empty string there's no match
if( !link[property] ) {
continue;
}
var linkProperty = link[property].toLowerCase();
if (linkProperty.indexOf(query) !== -1){
return true;
}
}
return false;
}
/**
* Flags if toggling of display is needed for a category.
*
* Loops through all categories. If a category needs some DOM
* action (hiding/showing) it is flagged for action. This helps in
* performance because we prevent unnecessary calls to the DOM (which are
* slow).
*
* cats = (array) an array with all the category-objects in it
*
* returns (array) cats, possibly in a different state
*/
function flagIfDisplayToggleNeeded(cats) {
for (var i = 0; i < cats.length; i++) {
// this happens the first time we look at a category
if (typeof(cats[i].currentlyDisplayed) == 'undefined') {
cats[i].currentlyDisplayed = true;
}
var visibleLinks = 0;
// a cat that matches, all links need to be shown
if (cats[i].matches) {
visibleLinks = cats[i].links.length;
} else {
// a cat that does not match
for (var k = 0; k < cats[i].links.length; k++) {
if (cats[i].links[k].matches) {
visibleLinks++;
}
}
}
// hide/show categories if they have any visible links
if (!cats[i].currentlyDisplayed && visibleLinks > 0 ) {
cats[i].domActionNeeded = 'show';
} else if( cats[i].currentlyDisplayed && visibleLinks == 0 ){
cats[i].domActionNeeded = 'hide';
}
}
return cats;
}
/**
* Flags categories to be moved to other position.
*
* Loops through all categories and looks if they are distributed properly.
* If not it moves them to another position. It remembers the old position so
* it can get the categories back in their original position.
*
* cats = (array) an array with all the category-objects in it
*
* returns (array) cats, possibly in a different state
*/
function flagIfMoveNeeded(cats) {
var numCats, numColumns, displayedCats, i, moveToColumn, tmp;
numColumns = getNumColumns(cats);
numDisplayedCats = getNumDisplayedCats(cats);
columnDistribution = divideInPiles(numDisplayedCats, numColumns);
// optional performance gain: only move stuff when necessary
// think about this some more
// we convert the distribution in columns to a table so we get columns
// and positions
catDistributionTable = convertColumnToTableDistribution(columnDistribution);
// sort the categories, highest positions first
// catPositionComparison is a function to do the sorting with
// we could improve performance by doing this only once
cats = cats.sort(catPositionComparison);
for (i = 0; i < cats.length; i += 1) {
if( categoryWillBeDisplayed(cats[i]) ){
tmp = getNewPosition(catDistributionTable); // returns multiple variables
catDistributionTable = tmp.catDistributionTable;
cats[i].moveToColumn = tmp.moveToColumn;
cats[i].moveToPosition = tmp.moveToPosition;
} else {
cats[i].moveToColumn = false;
cats[i].moveToPosition = false;
}
}
return cats;
}
/**
* A comparison function to help the sorting in flagIfMoveNeeded()
*
* This function compares two categories and returns an integer value
* enabling the sort function to work.
*
* cat1 = (obj) a category
* cat2 = (obj) another category
*
* returns (int) signaling which category should come before the other
*/
function catPositionComparison(cat1, cat2) {
if (cat1.category_position > cat2.category_position) {
return 1; // cat1 > cat2
} else if (cat1.category_position < cat2.category_position) {
return -1; // cat1 < cat2
}
// the positions are equal, so now compare on column, if we need the
// performance we could skip this
if (cat1.category_column > cat2.category_column) {
return 1; // cat1 > cat2
} else if (cat1.category_column < cat2.category_column) {
return -1; // cat1 < cat2
}
return 0; // position and column are equal
}
/**
* Checks if a category will be displayed for the currentQuery
*
* cat = category (object)
*
* returns (boolean) true/false
*/
function categoryWillBeDisplayed(cat) {
if( (cat.currentlyDisplayed === true && cat.domActionNeeded !== 'hide')
||
(cat.currentlyDisplayed === false && cat.domActionNeeded === 'show')
){
return true;
} else {
return false;
}
}
/**
* Gets the number of unique columns in all categories
*
* Loops through all cats and saves the columnnumbers as keys, insuring
* uniqueness. Returns the number of
*
* cats = (array) of category objects
*
* returns (int) number of unique columns of all categories
*/
function getNumColumns(cats) {
var columnNumber, uniqueColumns, numUniqueColumns, i;
uniqueColumns = [];
for (i = 0; i < cats.length; i += 1) {
columnNumber = cats[i].category_column;
uniqueColumns[columnNumber] = true;
}
numUniqueColumns = 0;
for (i = 0; i < uniqueColumns.length; i += 1) {
if( uniqueColumns[i] === true ){
numUniqueColumns += 1
}
}
return numUniqueColumns;
}
/**
* Gets the number of categories that will be displayed for the current query
*
* cats = (array) of category objects
*
* returns (int) number of categories that will be displayed
*/
function getNumDisplayedCats(cats) {
var numDisplayedCats, i;
numDisplayedCats = 0;
for (i = 0; i < cats.length; i += 1) {
if( categoryWillBeDisplayed(cats[i]) ){
numDisplayedCats += 1;
}
}
return numDisplayedCats;
}
/**
* Evenly divides a number of items into piles
*
* Uses a recursive algorithm to divide x items as evenly as possible over
* y piles.
*
* items = (int) a number of items to be divided
* piles = (int) the number of piles to divide items into
*
* return an array with numbers representing the number of items in each pile
*/
function divideInPiles(items, piles) {
var averagePerPileRoundedUp, rest, pilesDivided;
pilesDivided = [];
if (piles === 0) {
return false;
}
averagePerPileRoundedUp = Math.ceil(items / piles);
pilesDivided.push(averagePerPileRoundedUp);
rest = items - averagePerPileRoundedUp;
if (piles > 1) {
pilesDivided = pilesDivided.concat(divideInPiles(rest, piles - 1)); // recursion
}
return pilesDivided;
}
/**
* Converts a column distribution to a table
*
* Receives a one-dimensional distribution array and converts it to a two-
* dimensional distribution array.
*
* columnDist (array) an array of ints, example [3,3,2]
*
* returns (array) two dimensional array, rows with "cells"
* example: [[true,true,true],[true,true,true],[true,true,false]]
* returns false on failure
*/
function convertColumnToTableDistribution(columnDist) {
'use strict';
var numRows, row, numCols, col, tableDist;
if (columnDist[0] === 'undefined') {
return false;
}
// the greatest number of items are always in the first column
numRows = columnDist[0];
numCols = columnDist.length;
tableDist = []; // we
for (row = 0; row < numRows; row += 1) {
tableDist.push([]); // add a row
// add "cells"
for (col = 0; col < numCols; col += 1) {
if (columnDist[col] > 0) {
// the column still contains items
tableDist[row].push(true);
columnDist[col] -= 1;
} else {
tableDist[row][col] = false;
}
}
}
return tableDist;
}
/**
* Returns the next column and position to place a category in.
*
* Loops through the table to find the first position that can be used. Rows
* and positions have indexes that start at zero, we add 1 in the return
* object.
*
* catDistributionTable = (array) of rows, with positions in them
*
* returns (object) with the mutated catDistributionTable, a column and a
* position
*/
function getNewPosition(catDistributionTable) {
var numRows, row, col, numCols, moveToColumn, moveToPosition;
numRows = catDistributionTable.length;
findposition:
for (row = 0; row < numRows; row += 1) {
numCols = catDistributionTable[row].length;
for ( col = 0; col < numCols; col += 1) {
if (catDistributionTable[row][col] === true) {
moveToColumn = col;
moveToPosition = row;
catDistributionTable[row][col] = false;
break findposition;
}
}
}
// zero-indexed to how it is in the DOM, starting with 1
moveToColumn += 1;
moveToPosition += 1;
return {
'catDistributionTable' : catDistributionTable
,'moveToColumn' : moveToColumn
,'moveToPosition' : moveToPosition
};
}
/**
* Sets the target position of a category to its original location
*
* Each category in the DOM has attributes defining their original position.
* After moving them around we might want to move them back to their original
* position, this function flags all categories to do just that.
*
* cats = (array) of category objects
*
* All of the possible return values
*/
function flagMoveToOriginalPosition(cats) {
for (i = 0; i < cats.length; i += 1) {
cats[i].moveToColumn = cats.category_column;
cats[i].moveToPosition = cats.category_position;
}
return cats;
}
/**
* Execute DOM actions for the items that need DOM actions
*
* Parses all categories, executes DOM actions on the categories that
* require a DOM action.
*
* cats = (array) an array with all the category-objects in it
*
* no return values
*/
function executeDomActions(cats) {
for (var i = 0; i < cats.length; i++) {
var category_id = cats[i].category_id;
// toggle display of columns
if (cats[i].domActionNeeded == 'show') {
showCategory(category_id);
cats[i].currentlyDisplayed = true;
}
if (cats[i].domActionNeeded == 'hide') {
hideCategory(category_id);
cats[i].currentlyDisplayed = false;
}
cats[i].domActionNeeded = false;
// for every currentlyDisplayed category move it to new location
// if necessary
if (cats[i].currentlyDisplayed && cats[i].moveToColumn !== false) {
cats[i] = moveCat(cats[i]);
}
}
return cats;
}
/**
* Show a certain category
*
* category_id = (int) the id of the category that needs to be shown
*
* no return values
*/
function showCategory(category_id) {
$('#' + category_id).show();
}
/**
* Hide a certain category
*
* category_id = (int) the id of the category that needs to be hidden
*
* no return values
*/
function hideCategory(category_id) {
$('#' + category_id).hide();
}
/**
* Moves a category to the position set in its attributes
*
* A category can have attributes defining the column and position (or row)
* this function moves the category to the correct column and position.
*
* cat = (object) category
*
* returns (object) category
*/
function moveCat(cat) {
var columnSelector, catSelector;
columnSelector = '#column' + cat.moveToColumn + ' .column_inner' + ' .hiddenblocks';
catSelector = '#' + cat.category_id;
$(columnSelector).prepend($(catSelector));
// reset target coordinates
cat.moveToColumn = false;
cat.moveToPosition = false;
return cat;
}