Ir al contenido

Blog


Cinco retos para crear una biblioteca JavaScript isomórfica

6 de diciembre de 2022

|
Nick Fahrenkrog

Nick Fahrenkrog

Construir software hoy en día puede requerir trabajar en el lado del servidor y en el lado del cliente, pero construir bibliotecas JavaScript isomórficas puede ser un reto si no se es consciente de algunas cuestiones particulares, que pueden implicar elegir las dependencias correctas e importarlas selectivamente entre otras.

Para contextualizar, Isomorphic JavaScript, también conocido como Universal JavaScript, es código JavaScript que puede ejecutarse en cualquier entorno, incluido Node.js o el navegador web. La alternativa al JavaScript isomórfico es crear bibliotecas separadas y específicas para cada entorno, por ejemplo, una para Node.js y otra para el navegador. Disponer de una única biblioteca para todos los entornos ofrece ventajas evidentes, siempre que sepas afrontar los retos que implica crear una.

¿Por qué crear bibliotecas isomórficas? 

Al igual que muchos equipos de plataforma, el equipo de plataforma web de DoorDash crea y mantiene varias bibliotecas destinadas a mejorar la productividad de los desarrolladores front-end. Los desarrolladores de DoorDash escriben código en varios entornos JavaScript, incluidas aplicaciones React y servicios web Node.js. Además, DoorDash está migrando varias páginas de ser renderizadas del lado del cliente a renderizadas del lado del servidor, por lo que la línea entre el entorno en el que se ejecuta el código es cada vez más borrosa. Todas estas razones hacen que muchas de nuestras bibliotecas sean isomórficas porque la misma lógica tiene que funcionar en muchos entornos diferentes.

Sin embargo, el soporte de múltiples entornos también entraña una complejidad adicional. Esta complejidad recae en el creador de la biblioteca, no en quien la adopta. Para algunos casos de uso, este equilibrio entre complejidad y eficiencia puede no merecer la pena.

Para ilustrar algunas de estas complejidades adicionales, recorreremos el proceso de construcción de una biblioteca isomórfica ficticia sencilla utilizando fragmentos de código y, a continuación, destacaremos cinco retos específicos. Nuestro objetivo es proporcionar un contexto que resulte útil para evaluar si tiene sentido construir una futura biblioteca de forma isomórfica. También mostraremos algunas técnicas que pueden emplearse para afrontar estos retos.

Requisitos funcionales del ejemplo de biblioteca isomórfica

Nota: Lo siguiente está escrito en Typescript, que compila a JavaScript, por lo que puede incluir algunos retos de tipado que no son relevantes para los escritos directamente en JavaScript. 

Como se mencionó en la sección anterior, esta entrada del blog se sumergirá en cómo construir una biblioteca isomórfica ficticia. Para elegir un buen tema para esta biblioteca falsa, consideremos primero lo que hace que las bibliotecas isomórficas sean un reto, a saber, tener que manejar casos específicos del entorno. Algunos ejemplos de este reto pueden ser:

  • Depender de cualquier API que sea nativa en un entorno (para el navegador podría ser `document.cookies`, `fetch`, etc.) pero no nativa en otro.
  • Dependencias no isomórficas
  • Funciones que se comportan de forma diferente según el entorno
  • Exponer parámetros que no son necesarios en todos los entornos

Dado que esta entrada de blog se centra en ilustrar los retos del JavaScript isomórfico, nuestra biblioteca ficticia tiene todos estos rasgos. Para resumir lo que vamos a construir: una biblioteca que exporta una función que comprueba si un pedido de café está listo haciendo una petición de red y enviando un id único. Más específicamente, este ejemplo tiene todos los siguientes requisitos:

  • Exporta una única función asíncrona llamada `isCoffeeOrderReady` que opcionalmente toma `deviceId` como parámetro y devuelve un booleano
  • Sends an http POST request with the request body '{deviceId: <deviceId>}' to a hard-coded endpoint
  • Puede ejecutarse en Node.js o en el navegador
  • El navegador leerá el `deviceId` directamente de las cookies
  • Utiliza conexiones keep-alive

Una vez definidos los detalles de lo que hará esta biblioteca ficticia, vamos a analizar los cinco retos principales que pueden surgir en el desarrollo de bibliotecas isomórficas y cómo abordarlos.

Reto nº 1: Elegir las dependencias adecuadas

For the sake of illustration, assume that this is Node <=16 and that it doesn't use any experimental flags. Node 18 has native fetch support.

Primero debemos determinar cómo hacer una petición fetch. El navegador puede enviar una petición fetch de forma nativa, pero para soportar Node.js, se utilizará una librería isomórfica o una librería Node.js. Si se elige una biblioteca isomórfica, tendrá que cumplir los requisitos de cada entorno en cuanto a dependencias. En este caso, la biblioteca elegida puede ser examinada por su impacto en el tamaño del bundle en el navegador.

Para simplificar, utilizaremos isomorphic-fetch, que utiliza node-fetch internamente en Node.js y el polyfill de GitHub fetch en los navegadores. El siguiente código ilustra cómo realizar la solicitud de obtención:

import fetch from 'isomorphic-fetch'
 
// Request: { deviceId: '111111' }
// Response: { isCoffeeOrderReady: true }
export const isCoffeeOrderReady = async (deviceId: string): Promise<boolean> => {
 const response = await fetch('https://<some-endpoint>.com/<some-path>', {
   method: 'POST',
   body: JSON.stringify({ deviceId })
 })
 return (await response.json()).isCoffeeOrderReady
}

Nota: En aras de la brevedad, se ignorarán muchos detalles importantes como el reintento y la gestión de errores.

Acceso a document.cookie

El código en este punto ignora muchos requisitos. Por ejemplo, en Node.js el parámetro `deviceId` se utilizará como el deviceId que se envía en la solicitud de obtención, pero en el navegador el `deviceId` debe leerse directamente de una cookie.

Para comprobar si está en el navegador -- lo que significa que document.cookie debería estar definido - mira si la ventana está definida; `window` siempre debería estar definida en el navegador y no definida globalmente en Node. Este fragmento de código tiene el siguiente aspecto:

`typeof window === "undefined"`.

Aunque esta no es la única forma de detectar si el código está en el servidor o en el cliente, es una forma popular. Muchas respuestas en Stack Overflow o en entradas de blog utilizan este enfoque.

El código completo actualizado se parece al ejemplo siguiente:

import fetch from 'isomorphic-fetch'
import * as cookie from 'cookie'
 
export const isCoffeeOrderReady = async (deviceId: string): Promise<boolean> => {
 let id = deviceId
 if (typeof window !== 'undefined') {
   const cookies = cookie.parse(document?.cookie)
   id = cookies.deviceId
 }
 const response = await fetch('https://<some-endpoint>.com/<some-path>', {
   method: 'POST',
   body: JSON.stringify({ deviceId: id })
 })
 return (await response.json()).isCoffeeOrderReady
}

Aunque ahora estamos más cerca de cumplir los requisitos de la biblioteca, hemos introducido dos retos más para explorar..

Reto nº 2: Diseñar una API unificada entre entornos

El cambio de código anterior todavía requiere que la función `isCoffeeOrderReady` tenga un parámetro `deviceId` porque es necesario en entornos Node.js, pero el valor se ignora en el navegador. En su lugar, el valor `deviceId` se lee directamente de una cookie. La declaración de la función para ambos entornos debería ser diferente -- en los navegadores la función no debería tomar argumentos, pero en Node debería requerir un argumento - pero dado que es isomórfica,no puede. Así que las opciones restantes son:

  • La API puede escribirse como se muestra, requiriendo `deviceId.` Pero esta acción puede ser engañosa para los adoptantes porque ese valor debe pasarse en el navegador, aunque será ignorado; o bien 
  • Hacer `deviceId` opcional. Esta opción significa que en el entorno del navegador se puede llamar sin argumentos y en el entorno de Node se puede llamar con un `deviceId.` Sin embargo, esto también significa que la función se puede llamar en Node.js sin argumento; el análisis estático de Typescript no puede evitar este mal uso de la API.

Aunque el segundo enfoque puede ser la mejor opción, ese hecho podría pesar en contra de hacer esta biblioteca isomórfica, dado que el uso de la API es diferente entre entornos.

Manténgase informado con las actualizaciones semanales

Suscríbase a nuestro blog de ingeniería para recibir actualizaciones periódicas sobre los proyectos más interesantes en los que trabaja nuestro equipo.

Reto nº 3: Garantizar que las dependencias sólo afecten a los entornos previstos

Este cambio en el código `document.cookie` también introdujo otro problema: `cookie` se instalará en entornos Node.js a pesar de no utilizarse en absoluto en la ruta del código Node.js. Por supuesto, la instalación de dependencias innecesarias en Node.js no es tan perjudicial como la instalación de dependencias innecesarias en el navegador, dada la importancia de mantener un tamaño de paquete mínimo. Sin embargo, sigue siendo importante asegurarse de que no se incluyen dependencias innecesarias en un entorno determinado.

Una forma de solucionar este problema es crear archivos de índice separados para Node.js y el navegador y utilizar un bundler -- por ejemplo, webpack - que soporte tree shaking. Después, asegúrate de que las dependencias específicas del entorno están sólo en las rutas de código necesarias. Mostraremos el código necesario para hacer esto en la siguiente sección.

Utilizar conexiones keep-alive

Aunque a primera vista pueda parecer sencillo implementar conexiones keep-alive, en realidad es todo un reto. node-fetch no implementa una especificación idéntica a la de browser fetch nativo; un punto en el que las especificaciones difieren es en las conexiones keep-alive. Para usar una conexión keep-alive en browser fetch, añade la bandera:

fetch(url, { ..., keepalive: true })

En node-fetch, sin embargo, cree instancias http.Agent `http` y/o `https` y páselas como argumento de agente a la petición de obtención como se muestra aquí:

const httpAgent = new http.Agent({ keepAlive: true })
const httpsAgent = new https.Agent({ keepAlive: true })
fetch(url, { agent: (url) => {
 if (url.protocol === "http:") {
   return httpAgent
 } else {
   return httpsAgent
 }
}})

Aquí isomorphic-fetch utiliza node-fetch internamente pero no expone la opción de agente. Esto significa que en entornos Node.js, las conexiones keep-alive no pueden configurarse correctamente con isomorphic-fetch. En consecuencia, nuestro siguiente paso debe ser utilizar las bibliotecas node-fetch y fetch nativas por separado.

Para utilizar node-fetch y native fetch por separado, y mantener la ruta de código específica del entorno separada, se pueden utilizar puntos de entrada. Un ejemplo de configuración en webpack con Typescript es el siguiente:

paquete.json

{
   "main": "lib/index.js",
   "browser": "lib/browser.js",
   "typings": "lib/index.d.ts",
   ...
}

Observa también que aunque "main" para Node.js y "browser" apuntan a diferentes ficheros de índice, sólo se puede utilizar un fichero de declaración de tipos. Esto tiene sentido dado que el objetivo de una biblioteca isomórfica es exponer la misma API independientemente del entorno.

Como referencia, aquí hay una lista de algunas bibliotecas javascript isomórficas que utilizan este patrón específicamente con el fin de tener una obtención isomórfica:

Los pasos finales crean las rutas de código de Node.js y del navegador utilizando todo lo discutido anteriormente. Por simplicidad, nombraremos los archivos "index.ts" y "browser.ts" para que coincidan con los archivos del ejemplo "package.json", pero ten en cuenta que es una mala práctica incluir lógica dentro de un archivo index.

index.ts

import fetch from 'node-fetch'
import http from 'http'
import https from 'https'
import { SOME_URL } from './constants'
const httpAgent = new http.Agent({ keepAlive: true })
const httpsAgent = new https.Agent({ keepAlive: true })
 
type CoffeeOrderResponse = { isCoffeeOrderReady: boolean }
 
export const isCoffeeOrderReady = async (deviceId?: string): Promise<boolean> => {
 if (!deviceId) {
   // can throw error, etc. just need to handle undefined deviceId case
   return false
 }
 const response = await fetch(SOME_URL, {
   method: 'POST',
   agent: (url: URL) => {
     if (url.protocol === "http:") {
       return httpAgent
     } else {
       return httpsAgent
     }
   },
   body: JSON.stringify({ deviceId })
 })
 const json = await response.json() as CoffeeOrderResponse
 return json.isCoffeeOrderReady
}

navegador.ts

import * as cookie from 'cookie'
import { SOME_URL } from './constants'
export const isCoffeeOrderReady = async (deviceId?: string): Promise<boolean> => {
 let id = deviceId
 // still keep this check in for safety
 if (typeof window !== 'undefined') {
   const cookies = cookie.parse(document?.cookie)
   id = cookies.deviceId
 }
 const response = await fetch(SOME_URL, {
   method: 'POST',
   keepalive: true,
   body: JSON.stringify({ deviceId: id })
 })
 return (await response.json()).isCoffeeOrderReady
}

Con la creación de estos archivos, se completan todos los requisitos funcionales de la biblioteca. En esta biblioteca en particular, hay poco código compartido, por lo que no se muestran los beneficios del isomorfismo. Pero es fácil imaginar cómo proyectos más grandes compartirían mucho código entre entornos. También es importante asegurarse de que todo lo que se exporta tiene exactamente la misma API, ya que sólo se publicará una lista de archivos de declaración de tipos.

Reto nº 4: Probar todos los entornos

Ahora que la biblioteca está completa, es el momento de añadir las pruebas. La mayoría de las pruebas tendrán que ser escritas dos veces para asegurarse de que todo funciona correctamente en cada entorno. El isomorfismo acopla la lógica a través de todos los entornos, los cambios en un entorno ahora deben ser probados en todos los entornos. Puede haber retos adicionales a la hora de probar bibliotecas isomórficas en escenarios realistas. Por ejemplo, Jest sólo tiene soporte experimental para ESM.

Reto nº 5: Observabilidad: métricas y registros

Lo último que hay que tener en cuenta son las métricas, el registro y otras piezas de observabilidad. La infraestructura para la observabilidad tiene un aspecto muy diferente en cada entorno. En este ejemplo, la biblioteca en Node.js se puede ampliar para capturar todo tipo de métricas, incluyendo la latencia de la solicitud, la tasa de error, y los interruptores de circuito, así como las advertencias de registro y errores con el contexto para ayudar a rastrear a través de microservicios. Pero en el navegador, la biblioteca puede ampliarse para capturar únicamente errores. Estas diferencias pueden resolverse utilizando algunos de los mismos patrones de resolución de problemas presentados anteriormente. Sin embargo, vale la pena señalar este gran espacio donde las implementaciones son propensas a divergir.

Reflexiones finales

Incluso en esta biblioteca isomórfica ficticia tan sencilla surgieron varios retos:

  • Elegir las dependencias adecuadas
  • Diseño de una API unificada entre entornos
  • Garantizar que las dependencias sólo afecten a los entornos previstos
  • Probar todos los entornos
  • Observabilidad: métricas y registro

También analizamos si las ventajas del isomorfismo se ven compensadas por algunos de los compromisos y retos que conlleva. Si se tienen en cuenta estos retos a la hora de tomar la decisión isomórfica, es posible desarrollar soluciones viables.

Sobre el autor

  • Nick Fahrenkrog

    Nick Fahrenkrog es ingeniero de software en DoorDash, desde junio de 2021, en el equipo de la plataforma web centrado en análisis, privacidad de datos y experimentación.

Trabajos relacionados

Ubicación
San Francisco, CA; Mountain View, CA; Nueva York, NY; Seattle, WA
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA
Departamento
Ingeniería
Ubicación
San Francisco, CA; Sunnyvale, CA; Seattle, WA
Departamento
Ingeniería
ID de trabajo: 3013456
Ubicación
Pune, India
Departamento
Ingeniería
Ubicación
San Francisco, CA; Seattle, WA; Sunnyvale, CA
Departamento
Ingeniería