wandres.dev
SVG GENERADO · Construir gráficos por código

createElementNS y el espacio de nombres

Por qué createElement no funciona para SVG, qué es exactamente un HTMLUnknownElement, cuándo hace falta setAttributeNS, y las tres formas de construir marcado SVG desde JavaScript.

⏱ 16 min

El primer intento de crear un SVG desde JavaScript falla siempre de la misma manera: el elemento aparece en el inspector, tiene los atributos correctos, y no se ve. No hay error en consola, no hay aviso, nada. La causa es que createElement crea el elemento en el espacio de nombres equivocado, y entender qué significa eso aclara de paso por qué innerHTML sí funciona y por qué xlink sigue apareciendo en código antiguo.

🎯 Al terminar esta lección sabrás
  • Crear elementos SVG con el espacio de nombres correcto.
  • Explicar qué es un elemento desconocido de HTML y por qué no se renderiza.
  • Distinguir cuándo hace falta setAttributeNS y cuándo no.
  • Elegir entre construcción por DOM, por cadena y con DOMParser.

El fallo silencioso

// No funciona
const c = document.createElement('circle');
c.setAttribute('cx', 50);
c.setAttribute('cy', 50);
c.setAttribute('r', 40);
c.setAttribute('fill', 'red');
svg.appendChild(c);

El elemento se añade. svg.children.length aumenta. El inspector lo muestra. Y no hay ningún círculo.

La razón: document.createElement crea elementos en el espacio de nombres de HTML. Un elemento llamado circle en ese espacio de nombres no existe en la especificación de HTML, así que el navegador crea un HTMLUnknownElement: un elemento válido, con todos los métodos del DOM, sin ningún comportamiento y sin caja de renderizado propia. Es lo mismo que ocurriría con document.createElement('pepito').

Un HTMLUnknownElement dentro de un svg no significa nada para el motor de renderizado de SVG, que solo dibuja elementos del espacio de nombres SVG.

La versión correcta:

const NS = 'http://www.w3.org/2000/svg';

const c = document.createElementNS(NS, 'circle');
c.setAttribute('cx', 50);
c.setAttribute('cy', 50);
c.setAttribute('r', 40);
c.setAttribute('fill', '#f38ba8');
svg.appendChild(c);

Esa cadena de URL no es una dirección que se visite: es un identificador. No hay nada en ese servidor que el navegador consulte. Es simplemente una cadena única y acordada que distingue el vocabulario SVG del de HTML, de MathML y de cualquier otro.

La manera de verificarlo, útil al depurar:

console.log(c.namespaceURI);   // http://www.w3.org/2000/svg
console.log(c instanceof SVGCircleElement);  // true si esta bien

Si el segundo da false, ese es el problema y no otro.

Un ayudante mínimo

Escribir la constante en cada creación es tedioso. El ayudante estándar:

const NS = 'http://www.w3.org/2000/svg';

function el(nombre, atributos = {}, hijos = []) {
  const nodo = document.createElementNS(NS, nombre);
  for (const [k, v] of Object.entries(atributos)) {
    if (v == null || v === false) continue;
    nodo.setAttribute(k, String(v));
  }
  for (const h of hijos) {
    nodo.appendChild(typeof h === 'string' ? document.createTextNode(h) : h);
  }
  return nodo;
}

// Uso
const grafico = el('svg', { viewBox: '0 0 200 100', width: 400 }, [
  el('rect', { x: 10, y: 10, width: 60, height: 80, fill: '#89b4fa' }),
  el('text', { x: 40, y: 100, 'text-anchor': 'middle', 'font-size': 12 }, ['A']),
]);

Dos detalles del ayudante que se agradecen en uso real. El salto de los valores nulos permite construir atributos condicionales sin ramificar. Y el String(v) evita que un número se convierta en "NaN" sin avisar cuando el cálculo ha fallado; si prefieres detectarlo, añade una comprobación.

setAttribute a secas funciona para casi todos los atributos de SVG, porque casi todos están sin espacio de nombres. viewBox, d, fill, transform, stroke-width: todos con setAttribute normal.

La excepción histórica es xlink:href, que en SVG 1.1 pertenecía al espacio de nombres de XLink:

const XLINK = 'http://www.w3.org/1999/xlink';
use.setAttributeNS(XLINK, 'xlink:href', '#simbolo');

SVG 2 dejó obsoleto xlink:href en favor de un href normal, sin espacio de nombres:

use.setAttribute('href', '#simbolo');   // asi, en codigo nuevo

Los motores actuales soportan las dos formas. En código nuevo, usa href a secas y olvida XLink. Si mantienes código que usa setAttributeNS con XLink, funciona y no hay urgencia en cambiarlo.

Un detalle relacionado: el atributo xmlns no se pone con setAttribute en un SVG que va a vivir dentro de un documento HTML, porque el espacio de nombres ya lo determina createElementNS. Solo hace falta cuando serializas el SVG a un fichero independiente, y ahí se pone como atributo literal en la cadena.

innerHTML sobre un elemento SVG

Esta es la que sorprende: funciona.

const g = document.createElementNS(NS, 'g');
g.innerHTML = '<circle cx="20" cy="20" r="10" fill="#a6e3a1"/>';

Los elementos creados tienen el espacio de nombres correcto. La razón es que el analizador de fragmentos usa el contexto del elemento sobre el que se llama: como g está en el espacio de nombres SVG, lo que se parsea dentro también.

Lo que no funciona igual de bien:

  • insertAdjacentHTML sobre un elemento SVG: su soporte ha sido irregular.
  • Poner marcado SVG en el innerHTML de un elemento HTML sin un svg que lo envuelva: entonces el contexto es HTML y los elementos salen en el espacio de nombres equivocado. Un div.innerHTML = '<circle/>' produce el mismo HTMLUnknownElement del principio.

La vía robusta cuando construyes desde una cadena completa:

function desdeCadena(marcado) {
  const doc = new DOMParser().parseFromString(
    `<svg xmlns="${NS}">${marcado}</svg>`, 'image/svg+xml');
  const err = doc.querySelector('parsererror');
  if (err) throw new Error('SVG mal formado: ' + err.textContent);
  return [...doc.documentElement.childNodes]
    .map(n => document.importNode(n, true));
}

Tres cosas ahí. El xmlns explícito es obligatorio porque image/svg+xml se parsea como XML y el XML no tiene espacios de nombres implícitos. La comprobación de parsererror es necesaria porque el parseo de XML no lanza excepciones: devuelve un documento que contiene un elemento de error, y si no lo miras, insertas basura. Y importNode es imprescindible porque los nodos pertenecen a otro documento; insertarlos directamente falla o produce comportamiento indefinido.

El parseo XML es estricto, y eso es una ventaja que conviene explotar

DOMParser con image/svg+xml usa un analizador de XML, no el permisivo de HTML. Eso significa que rechaza lo que el de HTML repararía: una etiqueta sin cerrar, un atributo sin comillas, un & suelto en un texto, una anidación incorrecta.

Suena a inconveniente y es lo contrario. Cuando generas SVG desde datos, un fallo de escapado produce marcado inválido, y el analizador de HTML lo repara en silencio de la forma que le parece. El resultado es un DOM que no es el que querías, con elementos movidos de sitio o atributos perdidos, y ningún error en ninguna parte. Depurar eso es una tarde.

Con el analizador de XML, el mismo fallo produce un parsererror con la línea y la columna exactas.

El caso concreto y frecuente: una etiqueta de datos que contiene un ampersand.

const etiqueta = 'Ventas & marketing';
const svg = `<text x="10" y="20">${etiqueta}</text>`;
// El parser de HTML lo tolera; el de XML falla con "EntityRef"

La solución no es cambiar de analizador, es escapar siempre lo que viene de datos:

const esc = (s) => String(s)
  .replace(/&/g, '&amp;')
  .replace(/</g, '&lt;')
  .replace(/>/g, '&gt;')
  .replace(/"/g, '&quot;');

Y el corolario de seguridad, que es el que de verdad importa: construir SVG concatenando cadenas con datos de usuario sin escapar es una inyección. Un SVG admite <script> y admite manejadores de eventos como onload en atributos. Un nombre de serie que contenga marcado se ejecuta. Es exactamente el mismo riesgo que en HTML y se olvida más, porque «es solo un gráfico».

La construcción por DOM con createElementNS y setAttribute es inmune por construcción: los valores se asignan como cadenas, nunca se parsean. Es un argumento fuerte a favor de esa vía cuando los datos no son tuyos.

⚔️ Reto práctico

Escribe la misma barra de un gráfico de tres maneras: con createElement (para verla fallar), con createElementNS, y con innerHTML sobre un g existente. Comprueba en las tres el namespaceURI del elemento resultante. Después intenta la cuarta: innerHTML sobre un div, y comprueba qué espacio de nombres sale y por qué.