Crear aplicaciones fullstack con JavaScript y Firebase

Crear aplicaciones fullstack sin tener que lidiar con los problemas de un desarrollo backend es posible con JavaScript y Firebase de Google. En este artículo aprenderás todos los pasos necesarios.

A lo largo de esta guía irás implementado un proyecto real. Este proyecto consiste en crear una novela colaborativa, donde todos los usuarios que lleguen podrán escribir y leer.

Si haces click en la siguiente imagen, verás el resultado final del ejercicio.

Crea una novela colaborativa con Firebase. Haz click en la imagen para ver el ejercicio de hoy terminado.
Crea una novela colaborativa con Firebase. Haz click en la imagen para ver el ejercicio de hoy terminado.

En el artículo de hoy no analizaré una librería en concreto. En su lugar, he decidido crear una guía para que aprendas a desarrollar proyectos «fullstack» sin escribir una sola línea de código backend.

Para ello utilizaré un famoso producto gratuito de Google llamado Firebase. Firebase es una plataforma BaaS (de las siglas en inglés Backend as a Service) que pone a disposición de cualquier desarrollador, la capacidad de montar un entorno “backend” sin complejidades técnicas.

En seguida te cuento más acerca de este servicio, pero antes es necesario entender qué significa exactamente el término «fullstack». 

Si ya estás familiarizado con este concepto, puedes saltar directamente a la parte práctica.

Un breve repaso al término FullStack

En la programación de aplicaciones web es habitual separar entre dos áreas de desarrollo.

Por un lado existe el «frontend«, y suele hacer referencia a todo aquello que ocurre en la parte cliente

Por “cliente” entendemos el software con el que interactúa directamente el usuario. Desde una app creada a medida para un propósito determinado, hasta el navegador que tenga instalado en su dispositivo.

De hecho, en el ejercicio que he preparado al final del post, tu navegador será el que actuará de cliente para la webapp que crearemos.

Por otra parte, la existencia de un «frontend» implica también la de un «backend«. Así es, en este segundo entorno, es donde se atienden y gestionan las peticiones de todos los clientes. Este proceso ocurre en un servidor específicamente habilitado para eso.

No entraré en detalle a explicar cómo se establecen éstas peticiones para no alargar el texto más de lo necesario. Solo hace falta que sepas que se construyen sobre protocolos diseñados para enviar y recibir datos a través de una red como Internet.

Además, en toda aplicación, también es común almacenar datos de forma persistente, y posteriormente utilizarlos para varios fines. Cubrir esa necesidad, requiere de un gestor de bases de datos estrechamente conectado al backend.

Por supuesto, cada una de estas capas tecnológicas descritas tiene sus características y peculiaridades, como, por ejemplo, la utilización de lenguajes de programación específicos.

Y por si fuera poco, todo esto se debe enmarcar en una infraestructura de recursos hardware activamente mantenida y monitorizada.

Bien, pues un desarrollador fullstack debe conocer e involucrarse en todas las áreas descritas.

Como ya te habrás imaginado, tener bajo control cada uno de estos recursos es enormemente complejo, y no está al alcance de todo el mundo. 

Pero no te preocupes, precisamente aquí es donde entra en juego Firebase. Gracias a este producto gratuito de Google, podrás desplegar, a golpe de click, toda una red de servicios “backend” interconectados.

De este modo, te podrás centrar en crear la parte frontend de tu aplicación, y posteriormente conectarla a los servicios que Firebase ofrece.

Así pues, en las próximas líneas te enseñaré punto por punto a generar un proyecto nuevo de Firebase y vincularlo a tu aplicación web.

Creando una novela colaborativa gracias a Firebase y JavaScript

En concreto crearemos una webapp que permitirá a los usuarios trabajar en una novela colaborativa. Eso significa que cualquier persona que acceda a la web, tendrá la capacidad de crear uno o más párrafos de un escrito abierto.

Entre las funcionalidades que implementaremos, se añadirá un sistema de “login” anónimo o mediante una cuenta de Google. La posibilidad de enviar y guardar un texto, y la opción de borrarlo durante un período de tiempo limitado. Finalmente, añadiremos una capa de seguridad para garantizar que los datos solo se manipulan dentro de unas condiciones concretas.

Estos serán los pasos que seguiremos:

Por si en algún momento de esta guía te sientes perdido, te recomiendo tener a mano el repositorio del proyecto completado. Lo puedes encontrar en el siguiente enlace.

https://github.com/Danivalldo/libreriasjs/tree/master/Firebase

Crear un proyecto nuevo en Firebase

Para empezar a utilizar Firebase, es imprescindible disponer de una cuenta en Google. Puedes utilizar un correo con terminación “@gmail.com” si tienes uno.

Accede a https://firebase.google.com/ y haz click en el botón “Get started”. Una vez dentro selecciona la opción “Crear un proyecto”.

Acceso a Firebase
Acceso a Firebase

Configura tu proyecto dándole un nombre. Puedes activar la opción de vincular tu proyecto a una propiedad de Google Analytics. Aunque en esta guía no lo vamos a utilizar.

Dale un nombre al proyecto
Dale un nombre al proyecto

Con estas sencillas acciones, ya tienes a punto un proyecto nuevo dentro de la plataforma.

A continuación agregamos una app de tipo web. Como ves, Firebase también da soporte a clientes Android, iOS, e incluso apps creadas mediante Flutter o Unity.

Al seleccionar la opción para web debemos escoger un nombre de la aplicación. Dejaremos la opción de hosting sin marcar.

Opciones de app
Opciones de app

En la opción de agregar el SDK, seleccionamos “usar npm”. De momento ignoramos el comando NPM, ya que no lo usaremos hasta más adelante. Sin embargo, del código sugerido a continuación debes guardar la parte relativa a la configuración.

Seleccionar el SDK
Seleccionar el SDK
Ejemplo de código sugerido por Firebase
Ejemplo de código sugerido por Firebase

Así pues, genera un archivo con el nombre firebaseConfig.js dentro del directorio src/scripts, y guarda en él el objeto firebaseConfig.

const firebaseConfig = {
  apiKey: "AIzaSyBW775zwonhEGUOL6LOnAf90LVepyCkiUA",
  authDomain: "libreriasjs-firebase-demo.firebaseapp.com",
  projectId: "libreriasjs-firebase-demo",
  storageBucket: "libreriasjs-firebase-demo.appspot.com",
  messagingSenderId: "628146334298",
  appId: "1:628146334298:web:4b945e76fc27fd5e374663",
  measurementId: "G-MEEP74XKZJ",
};
export { firebaseConfig };

Llegados a este punto, damos por finalizado el primer paso. Ahora toca habilitar la funcionalidad de autenticación.

Habilitar la autenticación de usuarios anónimos y mediante Gmail

Ofreceremos las opciones de acceso mediante usuario anónimo, o a través de Google Gmail.

Para ello, seleccionamos el apartado autenticación y elegimos primeramente la opción anónimo. Sencillamente hay que activar el selector de habilitar, y guardar.

Métodos de autenticación
Métodos de autenticación

Repetimos el paso para la opción Google. Como ves, Firebase admite muchos otros métodos, aunque en esta guía no los repasamos todos, siéntete libre de habilitar cualquier otro.

Habilitar el método de autenticación
Habilitar el método de autenticación

Con los proveedores de acceso activados, ya podemos saltar al siguiente paso. Seguidamente habilitaremos una base de datos para almacenar los textos que los usuarios generen.

Crear una base de datos Cloud Firestore

El servicio encargado de cubrir esta función es Cloud Firestore, de modo que accedemos a su sección y seleccionamos “Crear base de datos”.

Cloud Firestore de Firebase
Cloud Firestore de Firebase

Tras hacer click, aparecerá una modal preguntándonos en qué modo deseamos iniciarla. De momento, seleccionamos modo de prueba, aunque más adelante modificaremos esas normas acorde a nuestras necesidades.

Modo de inicio de la base de datos
Modo de inicio de la base de datos

El siguiente cuadro nos pedirá la región en la que queremos ubicar la base de datos. Puedes elegir la que mejor se ajuste a tu interés, en mi caso selecciono “eur3 (europe-west)”. Esperamos unos segundos tras habilitarla, y ya estará lista para usar.

Ubicación de la base de datos
Ubicación de la base de datos

Las bases de datos Firestore de Firebase se organizan mediante colecciones que a su vez guardan documentos. De modo que, para comprender un poco mejor cómo funcionan, seguidamente crearemos nuestra primera colección.

Haremos clic en “+ iniciar colección”, aparecerá un cuadro de diálogo que nos pedirá un ID para la colección, la llamaremos “collaborative-novel”.

Crear una colección
Crear una colección

Por defecto, las colecciones no pueden estar vacías, así que tras seleccionar “siguiente”, generamos un documento nuevo.

En nuestro proyecto, cada documento representará una porción de la novela. La estructura de datos de cada porción será la siguiente.

  • date: variable de tipo timestamp y representará la hora en la que se generó el texto.
  • uid: variable de tipo string, y almacenará el ID del usuario que ha generado esa parte de la novela.
  • paragraph: variable también de tipo string que contendrá párrafo de la novela.
Crear un documento
Crear un documento

Como ves, he seleccionado ID automático, ya que dejaremos que sea Firestore quien le asigne automáticamente un identificador a cada porción de texto.

Más adelante veremos para qué sirven las reglas y cómo debemos configurarlas. Por ahora podemos dar por cerrado este apartado.

Crear la interfaz HTML / CSS de la aplicación web.

Acto seguido pasaremos a crear la interfaz gráfica de nuestra aplicación web. Como dije al inicio, puedes apoyarte en el repositorio para seguir cada bloque de código.

Comenzaremos definiendo una estructura HTML, y posteriormente le daremos estilos mediante SCSS.

Dentro del cuerpo del archivo index.html prepararemos tres contenedores.

El primero tendrá la clase login-view y servirá para mostrar el logo de la aplicación, y las opciones de login al usuario. También añadiremos un mensaje de “cargando” para mostrarlo cuando sea necesario.

Este es el código de “login-view”. Presta atención a las clases asignadas, ya que después las utilizaremos para asignar estilos y referenciar elementos del DOM.

<div class="login-view">
  <div class="login-buttons-container">
    <img src="imgs/logo_project_low.png" alt="" />
    <button class="btn anonymous-log-in-btn">
      INICIAR SESSIÓN ANÓNIMA
    </button>
    <button class="btn google-log-in-btn">
      INICIAR SESSIÓN CON GOOGLE
    </button>
  </div>
  <div class="loading-container">
    CARGANDO
    <div class="dot-flashing"></div>
  </div>
</div>

El segundo bloque contiene la clase “top-bar” y servirá para mostrar un menú en la parte superior de la pantalla. En esta área incluimos una versión del logo reducida, una imagen del usuario activo y un botón para desloguearse.

<div class="top-bar">
  <div>
    <img src="imgs/logo_project_low.png" alt="" />
  </div>
  <div>
    <img src="" alt="" class="photo-user" />
    <button class="log-out-btn">🚪🐒</button>
  </div>
</div>

Por último, generamos un contenedor con la clase “novel-view”. Como verás, este contiene una serie de secciones. En orden de aparición éstas servirán para mostrar una descripción. Agrupar la novela que cargaremos dinámicamente. Mostrar otro texto de carga cuando sea necesario. Y un formulario para ampliar la novela.

<div class="novel-view">
  <div class="description">
    <h1>¡Hola escrimono! Te estaba esperando</h1>
    <p>
      Delante de tí se encuentra la mejor novela jamás escrita. Solo hay un
      inconveniente, está inacabada... Tu deber, así como el del resto de
      escrimonos que lleguen aquí, será el de completarla.
    </p>
    <p>
      Solamente el tiempo dirá si esta novela colaborativa será la siguiente
      gran obra maestra.
    </p>
    <p>O la mayor 💩 jamás escrita...</p>
    <p>
      Deja volar la imaginación y escribe algunos párrafos que sigan con el
      relato planteado.
    </p>
  </div>
  <div class="novel-container"></div>
  <div class="loading-container">
    CARGANDO
    <div class="dot-flashing"></div>
  </div>
  <div class="form-container">
    <form class="new-novel-part-form">
      <textarea
        name=""
        id=""
        cols="30"
        rows="5"
        placeholder="Escribe aquí el siguiente párrafo de la novela"
        autofocus
      ></textarea>
      <button type="submit">Publicar✍🏽</button>
    </form>
  </div>
</div>

Adicionalmente, asegúrate de añadir la clase “logging” a la etiqueta “body”. Ahora toca definir los estilos de la aplicación.

Primeramente importamos una fuente de Google Fonts, y declaramos los estilos de la etiqueta “body”

body {
  font-family: "Space Mono", monospace;
  margin: 0;
  padding: 0;
  color: rgb(42, 42, 42);
  background-image: url("../assets/images/bananas.jpg");
  box-sizing: border-box;
  &.logged-in {
    .login-view {
      opacity: 0;
      visibility: hidden;
    }
  }
  &.logging {
    .login-view {
      .login-buttons-container {
        display: none;
      }
    }
    .loading-container {
      display: block;
      font-size: 2.3rem;
    }
  }
}

La idea detrás de esta estructura de estilos, será la de añadir y quitar clases al “body” afectando así de forma directa a los elementos hijos.

También daremos estilos CSS al contenedor y a los botones de acceso a la aplicación. Tal y como verás en el código, se trata de una capa en posición fija que ocupa toda la pantalla.

.login-view {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  z-index: 1;
  background-image: url("../assets/images/bananas.jpg");
  .login-buttons-container {
    .btn {
      cursor: pointer;
      display: block;
      width: 100%;
      padding: 20px 20px 20px 60px;
      background-image: url("../../static/imgs/google-original.svg");
      background-repeat: no-repeat;
      background-size: 30px;
      background-position: 10% center;
      background-color: #fffcef;
      border: none;
      margin-bottom: 20px;
      border-radius: 3px;
      box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
      &.anonymous-log-in-btn {
        background-image: url("../../static/imgs/account.svg");
      }
    }
  }
}
.loading-container {
  display: none;
}

Cuando el usuario acceda a la aplicación ocultaremos esos contenedores dejando al descubierto la barra de navegación superior y el contenedor de la novela.

Por consiguiente, el siguiente punto será dotar de estilos el contenedor “top-bar” y los elementos que contiene.

.top-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background-color: #e3dbb6;
  padding: 12px 5px 0px 5px;
  box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
  border-bottom: solid 3px #a9a078;
  & > :nth-child(1) {
    width: 200px;
    height: 95px;
    overflow: hidden;
    img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: center bottom;
      display: block;
    }
  }
  & > :nth-child(2) {
    display: flex;
    justify-content: center;
    align-items: center;
    img {
      border-radius: 50%;
      overflow: hidden;
      background-color: #fff;
      width: 80px;
      border: solid 3px #fffbe9;
      box-shadow: 0px 3px 5px rgba(0, 0, 0, 0.2);
      margin-right: 10px;
    }
    button {
      font-size: 3rem;
      background-color: #fffcef;
      letter-spacing: -17px;
      box-shadow: 0px 3px 5px rgba(0, 0, 0, 0.2);
      border-radius: 4px;
      overflow: hidden;
      border: none;
    }
  }
}

Por último, pero no por eso menos importante, incluiremos los estilos de los elementos que conforman la sección “novel-container”.

.novel-view {
  max-width: 1000px;
  margin: 0 auto 0 auto;
  padding: 30px 30px;
  .description {
    background-color: white;
    padding: 40px;
    h1 {
      margin: 0px;
    }
  }
  .novel-container {
    .novel-part-container {
      position: relative;
      padding: 20px 30px;
      border-bottom: solid 1px #cdcdcd;
      box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2);
      background-image: url("../../static/imgs/lined_paper.png");
      font-size: 1.3rem;
      .delete-btn {
        position: absolute;
        top: 5px;
        right: 25px;
        font-size: 1.5rem;
        cursor: pointer;
        background-color: transparent;
        border: none;
        .delete-label {
          font-size: 0.8rem;
        }
      }
      .meta {
        text-align: right;
        font-size: 0.8rem;
      }
    }
  }
  .form-container {
    textarea {
      display: block;
      box-sizing: border-box;
      border: none;
      width: 100%;
      padding: 20px;
      font-family: "Space Mono", monospace;
      margin-bottom: 10px;
      font-size: 1rem;
      resize: none;
    }
    button[type="submit"] {
      display: block;
      width: 100%;
      background-color: #fffcef;
      border: none;
      border-radius: 5px;
      box-shadow: 0 3px 5px rgba(0, 0, 0, 0.3);
      font-size: 1.5rem;
      font-family: "Space Mono", monospace;
      cursor: pointer;
      padding: 15px 0;
    }
  }
}

Puedes tomarte todo el tiempo que necesites para analizar detenidamente el código, y adaptarlo si lo consideras necesario.

Programar un servicio para conectar con los recursos de Firebase.

Ya tenemos la interfaz lista, es momento de entrar de lleno en la programación JavaScript. Empezaremos instalando la librería firebase mediante el comando que antes no hemos utilizado.

npm install firebase

A través de este SDK accederemos a los recursos de Firebase que hemos preparado, y los utilizaremos en nuestra aplicación.

Para tener nuestro código ordenado, agruparemos todas las funciones del SDK en una misma clase que crearemos específicamente para ello.

Llamaremos a esta clase FirebaseCtrl y la guardaremos en un archivo con el mismo nombre dentro del directorio src/scripts. Recuerda que en ese mismo nivel, deberías tener el archivo de configuración firebaseConfig.js

De momento se trata de una clase vacía, pero tranquilo, la iremos ampliando  progresivamente de métodos y propiedades.

Por lo pronto, importamos toda una serie de funciones de Firebase, que posteriormente utilizaremos. También incluiremos el archivo de configuración entre el grupo de “imports”.

import { initializeApp } from "firebase/app";
import { firebaseConfig } from "./firebaseConfig";
import {
  getAuth,
  signInAnonymously,
  onAuthStateChanged,
  GoogleAuthProvider,
  signInWithPopup,
} from "firebase/auth";
import {
  serverTimestamp,
  getFirestore,
  collection,
  addDoc,
  getDocs,
  query,
  limit,
  orderBy,
  doc,
  deleteDoc,
} from "firebase/firestore";
class FirebaseCtrl {
}
export default FirebaseCtrl;

Como es habitual, empezamos creando el constructor de nuestra clase. En este incluiremos las propiedades con las que trabajaremos. Aunque inicialmente las declaramos con “undefined” posteriormente les asignaremos su valor. Este es el listado de propiedades y su futuro propósito.

  • app: Guardará una instancia de la aplicación Firebase.
  • auth: Actuará como referencia al objeto de autenticación del usuario.
  • googleAuthProvider: Se ofrecerá la posibilidad de acceder mediante usuarios de Google, por ese motivo es necesario guardar una instancia de la clase GoogleAuthProvider de Firebase.
  • listeners: Esta propiedad nos va permitir guardar y ejecutar funciones de “callback” cuando determinados eventos ocurran.
  • db: Por supuesto, también debemos guardar una referencia al servicio de base de datos que preparamos anteriormente. Esta propiedad se encargará de ello.
  • userID: Una vez el usuario acceda a la aplicación mediante autenticación se le asignará un ID único. Esta propiedad guardará ese identificador durante la sesión.
constructor() {
  this.app = undefined;
  this.auth = undefined;
  this.googleAuthProvider = undefined;
  this.listeners = {};
  this.db = undefined;
  this.userID = undefined;
}

Como ya he comentado, instanciar un objeto de nuestra clase no asigna valores a estas propiedades. A continuación preparamos un método llamado “initApp” que definirá algunas de ellas.

En “initApp” definimos el valor de “app” mediante la función “initializeApp” de Firebase pasándole como parámetro el objeto de configuración. Una vez asignado el valor de app, podremos utilizarlo como argumento para la creación de “auth” y “db”. Para ello, utilizamos las funciones “getAuth” y “getFirestore” respectivamente.

initApp() {
  this.app = initializeApp(firebaseConfig);
  this.auth = getAuth(this.app);
  this.auth.useDeviceLanguage();
  this.db = getFirestore(this.app);
  this.googleAuthProvider = new GoogleAuthProvider();
  onAuthStateChanged(this.auth, this.onAuthChanged.bind(this));
}

En ese mismo método aprovechamos para programar algunas acciones más. Asignar el idioma de la autorización de acceso según el que tenga el usuario en su dispositivo con el método “useDeviceLanguage”. Declarar el valor de la propiedad “googleAuthProvider” con la clase “GoogleAuthProvider”. Y asignar una función de llamada de retorno, cuando se detecte un cambio de estado en el acceso del usuario a la aplicación.

Esta última acción requiere de preparar un método “onAuthChanged”que se ejecute cuando ocurra ese cambio de estado. Eso solo sucede cuando el usuario se conecta o desconecta del servicio.

onAuthChanged(user) {
  this.userID = user ? user.uid : undefined;
  if (typeof this.listeners["userauthchanged"] === "function") {
    this.listeners["userauthchanged"](user || null);
  }
}

Si se da el caso de que el usuario se ha “logueado” recibimos como parámetro el objeto “user”, entonces guardaremos el ID de usuario. Si el usuario se ha desconectado, reseteamos el valor del ID.

Para exponer ese cambio fuera de la clase, también buscamos entre la propiedad “listeners” si existe un “callback” vinculado al evento “userauthchanged”. Si es así lo ejecutamos pasando como argumento objeto usuario si existe.

Es necesario preparar un método para tener la opción de asignar funciones a eventos personalizados como “userauthchanged”.

Llamaremos a ese método como “on()” y admitirá dos parámetros, el nombre del evento y la función asociada.

on(eventKey, cb) {
  this.listeners[eventKey] = cb;
}

Como ves, solo se encargará de ampliar la propiedad listeners, para relacionar una función a un evento.

Seguimos ampliando la clase, en este punto preparamos un método de validación de acceso del usuario.

Declaramos el método asíncrono con el nombre “logIn”, y permitimos que se agregue un parámetro booleano llamado “anonymously”. Si se ejecuta la función con ese parámetro seteado como true, utilizaremos la función “signInAnonymously” de la librería Firebase pasando el objeto auth.

En caso de que no se setee, llamaremos a “signInWithPopup” con los parámetros auth y el googleAuthProvider. Este segundo sistema de autenticación abrirá una ventana emergente para que el usuario pueda seleccionar qué cuenta de Google quiere utilizar.

async logIn(anonymously) {
  try {
    if (typeof this.listeners["userloginstarted"] === "function") {
      this.listeners["userloginstarted"]();
    }
    if (anonymously) {
      await signInAnonymously(this.auth);
    } else {
      await signInWithPopup(this.auth, this.googleAuthProvider);
    }
  } catch (error) {
    console.log("error", error);
  } finally {
    if (typeof this.listeners["userloginended"] === "function") {
      this.listeners["userloginended"]();
    }
  }
}

En ambos casos, ejecutaremos funciones de callback que se hayan asociado a los eventos “userloginstarted” y “userloginended”.

Por supuesto, si construimos una vía para que el usuario acceda, también debemos crear un método para que se desconecte. Nombraremos a este otro método “logOut”.

En él, sencillamente llamamos al método “signOut()” de la propiedad “auth”.

logOut() {
  this.auth.signOut();
}

Con esto, hemos resuelto el apartado de registro a la aplicación. Ahora toca recuperar y ampliar la información de la base de datos. Como dije, en nuestro caso, se trata de una novela que se construye progresivamente por partes. Por ese motivo, el primer método a preparar se va encargar de pedir todas las partes existentes.

Declaramos el método como getNovel, y su primera instrucción será la de comprobar que existe la instancia para comunicarnos con la base de datos.

async getNovel() {
  if (!this.db) {
    return;
  }
  const q = query(
    collection(this.db, "collaborative-novel"),
    orderBy("date"),
    limit(500)
  );
  const querySnapshot = await getDocs(q);
  const novel = [];
  querySnapshot.forEach((doc) => {
    const data = doc.data();
    novel.push({
      id: doc.id,
      isOwner: data.uid === this.userID,
      canDelete: Date.now() - 1 * 60 * 60 * 1000 <= data.date.toMillis(),
      data: {
        ...data,
        pharagraph: data.pharagraph,
        date: data.date.toDate().toLocaleString(),
      },
    });
  });
  return novel;
}

Tras pasar esa primera validación, construimos una petición mediante la función “query” de Firebase. En ella elegimos sobre qué colección deseamos hacer la consulta, en qué orden y límite.

Justo después, utilizamos el objeto “q” devuelto, para pasárselo a la función getDocs también extraída de la librería Firebase. Cabe destacar que la llamada al servicio de Firebase ocurre de forma asíncrona, de modo que debemos realizar la petición precedida de la palabra clave “await”.

Una vez resuelta la llamada, recorremos los documentos obtenidos y generamos un array de objetos a partir de los datos extraídos de cada documento. Cada objeto del array representará una porción de la novela.

Estas son las propiedades de cada porción:

  • id: El string identificador de ese documento en la base de datos.
  • isOwner: Una booleana que determina si el usuario que ha realizado la consulta es el autor de ese párrafo.
  • canDelete: Una booleana que determina si esa parte de la novela ha sido creada hace más de una hora o no. La idea es que solo se puedan eliminar partes como máximo una hora después de su creación.
  • data: Contendrá la información a mostrar. El párrafo escrito por el usuario, la fecha, etc.

El siguiente método es “addPartToNovel”, otra función asíncrona que recibirá como argumento el texto del nuevo párrafo escrito por el usuario. 

async addPartToNovel(pharagraph) {
  if (!this.db || !this.userID) {
    return;
  }
  try {
    const docRef = await addDoc(collection(this.db, "collaborative-novel"), {
      date: serverTimestamp(),
      pharagraph: pharagraph,
      uid: this.userID,
    });
    return docRef.id;
  } catch (err) {}
}

En su interior incluimos instrucciones para comprobar si el id de usuario y la referencia a la base de datos existen. De ser así, utilizaremos la función “addDoc” de Firebase, esta función requiere de dos parámetros. El primero es una referencia a la colección “collaborative-novel”, y el segundo, un objeto con la estructura de datos que ya definimos.

Por último, pero no menos importante, generamos un método para eliminar partes de la novela.

async removeNovelPart(partId) {
  if (!this.db) {
    return;
  }
  try {
    await deleteDoc(doc(this.db, "collaborative-novel", partId));
  } catch (err) {
    console.log(err);
  }
}

En el método “removeNovelPart” recibimos como argumento el id del documento almacenado en la base de datos. A partir de ese ID, llamanos a la función “deleteDoc” tal y como se muestra en el bloque de código.

Sin duda esta clase ha sido la más compleja de preparar. Pero con este servicio creado, ahora tan solo queda vincular la vista HTML a las acciones correspondientes.

Programar un servicio para actualizar la UI.

No me detendré mucho a explicar este punto. En su lugar te dejaré un enlace directo a la clase en el repositorio.

https://github.com/Danivalldo/libreriasjs/blob/master/Firebase/src/scripts/UiCtrl.js

Esta clase actúa como un controlador simple para actualizar los elementos y estilos del DOM. La realidad es que existen frameworks excelentes como React o Vue, que resuelven esa tarea mucho mejor. Sin embargo, quería que esta guía fuera totalmente independiente de este tipo de recursos.

Solo explicaré un poco el método “updateNovel” ya que es la parte que quizá sea más compleja de entender.

updateNovel(novelParts) {
  this.novelContainer.innerHTML = "";
  novelParts.forEach((novelPart) => {
    const div = document.createElement("div");
    div.innerHTML = templateNovelPortion(novelPart);
    this.novelContainer.appendChild(div);
  });
}

Este método recibe como parámetro el array de partes de la novela construido en la función “getNovel” que ya vimos.

Cada porción de novela se mostrará a partir de la plantilla creada en el archivo src/scripts/templateNovelPortion.js

const templateNovelPortion = (portion) => {
  return `
    <div id="${portion.id}" class="novel-part-container">
      ${
        portion.isOwner && portion.canDelete
          ? `<button class="delete-btn">
              <span class="delete-label">
                Si pasa 1h o más despúes de publicar, ya no podrás eliminarlo
              </span>🗑️
            </button>`
          : ""
      }
      <div>
        ${portion.data.pharagraph}
      </div>
      <div class="meta">
        ${portion.data.date}
      </div>
    </div>
  `;
};
export default templateNovelPortion;

Te animo a que dediques unos minutos a entender la clase “UiCtrl” y el archivo “templateNovelPortion”, ya que en el siguiente punto programaremos un script que vincula una clase con la otra.

Vincular los servicios en un script

Iniciamos el script importando los dos servicios y los estilos para, acto seguido, instanciar cada una de ellas.

import FirebaseCtrl from "./scripts/FirebaseCtrl";
import UiCtrl from "./scripts/UiCtrl";
import "./SCSS/index.scss";
const firebaseCtrl = new FirebaseCtrl();
const uiCtrl = new UiCtrl();

Todas las instrucciones las ubicamos dentro de la función de retorno del evento “load”. Cuando se ejecute dicha función lanzamos los métodos “initApp” y “init” de los objetos “firebaseCtrl” y “uiCtrl”.

firebaseCtrl.initApp();
uiCtrl.init();

Aprovechando el método “on” de nuestra clase escucharemos los eventos “userloginstarted” “userloginended” y “userauthchanged”.

En los dos primeros “callbacks” mostramos y ocultamos el mensaje de carga.

firebaseCtrl.on("userloginstarted", () => {
  uiCtrl.showSpinner();
});
firebaseCtrl.on("userloginended", () => {
  uiCtrl.removeSpinner();
});

El tercer evento comprobará si el usuario se ha logueado o no para, seguidamente, mostrar u ocultar la pantalla de acceso, actualizar la imagen de usuario y pedir los fragmentos de la novela.

firebaseCtrl.on("userauthchanged", async (user) => {
  if (user) {
    uiCtrl.removeLogin();
    uiCtrl.updateUserImage(user.photoURL || "imgs/space-invaders.svg");
    uiCtrl.showSpinner();
    const novel = await firebaseCtrl.getNovel();
    uiCtrl.updateNovel(novel);
    uiCtrl.removeSpinner();
    return;
  }
  uiCtrl.showLogin();
});

Las tres instrucciones siguientes asignan eventos de tipo click a botones del DOM. Dos de ellos desencadenan la acción de autenticación, uno de forma anónima, y el otro mediante Google. El tercer botón permitirá al usuario cerrar la sesión.

uiCtrl.on("anonymousLogInBtn", "click", () => {
  firebaseCtrl.logIn(true);
});
uiCtrl.on("googleLogInBtn", "click", () => {
  firebaseCtrl.logIn();
});
uiCtrl.on("logOutBtn", "click", () => {
  firebaseCtrl.logOut();
});

Otro paso importante será escuchar cuando el formulario es enviado y lanzar una función para controlar las siguientes acciones

Obtener el texto del campo del formulario, y actualizar la novela con ese nuevo párrafo. Una vez completada la actualización pediremos de nuevo toda la novela.

uiCtrl.on("newNovelPartForm", "submit", async (e) => {
  e.preventDefault();
  const textArea = e.target.querySelector("textarea");
  const newParagraph = textArea.value.trim();
  if (!newParagraph) {
    return;
  }
  uiCtrl.showSpinner();
  const response = await firebaseCtrl.addPartToNovel(newParagraph);
  const novel = await firebaseCtrl.getNovel();
  uiCtrl.updateNovel(novel);
  textArea.value = "";
  uiCtrl.removeSpinner();
});

Finalmente, también asignaremos una acción al botón de borrar, por si el usuario se arrepienta del mensaje enviado.

uiCtrl.onClickNovelPartDeleteBtn(async (idPart) => {
  uiCtrl.showSpinner();
  await firebaseCtrl.removeNovelPart(idPart);
  const novel = await firebaseCtrl.getNovel();
  uiCtrl.updateNovel(novel);
  uiCtrl.removeSpinner();
});

En este punto la aplicación ya es completamente funcional, de hecho te animo que trates de interactuar con ella generando algunos fragmentos del texto. Si accedes al panel de gestión de Firebase, verás que, efectivamente la base de datos se va actualizando correctamente.

Aplicar normas para proteger el acceso a la base de datos.

No obstante, todavía queda implementar algunos temas más. En concreto, vamos a mejorar la seguridad de la aplicación en distintos niveles.

En primer lugar debemos evitar que un usuario malicioso pueda inyectar código HTML como parte del texto de la novela. De hecho, si no se corrige este problema, estaríamos dejando al descubierto una vulnerabilidad de tipo XSS.

Para prevenir este problema, instalaremos una librería adicional.

npm install sanitize-html

Mediante “sanitizeHtml” filtraremos el contenido de texto para detectar y eliminar todo el contenido formateado como HTML.

Abre el archivo src/scripts/FirebaseCtrl.js e importa la nueva librería al inicio.

import sanitizeHtml from "sanitize-html";

Luego edita los métodos “getNovel” y “addPartToNovel” para que los párrafos recuperados y enviados pasen por el filtro de la librería.

novel.push({
  id: doc.id,
  isOwner: data.uid === this.userID,
  canDelete: Date.now() - 1 * 60 * 60 * 1000 <= data.date.toMillis(),
  data: {
    ...data,
    pharagraph: sanitizeHtml(data.pharagraph, { allowedTags: [] }),
    date: data.date.toDate().toLocaleString(),
  },
});
const docRef = await addDoc(collection(this.db, "collaborative-novel"), {
  date: serverTimestamp(),
  pharagraph: sanitizeHtml(pharagraph, { allowedTags: [] }),
  uid: this.userID,
});

Para terminar, deberemos incluir algunas normas en la base de datos de Firebase para garantizar que se cumplan ciertas restricciones necesarias. Debes tener en cuenta que delegar toda la seguridad al frontend es una mala idea.

Las normas que especificaremos serán estas:

  • Solo permitiremos a usuarios validados (autenticados) leer datos de la base.
  • Nos aseguramos de que el usuario que cree una porción de novela sea realmente el autor.
  • Solo permitiremos eliminar un documento, si el usuario validado es el autor, y si se encuentra dentro del período permitido.

Para aplicar estas restricciones accederemos a la pestaña reglas en Cloud Firestore. Allí pegaremos este bloque de código

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /collaborative-novel/{documentID}  {
      allow read: if isValidUser();
      allow create: if isValidUser() && isCreator();
      allow delete: if isValidUser() && isOwner(documentID) && canDelete(documentID);
      
      function isValidUser(){
        return request.auth != null;
      }
      
      function isCreator(){
      	return request.auth.uid == request.resource.data.uid;
      }
      function isOwner(documentID){
      	let creatorID = get(/databases/$(database)/documents/collaborative-novel/$(documentID)).data.uid;
        return request.auth.uid == creatorID;
      }
      
      function canDelete(documentID){
      	let creationDate = get(/databases/$(database)/documents/collaborative-novel/$(documentID)).data.date;
        return request.time.toMillis() - (1 * 60 * 60 * 1000) <= creationDate.toMillis();
      }
    }
  }
}

Cloud Firestore utiliza un pseudocódigo parecido a JavaScript para definir las reglas. Como verás, primero capturamos las peticiones que hagan “match” a una ruta concreta, por ejemplo “/databases/{database}/documents/collaborative-novel/{documentID}”.

Allí especificamos qué condición se debe cumplir para leer, escribir o borrar un documento de esa ruta.

Por comodidad, la forma en la que generamos esas condiciones es mediante funciones independientes que aprovechamos si es necesario.

Fin! Enhorabuena, has aprendido a crear aplicaciones fullstack con JavaScript y Firebase

Ahora si, has completado el tutorial de cabo a rabo, solo me queda darte la enhorabuena! La verdad es que ha quedado una guía más extensa de lo que me hubiera gustado, pero por otra parte no quería dejarme demasiadas cosas en el tintero.

Con todo, aún quedan temas por pulir. Por ejemplo, se debería incluir una paginación para que el usuario pueda navegar por distintas páginas de la novela. También sería interesante modificar el campo de texto, para que permita generar texto enriquecido.

¿No tienes claro cómo añadir un editor de texto enriquecido? Aquí te dejo un artículo donde abordé este tema

Crear editor WYSIWYG con TinyMCE

Te animo a que completes estas funcionalidades.

Sin duda Firebase es un servicio magnífico para poder crear aplicaciones de todo tipo. De hecho, incluso se podría integrar en aplicaciones desarrolladas con tecnología híbrida. ¿No conoces qué tecnología es esa? Te dejo un enlace a un artículo detallado a continuación

Desarrollar aplicaciones híbridas multiplataforma

Espero sinceramente que haya sido de ayuda. Te animo a dejar en los comentarios cualquier duda u observación que quieras compartir conmigo.

También puedes escribirme directamente a través de cualquiera de mis redes sociales.

Un abrazo desarrollador!

4 comentarios en «Crear aplicaciones fullstack con JavaScript y Firebase»

  1. Me ha parecido bastante completo el tutorial… salvo qué no explicas cómo hacerlo correr. Los ficheros que creamos hay que subirlos a Firebase, los tengo que subir a mi propio servidor? Sé queda cojo, porque no hay información de cómo hacer correr la aplicación.

    Responder
    • Hola Adela, agradezco mucho tu comentario. Tienes razón, la parte de publicación en el servidor no está detenidamente cubierta en este tutorial. En los próximos días trataré de ampliar este punto.

      En cualquier caso, intentaré resolver tu duda.

      El primer paso es crear el ejercicio en un entorno de desarrollo local. Puedes hacerlo con herramientas como Vite o Webapck. Por ejemplo, si accedes al repositorio Github del ejercicio, verás que preparé mi entorno con Webpack.

      Si quieres profundizar más en estas herramientas puedes leer el artículo que hice sobre ellas.

      Preparar entornos de desarrollo frontend

      Una vez tengas este entorno en local, deberías compilar el resultado con el comando “npm run build” y el directorio resultante podrás publicarlo en tú servidor de producción.

      Si no tienes servidor propio, puedes utilizar servicios como Vercel, tambien te dejo una guía cómo hacer “deploy” allí

      Deploy de proyectos frontend en Vercel

      Espero haber aportado un poco de luz a tu duda, una vez más, grácias!

      Responder

Deja un comentario