Patrón MVC en vanilla JavaScript

TEORÍA

El patrón MVC (Modelo-Vista-Controlador) consiste en la modularización entre la parte del código que maneja la vista (esto es, el código que manipula el DOM), el modelo (los datos, normalmente recabados de una BD) y el controlador (el manejador de eventos que manipula el modelo).

Para cerrar el círculo, el modelo avisará a la vista de los cambios que se produzcan en él para que esta se actualice (o sea, actualice el DOM) adecuadamente; aunque en realidad, siendo puristas, en el patrón MVC, la vista «está está pendiente» de los cambios del modelo mediante eventos, pero en JavaScript no hay eventos para otra cosa que no sean elementos del DOM u otros objetos especiales de JS (como los objetos de la clase XMLHttpRequest).

Entonces, la idea feliz es que los 3 módulos (Modelo, Vista y Controlador) se relacionan entre sí de tal manera que ninguno de ellos es conocedor de los otros (excepto el controlador, que debe saber qué modelo y qué vista debe tocar en todo momento y qué métodos de ellos debe invocar).

Un resumen de las «tareas» que debe realizar cada módulo es el siguiente:

Controlador
  • Mantiene la relación entre la vista y el modelo (tiene acceso a los dos en todo momento y sabe a quién debe manipular).
  • Inicializa la vista (manda a la vista que se pueble con los datos del modelo que se le pase por parámetro, como copia, para que la vista no pueda nunca manipular el modelo).
  • Añade los «EventListeners» a la vista (la vista tendrá métodos para darle al controlador los elementos del DOM a los que tiene que añadirle los «EventListeners«).
  • Incluye a la vista como un observador («Observer«) del modelo.
  • Manipula el modelo (modifica sus datos).
  • Lee los datos de la vista (los inputs) cuando se trata de un formulario para manipular el modelo usando dicho formulario.
Vista
  • Manipula el DOM para representar adecuadamente los datos (el modelo).
  • Mantiene variables globales del tipo «la fila de la tabla actualmente seleccionada» ó «el div que se está mostrando ahora mismo».
  • Puede tener variables estáticas y únicas que representen a elementos del DOM que no sean exclusivos para cada modelo; por ejemplo, una ventana modal única en el DOM con inputs donde se escriben los datos del modelo al ser seleccionada una fila de una tabla, para que se muestren sus detalles ahí.
  • De la misma forma, puede tener funciones estáticas que inicializan y utilizan las variables estáticas.
  • Nunca tiene acceso al modelo, todos los datos se los pasa el controlador por copia.
Modelo
  • Contiene los datos.
  • Contiene la lógica de reacción ante los «setters» (por ejemplo: filtrar los datos mal formados, actualizarse en la BD, etc).
  • Avisa a la vista de sus cambios mediante la implementación del patrón «Observer«.
Resumen

PRÁCTICA

Vamos a suponer que tenemos un conjunto de datos de clientes que queremos que se muestren en pantalla (en un documento HTML).

Estos datos se van a mostrar de dos maneras: una global y resumida (en una tabla) y otra de una manera más detallada (con más datos en un div).

Podremos manipular los datos uno a uno (pulsando en la fila de la tabla y luego modificando los valores del div de los detalles) o en lote (seleccionando varias filas con checkboxes). La tabla debe permitirnos también seleccionar y deseleccionar todas las filas a la vez.

De forma ilustrativa, también tendremos un evento (que se lanzará al pulsar un botón) que modificará alguno de los modelos al azar (la idea es simular que algún evento en código puede manipular el modelo).

En HTML tendríamos:

<body>
  <table id="myTable">
    <thead>
      <tr>
        <th><input type="checkbox" id="myTableCheckbox"></th>
        <th>Fecha creación</th>
        <th>Nombre</th>
        <th>Observaciones</th>
      </tr>
    </thead>
    <tbody>
      
    </tbody>
  </table>

  <br><br>

  <div id="myDetails">
    Fecha: <input type="text" name="date">
    Nombre: <input type="text" name="name">
    <br>
    Observaciones:<br>
    <textarea name="obs">
      
    </textarea>
  </div>
  <br>
  <button id="update_model">Actualizar modelo por evento random</button>

</body>

Dicho esto, la vista final sería la siguiente:

Entonces, tenemos las tareas:

  • Al seleccionar una fila se muestra el div con los detalles
  • Al modificar algún campo del div de los detalles, se modifica el modelo (y por ende debe actualizarse la vista).
  • Al chequear varios checkboxes de las filas y modificar algún campo del div de los detalles, se modifican los modelos de las filas seleccionadas (y por ende sus vistas).
  • Al chequear el checkbox de la cabecera de la tabla se seleccionan todas las filas y sus checkboxes.
  • Al pulsar en el botón «actualizar modelo por evento random» se actualiza algún modelo y por ende sus vistas.

¡Comencemos!

Estructura MVC

Antes de nada, hay que tener una estructura de base para el MVC y luego pasamos a las tareas que ilustrarán cómo debe usarse esta estructura MVC.

Modelo

El modelo tendrá los atributos propios (el id, la fecha de creación del cliente, el nombre, etc) y un array de observadores (Observers), que no son más que objetos que implementan el método «update(datos)«, es decir, el modelo avisará a los observadores de que sus datos han cambiado (pasándoles los datos nuevos) a través del método «notifyAll«.

Para conseguir que en cada manipulación de cada atributo del modelo provoque obligatoriamente la ejecución del método «notifyAll«, se tiene que definir cada uno de sus atributos como una propiedad del modelo (Usando el método «Object.defineProperty(objeto, nombreAtributo, funciones)«), ya que, de esta forma, se puede definir qué es lo que debe ocurrir al devolver los datos de dicho atributo y lo que debe ocurrir al asignarle datos.

//Ejemplo de datos del cliente
{
  id: 4,
  fecha:'2018-01-04',
  nombre: 'nombre4',
  observaciones: 'observaciones4'
}

//Modelo Cliente
Client = function(id, fecha, nombre, observaciones){
  this.id = id;
  //Cuando los definimos como propiedades, debemos
  //definirlos como variables locales
  var date = fecha;
  var name = nombre;
  var observations = observaciones;

  //Observers
  this.observers = [];
  this.registerObserver = function(observer){
    this.observers.push(observer);
  }
  this.unregisterObserver = function(observer){
    var observerIndex = this.observers.indexOf(observer);
    this.observers.slice(observerIndex, 1);
  }
  this.notifyAll = function(){
    for (var i = this.observers.length - 1; i >= 0; i--) {
      this.observers[i].update(this.copy());
    }
  }

  //Definir atributos como propiedades con getters y setters
  // Al realizar un "set" se auto notifica del cambio a los observadores
  Object.defineProperty(this,"date",{
    get: function() { return date; },
    set: function(newDate) { 
      //Filtrar los datos
      var dateAux = new Date(newDate);
      if(dateAux.toString()==='Invalid Date'){
        return false;
      }
      date = newDate;
      //Notificar a todos los observadores
      this.notifyAll();
      return true;
    }
  });

  //...
        //más atributos
}

// Para poder pasar el objeto como copia y no como referencia
Client.prototype.copy = function(){
  return {
    id: this.id,
    date: this.date,
    name: this.name,
    observations: this.observations
  }
}
Vista

En la vista habría que tener un método estático para inicializar las variables estáticas (las correspondientes a vistas compartidas, como, en nuestro caso, el div de los detalles del cliente) y un método «update(datos)«.

// Vista Cliente
// Indica dónde se van a mostrar los datos del cliente que le irá dando el controlador
ClientView = function(){
  //Elementos de la vista 
  //this.tr = la fila que se va a insertar en la tabla.
}

// Variables globales para la vista
//ClientView.divDetails = null;
//...
// Inicializar las variables globales de la vista que sólo puedan leerse en el window.onload()
ClientView.init = function(){
  //ClientView.divDetails = div de detalles de los clientes
}

ClientView.prototype.update = function(model) {
  //Actualizar la vista con los datos pasados por parámetro
};

ClientView.prototype.populate = function(model) {
  //Población inicial (la tabla de clientes)
};
Controlador

El controlador va a registrar la vista como un observador del modelo («Observer«) y le va a dar los datos a la vista para que se pueble inicialmente.

Tenemos que definir al modelo como una propiedad para que, al pedir el modelo desde fuera de la clase del controlador, se le pase una copia y no la referencia al modelo (es similar a hacer privado al modelo y definirle un «getter«).

// Controlador de la vista y el modelo del cliente
ClientController = function(model, view){
  var model = model;
  var view = view;
  model.registerObserver(view);
  view.populate(model);

  //EVENT LISTENERS
  //...
  //END EVENT LISTENERS

  // Definir los atributos como propiedades del objeto
  // Esto me impide que desde fuera de esta clase haga cosas como:
  // Controller.model=null;
  // ó
  // Controller.model.name='blabla';
  // Pero sí que me permite cambiar los atributos del modelo desde aquí
  // model.name="blabla";
  // Es decir, convierto el atributo en privado (y no requiere de getter ni setter ya que
  // esto mismo se realiza al acceder al atributo (Controller.model es el propio getter) )
  Object.defineProperty(this,"model",{
    get: function() { return model.copy(); },
    set: function() { }
  });

  Object.defineProperty(this,"view",{
    get: function() { return view },
    set: function() { }
  });
}

Y finalmente, inicializarlo todo:

var clients = [
  {
    id: 1,
    fecha:'2018-01-01',
    nombre: 'nombre1',
    observaciones: 'observaciones1'
  },
  {...},
  ...
];

window.addEventListener('load', function(e){
  //Inicializar las variables estáticas de la vista
  ClientView.init();
  for (var i = clients.length - 1; i >= 0; i--) {
    var client = new Client(
      clients[i].id, 
      clients[i].fecha, 
      clients[i].nombre, 
      clients[i].observaciones);
    var view = new ClientView();
    var controller = new ClientController(client, view);
  }
});

Ejemplos prácticos de uso del MVC

Vamos a implementar las tareas antes expuestas ya que van a ayudar a comprender el uso práctico del MVC.

Mostrar detalles al seleccionar una fila de la tabla

Este es el caso en el que el controlador le pasa datos a la vista para que esta se actualice.

Primero, al inicializar la vista añadimos una fila de tabla (un tag tr) que va a representar a su modelo en la tabla de los clientes:

ClientView = function(){
  this.tr = document.createElement('tr');
  document.getElementById('myTable').querySelector('tbody').appendChild(this.tr);
}

Necesitarémos poblar esta fila con los datos que nos pase el controlador, definiendo así el método «populate(datos)«.

ClientView.prototype.populate = function(model) {
  var cell_checkbox = document.createElement('td');
  cell_checkbox.setAttribute('name', 'multiple_actions');
  var cell_date = document.createElement('td');
  cell_date.setAttribute('name', 'date');
  var cell_name = document.createElement('td');
  cell_name.setAttribute('name', 'name');
  var cell_observations = document.createElement('td');
  cell_observations.setAttribute('name', 'observations');

  var input_checkbox = document.createElement('input');
  input_checkbox.type = 'checkbox';
  cell_checkbox.appendChild(input_checkbox);
  cell_date.innerHTML = model.date;
  cell_name.innerHTML = model.name;
  cell_observations.innerHTML = model.observations;

  this.tr.appendChild(cell_checkbox);
  this.tr.appendChild(cell_date);
  this.tr.appendChild(cell_name);
  this.tr.appendChild(cell_observations);
}

Y a su vez, como el div de los detalles está compartido (es decir, no pertenece a un único modelo, sino que todos los modelos comparten esa vista) definimos el div de detalles como una variable estática de la vista que será inicializada cuando se produzca el evento «window.onload«:

// El div de detalles de los clientes
ClientView.divDetails = null;
// Inicializar las variables globales de la vista que sólo puedan leerse en el window.onload()
ClientView.init = function(){
  ClientView.divDetails = document.getElementById('myDetails');
}

//...

window.addEventListener('load', function(e){
  //Inicializar las variables estáticas de la vista
  ClientView.init();
  //...
}

Definimos el método «update(datos)» para que se actualicen todos los elementos del DOM que estén representando al modelo. Como uno de los elementos del DOM es compartido (el div de los detalles), sólo debemos actualizarlo si esta vista es la que está utilizando actualmente el div de detalles (en otro caso sólo actualiza la fila de la tabla de clientes), para ello definimos la variable estática «showingDetails«:

// ClientView showingDetails: Determina qué ClientView está utilizando el div de detalles
ClientView.showingDetails = null;

ClientView.prototype.update = function(model) {
  var cell_date = this.tr.querySelector('td[name="date"]');
  cell_date.innerHTML = model.date;
  var cell_name = this.tr.querySelector('td[name="name"]');
  cell_name.innerHTML = model.name;
  var cell_observations = this.tr.querySelector('td[name="observations"]');
  cell_observations.innerHTML = model.observations;

  //Si el div de detalles lo está usando esta vista 
  // => actualizar el div de detalles también	
  if(ClientView.showingDetails == this){
    ClientView.divDetails.querySelector('input[name="date"]').value = model.date;
    ClientView.divDetails.querySelector('input[name="name"]').value = model.name;
    ClientView.divDetails.querySelector('textarea[name="obs"]').value = model.observations;
  }
}

Para mostrar los detalles del modelo, definimos el método «showDetails» en la vista, que no hará otra cosa que mostrar el div de detalles (inicialmente oculto), indicar que esta vista es la que está usando ese div de detalles y actualizar los datos de la vista:

ClientView.prototype.showDetails = function(modelData){
  ClientView.divDetails.style.display = 'block';
  ClientView.showingDetails = this;
  this.update(modelData);
}

Y en la inicialización del controlador, añadimos un «EventListener» a la fila de la vista para que, al ser pulsada, se ejecute el método «showDetails» de la vista.

ClientController = function(model, view){
  //...

  //EVENT LISTENERS
  // click en la fila del cliente
  view.tr.addEventListener('click', view.showDetails.bind(view/*acceso a this*/, model));

  //...
}

Modificar inputs del div de detalles actualiza el modelo

Este es el caso en el que el controlador lee la vista y actualiza el modelo en base a lo que haya en la vista.

Para simplificar el ejemplo, sólo vamos a fijarnos en el input de la fecha de creación del cliente.

Primero definimos un método estático para la vista que nos devuelva el input de la fecha del div de detalles:

ClientView.getInputDate = function(){
  return ClientView.divDetails.querySelector('input[name="date"]');
}

Y en el controlador añadimos un «EventListener» al input de la fecha que actualice su modelo. Como se crea un controlador por modelo, significa que va a haber varios «EventListener» apuntando a dicho input, así que el evento sólo debe continuar si la vista del controlador es la que se está mostrando actualmente en el div de detalles (el método estático de la vista que hemos llamado «showingDetails«):

ClientView.getInputDate = function(){
  return ClientView.divDetails.querySelector('input[name="date"]');
}

ClientController = function(model, view){
  //...
  // se modifica el input "date" del div de detalles del cliente
  this.onClientDateInputChange = this.onClientDateInputChange_single.bind(this, model);
  ClientView.getInputDate().addEventListener('change', 
    this.onClientDateInputChange);
}

// Si cambia el input date y la fila NO está seleccionada con un checkbox
ClientController.prototype.onClientDateInputChange_single = function(model, e){
  //Si mi vista es la que se está mostrando en el div de 
  //detalles, procedo a modificar el modelo
  if(ClientView.showingDetails === this.view){
    model.date = ClientView.getInputDate().value;
  }
}

De esta forma, cuando pongamos una fecha en el input de los detalles se actualizará el modelo, el modelo realizará el filtrado de datos y si la fecha está bien formada, se asigna el nuevo valor al modelo, lo que provoca que se notifique a todos los observadores, en este caso, la vista, y que esta última actualice sus datos (lo que provoca que se muestren los datos nuevos también en la fila de la tabla).

Modificar varios modelos a la vez

Cuando seleccionemos uno o varios checkboxes de las filas de la tabla, y modifiquemos el input de la fecha de creación, se van a modificar todos los modelos de las filas seleccionadas.

Este caso es muy parecido al anterior, solo que en esta ocasión jugamos con los eventos: modificamos el evento que se ejecutará al cambiar el input de la fecha de creación del cliente cuando el checkbox de su fila cambie, de tal forma que si no está seleccionado funciona como siempre (es decir, comprueba que su vista es la que se está mostrando en el div), y si está seleccionado modifica su modelo (aunque no sea su vista la que se esté mostrando).

Para ello creamos unos métodos en la vista que nos den el checkbox de la fila (para poder añadirle un «EventListener«) y para seleccionarla/deseleccionarla:

ClientView.prototype.getRowCheckbox = function(){
  return this.tr.querySelector('td[name="multiple_actions"] > input[type="checkbox"]');
}

ClientView.prototype.selectRow = function(){
  var rowCheckbox = this.tr.querySelector('td[name="multiple_actions"] > input[type="checkbox"]');
  rowCheckbox.checked = true;
  this.tr.classList.add('selected');
}

ClientView.prototype.deselectRow = function(){
  var rowCheckbox = this.tr.querySelector('td[name="multiple_actions"] > input[type="checkbox"]');
  rowCheckbox.checked = false;
  this.tr.classList.remove('selected');
}

En el controlador se añade un «EventListener» al checkbox de la fila:

ClientController = function(model, view){
  //...
  // click en el checkbox de la fila del cliente
  view.getRowCheckbox().addEventListener('change', this.rowClientCheckboxChanged.bind(this, view, model));
  //...
}

Y ahora, dependiendo de si el checkbox está chequeado o no, se le pone un «EventListener» u otro, para ello primero se tiene que eliminar el «EventListener» anterior y poner el nuevo, es por ello que este «EventListener» debe estar almacenado en la clase como un atributo más (sino no podríamos remover el «EventListener«):

ClientController = function(model, view){
  //...
  // almacenamos la función del EventListener para poder 
  // borrarla cuando cambie el checkbox
  this.onClientDateInputChange = this.onClientDateInputChange_single.bind(this, model);
}
ClientController.prototype.rowClientCheckboxChanged = function(view, model, e){
  if(e.target.checked){
    view.selectRow();
    
    //Cambiar el evento del input de la fecha de creación
    // de onClientDateInputChange_single
    // a onClientDateInputChange_multiple
    ClientView.getInputDate().removeEventListener('change', 
      this.onClientDateInputChange);
    this.onClientDateInputChange = this.onClientDateInputChange_multiple.bind(this, model);
    ClientView.getInputDate().addEventListener('change', 
      this.onClientDateInputChange);
  }
  else{
    view.deselectRow();

    //Cambiar el evento del input de la fecha de creación
    // de onClientDateInputChange_multiple
    // a onClientDateInputChange_single
    ClientView.getInputDate().removeEventListener('change', 
      this.onClientDateInputChange);
    this.onClientDateInputChange = this.onClientDateInputChange_single.bind(this, model);
    ClientView.getInputDate().addEventListener('change', 
      this.onClientDateInputChange);
  }
}

// Si cambia el input date y la fila NO está seleccionada con un checkbox
ClientController.prototype.onClientDateInputChange_single = function(model, e){
  //Si yo soy el controlador que se está mostrando en los detalles, modifico mi modelo
  if(ClientView.showingDetails === this.view){
    model.date = ClientView.getInputDate().value;
  }
}

// Si cambia el input date y la fila está seleccionada con un checkbox
ClientController.prototype.onClientDateInputChange_multiple = function(model, e){
  //No es necesario comprobar si mi vista es la del div de
  //detalles ya que su fila ha sido seleccionada
  model.date = ClientView.getInputDate().value;
}

Seleccionar todos los checkboxes usando el de la cabecera de la tabla

Este caso es muy sencillo y la idea es que, ante eventos «globales» como este, pensémos «¿Qué tiene que hacer cada modelo, individualmente, para reaccionar ante él?» en lugar de pensar en arrays de controladores o algo similar que se tengan que recorrer para realizar una tarea.

En este caso, todos los controladores apuntan al mismo checkbox, el de la cabecera de la tabla, entonces todos deben reaccionar ante el cambio de este como si hubiese cambiado el checkbox de su fila; es decir, si se selecciona el checkbox de la cabecera, es igual a que me hubiesen seleccionado mi checkbox:

ClientController = function(model, view){
  //...
  document.getElementById('myTableCheckbox').addEventListener(
    'change', 
    this.rowClientCheckboxChanged.bind(this, view, model));
  //...
}

Actualizar el modelo por acción de un evento no relacionado con la vista

En ciertas ocasiones podríamos tener eventos que modifiquen el modelo que no tengan nada que ver con los inputs de la vista (por ejemplo, eventos que se producen al recibir un SSE o un dato nuevo de un WebSocket).

En ese caso, lo ideal sería tener un identificador de modelo (ya que suponemos que los datos representados en la vista provienen de una BD) y que todos los controladores estén atentos de los eventos de cambio del WebSocket, SSE, etc. Cuando traiga datos nuevos, el controlador comparará el Id del dato nuevo con el que tenía previamente, y si es igual procede a actualizar su modelo.

En este caso simularemos que dicho evento de cambio se produce al pulsar el botón de «Evento random» del documento HTML:

var newModelData = {
  id: Math.floor(Math.random()*4) + 1,
  date: '1999-09-09',
  name: 'nombreMOD',
  observations: 'observacionesMOD'
}

ClientController = function(model, view){
  //...
  // Simular un evento random en el script que provoca 
  // la actualización del modelo
  document.getElementById('update_model').addEventListener(
    'click', 
    this.randomEventChangeModel.bind(this, model));
  //...
}

ClientController.prototype.randomEventChangeModel = function(model, e){
  //newModelData es una variable global que genera un id aleatorio
  //Si NO es mi modelo => break
  if(newModelData.id != model.id) return;
  model.date = newModelData.date;
  model.name = newModelData.name;
  model.observations = newModelData.observations;
}

Descarga el ejemplo

Puedes descargar el ejemplo completo aquí

Deja una respuesta