Saltar al contenido

c

Obteniendo datos del servidor

Desde hace un tiempo solo hemos estado trabajando en el "frontend", es decir, la funcionalidad del lado del cliente (navegador). Comenzaremos a trabajar en el "backend", es decir, la funcionalidad del lado del servidor en la tercera parte de este curso. No obstante, ahora daremos un paso en esa dirección familiarizándonos con cómo el código que se ejecuta en el navegador se comunica con el backend.

Usemos una herramienta diseñada para ser utilizada durante el desarrollo de software llamada JSON Server para que actúe como nuestro servidor.

Crea un archivo llamado db.json en el directorio raíz del proyecto con el siguiente contenido:

{
  "notes": [
    {
      "id": 1,
      "content": "HTML is easy",
      "important": true
    },
    {
      "id": 2,
      "content": "Browser can execute only JavaScript",
      "important": false
    },
    {
      "id": 3,
      "content": "GET and POST are the most important methods of HTTP protocol",
      "important": true
    }
  ]
}

Puedes instalar el servidor JSON globalmente en tu máquina usando el comando npm install -g json-server. Una instalación global requiere privilegios administrativos, lo que significa que no es posible en las computadoras de la facultad o en las computadoras portátiles de primer año.

Después de instalar, ejecuta el siguiente comando para ejecutar json-server. Por defecto, json-server se inicia en el puerto 3000; ahora definiremos un puerto alternativo 3001, para json-server. La opción --watch busca automáticamente cualquier cambio guardado en db.json.

json-server --port 3001 --watch db.json

Sin embargo, no es necesaria una instalación global. Desde el directorio raíz de su aplicación, podemos ejecutar json-server usando el comando npx:

npx json-server --port 3001 --watch db.json

Naveguemos hasta la dirección http://localhost:3001/notes en el navegador. Podemos ver que json-server sirve las notas que escribimos previamente en el archivo en formato JSON:

notas en formato json en el navegador en la url localhost:3001/notes

Si tu navegador no puede formatear la visualización de datos JSON, entonces instala una extension apropiada, por ejemplo, JSONVue para hacerte la vida más fácil.

De ahora en adelante, la idea será guardar las notas en el servidor, lo que en este caso significa guardarlas en json-server. El código de React obtiene las notas del servidor y las muestra en la pantalla. Siempre que se agrega una nueva nota a la aplicación, el código de React también la envía al servidor para que la nueva nota persista en la "memoria".

json-server almacena todos los datos en el archivo db.json, que reside en el servidor. En el mundo real, los datos se almacenarían en algún tipo de base de datos. Sin embargo, json-server es una herramienta útil que permite el uso de la funcionalidad del lado del servidor en la fase de desarrollo sin la necesidad de programar nada de eso.

Nos familiarizaremos con los principios de implementación de la funcionalidad del lado del servidor con más detalle en la parte 3 de este curso.

El navegador como entorno de ejecución

Nuestra primera tarea es recuperar las notas ya existentes en nuestra aplicación React desde la dirección http://localhost:3001/notes.

En el proyecto de ejemplo ya aprendimos una manera de obtener datos de un servidor usando JavaScript. El código del ejemplo obtenía los datos mediante XMLHttpRequest, también conocido como solicitud HTTP realizada mediante un objeto XHR. Esta es una técnica introducida en 1999, que todos los navegadores han admitido durante un buen tiempo.

Ya no se recomienda el uso de XHR, y los navegadores ya admiten ampliamente el método fetch, que se basa en las llamadas promesas (promises), en lugar del modelo impulsado por eventos utilizado por XHR.

Como recordatorio de la parte 0 (que de hecho no deberías usar sin una buena razón), los datos se obtuvieron usando XHR de la siguiente manera:

const xhttp = new XMLHttpRequest()

xhttp.onreadystatechange = function() {
  if (this.readyState == 4 && this.status == 200) {
    const data = JSON.parse(this.responseText)
    // handle the response that is saved in variable data
  }
}

xhttp.open('GET', '/data.json', true)
xhttp.send()

Justo al principio, registramos un controlador de eventos en el objeto xhttp que representa la solicitud HTTP, que será invocada por el entorno de ejecución de JavaScript siempre que el estado del objeto xhttp cambie. Si el cambio de estado significa que ha llegado la respuesta a la solicitud, los datos se manejan en consecuencia.

Vale la pena señalar que el código en el controlador de eventos se define antes de que la solicitud se envíe al servidor. A pesar de esto, el código dentro del controlador de eventos se ejecutará en un momento posterior. Por lo tanto, el código no se ejecuta de forma síncrona "de arriba a abajo", sino que lo hace asincrónicamente. JavaScript llama al controlador de eventos que se registró para la solicitud en algún momento.

Una forma síncrona de realizar solicitudes que es común en la programación Java, por ejemplo, se desarrollaría de la siguiente manera (NB, esto no es realmente un código Java que funcione):

HTTPRequest request = new HTTPRequest();

String url = "https://studies.cs.helsinki.fi/exampleapp/data.json";
List<Note> notes = request.get(url);

notes.forEach(m => {
  System.out.println(m.content);
});

En Java el código se ejecuta línea por línea y se detiene para esperar la solicitud HTTP, lo que significa esperar a que finalice el comando request.get(...). Los datos devueltos por el comando, en este caso las notas, se almacenan en una variable y comenzamos a manipular los datos de la manera deseada.

Por otro lado, los motores JavaScript o los entornos de ejecución siguen el modelo asíncrono. En principio, esto requiere que todas las operaciones IO (con algunas excepciones) se ejecuten como no bloqueantes. Esto significa que la ejecución del código continúa inmediatamente después de llamar a una función IO, sin esperar a que regrese.

Cuando se completa una operación asíncrona, o más específicamente, en algún momento después de su finalización, el motor de JavaScript llama a los controladores de eventos registrados en la operación.

Actualmente, los motores de JavaScript son de un solo thread, lo que significa que no pueden ejecutar código en paralelo. Como resultado, es un requisito en la práctica utilizar un modelo sin bloqueo para ejecutar operaciones IO. De lo contrario, el navegador se "congelaría" durante, por ejemplo, la obtención de datos de un servidor.

Otra consecuencia de esta naturaleza de un solo thread de los motores de JavaScript es que si la ejecución de algún código lleva mucho tiempo, el navegador se atascará mientras dure la ejecución. Si agregamos el siguiente código en la parte superior de nuestra aplicación:

setTimeout(() => {
  console.log('loop..')
  let i = 0
  while (i < 50000000000) {
    i++
  }
  console.log('end')
}, 5000)

todo funcionaría normalmente durante 5 segundos. Sin embargo, cuando se ejecuta la función definida como parámetro para setTimeout, el navegador se bloqueará mientras dure la ejecución del bucle largo. Incluso la pestaña del navegador no se puede cerrar durante la ejecución del bucle, al menos no en Chrome.

Para que el navegador permanezca receptivo, es decir, para poder reaccionar continuamente a las operaciones del usuario con suficiente velocidad, la lógica del código debe ser tal que ningún cálculo individual pueda llevar demasiado tiempo.

Existe una gran cantidad de material adicional sobre el tema que se puede encontrar en Internet. Una presentación particularmente clara del tema es el discurso de apertura de Philip Roberts titulado ¿Qué diablos es el ciclo del evento de todos modos?

En los navegadores actuales, es posible ejecutar código paralelo con la ayuda de los llamados web workers. Sin embargo, el bucle de eventos de una ventana individual del navegador solo es manejada por un hilo único.

npm

Volvamos al tema de la obtención de datos del servidor.

Podríamos usar la función basada en promesas fetch mencionada anteriormente para extraer los datos del servidor. Fetch es una gran herramienta. Está estandarizado y es compatible con todos los navegadores modernos (excepto IE).

Dicho esto, usaremos la librería axios en su lugar para la comunicación entre el navegador y el servidor. Funciona como fetch, pero es algo más agradable de usar. Otra buena razón para usar axios es que nos familiarizamos con la adición de librerías externas, los llamados paquetes npm, a los proyectos de React.

Hoy en día, prácticamente todos los proyectos de JavaScript se definen utilizando el administrador de paquetes de node, también conocido como npm. Los proyectos creados con Vite también siguen el formato npm. Un indicador claro de que un proyecto usa npm es el archivo package.json ubicado en la raíz del proyecto:

{
  "name": "notes-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.15",
    "@types/react-dom": "^18.2.7",
    "@vitejs/plugin-react": "^4.0.3",
    "eslint": "^8.45.0",
    "eslint-plugin-react": "^7.32.2",
    "eslint-plugin-react-hooks": "^4.6.0",
    "eslint-plugin-react-refresh": "^0.4.3",
    "vite": "^4.4.5"
  }
}

En este punto, la parte de dependencies es la de mayor interés para nosotros ya que define qué dependencias, o librerías externas, tiene el proyecto.

Ahora queremos usar axios. En teoría, podríamos definir la librería directamente en el archivo package.json, pero es mejor instalarlo desde la línea de comandos.

npm install axios

NB los comandos de npm siempre deben ejecutarse en el directorio raíz del proyecto, que es donde se puede encontrar el archivo package.json.

Axios ahora se incluye entre las otras dependencias:

{
  "name": "notes-frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
  "dependencies": {
    "axios": "^1.4.0",    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  // ...
}

Además de agregar axios a las dependencias, el comando npm install también descargó el código de la librería. Al igual que con otras dependencias, el código se puede encontrar en el directorio node_modules ubicado en la raíz. Como uno podría haber notado, node_modules contiene una buena cantidad de cosas interesantes.

Hagamos otra adición. Instala json-server como una dependencia de desarrollo (solo se usa durante el desarrollo) ejecutando el comando:

npm install json-server --save-dev

y haciendo una pequeña adición a la parte scripts del archivo package.json:

{
  // ... 
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "server": "json-server -p3001 --watch db.json"  },
}

Ahora podemos convenientemente, sin definiciones de parámetros, iniciar json-server desde el directorio raíz del proyecto con el comando:

npm run server

Nos familiarizaremos con la herramienta npm en la tercera parte del curso.

NB El servidor json iniciado previamente debe terminarse antes de iniciar uno nuevo, de lo contrario habrá problemas:

error: no se puede enlazar al puerto 3001

La letra roja en el mensaje de error nos informa sobre el problema:

Cannot bind to the port 3001. Please specify another port number either through --port argument or through the json-server.json configuration file

(No se puede vincular al puerto 3001. Especifique otro número de puerto a través del argumento --port o mediante el archivo de configuración json-server.json)

Como podemos ver, la aplicación no es capaz de vincularse al puerto. La razón es que el puerto 3001 ya está ocupado por el servidor json iniciado anteriormente.

Usamos el comando npm install dos veces, pero con ligeras diferencias:

npm install axios
npm install json-server --save-dev

Hay una pequeña diferencia en los parámetros. axios se instala como una dependencia de entorno de ejecución de la aplicación, porque la ejecución del programa requiere la existencia de la librería. Por otro lado, json-server se instaló como una dependencia de desarrollo (-- save-dev), ya que el programa en sí no lo requiere. Se utiliza como ayuda durante el desarrollo de software. Habrá más sobre diferentes dependencias en la próxima parte del curso.

Axios y promesas

Ahora estamos listos para usar axios. En el futuro, se asume que json-server se está ejecutando en el puerto 3001.

NB: Para ejecutar json-server y su aplicación react simultáneamente, es posible que debas usar dos ventanas de terminal. Uno para mantener json-server en ejecución y el otro para ejecutar react-app.

La librería se puede poner en uso de la misma manera que otras librerías, por ejemplo, React, es decir, utilizando una instrucción import adecuada.

Agrega lo siguiente al archivo main.jsx:

import axios from 'axios'

const promise = axios.get('http://localhost:3001/notes')
console.log(promise)

const promise2 = axios.get('http://localhost:3001/foobar')
console.log(promise2)

Si abres http://localhost:5173/ en el navegador, esto debería imprimirse en la consola:

promesas imprimidas en la consola

El método de Axios get devuelve una promesa.

La documentación del sitio de Mozilla establece lo siguiente sobre las promesas:

Una promesa es un objeto que representa la eventual finalización o falla de una operación asíncrona.

En otras palabras, una promesa es un objeto que representa una operación asíncrona. Una promesa puede tener tres estados distintos:

  • La promesa está pendiente: significa que el valor final (uno de los dos siguientes) aún no está disponible.
  • La promesa está cumplida: Significa que la operación se ha completado y el valor final está disponible, que generalmente es una operación exitosa. Este estado a veces también se denomina resuelto.
  • La promesa es rechazada: Significa que un error impidió determinar el valor final, que generalmente representa una operación fallida.

La primera promesa en nuestro ejemplo está cumplida, lo que representa una solicitud a axios.get('http://localhost:3001/notes') exitosa. La segunda, sin embargo, está rechazada y la consola nos dice el motivo. Parece que estábamos intentando realizar una solicitud HTTP GET a una dirección inexistente.

Si, y cuando, queremos acceder al resultado de la operación representada por la promesa, debemos registrar un controlador de eventos en la promesa. Esto se logra usando el método then:

const promise = axios.get('http://localhost:3001/notes')

promise.then(response => {
  console.log(response)
})

Se imprime lo siguiente en la consola:

objeto de datos json impreso en la consola

El entorno de ejecución de JavaScript llama a la función callback registrada por el método then, proporcionándole un objeto response como parámetro. El objeto response contiene todos los datos esenciales relacionados con la respuesta de una solicitud HTTP GET, que incluiría los datos devueltos, el código de estado (status code) y los encabezados (headers).

Por lo general, no es necesario almacenar el objeto de la promesa en una variable y, en cambio, es común encadenar la llamada al método then a la llamada al método axios, de modo que la siga directamente:

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  console.log(notes)
})

La función de callback ahora toma los datos contenidos en la respuesta, los almacena en una variable e imprime las notas en la consola.

Una forma más legible de formatear llamadas de método encadenadas es colocar cada llamada en su propia línea:

axios
  .get('http://localhost:3001/notes')
  .then(response => {
    const notes = response.data
    console.log(notes)
  })

Los datos devueltos por el servidor son texto sin formato, básicamente solo una cadena larga. La librería axios aún puede analizar los datos en una matriz de JavaScript, ya que el servidor ha especificado que el formato de datos es application/json; charset=utf-8 (ver imagen anterior) usando el encabezado content-type.

Finalmente podemos comenzar a utilizar los datos obtenidos del servidor.

Intentemos solicitar las notas de nuestro servidor local y renderizarlas, inicialmente como el componente App. Ten en cuenta que este enfoque tiene muchos problemas, ya que estamos procesando todo el componente App solo cuando recuperamos con éxito una respuesta:

import ReactDOM from 'react-dom/client'
import axios from 'axios'
import App from './App'

axios.get('http://localhost:3001/notes').then(response => {
  const notes = response.data
  ReactDOM.createRoot(document.getElementById('root')).render(<App notes={notes} />)
})

Este método podría ser aceptable en algunas circunstancias, pero es algo problemático. En su lugar, movamos la búsqueda de datos al componente App.

Sin embargo, lo que no es inmediatamente obvio es dónde se debe colocar el comando axios.get dentro del componente.

Effect-hooks

Ya hemos utilizado state hooks que se introdujeron junto con la versión de React 16.8.0, que proporciona el estado de los componentes de React definidos como funciones, los llamados componentes funcionales. La versión 16.8.0 también presenta los hooks de efectos como una nueva característica. Según los documentos oficiales:

Los efectos permiten que un componente se conecte y se sincronice con sistemas externos. Esto incluye manejar la red, el DOM del navegador, animaciones, widgets escritos usando una librería de interfaz de usuario diferente, y otro código que no es de React.

Como tal, los hooks de efectos son precisamente la herramienta adecuada para usar cuando se obtienen datos de un servidor.

Eliminemos la obtención de datos de main.jsx. Dado que vamos a obtener las notas del servidor, ya no es necesario pasar datos como props al componente App. Entonces main.jsx se puede simplificar a:

import ReactDOM from "react-dom/client";
import App from "./App";

ReactDOM.createRoot(document.getElementById("root")).render(<App />);

El componente App cambia de la siguiente manera:

import { useState, useEffect } from 'react'import axios from 'axios'import Note from './components/Note'

const App = () => {  const [notes, setNotes] = useState([])  const [newNote, setNewNote] = useState('')
  const [showAll, setShowAll] = useState(true)

  useEffect(() => {    console.log('effect')    axios      .get('http://localhost:3001/notes')      .then(response => {        console.log('promise fulfilled')        setNotes(response.data)      })  }, [])  console.log('render', notes.length, 'notes')
  // ...
}

También hemos agregado algunas impresiones útiles, que aclaran la progresión de la ejecución.

Esto se imprime en la consola


render 0 notes
effect
promise fulfilled
render 3 notes

Primero se ejecuta el cuerpo de la función que define el componente y el componente se renderiza por primera vez. En este punto, se imprime render 0 notes, lo que significa que los datos aún no se han obtenido del servidor.

La siguiente función, o efecto en el lenguaje de React:

() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

se ejecuta inmediatamente después de la renderización. La ejecución de la función da como resultado que effect se imprima en la consola, y el comando axios.get inicia la obtención de datos del servidor y registra la siguiente función como un controlador de eventos para la operación:

response => {
  console.log('promise fulfilled')
  setNotes(response.data)
})

Cuando llegan datos del servidor, el entorno de ejecución de JavaScript llama a la función registrada como el controlador de eventos, que imprime promise fulfilled en la consola y almacena las notas recibidas del servidor en el estado mediante la función setNotes(response.data).

Como siempre, una llamada a una función de actualización de estado desencadena la re-renderización del componente. Como resultado, render 3 notes se imprime en la consola y las notas obtenidas del servidor se muestran en la pantalla.

Finalmente, echemos un vistazo a la definición del hook de efectos como un todo:

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes').then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

Reescribamos el código de forma un poco diferente.

const hook = () => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}

useEffect(hook, [])

Ahora podemos ver más claramente que la función useEffect en realidad toma dos parámetros. El primero es una función, el efecto en sí mismo. Según la documentación:

De forma predeterminada, los efectos se ejecutan después de cada renderizado completo, pero puedes elegir activarlo solo cuando ciertos valores han cambiado.

Por lo tanto, por defecto, el efecto siempre se ejecuta después de que el componente ha sido renderizado. En nuestro caso, sin embargo, solo queremos ejecutar el efecto junto con el primer render.

El segundo parámetro de useEffect se usa para especificar la frecuencia con la que se ejecuta el efecto. Si el segundo parámetro es una matriz vacía [], entonces el efecto solo se ejecuta junto con el primer renderizado del componente.

Hay muchos casos de uso posibles para un hook de efecto ademas de la obtención de datos del servidor. Sin embargo, por ahora esto es suficiente para nosotros.

Piensa en la secuencia de eventos que acabamos de comentar. ¿Qué partes del código se ejecutan? ¿En qué orden? ¿Con qué frecuencia? ¡Entender el orden de los eventos es fundamental!

Ten en cuenta que también podríamos haber escrito el código de la función de efecto de esta manera:

useEffect(() => {
  console.log('effect')

  const eventHandler = response => {
    console.log('promise fulfilled')
    setNotes(response.data)
  }

  const promise = axios.get('http://localhost:3001/notes')
  promise.then(eventHandler)
}, [])

Se asigna una referencia a una función de controlador de eventos a la variable eventHandler. La promesa devuelta por el método get de Axios se almacena en la variable promise. El registro del callback ocurre dándole la variable eventHandler, refiriéndose a la función del controlador de eventos, como un parámetro para el método then de la promesa. Por lo general, no es necesario asignar funciones y promesas a las variables, y una forma más compacta de representar las cosas, como se ve más arriba, es suficiente.

useEffect(() => {
  console.log('effect')
  axios
    .get('http://localhost:3001/notes')
    .then(response => {
      console.log('promise fulfilled')
      setNotes(response.data)
    })
}, [])

Todavía tenemos un problema en nuestra aplicación. Al agregar nuevas notas, no se almacenan en el servidor.

El código de la aplicación, como se ha descrito hasta ahora, se puede encontrar completo en github, en la rama part2-4.

El entorno de ejecución de desarrollo

La configuración de toda nuestra aplicación se ha vuelto cada vez más compleja. Repasemos qué pasa y dónde. La siguiente imagen describe la composición de la aplicación

diagrama de la composición de la aplicación react

El código JavaScript que compone nuestra aplicación React se ejecuta en el navegador. El navegador obtiene el JavaScript del servidor de desarrollo de React, que es la aplicación que se ejecuta después de ejecutar el comando npm run dev. El servidor de desarrollo transforma el JavaScript a un formato comprensible para el navegador. Entre otras cosas, une JavaScript de diferentes archivos en un solo archivo. Analizaremos el servidor de desarrollo con más detalle en la parte 7 del curso.

La aplicación React que se ejecuta en el navegador obtiene los datos formateados JSON desde json-server que se ejecuta en el puerto 3001 de la máquina. El servidor del que consultamos los datos - json-server - obtiene sus datos del archivo db.json.

En este punto del desarrollo, todas las partes de la aplicación residen en la máquina del desarrollador de software, también conocida como localhost. La situación cambia cuando la aplicación se despliega en el internet. Haremos esto en la parte 3.