El DOM: la página desde JavaScript
En resumen: El DOM es el árbol de objetos con el que JavaScript lee y cambia la página: querySelector encuentra un elemento, textContent cambia su texto, y createElement y append añaden otros nuevos.
Problema
El precio se queda en «—»
El precio y el stock del producto vienen de JavaScript. Un compañero puso el script justo al principio «para que arranque antes». La página muestra «—» en lugar de un precio.
Consola
La consola dice dos cosas. console.log(price) imprimió null, y la línea siguiente falla con TypeError: Cannot set properties of null. El elemento <strong id="price"> está ahí mismo, en el HTML. ¿Por qué JavaScript no lo encuentra?
La idea
El documento como objeto
En el módulo de HTML viste que el navegador construye un árbol a partir del texto. JavaScript trabaja con el mismo árbol: para él la página es un objeto llamado document. Cada elemento del árbol es un objeto con campos y métodos. Por eso el árbol se llama DOM: Document Object Model.
Un elemento se busca con un selector, igual que en CSS:
const price = document.querySelector('#price'); // por idconst title = document.querySelector('h1'); // por etiquetaconst first = document.querySelector('.product'); // por clase: el primeroconst links = document.querySelectorAll('nav a'); // todos los enlaces dentro de navquerySelectorrecorre el árbol de arriba abajo y devuelve el primer elemento que coincide. Si no hay ninguno, devuelvenull.querySelectorAlllos devuelve todos como una NodeList: una lista conlengthyforEach, pero sinmapnifilter. Para usarlos, conviértela en un array:[...links]. Los arrays yforEachson del curso de JavaScript: arrays y lambdas.- La NodeList es una instantánea: un elemento añadido después no está en ella.
null significa «no hay tal elemento», no «un elemento vacío». El error llega después: null no tiene textContent, y el código falla en la línea siguiente.
La idea
Cuándo se ejecuta el script
El parser lee el HTML de arriba abajo. Cuando llega a <script>, se detiene, ejecuta el script y solo entonces sigue. En el problema el script está antes de <article>. Cuando se ejecuta, el árbol solo tiene <body>, y el precio todavía no está. Por eso querySelector devuelve null.
La solución es hacer que el script espere al árbol:
<head> <meta charset="utf-8"> <script src="script.js" defer></script></head>deferdice: descarga el script ya, pero ejecútalo cuando todo el HTML esté leído. Para entonces cada elemento está en el árbol.- La forma antigua es un
<script>al final del todo, justo antes de</body>. Funciona por la misma razón. defersolo funciona en un script consrc. En un script con código entre las etiquetas no hace nada.
Pruébalo
Arregla el precio
Arregla la página para que muestre el precio y el stock. No toques script.js: el problema es cuándo se ejecuta, no lo que hace.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Pan Dobrudzha</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article>
<h1>Pan Dobrudzha</h1>
<p>Precio: <strong id="price">—</strong></p>
<p id="stock">—</p>
<button type="button">Añadir al carrito</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst price = document.querySelector('#price');
console.log(price);
price.textContent = '1,80 €';
document.querySelector('#stock').textContent = 'En stock: 12 uds.';
<script src="script.js" defer></script> en el <head> espera a que el árbol esté listo. Entonces #price y #stock ya están en él y querySelector los encuentra. Sin defer, un script en el <head> se ejecuta aún antes y vuelve a encontrar null. Una comprobación if (price) en script.js esconde el error, pero el precio sigue sin aparecer: protege contra null, no lo arregla.
La idea
El texto: textContent e innerHTML
El elemento que encontraste lo cambias a través de sus campos. Para el texto hay dos:
price.textContent = '1,80 €'; // el texto tal cualnote.innerHTML = 'Precio: <strong>1,80 €</strong>'; // el texto se lee como HTMLtextContentpone texto. Si contiene<strong>, se ve como caracteres, no como una etiqueta.innerHTMLle pasa el texto al parser, que construye elementos nuevos con él. Los hijos anteriores del elemento desaparecen.
innerHTML con datos de un usuario (un nombre, una reseña, una búsqueda) es peligroso. Si contiene HTML con código, ese código se ejecuta en el navegador de cualquiera que abra la página: es un ataque XSS, del que trata la lección «Entrada no confiable». Por eso el texto se pone con textContent, y los elementos nuevos se crean con createElement (más abajo).
Después del cambio el navegador vuelve a dibujar por sí solo. No toda la página, solo lo que cambió.
Míralo
Mira cómo cambia la página
A la izquierda está el árbol, a la derecha la página que el navegador dibuja a partir de él. El código encuentra el carrito, cambia su texto y su clase y añade una línea nueva a la lista.
const cart = document.querySelector('#cart');cart.textContent = 'Carrito: 1';cart.classList.add('full');const li = document.createElement('li');li.textContent = 'Leche';document.querySelector('ul').append(li);Paso a paso, en palabras
- El parser ya construyó el árbol a partir del HTML. A la derecha está la página que el navegador dibuja con él. JavaScript ve el mismo árbol a través de document.
- querySelector('#cart') recorre el árbol desde arriba y compara cada elemento con el selector. Devuelve el primero que coincide: cart ya apunta a ese nodo. Si no hay ninguno, devuelve null.
- textContent reemplaza el texto del nodo. El navegador nota el cambio y vuelve a dibujar solo esa parte de la página. Lo demás se queda como estaba.
- classList.add pone una clase en el elemento. Una regla CSS para .full le da un color nuevo, y otra vez solo se vuelve a dibujar el carrito.
- createElement crea un <li> nuevo, y textContent le da texto. Todavía no está en el árbol, así que la página no cambia.
- append lo pone como último hijo de <ul>. Ahora está en el árbol y la lista de la página crece una línea.
- JavaScript nunca dibuja la página por sí mismo. Cambia el árbol, y el navegador dibuja lo que cambió. La página no se vuelve a cargar.
JavaScript no dibuja nada por sí mismo. Cambia el árbol, y el navegador dibuja el cambio. La página no se vuelve a cargar, así que el texto de los campos y el punto donde estabas con el scroll se conservan.
Pruébalo
Quedan 3 unidades
Muestra el stock en #stock: «Quedan 3 uds.». Cuando queden menos de 5, añade al elemento la clase low (el CSS la pone en rojo). El texto en negrita de la clase stock tiene que seguir.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Leche fresca 3 %</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article>
<h1>Leche fresca 3 %</h1>
<p id="stock" class="stock">—</p>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.stock { font-weight: bold; }
.low { color: #c2452a; }
script.jsconst stock = 3;
const text = document.querySelector('#stock');
text.textContent = `Quedan ${stock} uds.`;
if (stock < 5) {
text.classList.add('low');
}
text.textContent = `Quedan ${stock} uds.` pone el texto, y text.classList.add('low') añade la clase junto a stock. className = 'low' también lo pone en rojo, pero reemplaza todas las clases: stock desaparece y el texto ya no está en negrita. Y text.class = 'low' no hace nada visible: es un campo nuevo del objeto, no la clase del elemento.
La idea
Atributos, propiedades y clases
En HTML un elemento tiene atributos: value="1", disabled, data-id="7". En JavaScript tiene propiedades: campos del objeto. La mayoría de los atributos tienen una propiedad con el mismo nombre, pero no son lo mismo.
input.valuees lo que hay ahora en el campo. El atributovaluees el valor inicial y no cambia mientras la compradora escribe. El atributo se lee congetAttribute('value').button.disabled = trueapaga el botón. La propiedad estrueofalse.- Los atributos
data-…sirven para tus propios datos. Los lees condataset:data-stockesdataset.stock, ydata-product-idesdataset.productId. El valor es siempre una cadena. - Con las clases trabajas a través de
classList:add,remove,toggle(añade la clase si falta y la quita si está) ycontains.
Consola
La consola muestra que la propiedad es 3, mientras que el atributo sigue siendo 1. dataset.stock es "12", una cadena: conviértela en número con Number(...) antes de calcular con ella.
Pruébalo
Encuentra el error
La oferta debería poner todos los precios en rojo, pero la consola dice Cannot read properties of undefined (reading 'add').
Elige la línea con el error.
querySelectorAll devuelve una NodeList, y una NodeList no tiene classList: prices.classList es undefined. La clase se pone en cada elemento: prices.forEach((p) => p.classList.add('sale')).
La idea
Elementos nuevos
Un elemento nuevo se hace en tres pasos: crearlo, darle contenido y ponerlo en el árbol.
const li = document.createElement('li'); // un <li> nuevo, todavía no está en la páginali.textContent = 'Leche';li.dataset.id = '2';document.querySelector('ul').append(li); // ahora es el último hijo de <ul>Mientras no lo pongas con append, el elemento no está en la página. append también acepta varios elementos a la vez: li.append(title, button). prepend lo pone al principio, y remove() saca el elemento del árbol.
Más precisamente, para curiosos
Más formas de buscar. getElementById('price') equivale a querySelector('#price'). getElementsByClassName y getElementsByTagName devuelven una lista «viva»: cambia cuando cambia el árbol. querySelectorAll devuelve una instantánea. Después de un append de un .price nuevo, la lista viva tiene un elemento más y la instantánea no.
Buscar dentro de un elemento. Todos los elementos tienen querySelector, no solo document: card.querySelector('.price') busca únicamente dentro de la tarjeta.
setAttribute('disabled', 'false') apaga el botón. En disabled solo importa que el atributo esté presente, no su valor. Por eso se escribe button.disabled = false o removeAttribute('disabled').
innerHTML y <script>. Un script puesto con innerHTML no se ejecuta. Un atributo como onerror en una imagen, sí. Por eso innerHTML con datos ajenos es peligroso incluso sin <script>.
className frente a classList. className es todo el atributo class como una sola cadena. Si le asignas algo, reemplazas todas las clases de golpe.
Ejercicio
La tarjeta del producto
script.js tiene un objeto product. Rellena la tarjeta con él: el nombre es el encabezado (#name), el precio es «8,90 €» (dos decimales, con coma) y el stock es «Agotado» cuando stock es 0. Entonces la tarjeta recibe la clase sold-out y el botón se apaga. Pon en la tarjeta un data-id con el id del producto. Por cada etiqueta de badges, añade un <li> a #badges.
Consola
Solución
index.html<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Queso Vitosha</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article class="product">
<h2 id="name"></h2>
<p id="price"></p>
<p id="stock"></p>
<ul id="badges" aria-label="Etiquetas"></ul>
<button type="button" id="add">Añadir al carrito</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.product { border: 1px solid #ccc; border-radius: 8px; padding: 12px 16px; max-width: 320px; }
.sold-out { opacity: 0.6; }
#badges { list-style: none; padding: 0; display: flex; gap: 8px; }
#badges li { background: #f3efe6; border-radius: 4px; padding: 2px 8px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst product = {
id: 7,
name: 'Queso Vitosha',
price: 8.9,
stock: 0,
badges: ['nuevo', 'búlgaro'],
};
const card = document.querySelector('.product');
card.dataset.id = product.id;
document.querySelector('#name').textContent = product.name;
document.querySelector('#price').textContent = `${product.price.toFixed(2).replace('.', ',')} €`;
const stock = document.querySelector('#stock');
const add = document.querySelector('#add');
if (product.stock === 0) {
stock.textContent = 'Agotado';
card.classList.add('sold-out');
add.disabled = true;
} else {
stock.textContent = `En stock: ${product.stock} uds.`;
}
const badges = document.querySelector('#badges');
product.badges.forEach((badge) => {
const li = document.createElement('li');
li.textContent = badge;
badges.append(li);
});
card.dataset.id = product.id escribe el atributo data-id="7". El precio es `${product.price.toFixed(2).replace('.', ',')} €`: toFixed(2) da "8.90", y replace cambia el punto por la coma. Cuando stock === 0, tres cosas van juntas: el texto, la clase y add.disabled = true. Las etiquetas son un forEach sobre el array, con createElement('li') y append para cada una. badges.textContent = product.badges.join(', ') muestra las mismas palabras, pero como un solo texto: la lista no tiene ningún elemento.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: El documento HTML · Formularios · Selectores y la cascada
- Continúa en: Eventos · Una lista a partir de datos · Datos desde una API · El primer componente
- Ejercicios sobre el tema: JavaScript en la página