Al cerrar la lección anterior el modelo de Nómada Tareas estaba completo y protegido: Tarea con su estado privado, Tablero con su API pública mínima, ErrorDeValidacion con su jerarquía. El problema es que todo eso vive en un único js/app.js que ya pasa de las trescientas líneas, mezclado con las constantes, el backlog de ejemplo y el código que lo ejecuta. Partirlo en varios ficheros y cargarlos con <script> no soluciona nada: todos comparten el mismo espacio global, el orden de carga se convierte en una dependencia invisible que nadie ha escrito, y basta que dos ficheros declaren const PESOS para que la aplicación se caiga. En esta lección aprenderás el sistema de módulos de JavaScript: fronteras explícitas entre ficheros, con export para declarar qué se ofrece e import para declarar qué se necesita. Al final habrás reorganizado el proyecto entero en seis ficheros reales, con un grafo de dependencias que se puede dibujar.
Contenido
- El problema: ámbito global, orden y dependencias invisibles
export: exportaciones con nombreimport: traer lo que necesitas- Renombrar con
ase importar todo con* as export default- Reexportar: el fichero de barril
- Ámbito propio y ejecución única
<script type="module">y qué cambiaimport()dinámico yawaita nivel de módulo- CommonJS:
requireymodule.exports - Node y
"type": "module" - Dependencias circulares
- Nómada Tareas, reorganizado
- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El problema: ámbito global, orden y dependencias invisibles
Supongamos que partes el fichero grande en tres y los cargas como aprendiste en el Módulo 1:
<script src="js/constantes.js"></script>
<script src="js/tarea.js"></script>
<script src="js/app.js"></script>Esto tiene tres problemas serios, y ninguno da un mensaje de error claro.
Problema 1: todo comparte el mismo ámbito global. Cada <script> clásico ejecuta su código en el ámbito global, así que todas las declaraciones de nivel superior conviven en el mismo espacio de nombres.
// js/constantes.js
const PESOS = { alta: 3, media: 2, baja: 1 };
// js/formato.js
const PESOS = { negrita: 700, normal: 400 };
// ✗ SyntaxError: Identifier 'PESOS' has already been declaredDos ficheros escritos por dos personas distintas, cada uno perfectamente razonable, y la aplicación no arranca. Con var sería aún peor: no habría error, el segundo pisaría al primero en silencio y el cálculo del esfuerzo daría NaN en algún sitio inesperado.
Problema 2: el orden importa y no está escrito en ninguna parte. Si alguien mueve el <script> de tarea.js por encima del de constantes.js, la clase Tarea intentará usar PESOS antes de que exista. El HTML se convierte en un fichero de configuración crítico que nadie documenta.
Problema 3: las dependencias son invisibles. Abre tarea.js y dime de qué depende. No puedes saberlo sin leerlo entero y buscar los identificadores que no declara. No hay ninguna línea que diga «este fichero necesita PESOS y ErrorDeValidacion».
El apaño clásico, antes de que existieran los módulos, era el patrón módulo de 03-04: encerrarlo todo en una función que se invoca a sí misma y colgar del global una sola variable.
var NomadaTareas = (function () {
const PESOS = { alta: 3, media: 2, baja: 1 }; // privado gracias al closure
class Tarea { /* … */ }
return { Tarea }; // única cosa pública
})();Funciona —era la mejor práctica durante años— pero sigue sin resolver el orden ni hacer explícitas las dependencias, y basta que dos librerías elijan el mismo nombre global para volver al problema 1. Desde ES2015, el lenguaje trae la solución de serie.
export: exportaciones con nombre
export: exportaciones con nombreUn módulo es simplemente un fichero .js que usa export o import. Todo lo que declara es privado a ese fichero salvo lo que exporta explícitamente.
// util/fechas.js
export const HOY = '2026-09-20';
export function diasEntre(desde, hasta) {
const ms = new Date(hasta) - new Date(desde);
return Math.round(ms / (1000 * 60 * 60 * 24));
}
export function estaVencida(fechaLimite, estado, hoy = HOY) {
return fechaLimite < hoy && estado !== 'hecha'; // R10
}
// Esta NO se exporta: es un detalle interno, invisible desde fuera
function normalizarFecha(texto) {
return new Date(texto).toISOString().slice(0, 10);
}Hay dos formas de escribir lo mismo. La primera es poner export delante de cada declaración, como arriba. La segunda es agrupar al final del fichero, lo que da una «tabla de contenidos» muy cómoda de leer:
// util/fechas.js — variante con lista de exportación
const HOY = '2026-09-20';
function diasEntre(desde, hasta) { /* … */ }
function estaVencida(fechaLimite, estado, hoy = HOY) { /* … */ }
function normalizarFecha(texto) { /* … */ }
export { HOY, diasEntre, estaVencida }; // normalizarFecha se queda dentroSe puede exportar cualquier cosa con nombre: const, let, function, class. Y se puede renombrar en la propia exportación:
Las declaraciones
exporteimportdeben estar en el nivel superior del módulo, nunca dentro de unif, un bucle o una función. El motivo es que se analizan antes de ejecutar nada: eso permite al navegador descubrir el grafo de ficheros y descargarlos en paralelo, y es también lo que hace posible detectar errores de importación sin llegar a ejecutar el programa. Para importar condicionalmente existeimport()dinámico (apartado 9).
import: traer lo que necesitas
import: traer lo que necesitasEn el otro extremo, import declara qué se necesita y de dónde viene.
// modelo/tarea.js
import { HOY, estaVencida } from '../util/fechas.js';
export class Tarea {
// …
estaVencida(hoy = HOY) {
return estaVencida(this.fechaLimite, this.estado, hoy);
}
}Las llaves de import { … } no son desestructuración, aunque se parezcan mucho a la de 04-07. Es una sintaxis propia del sistema de módulos: los nombres tienen que coincidir exactamente con los exportados, no admite valores por defecto ni patrones anidados, y se resuelve antes de ejecutar.
Sobre la ruta del módulo (el especificador), tres reglas que causan muchos tropiezos en el navegador:
| Especificador | Significado | ¿Vale en el navegador? |
|---|---|---|
'./fechas.js' |
Relativo a este fichero, misma carpeta | Sí |
'../util/fechas.js' |
Relativo, subiendo un nivel | Sí |
'/js/util/fechas.js' |
Absoluto desde la raíz del sitio | Sí |
'fechas.js' |
Bare specifier (sin ./) |
No: el navegador lo interpreta como el nombre de un paquete |
Y la más importante: en el navegador, la extensión .js es obligatoria. import { HOY } from './util/fechas' falla con un 404. Node en modo ESM también la exige. Es el error número uno de quien viene de otros entornos donde se puede omitir.
- Renombrar con
as e importar todo con * as
as e importar todo con * asSi dos módulos exportan algo con el mismo nombre, o si el nombre original choca con una variable local, se renombra al importar:
import { estaVencida as fechaVencida } from '../util/fechas.js';
import { estaVencida as tareaVencida } from './reglas.js';
console.log(fechaVencida('2026-09-05', 'pendiente')); // trueY si quieres todo el módulo agrupado bajo un nombre:
import * as fechas from '../util/fechas.js';
console.log(fechas.HOY); // '2026-09-20'
console.log(fechas.diasEntre('2026-09-20', '2026-09-30')); // 10
console.log(fechas.normalizarFecha); // undefined ← no estaba exportadaEse fechas es el objeto de espacio de nombres del módulo: contiene una propiedad por cada exportación. Tiene dos particularidades: está congelado (no puedes añadirle ni cambiarle nada) y sus propiedades son enlaces vivos, no copias, algo que verás en el apartado 7.
| Forma | Cuándo usarla |
|---|---|
import { a, b } from '…' |
Lo normal: se ve de un vistazo qué se usa |
import { a as x } from '…' |
Hay colisión de nombres o el original es poco claro aquí |
import * as ns from '…' |
El módulo exporta muchas cosas relacionadas y el prefijo aporta claridad (fechas.diasEntre) |
import '…' |
Solo interesa el efecto de ejecutarlo (registrar algo, cargar estilos); no se trae ningún nombre |
export default
export defaultCada módulo puede tener una exportación por defecto, pensada para cuando el fichero representa una sola cosa.
// app.js
import Tablero from './modelo/tablero.js'; // sin llaves, y el nombre lo eliges tú
import ElTableroDelTaller from './modelo/tablero.js'; // también válido: es el mismoLa diferencia con las exportaciones con nombre:
| Con nombre | Por defecto | |
|---|---|---|
| Cuántas por módulo | Las que quieras | Una |
| Sintaxis de importación | import { X } from … |
import X from … |
| ¿El nombre debe coincidir? | Sí | No, lo pone quien importa |
| Errores de tecleo | Se detectan al cargar | Pasan desapercibidos |
| Autocompletado del editor | Funciona bien | Peor |
Se pueden combinar en el mismo fichero:
// modelo/tarea.js
export default class Tarea { /* … */ }
export class TareaRecurrente extends Tarea { /* … */ }
export const ESTADOS = ['pendiente', 'en-curso', 'hecha'];En este proyecto usaremos exportaciones con nombre en todos los ficheros. No es la única opción válida —muchos equipos usan default para la pieza principal de cada módulo—, pero las nombradas tienen dos ventajas prácticas: el nombre es el mismo en todo el código base, lo que hace que buscar Tablero encuentre todos los usos, y un error de tecleo salta al instante en lugar de dar undefined a mitad de la ejecución.
- Reexportar: el fichero de barril
Un módulo puede reexportar lo que importa de otros, sin llegar a usarlo. Sirve para ofrecer un punto de entrada único a un grupo de ficheros.
// modelo/index.js — un "barril"
export { Tarea, TareaRecurrente } from './tarea.js';
export { Tablero } from './tablero.js';
export { ErrorDeValidacion, ErrorDeDatos } from './errores.js';
// También se puede reexportar todo lo de un módulo:
export * from './reglas.js';
// O reexportar un default con nombre:
export { default as Formateador } from './formato.js';Quien lo consume ve un único módulo:
Los barriles son cómodos, pero conviene una advertencia honesta: al importar del barril arrastras la carga de todos los ficheros que reexporta, aunque solo uses uno. Con seis ficheros es irrelevante; en proyectos grandes es una de las causas típicas de que el arranque se vuelva lento, y su relación con la división de código se trata en 09-05. Para un proyecto del tamaño de Nómada Tareas, importar directamente de cada fichero es más claro.
- Ámbito propio y ejecución única
Dos propiedades de los módulos que resuelven exactamente los problemas del apartado 1.
Cada módulo tiene su propio ámbito. Nada de lo que declaras es global, así que dos módulos pueden declarar const PESOS sin enterarse el uno del otro. Se acabó el problema 1.
// util/formato.js
const PESOS = { negrita: 700, normal: 400 }; // ✓ sin conflicto con el de reglas.jsUn módulo se ejecuta una sola vez, por muchas veces que se importe. La primera importación lo carga y ejecuta; las demás reciben el resultado ya calculado. Esto convierte a cada módulo en un singleton de facto:
// datos/contador.js
console.log('⚙ contador.js se está ejecutando');
export const registro = [];
export function anotar(texto) { registro.push(texto); }// modelo/tablero.js
import { anotar } from '../datos/contador.js';
anotar('tablero cargado');
// modelo/tarea.js
import { anotar, registro } from '../datos/contador.js';
anotar('tarea cargada');
console.log(registro); // [ 'tablero cargado', 'tarea cargada' ] ← el MISMO arrayEn consola, '⚙ contador.js se está ejecutando' aparece una sola vez. Los dos módulos comparten el mismo registro porque comparten la misma instancia del módulo. Esa propiedad es utilísima —un módulo de configuración, una caché, un almacén compartido—, pero conviene tenerla presente: el estado que pongas a nivel de módulo es global para toda la aplicación, con todos los inconvenientes que eso tiene para las pruebas del Módulo 8.
Y un detalle fino: las importaciones son enlaces vivos, no copias. Si el módulo de origen reasigna una variable exportada, quien la importó ve el valor nuevo.
// app.js
import { llamadas, incrementar } from './contador.js';
console.log(llamadas); // 0
incrementar();
console.log(llamadas); // 1 ← se actualizó solo
// llamadas = 5; // ✗ TypeError: Assignment to constant variableLas variables importadas son de solo lectura desde el módulo que importa: solo el módulo propietario puede cambiarlas. Es una forma de encapsulación que encaja perfectamente con lo que aprendiste en 05-03.
<script type="module"> y qué cambia
<script type="module"> y qué cambiaPara que el navegador trate un fichero como módulo hay que decírselo:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<title>Nómada Tareas</title>
<link rel="stylesheet" href="css/estilos.css">
</head>
<body>
<h1>Nómada Tareas · Taller Nómada</h1>
<!-- UN solo script: el punto de entrada. Los imports traen el resto. -->
<script type="module" src="js/app.js"></script>
</body>
</html>Un solo <script> para toda la aplicación: los import se encargan del resto, y el navegador descubre y descarga el grafo completo. Estas son las diferencias respecto a un script clásico:
| Aspecto | <script> clásico |
<script type="module"> |
|---|---|---|
| Ámbito de las declaraciones | Global compartido | Propio del módulo |
| Modo estricto | Solo con 'use strict' |
Siempre activo |
| Momento de ejecución | Bloquea el HTML al descargar y ejecutar | defer implícito: espera a que el HTML esté listo |
| Ejecuciones si se incluye dos veces | Dos | Una |
import/export |
SyntaxError |
Permitidos |
this en el nivel superior |
window |
undefined |
| Origen del fichero | Cualquiera, incluido file:// |
Requiere HTTP y respeta CORS |
El defer implícito es una buena noticia práctica: cuando llegues al Módulo 6 y empieces a buscar elementos de la página, el HTML ya estará completamente construido, sin necesidad de esperar a ningún evento. Y si necesitas lo contrario —que se ejecute en cuanto se descargue, sin esperar—, existe <script type="module" async src="…">.
La última fila es la que más quebraderos de cabeza da. Abrir el index.html con doble clic no funciona con módulos:
Access to script at 'file:///…/js/app.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, https, …
El motivo es de seguridad: los módulos se descargan con las mismas reglas que cualquier petición de red, y el protocolo file:// no tiene un origen válido. La solución es la que ya conociste en 01-02: levantar un servidor local.
# Con la extensión Live Server de VS Code: clic derecho → "Open with Live Server"
# Con Python, si lo tienes instalado
python3 -m http.server 8000
# Con Node
npx serveY después abrir http://localhost:8000, no la ruta del fichero. A partir de ahora, todo el desarrollo del proyecto se hace con un servidor local en marcha.
import() dinámico y await a nivel de módulo
import() dinámico y await a nivel de móduloLas importaciones estáticas van al principio del fichero y se resuelven antes de ejecutar. Cuando necesitas cargar algo condicionalmente o en un momento concreto, existe la forma de función:
// import() se parece a una llamada a función y devuelve una promesa
const modulo = await import('./informes/exportadorPDF.js');
modulo.exportar(tablero);Nos centramos aquí solo en la sintaxis, porque su uso principal —dividir la aplicación en trozos que se descargan bajo demanda para que arranque antes— es materia de 09-05, y porque lo que devuelve es una promesa, un objeto que estudiarás a fondo en 05-06. Retén tres diferencias:
import estático |
import() dinámico |
|
|---|---|---|
| Dónde puede escribirse | Solo en el nivel superior | En cualquier sitio: dentro de un if, de una función… |
| Cuándo se resuelve | Antes de ejecutar el módulo | En el momento en que se ejecuta la línea |
| La ruta puede ser variable | No, debe ser un literal | Sí: import(\./idiomas/${codigo}.js`)` |
| Qué devuelve | Nada (declara enlaces) | Una promesa con el objeto de espacio de nombres |
Ese await del ejemplo tampoco es habitual fuera de una función: en los módulos ES está permitido usar await directamente en el nivel superior, algo que se llama top-level await.
// datos/configuracion.js
const respuesta = await cargarConfiguracionSimulada(); // ✓ solo válido en un módulo ES
export const config = respuesta;Cuando un módulo usa await en el nivel superior, todos los módulos que lo importan esperan a que termine antes de ejecutarse. Es una herramienta cómoda, pero tiene un coste real en el arranque de la aplicación, así que conviene usarla con cabeza. Volverás sobre ella en 05-06, cuando await deje de ser una palabra que aparece de pasada.
- CommonJS:
require y module.exports
require y module.exportsAntes de que los módulos ES existieran, Node.js inventó su propio sistema, llamado CommonJS, y sigue siendo omnipresente. Vas a encontrarlo constantemente, así que hay que reconocerlo.
// util/fechas.js — versión CommonJS
const HOY = '2026-09-20';
function diasEntre(desde, hasta) {
return Math.round((new Date(hasta) - new Date(desde)) / 86400000);
}
module.exports = { HOY, diasEntre };
// o bien: exports.HOY = HOY; exports.diasEntre = diasEntre;// app.js — versión CommonJS
const { HOY, diasEntre } = require('./util/fechas.js');
const fechas = require('./util/fechas.js'); // el objeto entero
console.log(diasEntre(HOY, '2026-09-30')); // 10Aquí las llaves sí son desestructuración de verdad (04-07), porque require devuelve un objeto normal y corriente. Esa es la diferencia de fondo entre los dos sistemas:
ESM (import/export) |
CommonJS (require/module.exports) |
|
|---|---|---|
| Estándar | Del lenguaje, desde ES2015 | De Node.js |
| Cuándo se resuelve | Estáticamente, antes de ejecutar | En tiempo de ejecución, al llegar a la línea |
| Carga | Asíncrona | Síncrona (bloquea) |
| Dónde puede escribirse | Solo en el nivel superior | En cualquier sitio, incluido dentro de un if |
| Ruta variable | No | Sí |
| Qué se importa | Enlaces vivos | Una copia del objeto en ese instante |
| Funciona en el navegador | Sí | No sin herramientas |
await en el nivel superior |
Sí | No |
Extensión .js obligatoria |
Sí | No |
La diferencia entre «enlace vivo» y «copia» tiene consecuencias visibles:
// contador.cjs
let llamadas = 0;
function incrementar() { llamadas += 1; }
module.exports = { llamadas, incrementar };const { llamadas, incrementar } = require('./contador.cjs');
incrementar();
console.log(llamadas); // 0 ← ¡copia del valor en el momento de importar!Con ESM, ese mismo código imprimiría 1. Es una fuente clásica de confusión al migrar entre sistemas.
Cuál usar hoy: ESM en todo lo nuevo. Es el estándar del lenguaje, funciona en navegador y en Node, y permite el análisis estático del que dependen las herramientas de 09-05. CommonJS lo verás en proyectos con años, en muchos ficheros de configuración y en dependencias antiguas.
- Node y
"type": "module"
"type": "module"En el navegador, type="module" en la etiqueta decide el sistema. En Node lo decide el package.json de la carpeta:
{
"name": "nomada-tareas",
"version": "1.0.0",
"type": "module",
"scripts": {
"inicio": "node js/app.js"
}
}Con "type": "module", Node interpreta todos los .js de ese proyecto como módulos ES. Sin esa línea (o con "type": "commonjs"), los interpreta como CommonJS. Y si necesitas mezclar, las extensiones mandan sobre el package.json:
| Extensión | Se interpreta como |
|---|---|
.js |
Lo que diga "type" en el package.json más cercano |
.mjs |
Siempre módulo ES |
.cjs |
Siempre CommonJS |
Con eso, ya puedes ejecutar el proyecto desde la terminal, algo muy cómodo para probar la lógica sin abrir el navegador:
Un aviso frecuente: en un módulo ES no existen require, __dirname ni __filename. Si los necesitas, hay equivalentes modernos, pero lo normal es no echarlos de menos.
- Dependencias circulares
Dos módulos que se importan mutuamente forman un ciclo. No es un error del lenguaje, pero suele acabar en undefined en el peor momento.
// modelo/tarea.js
import { Tablero } from './tablero.js';
export class Tarea {
mover(destino) { return new Tablero(destino); }
}
// modelo/tablero.js
import { Tarea } from './tarea.js';
export class Tablero {
agregar(datos) { return new Tarea(datos); }
}Lo que pasa por dentro es que un módulo empieza a ejecutarse, encuentra el import, va al otro, y ese vuelve al primero —que aún no ha terminado de ejecutarse—. Con las class, que tienen zona muerta temporal (05-02), el resultado típico es un error del tipo Cannot access 'Tarea' before initialization.
flowchart LR
A["tarea.js"] -->|"import { Tablero }"| B["tablero.js"]
B -->|"import { Tarea }"| A
A -.->|"⚠ ciclo"| A
Casi siempre un ciclo es una señal de diseño: dos módulos que se necesitan mutuamente en realidad son uno, o les falta un tercero. Las tres salidas habituales:
- Extraer lo común a un tercer módulo. Si ambos necesitan las constantes y los errores, sácalos a
modelo/errores.jsymodelo/reglas.js, y que los dos dependan de ellos. - Invertir la dependencia. Que
Tableroconozca aTarea(agrega tareas), pero queTareano conozca aTablero. Si una tarea necesita algo del tablero, que se lo pasen por parámetro. - Fusionar los dos módulos si de verdad son inseparables.
En Nómada Tareas aplicamos la número 2, que es la que produce un grafo limpio: las capas de abajo no conocen a las de arriba.
- Nómada Tareas, reorganizado
Aquí está el proyecto entero repartido en sus módulos definitivos. Esta es la estructura sobre la que trabajarán todos los módulos siguientes del curso.
nomada-tareas/
├── index.html
├── package.json ← { "type": "module" }
├── css/
│ └── estilos.css
└── js/
├── app.js ← punto de entrada
├── modelo/
│ ├── tarea.js ← class Tarea, TareaRecurrente
│ ├── tablero.js ← class Tablero
│ └── errores.js ← ErrorDeValidacion, ErrorDeDatos
├── datos/
│ └── backlog.js ← el backlog canónico
└── util/
├── fechas.js ← HOY, diasEntre, estaVencida
└── formato.js ← marcas, plurales, tablas de textoY su grafo de dependencias:
flowchart TD
APP["js/app.js<br/>punto de entrada"]
TAB["modelo/tablero.js"]
TAR["modelo/tarea.js"]
BAC["datos/backlog.js"]
ERR["modelo/errores.js"]
FEC["util/fechas.js"]
FOR["util/formato.js"]
APP --> TAB
APP --> BAC
APP --> FOR
APP --> ERR
TAB --> TAR
TAB --> ERR
TAR --> ERR
TAR --> FEC
TAR --> FOR
BAC --> TAR
Fíjate en que todas las flechas van hacia abajo: util/ y modelo/errores.js no dependen de nadie, modelo/tarea.js depende de ellos, modelo/tablero.js depende de la tarea, y app.js está arriba del todo. No hay ciclos, y cada fichero declara en sus primeras líneas exactamente de qué depende.
Los ficheros, ahora. Primero las hojas del grafo:
// js/util/fechas.js
export const HOY = '2026-09-20';
/** Días de diferencia entre dos fechas ISO (negativo si 'hasta' ya pasó). */
export function diasEntre(desde, hasta) {
return Math.round((new Date(hasta) - new Date(desde)) / 86400000);
}
/** R10: vencida = fecha límite pasada y la tarea sin terminar. */
export function estaVencida(fechaLimite, estado, hoy = HOY) {
return fechaLimite < hoy && estado !== 'hecha';
}
/** '2026-09-05' → '5 de septiembre de 2026' */
export function fechaLegible(iso) {
const MESES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio',
'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre'];
const [anio, mes, dia] = iso.split('-');
return `${Number(dia)} de ${MESES[Number(mes) - 1]} de ${anio}`;
}// js/util/formato.js
export const MARCAS = Object.freeze({ pendiente: '○', 'en-curso': '▸', hecha: '✓' });
export const PESOS = Object.freeze({ alta: 3, media: 2, baja: 1 });
export function marcaDeEstado(estado) {
return MARCAS[estado] ?? '?';
}
export function plural(cantidad, singular, pluralForma) {
return `${cantidad} ${cantidad === 1 ? singular : pluralForma}`;
}
/** Alinea un texto a la derecha con espacios, para tablas de consola. */
export function alinear(texto, ancho) {
return String(texto).padStart(ancho, ' ');
}// js/modelo/errores.js
export class ErrorDeValidacion extends Error {
constructor(mensaje, campo, valorRecibido) {
super(mensaje);
this.name = 'ErrorDeValidacion';
this.campo = campo;
this.valorRecibido = valorRecibido;
}
describir() {
return `[${this.name}] ${this.campo}: ${this.message}`;
}
}
export class ErrorDeDatos extends Error {
constructor(mensaje, causa) {
super(mensaje);
this.name = 'ErrorDeDatos';
this.causa = causa;
}
}Ahora el modelo, que ya solo contiene lo que le corresponde:
// js/modelo/tarea.js
import { HOY, estaVencida as fechaVencida } from '../util/fechas.js';
import { PESOS, marcaDeEstado } from '../util/formato.js';
import { ErrorDeValidacion } from './errores.js';
const TRANSICIONES = Object.freeze({ // R6
pendiente: ['en-curso'],
'en-curso': ['pendiente', 'hecha'],
hecha: []
});
export class Tarea {
#estado = 'pendiente'; // R5
#horas;
constructor(datos) {
if (typeof datos.titulo !== 'string' || datos.titulo.trim() === '') {
throw new ErrorDeValidacion('El título no puede estar vacío.', 'titulo', datos.titulo); // R2
}
this.id = datos.id;
this.titulo = datos.titulo.trim();
this.responsable = datos.responsable || null; // R8
this.prioridad = datos.prioridad ?? 'media';
this.etiquetas = [...new Set((datos.etiquetas ?? []).map((e) => e.trim().toLowerCase()))]; // R9
this.fechaLimite = datos.fechaLimite;
this.revisor = datos.revisor ?? null;
this.horasEstimadas = datos.horasEstimadas; // pasa por el setter (R3)
if (datos.estado !== undefined) {
if (!Object.hasOwn(TRANSICIONES, datos.estado)) {
throw new ErrorDeValidacion(`Estado desconocido: "${datos.estado}".`, 'estado', datos.estado);
}
this.#estado = datos.estado;
}
}
get estado() { return this.#estado; }
get horasEstimadas() { return this.#horas; }
get abierta() { return this.#estado !== 'hecha'; }
get esfuerzo() { return (PESOS[this.prioridad] ?? 0) * this.#horas; }
set horasEstimadas(valor) {
if (typeof valor !== 'number' || !(valor > 0 && valor <= 40)) {
throw new ErrorDeValidacion('Las horas deben estar entre 1 y 40.', 'horasEstimadas', valor); // R3
}
this.#horas = valor;
}
estaVencida(hoy = HOY) {
return fechaVencida(this.fechaLimite, this.#estado, hoy);
}
cambiarEstado(nuevo) {
if (!(TRANSICIONES[this.#estado] ?? []).includes(nuevo)) {
throw new ErrorDeValidacion(
`Transición no permitida: "${this.#estado}" → "${nuevo}".`, 'estado', nuevo); // R6
}
this.#estado = nuevo;
return this;
}
descripcion(hoy = HOY) {
const aviso = this.estaVencida(hoy) ? ' ⚠ VENCIDA' : '';
return `${marcaDeEstado(this.#estado)} [${this.id}] ${this.titulo} · ${this.responsable ?? 'sin asignar'} · ${this.#horas} h${aviso}`;
}
toJSON() {
return { id: this.id, titulo: this.titulo, responsable: this.responsable,
prioridad: this.prioridad, estado: this.#estado, etiquetas: [...this.etiquetas],
horasEstimadas: this.#horas, fechaLimite: this.fechaLimite, revisor: this.revisor };
}
static desdeJSON(datos) {
return new Tarea(typeof datos === 'string' ? JSON.parse(datos) : datos);
}
}
export class TareaRecurrente extends Tarea {
constructor(datos) {
super(datos);
this.periodicidad = datos.periodicidad ?? 'semanal';
}
descripcion(hoy) {
return `${super.descripcion(hoy)} · se repite ${this.periodicidad}`;
}
}Observa el import { estaVencida as fechaVencida }: la función de utilidad y el método de la clase se llaman igual, y as resuelve el choque sin renombrar ninguno de los dos. Es exactamente para esto que existe.
// js/modelo/tablero.js
import { Tarea } from './tarea.js';
import { ErrorDeValidacion } from './errores.js';
export class Tablero {
#tareas = [];
constructor(nombre, tareas = []) {
this.nombre = nombre;
for (const t of tareas) this.agregar(t);
}
get total() { return this.#tareas.length; }
get tareas() { return [...this.#tareas]; }
get abiertas() { return this.#tareas.filter((t) => t.abierta); }
get horasTotales() { return this.#tareas.reduce((s, t) => s + t.horasEstimadas, 0); }
get horasAbiertas() { return this.abiertas.reduce((s, t) => s + t.horasEstimadas, 0); }
get esfuerzo() { return this.#tareas.reduce((s, t) => s + t.esfuerzo, 0); }
buscarPorId(id) { return this.#tareas.find((t) => t.id === id) ?? null; }
filtrar(predicado) { return this.#tareas.filter(predicado); }
vencidas(hoy) { return this.abiertas.filter((t) => t.estaVencida(hoy)); }
horasPorResponsable() {
return this.abiertas.reduce((acc, t) => {
const clave = t.responsable ?? 'sin asignar';
acc[clave] = (acc[clave] ?? 0) + t.horasEstimadas;
return acc;
}, {});
}
agregar(tarea) {
if (!(tarea instanceof Tarea)) {
throw new ErrorDeValidacion('Solo se admiten instancias de Tarea.', 'tarea', tarea);
}
if (this.buscarPorId(tarea.id)) {
throw new ErrorDeValidacion(`Id duplicado: ${tarea.id}.`, 'id', tarea.id); // R1
}
this.#tareas.push(tarea);
return this;
}
cambiarEstado(id, nuevo) {
const tarea = this.buscarPorId(id);
if (tarea === null) throw new ErrorDeValidacion(`No existe la tarea ${id}.`, 'id', id);
tarea.cambiarEstado(nuevo);
return this;
}
resumen(hoy) {
return { total: this.total, abiertas: this.abiertas.length,
horasTotales: this.horasTotales, horasAbiertas: this.horasAbiertas,
vencidas: this.vencidas(hoy).length, esfuerzo: this.esfuerzo };
}
}// js/datos/backlog.js
import { Tarea } from '../modelo/tarea.js';
/** Datos planos del backlog canónico de Taller Nómada. */
export const datosBacklog = [
{ id: 1, titulo: 'Rediseñar la sala polivalente', responsable: 'Iván', prioridad: 'alta', estado: 'en-curso', etiquetas: ['espacio', 'diseño'], horasEstimadas: 12, fechaLimite: '2026-09-30', revisor: 'Marta' },
{ id: 2, titulo: 'Cartelería del taller de serigrafía', responsable: 'Marta', prioridad: 'media', estado: 'pendiente', etiquetas: ['serigrafía', 'comunicación'], horasEstimadas: 6, fechaLimite: '2026-10-15', revisor: null },
{ id: 3, titulo: 'Actualizar la web de reservas', responsable: 'Lucía', prioridad: 'alta', estado: 'pendiente', etiquetas: ['web', 'reservas'], horasEstimadas: 14, fechaLimite: '2026-10-02', revisor: 'Iván' },
{ id: 4, titulo: 'Inventario de tintas de serigrafía', responsable: 'Marta', prioridad: 'baja', estado: 'hecha', etiquetas: ['serigrafía', 'almacén'], horasEstimadas: 3, fechaLimite: '2026-09-12', revisor: null },
{ id: 5, titulo: 'Guía de encuadernación para residentes', responsable: 'Iván', prioridad: 'media', estado: 'en-curso', etiquetas: ['encuadernación', 'documentación'], horasEstimadas: 8, fechaLimite: '2026-11-05', revisor: 'Lucía' },
{ id: 6, titulo: 'Presupuesto de la carpintería', responsable: 'Iván', prioridad: 'alta', estado: 'pendiente', etiquetas: ['carpintería', 'compras'], horasEstimadas: 5, fechaLimite: '2026-09-05', revisor: 'Marta' }
];
/** Devuelve instancias nuevas cada vez: nadie comparte estado por accidente. */
export function crearBacklog() {
return datosBacklog.map((datos) => new Tarea(datos));
}Ese crearBacklog() como función, en lugar de un array exportado ya construido, es una decisión deliberada. Como los módulos se ejecutan una sola vez (apartado 7), un array exportado directamente sería el mismo para toda la aplicación, y una prueba que cambiara el estado de una tarea contaminaría a la siguiente. Devolviendo instancias nuevas en cada llamada, cada consumidor tiene las suyas.
Y por fin el punto de entrada, que ya no contiene lógica: solo orquesta.
// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY, fechaLegible } from './util/fechas.js';
import { plural, alinear } from './util/formato.js';
import { ErrorDeValidacion } from './modelo/errores.js';
const tablero = new Tablero('Taller Nómada', crearBacklog());
console.log(`— ${tablero.nombre} · ${fechaLegible(HOY)} —`);
for (const tarea of tablero.tareas) {
console.log(tarea.descripcion(HOY));
}
const r = tablero.resumen(HOY);
console.log(`\n${plural(r.total, 'tarea', 'tareas')}, ${r.abiertas} abiertas`);
console.log(`Horas: ${r.horasAbiertas} abiertas de ${r.horasTotales} totales`);
console.log(`Vencidas: ${r.vencidas} · Esfuerzo ponderado: ${r.esfuerzo}`);
console.log('\nCarga por responsable:');
for (const [persona, horas] of Object.entries(tablero.horasPorResponsable())) {
console.log(` ${persona.padEnd(8)} ${alinear(horas, 3)} h`);
}
try {
tablero.cambiarEstado(6, 'hecha'); // pendiente → hecha: prohibido (R6)
} catch (error) {
if (error instanceof ErrorDeValidacion) console.error(`\n⚠ ${error.describir()}`);
else throw error;
}Ejecutándolo con node js/app.js (o abriendo index.html desde el servidor local y mirando la consola):
— Taller Nómada · 20 de septiembre de 2026 — ▸ [1] Rediseñar la sala polivalente · Iván · 12 h ○ [2] Cartelería del taller de serigrafía · Marta · 6 h ○ [3] Actualizar la web de reservas · Lucía · 14 h ✓ [4] Inventario de tintas de serigrafía · Marta · 3 h ▸ [5] Guía de encuadernación para residentes · Iván · 8 h ○ [6] Presupuesto de la carpintería · Iván · 5 h ⚠ VENCIDA 6 tareas, 5 abiertas Horas: 45 abiertas de 48 totales Vencidas: 1 · Esfuerzo ponderado: 124 Carga por responsable: Iván 25 h Marta 6 h Lucía 14 h ⚠ [ErrorDeValidacion] estado: Transición no permitida: "pendiente" → "hecha".
Los números canónicos de siempre, ahora producidos por seis ficheros con fronteras claras. Y lo más importante: si mañana hay que cambiar cómo se calcula el esfuerzo, sabes exactamente qué fichero abrir.
Errores Comunes y Consejos
- Olvidar la extensión
.jsen el especificador.from './util/fechas'da un 404 en el navegador y unERR_MODULE_NOT_FOUNDen Node. La extensión es obligatoria. - Abrir el HTML con doble clic. Los módulos no funcionan sobre
file://por CORS. Servidor local siempre. - Confundir
import { X }con desestructuración. No admite defectos, ni renombrado con:, ni patrones anidados; para renombrar se usaas. - Nombre mal escrito en una importación con nombre. Da
SyntaxError: The requested module does not provide an export named 'Tabler'. Es un buen error: aparece al cargar, no a mitad de la ejecución. - Mezclar
export defaulty con nombre sin criterio. Elige una convención por proyecto y respétala. En Nómada Tareas: siempre con nombre. - Poner un
importdentro de unifo de una función. EsSyntaxError; para eso estáimport()dinámico. - Estado mutable a nivel de módulo. Como el módulo se ejecuta una vez, un
export const cache = new Map()es global para toda la aplicación. A veces es justo lo que quieres; otras, un dolor de cabeza en las pruebas del Módulo 8. Exporta funciones fábrica cuando cada consumidor deba tener lo suyo. - Dependencias circulares. Si ves
Cannot access 'X' before initialization, busca el ciclo. La solución casi nunca es un truco: es rediseñar para que las dependencias apunten en una sola dirección. - Barriles en proyectos grandes. Un
index.jsque reexporta cincuenta ficheros los carga todos aunque uses uno. Cómodo, pero con coste (09-05). - Consejo: ordena los
importde cada fichero por capas —primero librerías externas, luego módulos del proyecto, luego los más cercanos—. Es la convención que aplican las herramientas de 08-02 y hace que el fichero se lea como una declaración de intenciones.
Ejercicios
Ejercicio 1 — Detectar el ciclo. Este proyecto no arranca. Dibuja el grafo, identifica el ciclo y propón una reorganización con un módulo nuevo.
// a/informe.js
import { Tablero } from '../b/tablero.js';
export function generarInforme(tareas) { return new Tablero('temp', tareas).resumen(); }
// b/tablero.js
import { formatearInforme } from '../a/informe.js';
export class Tablero {
imprimir() { return formatearInforme(this.resumen()); }
}Ejercicio 2 — Un módulo util/estadisticas.js. Crea un módulo que exporte media(numeros), maximo(objetos, campo) y agruparPor(objetos, campo), más una constante VERSION. Ninguna de las tres puede importar nada del modelo (deben servir para cualquier array de objetos). Después escribe un app.js que lo importe y calcule, sobre el backlog canónico: la media de horas de las tareas abiertas, la tarea de más horas y el agrupamiento por prioridad.
Ejercicio 3 — Contador compartido. Crea datos/metricas.js que exporte registrar(evento) y informe(), guardando el recuento en estado de módulo. Impórtalo desde modelo/tarea.js y desde modelo/tablero.js, registra un evento en cada cambio de estado y demuestra con una traza de consola que los dos módulos comparten el mismo contador. Después explica en dos líneas cuándo eso sería un problema.
Soluciones
Ejercicio 1
El ciclo es directo: informe.js → tablero.js → informe.js.
flowchart LR
I["a/informe.js"] -->|"import Tablero"| T["b/tablero.js"]
T -->|"import formatearInforme"| I
La causa de fondo es una inversión de capas: Tablero es una pieza del modelo y no debería saber nada de cómo se formatean los informes. La solución no es un truco de importación, sino ordenar las responsabilidades:
// util/formatoInforme.js — capa baja: no importa nada
export function formatearInforme(resumen) {
return `${resumen.total} tareas · ${resumen.horasAbiertas} h abiertas · esfuerzo ${resumen.esfuerzo}`;
}
// modelo/tablero.js — capa media: solo depende de utilidades
export class Tablero {
resumen() { /* … */ }
// imprimir() desaparece: el tablero calcula, no presenta
}
// informes/informe.js — capa alta: conoce a las dos de abajo
import { Tablero } from '../modelo/tablero.js';
import { formatearInforme } from '../util/formatoInforme.js';
export function generarInforme(tareas) {
return formatearInforme(new Tablero('temp', tareas).resumen());
}Grafo resultante, sin ciclos y con todas las flechas en la misma dirección:
flowchart TD
INF["informes/informe.js"] --> TAB["modelo/tablero.js"]
INF --> FMT["util/formatoInforme.js"]
TAB --> FMT
La lección general: cuando aparece un ciclo, casi siempre hay una capa que está mirando hacia arriba. Aquí era Tablero.imprimir(), un método de presentación en una clase de modelo.
Ejercicio 2
// js/util/estadisticas.js
export const VERSION = '1.0.0';
/** Media aritmética de un array de números. Devuelve 0 si está vacío. */
export function media(numeros) {
if (numeros.length === 0) return 0;
return numeros.reduce((s, n) => s + n, 0) / numeros.length;
}
/** Objeto con el valor máximo en el campo indicado, o null si el array está vacío. */
export function maximo(objetos, campo) {
return objetos.reduce((mejor, actual) =>
(mejor === null || actual[campo] > mejor[campo]) ? actual : mejor, null);
}
/** Agrupa por el valor de un campo: { alta: [...], media: [...] } */
export function agruparPor(objetos, campo) {
return objetos.reduce((acc, obj) => {
const clave = obj[campo] ?? 'sin valor';
(acc[clave] ??= []).push(obj);
return acc;
}, {});
}// js/app.js
import { crearBacklog } from './datos/backlog.js';
import { media, maximo, agruparPor, VERSION } from './util/estadisticas.js';
const backlog = crearBacklog();
const abiertas = backlog.filter((t) => t.abierta);
console.log(`estadisticas v${VERSION}`);
console.log(media(abiertas.map((t) => t.horasEstimadas)).toFixed(1)); // '9.0' (45 / 5)
console.log(maximo(backlog, 'horasEstimadas').titulo); // 'Actualizar la web de reservas'
const porPrioridad = agruparPor(backlog, 'prioridad');
for (const [prioridad, tareas] of Object.entries(porPrioridad)) {
console.log(`${prioridad}: ${tareas.length}`);
}
// alta: 3 · media: 2 · baja: 1La restricción del enunciado —que no importe nada del modelo— es el punto del ejercicio. media, maximo y agruparPor funcionan igual con tareas, con residentes o con reservas de sala, y por eso viven en util/ y no dependen de nadie: son una hoja del grafo. Un módulo de utilidades que importa del modelo deja de ser reutilizable y es la semilla del próximo ciclo. Nota además que maximo devuelve el primer máximo en caso de empate, porque la comparación es estricta (>).
Ejercicio 3
// js/datos/metricas.js
const cuenta = new Map(); // estado de módulo: uno solo para toda la aplicación
export function registrar(evento) {
cuenta.set(evento, (cuenta.get(evento) ?? 0) + 1);
}
export function informe() {
return Object.fromEntries([...cuenta.entries()].sort((a, b) => b[1] - a[1]));
}
export function reiniciar() { // imprescindible para las pruebas del Módulo 8
cuenta.clear();
}// js/modelo/tarea.js (añadido)
import { registrar } from '../datos/metricas.js';
cambiarEstado(nuevo) {
// …validación R6…
this.#estado = nuevo;
registrar(`tarea:${nuevo}`);
return this;
}// js/modelo/tablero.js (añadido)
import { registrar } from '../datos/metricas.js';
cambiarEstado(id, nuevo) {
// …
tarea.cambiarEstado(nuevo);
registrar('tablero:cambioEstado');
return this;
}// js/app.js
import { informe } from './datos/metricas.js';
tablero.cambiarEstado(2, 'en-curso');
tablero.cambiarEstado(3, 'en-curso');
tablero.cambiarEstado(1, 'hecha');
console.log(informe());
// { 'tablero:cambioEstado': 3, 'tarea:en-curso': 2, 'tarea:hecha': 1 }Que el informe sume las llamadas hechas desde dos módulos distintos demuestra que ambos recibieron la misma instancia de metricas.js: el módulo se cargó y ejecutó una sola vez, y ese Map es único.
¿Cuándo es un problema? Cuando el estado compartido debería ser independiente. En las pruebas del Módulo 8, cada test heredaría los recuentos del anterior y fallaría de forma intermitente —de ahí que hayamos exportado reiniciar()—. Y si la aplicación mostrara dos tableros a la vez, las métricas de ambos se mezclarían sin poder separarlas. La alternativa es la misma que aplicamos en crearBacklog(): exportar una fábrica (crearMetricas()) para que cada consumidor tenga su instancia, y reservar el estado de módulo para lo que de verdad es global, como la configuración de la aplicación.
Conclusión
El proyecto ha dejado de ser un fichero para convertirse en una arquitectura. Los tres problemas del principio están resueltos por construcción: cada módulo tiene su propio ámbito, así que dos ficheros pueden declarar PESOS sin enterarse; el orden de carga lo deduce el motor a partir de los import, no el HTML; y las dependencias son explícitas, escritas en las primeras líneas de cada fichero, hasta el punto de que se puede dibujar el grafo y comprobar de un vistazo que todas las flechas apuntan hacia abajo.
Dominas la sintaxis completa. export delante de una declaración o agrupado en una lista al final; import { a, b } con nombres que deben coincidir —no es desestructuración—; as para renombrar en cualquiera de los dos extremos, como hiciste con estaVencida as fechaVencida para resolver un choque real; import * as ns para traer el espacio de nombres completo; export default para el caso de «este fichero es una sola cosa», con sus pros y sus contras frente a las nombradas; y la reexportación en barriles, cómoda pero con un coste de carga que conviene conocer. Sabes que un módulo se ejecuta una sola vez y funciona como un singleton, que las importaciones son enlaces vivos de solo lectura, y que por eso crearBacklog() es una función y no un array exportado.
En el lado del entorno tienes el cuadro completo: <script type="module"> con su ámbito propio, su modo estricto permanente, su defer implícito —que te vendrá muy bien en el Módulo 6— y su exigencia de servidor local por CORS; import() dinámico y await a nivel de módulo como sintaxis que retomarás en 05-06 y 09-05; CommonJS con require/module.exports para reconocerlo cuando aparezca, con su tabla de diferencias frente a ESM —resolución estática frente a dinámica, enlaces vivos frente a copias, navegador sí o no—; y el "type": "module" del package.json que permite ejecutar el proyecto con node js/app.js. Y sabes diagnosticar una dependencia circular: no como un problema de importación que se arregla con un truco, sino como el síntoma de una capa que está mirando hacia arriba.
Nómada Tareas vive ahora en modelo/tarea.js, modelo/tablero.js, modelo/errores.js, datos/backlog.js, util/fechas.js, util/formato.js y js/app.js, y sigue dando los mismos números de siempre: 48 h totales, 45 abiertas, 1 vencida, esfuerzo 124, Iván con 25 h, Lucía con 14 y Marta con 6. Con una diferencia importante: ese datos/backlog.js es hoy un array escrito a mano, y todo el mundo sabe que en la aplicación real los datos vendrán de algún sitio —un fichero, un servidor— y tardarán en llegar. Un lenguaje de un solo hilo no puede quedarse esperando de brazos cruzados mientras eso ocurre, porque mientras espera la interfaz entera se congela. Cómo se programa una espera sin bloquear nada es el otro gran salto del curso, y empieza en JavaScript Asíncrono: Callbacks.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
