Ejemplo lista de tareas con Backbone y React

Escrito por el , actualizado el .
software programacion javascript planeta-codigo
Comentarios

He desarrollado este ejemplo de lista de tareas usando diferentes herramientas javascript primeramente con solo Backbone y después con Marionette. En este caso realizaré el mismo ejemplo para ver las diferencias usando la combinación Backbone para los modelos y React para las vistas junto con otras herramientas como ReactJS, Mustache, Jasmine, Grunt e i18n. Un ejemplo bastante completo de lo que ofrece javascript en estos momentos con la composición de herramientas que más me ha gustado.

Backbone
React

En anteriores entradas explicaba como hacer el típico ejemplo que se suele usar como demostración en los framework MVC de JavaScript que consiste en una lista de tareas en la que se pueden añadir nuevas, marcarlas como completadas y eliminarlas. Realice este ejemplo en uno de ellos usando solo Backbone y posteriormente usando Marionette. Estos ejemplos eran parte de una serie de artículos sobre JavaScript que la que mostaba como usar muchas otras herramientas como Require JS, Mustache, logging con javscript, capturar errores en javascript, introducción a Backbone, lista de tarea con Backbone, RESTEasy y Tapestry, pruebas unitarias con Jasmine y Sinon, Usar Grunt para ejecutar teses unitarios de código JavaScript y que constituyen parte del actual «estado del arte» en cuanto a desarrollo con JavaScript.

La lista de tareas aunque es un ejemplo sencillo sirve perfectamente como ejercicio para mostrar el uso de los frameworks MVC. En esta entrada voy a mostrar como hacer el mismo ejemplo usando Backbone y React y veremos, en mi opinión, que el ejemplo es mucho mas sencillo y lógico.

La parte más complicada y menos intuitiva del ejemplo de la lista de tareas con solamente Backbone o con Marionette probablemente era la V del MVC. Backbone es un framework que deja bastante libertad al desarrollador pudiendo usar únicamente las partes que necesitemos de él, sin embargo, esta sencillez nos obliga a gestionar ciertas «tareas de fontanería» y repetitivas nosotros mismos como la gestión de las vistas y la memoria. Marionette trata de dar solución a parte de estas tareas necesarias además de proporcionar unas guías y arquitectura para el desarrollo de las aplicaciones. Sin embargo, aún con Marionette la construcción de la parte de la vista con el uso de ItemView, CollectionView y Layout comentados en la documentación me resultó poco intuitivo y en cierta medida todavía complicado, no acabé convencido del todo, con React he acabado con la sensación que hacer algo más complejo que este ejemplo es algo al menos manejable.

React es una librería que en algunos casos se está usando en aplicaciones junto con Backbone para proporcionar la parte de la vista y controlador que en conjunto definen lo que en React se conoce como un componente. De esta manera Backbone proporciona los modelos, eventos, routing , … y React proporciona la representación de esos modelos en html y el código encargado de gestionar el estado de esa vista. React tiene ciertas ventajas adicionales por su funcionamiento y es que cuando se cambia algo en la vista no se reemplaza el html completo de la vista y se inserta uno nuevo sino que React busca las diferencias entre la vista actual y la nueva y realiza únicamente los cambios necesarios para tener la vista nueva, esto tiene la ventaja de que el proceso es más eficiente y rápido y puede notarse en aplicaciones con muchos datos gestionados en el cliente. Pero lo que más me ha gustado de React es la definición del concepto de componente (vista + controlador) que por una parte hace que la creación de las vistas sea mucho más sencilla e intuitiva que en Backbone o Marionette y que junto con el controlador permite crear piezas reusables de código.

Los componentes de React reemplazan a las vistas de Backbone y vistas, controladores y layouts de Marionette. En el nuevo ejemplo los cambios principales se encuentran en el archivo tareas.js que contiene el código de la aplicación de lista de tareas.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
define('tareas', [ 'jquery', 'underscore', 'backbone', 'react', 'mustache', 'plantillas', 'i18n!i18n/nls/mensajes' ], function($, _, Backbone, React, Mustache, Plantillas, Mensajes) {
    function render(plantilla, datos, mensajes) {
        var d = datos || {};
        var m = mensajes || {};
        
        var vista = _.extend(d, {
            message: m
        });
        
        var p = Plantillas[plantilla];
        var pp = p();
        return pp(vista);
    }
    
    // An example generic Mixin that you can add to any component that should react
 // to changes in a Backbone component. The use cases we've identified thus far
 // are for Collections -- since they trigger a change event whenever any of
 // their constituent items are changed there's no need to reconcile for regular
 // models. One caveat: this relies on getBackboneModels() to always return the
 // same model instances throughout the lifecycle of the component. If you're
 // using this mixin correctly (it should be near the top of your component
 // hierarchy) this should not be an issue.
 var BackboneMixin = {
        componentDidMount: function() {
            // Whenever there may be a change in the Backbone data, trigger a reconcile.
         this.getBackboneModels().forEach(function(model) {
                model.on('add change remove reset', this.forceUpdate.bind(this, null), this);
            }, this);
        },
        componentWillUnmount: function() {
            // Ensure that we clean up any dangling references when the component is
         // destroyed.
         this.getBackboneModels().forEach(function(model) {
                model.off(null, null, this);
            }, this);
        }
    };

    var Tarea = Backbone.Model.extend({
        urlRoot : 'rest/tareas/tarea',
        defaults : {
            id : null,
            descripcion : '',
            completada : false
        },
        toogle: function() {
            this.set('completada', !this.get('completada'));
        }
    });

    var Tareas = Backbone.Collection.extend({
        url: 'rest/tareas',
        model: Tarea,
        findCompletadas: function() {
            return this.models.filter(function(tarea) {
                return tarea.get('completada');
            });
        },
        removeCompletadas: function() {
            _.each(this.findCompletadas(), function(tarea) {
                tarea.destroy();
            });
        }
    });

    var TareaComponent = React.createClass({
        componentDidMount: function() {
            var _this = this;
            this.ui = {
                completada: $('input[name=completada]', this.getDOMNode())
            };
            
            this.ui.completada.change(function(event) {
                _this.props.tarea.toogle();
                _this.props.tarea.save();
            });
        },       
        render: function() {
//         return (
//              <label className="checkbox">
//                  <input type="checkbox" name="completada" checked={(this.props.tarea.get('completada'))?'checked':''}/> <span className={this.props.tarea.completada}>{this.props.tarea.get('descripcion')}</span>
//              </label>
//          );
         return React.DOM.label({className:'checkbox'},
                React.DOM.input({type:'checkbox', name:'completada', checked:(this.props.tarea.get('completada'))?'checked':''}),
                React.DOM.span({className:this.props.tarea.completada}, this.props.tarea.get('descripcion'))
            );
        }
    });
    
    var TareasComponent = React.createClass({
        render: function() {
            var tareas = this.props.tareas.map(function(tarea) {
//             return (
//                  <li><TareaComponent tarea={tarea}/></li>
//              );
             return React.DOM.li(null,
                        TareaComponent({tarea:tarea})
                );
            }, this);
//         return (
//              <ul>{tareas}</ul>               
//          );
         return React.DOM.ul(null, tareas);
        }
    });
    
    var EstadoComponent = React.createClass({
        render: function() {
            var d = this.getData();
            var m = {
                'COMPLETADAS_tareas_de_TOTAL_completadas': Mustache.render(Mensajes.COMPLETADAS_tareas_de_TOTAL_completadas, d),
                'Muy_bien_has_completado_todas_las_tareas': Mensajes.Muy_bien_has_completado_todas_las_tareas,
            };
            var estado = render('estado', d, m);
//         return (
//              <span className="estado">{estado}</span>
//          );
         return React.DOM.span({className:'estado'}, estado);
        },
        // Métodos
     getData: function() {
            var completadas = this.props.tareas.findCompletadas().length;
            var total = this.props.tareas.length;

            return {
                completadas: completadas,
                total: total
            };
        }
    });
    
    var TareasApp = React.createClass({
        mixins: [BackboneMixin],
        getBackboneModels: function() {
            return [this.state.tareas];
        },
        getInitialState: function() {
            return {tareas: new Tareas()};
        },
        componentDidMount: function() {
            var _this = this;
            this.ui = {
                nuevaTarea: $('input[name=nuevaTarea]', this.getDOMNode()),
                limpiar: $('input[name=limpiar]', this.getDOMNode())
            };
            
            this.ui.nuevaTarea.focus();
            
            // Eventos
         this.ui.nuevaTarea.keypress(function(event) {
                // Comprobar si la tecla pulsada es el return
             if (event.which == 13) {
                    var descripcion = _this.ui.nuevaTarea.val().trim();

                    // Comprobar si se ha introducido descripción de la tarea
                 if (descripcion == '') {
                        return;
                    }

                    // Añadir la tarea y limpiar el input
                 var tarea = new Tarea({
                        descripcion: descripcion,
                        completada: false
                    });
                    _this.addTarea(tarea);
                    _this.ui.nuevaTarea.val('');
                }
            });
            
            this.ui.limpiar.click(function() {
                _this.removeTareasCompletadas();
            });
        },
        render: function() {
//         return (
//              <div>
//                  <h2>{Mensajes.Lista_de_tareas}</h2>
//                  <input type="text" name="nuevaTarea" className="form-control" placeholder={Mensajes.Introduce_una_nueva_tarea} />
//                  <TareasComponent tareas={this.state.tareas} />
//                  <EstadoComponent tareas={this.state.tareas} />
//                  <br/>
//                  <input type="button" name="limpiar" value={Mensajes.Limpiar} disabled={(this.isTareasCompletadas())?null:'disabled'} className="btn" />
//              </div>
//          );
         return React.DOM.div(null,
                    React.DOM.h2(null, Mensajes.Lista_de_tareas),
                    React.DOM.input({type:'text', name:'nuevaTarea', className:'form-control', placeholder:Mensajes.Introduce_una_nueva_tarea}),
                    TareasComponent({tareas:this.state.tareas}),
                    EstadoComponent({tareas:this.state.tareas}),
                    React.DOM.br(),
                    React.DOM.input({type:'button', name:'limpiar', value:Mensajes.Limpiar, disabled:(this.isTareasCompletadas())?'':'disabled', className:'btn'})
            );
        },
        // Métodos
     isTareasCompletadas:function() {
            return this.state.tareas.findCompletadas().length > 0;
        },
        addTarea: function(tarea) {
            this.state.tareas.add(tarea);
            tarea.save();
        },
        removeTareasCompletadas: function() {
            this.state.tareas.removeCompletadas();
        },
        resetTareas: function(tareas) {
            this.state.tareas.reset(tareas);
        },
        fetch: function() {
            // Con reset:true solo se lanza un evento para todos los cambios que se produzcan en la colección
         this.state.tareas.fetch({reset:true});
        }
    });
    
    return {
        Tarea: Tarea,
        Tareas: Tareas,
        TareaComponent: TareaComponent,
        TareasComponent: TareasComponent,
        EstadoComponent: EstadoComponent,
        TareasApp: TareasApp
    };
});

El resultado es el siguiente:

Los elementos de las vistas se recomienda definirlas con los elementos que proporciona React con React.DOM, pueden definirse más al estilo de html con jsx pero esto hace que el javascript haya de compilarse para transformar ese jsx/html a los elementos React.DOM, el mayor problema es que esto es un proceso costoso lo que puede ralentizar la carga de una página y que el compilador tiene un tamaño considerable de unos 300 KiB. El JSX es más claro y parecido al resultado final que el código equivalente React.DOM pero aún así el código javascript es suficientemente claro. Si aún así quisiésemos usar JSX lo recomendable sería que los archivos con contenido jsx se precompilase en un momento anterior de enviarlo al cliente, posiblemente antes del despliegue de la aplicación en el servidor.

Para probar el código podemos hacerlo abriendo el archivo test/javascript/SpecRunner.html, sin embargo, deberemos hacerlo con Chrome o Chromium y lanzándolo con un parámetro opcional para permitir la carga de los archivos.

1
$ chromium --allow-file-access-from-files

También podríamos probarlo usando gradle con:

1
$ ./gradlew jasmine

Sin embargo, PhantomJS que es lo que se utiliza para simular el navegador en las pruebas con jasmine y grunt, no soporta la función bind produciéndose la siguiente excepción al usarse en la librería de React.

1
2
3
Error: define: 'undefined' is not a function (evaluating 'RegExp.prototype.test.bind(
>>     /^(data|aria)-[a-z_][a-z\d_.\-]*$/
>>   )')

Para evitarlo debemos añadir un polyfill. Deberemos añadir los polyfills de cujojs/poly, podemos hacer uso de ellos con RequireJS basta como añadirlo como dependencia:

1
2
define(['poly/function', 'specs/tareas-specs'], function() {
});

Este problema de la función bind ya esta incluido como peticion en PhantomJS y probablemente se resuelva en la versión 2.0.

El ejemplo con el código fuente completo de este ejemplo está en mi repositorio de GitHub, puedes probarlo en tu equipo con el siguiente comando:

1
$ ./gradlew tomcatRun