Mejorando el rendimiento con el API DOM desde Javascript

17 May 2012

Introducción

Actualmente me encuentro realizando algunos proyectos que requieren de mucho trabajo con el API DOM y el manejo de nodos desde Javascript. Para ayudarnos con este tipo de tareas, existen muchas bibliotecas como jQuery, Zepto o Underscore que nos hacen la vida más fácil pero siempre a costa de rendimiento. Por lo general, este coste es asumible, pero hay veces que necesitamos exprimir al máximo la capacidad del navegador porque estamos manejando objetos enormes.

Cuando precisamente necesitamos que nuestra aplicación sea lo más rápida posible, tenemos que intentar utilizar los métodos nativos del lenguaje para evitar la ‘sobrecarga’ que supone el uso de bibliotecas de terceros. Y es aquí donde, por culpa de algunos errores frecuentes mientras programamos, podemos echar por tierra cualquier esfuerzo por optimizar nuestro código.

Aunque muchos de los consejos que veremos a continuación son de sobra conocidos, no está de mal recopilarlos a modo de pequeña guía o recetario para quienes buscan el máximo rendimiento cuando se manejan objetos o listas de nodos.

Seleccionando nodos

Obviamente, lo primero que tenemos que tener en cuenta para manejar nodos de forma efectiva es el cómo seleccionarlos.

querySelectorAll

Actualmente, disponemos del método querySelectorAll dentro del objeto Document, el cual nos permite seleccionar todos aquellos elementos que cumplan con un selector indicado.

Su sintaxis es la siguiente:

elementList = document.querySelectorAll(selectors);

Donde selectors es una cadena que contiene uno o más selectores CSS separados por comas.

Por ejemplo, si quisieramos seleccionar todas las etiquetas div de una página, haríamos lo siguiente:

var myDivs = document.querySelectorAll('div');

Como podemos agrupar selectores, es posible seleccionar todos los párrafos que tengan por ejemplo tanto la clase «first» como «last» en un mismo conjunto:

var myTexts = document.querySelectorAll('p.first, p.last');

NOTA: querySelectorAll es no es compatible con versiones anteriores a IE8, pero se utilizará en este artículo con fines didácticos. Para ofrecer compatibilidad con navegadores antiguos, consúltese la API DOM.

Apunte rápido sobre jQuery: pasar de colección a Array

Como bien sabemos, cuando realizamos una selección de elementos con jQuery, la biblioteca nos devuelve un nuevo objeto propio (es decir, un objeto jQuery) con todos aquellos elementos encontrados.

Sin embargo, si necesitamos trabajar con el nodo directamente, y no con el objeto jQuery, podemos obtenerlo mediante una llamada a su correspondiente índice:

var $myCollection = $('div');
console.log( $myCollection instanceof jQuery ); // true
console.log( Object.prototype.toString.call( $myCollection ) ); // [object Object]
 
var element = $myCollection[0];
console.log( Object.prototype.toString.call( element ) ); // [object HTMLDivElement]

En el caso anterior, estaríamos seleccionando directamente el primer nodo de la colección devuelta por la biblioteca para trabajarlo ya no como un objeto jQuery sino como un objeto HTML.

Podemos ir un paso más allá y convertir toda una colección en un verdadero array si fuese necesario:

var arr = Array.prototype.slice.call( $collection );
 
console.log( arr instanceof Array ); // true
console.log( Object.prototype.toString.call( arr ) );  // [object Array]

Nodelists

El resultado de seleccionar elementos a través del API DOM es un NodeList, o lista de nodos: un tipo objeto especial que, como ya hemos visto en otra ocasión, parece un array pero no lo es.

Así, por ejemplo, tenemos acceso a su propiedad length, o acceso directo a sus elementos mediante la notación tradicional:

// Tests sobre la página principal de OpenLibra (http://www.openlibra.org)
var obj = document.querySelectorAll('div');
 
console.log( obj.length ); // 127
console.log( obj[10] ); // <div class="title_section">

Sin embargo, si preguntamos por su tipo, observamos que, efectivamente, no es un array:

console.log( obj instanceof Array ); // false
console.log( Object.prototype.toString.call(obj) ); // "[object NodeList]"

NOTA: Para entender cómo se ha obtenido el tipo real de esta valor, se puede consultar el artículo: Cómo obtener el tipo de datos preciso de una variable en Javascript.

Sin embargo, poco nos va a importar ahora que no se trate de un genuino array ya que para los métodos de optimización que veremos a continuación, es irrelevante.

Con estos prolegómenos ya aclarados, pasemos a ver cómo optimizar nuestros códigos…

Seleccionar el BODY

Todavía encuentro con frecuencia que, para seleccionar la etiqueta body (muy útil para acceder rápidamente al árbol de nodos), se escriba lo siguiente:

var body = document.getElementsByTagName('body')[0]

Esto, además de resultar un código feo, es muy ineficiente. En su lugar, deberíamos utilizar siempre el selector nativo que ofrece el DOM:

var body = document.body;

Minimizando el acceso a propiedades y subnodos: caché, caché y más caché

Cuando estamos tratando con listas de nodos, resulta especialmente interesante el reducir al máximo posible el acceso a los subnodos mediante la notación de puntos (object.property) o corchetes (object[«property»]). Para ello, tenemos que hacer uso y abuso de variables intermedias a modo de caché.

NOTA: Más adelante veremos que quizá ‘abusar’ de variables intermedias no es lo más idóneo, pero en este caso, las ventajas superan a los contras…

Por lo anterior, en lugar de tener lo siguiente:

for(var x = 0; x < 1000; x++){
  object.property.property.property(x);
}

sería mucho más eficiente escribir:

var cached = object.property.property.property;
for(var x = 0; x < 1000; x++){
  cached(x);
}

La idea detrás de todo esto es evitar en la medida de lo posible que el navegador recorra todo o gran parte del árbol en cada iteración buscando un determinado elemento. Para ello, la mejor técnica es la de ir almacenando las referencias a aquellos bloques que más utilicemos:

var window = document.window,
  body = document.body,
  ObjectProto = Object.prototype,
  toString = ObjectProto.toString;

Crear accesos rápidos (shortcuts) a diversos métodos que reutilizaremos frecuentemente minimiza el acceso constante al entorno global tras cada llamada y, por lo tanto, supone una mejora en el rendimiento de la rutina.

Bucles iterativos

La mayoría de nosotros hace ya tiempo que sabe que es mejor cachear la longitud de un array en el paso previo a iniciar un bucle de tipo for:

var myArr = ["Hello", "World", "Goodbye", "Lenin", "foo", "bar"];
 
// BAD
for(var x = 0; x < myArr.length; x++){
  // Do something
}
 
// GOOD
for(var x = 0, i = myArr.length; x < i; x++){
  // Do something
}

Con lo anterior evitamos de nuevo que a cada iteración se consulte la longitud del array, lo que supone una mejora en el rendimiento significativa cuanto más grande es la matriz.

Sin embargo, con las listas de nodos, podemos ir incluso un paso más allá al acceder directamente al puntero que indica la variable que utilizamos como índice:

var obj = document.querySelectorAll('div');
 
// BEST
for(var x = 0; obj[x]; x++){
  // Do something
}

La condición anterior no se cumplirá cuando alcancemos el último elemento del conjunto y, entonces, el intérprete devuelva false. Este mismo método podría utilizarse en un array, pero solo cuando sepamos con certeza que no contiene elementos falsy intermedios que rompan el bucle. En el caso de las listas de nodos, no podemos tener valores falsy, por lo que se puede considerar una práctica segura.

El porqué de que este método sea algo más rápido es porque en Javascript, el acceso a un índice concreto de un array (u objeto similar) es instantáneo. De este modo evitamos variables intermedias e incluso consultar la longitud.

NOTA: El rendimiento del ejemplo anterior varía según el navegador en que se testee. Actualmente, en Firefox 12, parece ligeramente más lenta la segunda propuesta que la primera; sin embargo, en versiones anteriores de Firefox y en la actual de Chrome (v. 19), es efectivamente más rápida.

Editar nodos de modo offline

La acción de repintar nodos en el navegador exige un alto coste de procesado a la CPU y obliga al motor de renderizado a realizar barridos consecutivos sobre el documento.

Es por esto que, cuando tenemos la necesidad de crear elementos HTML de forma dinámica, una forma de ahorrar tiempo de cálculo y liberar a la CPU es trabajar de modo offline para luego inyectar el resultado completo al documento en un único paso.

Un ejemplo de lo anterior, podría ser la construcción de una lista HTML. Así, en lugar de:

var ul = document.getElementById("myUL");
for (var i = 0; i < 200; i++) {
  ul.appendChild(document.createElement("LI"));
}

Pondríamos:

var ul = document.getElementById( "myUL" ),
	li = document.createElement( "LI" ),
	parent = ul.parentNode;
 
parent.removeChild(ul);
 
for ( var i = 0; i < 200; i++ ) {
  ul.appendChild( li.cloneNode( true ) );
}
 
parent.appendChild(ul);

En el segundo ejemplo, creamos el elemento fuera del bucle, lo asociamos al padre y luego, una vez que hemos construído todo el árbol, lo inyectamos de nuevo en el documento.

NOTA: Este procedimiento recuerda al que hacemos en los elementos canvas cuando tenemos que repintar constantemente un área completa (por ejemplo en un juego): en ese caso, realizamos el dibujado en un elemento invisible (offline) que luego trasladamos al visible en lugar de operar directamente sobre este último. Esta técnica es conocida en el argot del desarrollo de videojuegos en canvas como DobleBuffer.

Optimizaciones generales

Al trabajar con listas de nodos, también podemos tener en cuenta algunas de las reglas básicas de optimización de objetos en Javascript:

Reducir el número de variables

Es importante tener en cuenta que la declaración de variables tiene un coste de proceso que no es fácil ni preveer ni identificar.

Por lo general, dicho coste no tiene lugar cuando se realiza una asignación, sino cuando llega el momento en que Javascript libera el valor almacenado de la variable. Es lo que conocemos como recogida de basura y uno de los grandes lastres que el lenguaje arrastra desde siempre.

Es por tanto buena práctica el reutilizar variables ya iniciadas en lugar de ir indiscriminadamente declarando nuevas.

También resulta interesante recordar que las variables locales son varias magnitudes más rápidas que las globales, ya que el intérprete no tiene que escalar la cadena de ámbitos (scopes) hasta encontrarla.

Esto resulta especialmente relevante a la hora de manipular bucles, por lo que, en lugar de:

var obj = document.querySelectorAll('div'),
 x = 0,
 i = obj.length;
 
function myLoop(){
  for(x = 0; x < i; x++ ){
  	// Do something with obj[x]
  }
}
 
myLoop(); // Call the function!

Resulta mucho más rápido:

var obj = document.querySelectorAll('div');
 
function myLoop( obj ){
  for(var x = 0, i = obj.length; x < i; x++ ){
  	// Do something with obj[x]
  }
}
 
myLoop( obj );

Como podemos comprobar, ahora el objeto se pasa como argumento de la función haciéndolo local a su ámbito. Del mismo modo, las variables del bucles x e i se han declarado dentro de la función (ámbito local) en lugar de fuera.

NOTA: En esta misma línea es importante recordar una vez más que es necesario declarar las variables en Javascript con el comando var para evitar así que sean inmediatamente globales. 

Conclusión

No hemos inventado nada nuevo con este artículo, pero la idea es recordar algunas pautas que tenemos que tener en cuenta a la hora de trabajar con listas de nodos en Javascript para obtener un buen rendimiento en nuestras aplicaciones RIAs.

Todas las optimizaciones anteriores son a nivel de DOM; en otra ocasión estudiaremos aquellas que pueden aplicarse al flujo natural del código: llamadas a funciones, operadores bitwise, uso o no de paréntesis, try/catch…

Más:

{9} Comentarios.

  1. Soren

    ¡Qué pedazo artículo! He descubierto recientemente tu blog (creo que a través de VariableNotFound) y estoy francamente impresionado, tu blog es una mina.

  2. Raul

    Hola, siempre es un placer leerte y aprender de lo que escribes.
    Solo una observación, en el último ejemplo se te pasó declarar la variable i …

    for(var x = 0, i = obj.length; x < i; x++ ){

    por

    for(var x = 0, var i = obj.length; x < i; x++ ){


    Saludos.

    • Carlos Benítez

      Hola!
      No; no se me ha olvidado! En Javascript podemos declarar variables de forma sucesiva con un único ‘var’ separando los nombres con comas. Por tanto, es equivalente:

      var a, b, c;
      

      a

      var a;
      var b;
      var c;
      

      De hecho, el ejemplo que me devuelves, daría error de sintaxis ya que, para usar la palabra ‘var’ en ‘i’, deberíamos separarla de ‘x’ con punto y coma (;) en lugar de coma (,).

      Saludos!

  3. Raul

    Uuuups tienes toda la razón, a la próxima verifico antes de soltar burradas XD

  4. Jonny

    Por fin otro artículo!! Ya tenía mono!
    Me estoy iniciando en nodejs y eres un gran guía!
    Gracias por este gran blog

    Saludos

  5. Lucas

    En cuanto a los for() tenes una manera mejor todavia…

    En lugar de:

    var array = document.getElementByName(‘div’);
    for(i = 0; i count».

    La unica contra que tiene este bucle es que corre de mayor a menor, si uno necesita ir de menor a mayor tiene que pensar otra solucion, pero algunas mejoras se siguen aplicando.

    Saludos desde Buenos Aires.

  6. Lucas

    En cuanto a los for() tenes una manera mejor todavia…

    En lugar de:

    var array = document.getElementByName('div');
    for(i = 0; i < array.lenght; i++) {
    //algo
    }
    

    Deberias hacer:

    var count = array.length;
    for(var i = count; i--;) {}
    

    Explicacion: En el caso clasico (el primero que señalo) a la variable i la crea fuera del scope del for y por ende tiene que ir a buscarla afuera cada vez que la quiera utilizar y eso tiene un costo extra; luego y mas importante, cada vez que hace array.lenght recalcula el valor y como si esto ya no fuera un costo adicional vuelve a obtener array por si el bucle cambio algo con lo cual hace un document.getElementByName(‘div’) mas por cada iteraccion e interactuar con el DOM es lo mas costoso que hay.
    En el otro caso, el mas performante, la variable i esta dentro del scope del for por lo que no tiene que buscarlo fuera, count es un int y no tiene que recalcular nada. Al ser i = count el bucle corre de mayor a menor y el i– lo va empujando hacia el cero, cero se interpreta como false y cuando llega a este numero ejectura un break y sale. Por lo tanto nos ahorramos tambien el condicional «i > count».

    La unica contra que tiene este bucle es que corre de mayor a menor, si uno necesita ir de menor a mayor tiene que pensar otra solucion, pero algunas mejoras se siguen aplicando.

    Saludos desde Buenos Aires.

    • Carlos Benítez

      Si; efectivamente, los bucles inversos que tienden a cero son siempre, por arquitectura del procesador, más rápidos que los que incrementan un contador.

      Últimamente, para obtener el máximo rendimiento durante iteraciones manteniendo el orden original, se está viendo mucho el siguiente esquema:

      var i = -1;
      var length = myArray.length;
      
      while( ++index < length ){
        //
      }
      

      Un ejemplo de lo anterior lo podemos encontrar en la biblioteca Lodash de JDalton.

      Saludos!

  7. Gustavo Cañete

    Hola Carlos,

    excelente e interesante articulo!

    Con respecto a tú último comentario hay un error ya que «index» es undefined, debería haber sido var index = -1; o while (++i < length)

    Con respecto al comentario de Lucas, una mejora puede ser:
    var count = array.length;
    for(; count–;) {}

    No es necesario crear una nueva variable i, como dice el articulo, usar la menor cantidad de variables.
    Una alternativa al código de Lucas usando while podría ser:
    var count = array.length;
    while (count–) {}

    Muchas gracias por su aporte, saludos desde Uruguay!!!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *