Unicode y la Interna de Strings en JavaScript
Conocimiento avanzado
Esta sección profundiza en los detalles internos de los strings. Este conocimiento te será útil si planeas trabajar con emojis, caracteres matemáticos inusuales, jeroglíficos u otros símbolos inusuales.
Como se mencionó anteriormente, los strings en JavaScript están basados en Unicode: cada carácter está representado por una secuencia de entre 1 y 4 bytes.
JavaScript nos permite insertar un carácter en un string utilizando su código hexadecimal Unicode, empleando estas tres notaciones:
\xXX
XX debe ser un valor hexadecimal de dos dígitos entre 00 y FF. Así, \xXX representa el carácter cuyo código Unicode es XX.
Dado que la notación \xXX admite solo dos dígitos hexadecimales, puede representar únicamente los primeros 256 caracteres Unicode.
Estos primeros 256 caracteres incluyen el alfabeto latino, la mayoría de los caracteres de sintaxis básicos y algunos otros. Por ejemplo, «\x7A» es equivalente a «z» (Unicode U+007A).
alert("\x7A"); // z
alert("\xA9"); // ©, el símbolo de copyright
\uXXXX
XXXX debe ser un valor hexadecimal de exactamente 4 dígitos, entre 0000 y FFFF. Así, \uXXXX representa el carácter cuyo código Unicode es XXXX.
Los caracteres con un valor Unicode superior a U+FFFF también pueden ser representados con esta notación, pero en ese caso, debemos usar los llamados «pares sustitutos», que se describen más adelante.
alert("\u00A9"); // ©, igual que \xA9, usando la notación de 4 dígitos hexadecimales
alert("\u044F"); // я, letra del alfabeto cirílico
alert("\u2191"); // ↑, símbolo de flecha
\u{X…XXXXXX}
X…XXXXXX debe ser un valor hexadecimal de 1 a 6 dígitos, entre 0 y 10FFFF (el mayor punto de código definido por Unicode). Esta notación permite representar fácilmente todos los caracteres Unicode existentes.
alert("\u{20331}"); // 佫, un carácter chino raro
alert("\u{1F60D}"); // 😍, un emoji de cara sonriente
Pares Sustitutos
Todos los caracteres comunes tienen códigos de 2 bytes (4 dígitos hexadecimales). Las letras de la mayoría de los idiomas europeos, los números y los conjuntos básicos de caracteres ideográficos unificados CJK (CJK: chino, japonés y coreano), tienen una representación de 2 bytes.
Inicialmente, JavaScript estaba basado en la codificación UTF-16, que solo permite 2 bytes por carácter. Pero 2 bytes solo permiten 65536 combinaciones, lo cual no es suficiente para cada símbolo Unicode posible.
Por lo tanto, los símbolos raros que requieren más de 2 bytes se codifican con un par de caracteres de 2 bytes, llamado «par sustituto».
Como efecto secundario, la longitud de tales símbolos es 2:
alert('𝒳'.length); // 2, carácter matemático X capitalizado
alert('😂'.length); // 2, cara con lágrimas de risa
alert('𩷶'.length); // 2, un carácter chino raro
Esto es porque los pares sustitutos no existían cuando JavaScript fue creado, por lo que no son procesados correctamente por el lenguaje.
En realidad, tenemos un solo símbolo en cada línea de los strings anteriores, pero la propiedad length los muestra con una longitud de 2.
Obtener un símbolo puede ser complicado, porque la mayoría de las características del lenguaje tratan a los pares sustitutos como dos caracteres separados.
Por ejemplo, aquí vemos dos caracteres extraños en la salida:
alert('𝒳'[0]); // muestra símbolos extraños...
alert('𝒳'[1]); // ...partes del par sustituto
Las dos partes del par sustituto no tienen significado por sí solas. Por lo tanto, las alertas del ejemplo en realidad muestran basura.
Técnicamente, los pares sustitutos son también detectables por su propio código: si un carácter tiene código en el rango de 0xd800..0xdbff, entonces es la primera parte de un par sustituto. El siguiente carácter (segunda parte) debe tener el código en el rango de 0xdc00..0xdfff. Estos intervalos están reservados exclusivamente para pares sustitutos según el estándar.
Los métodos String.fromCodePoint y str.codePointAt fueron añadidos en JavaScript para manejar los pares sustitutos correctamente.
Esencialmente, son equivalentes a String.fromCharCode y str.charCodeAt, pero tratan a los pares sustitutos de manera adecuada.
Podemos ver la diferencia aquí:
// charCodeAt no percibe los pares sustitutos, por lo tanto da el código de la primera parte de 𝒳:
alert('𝒳'.charCodeAt(0).toString(16)); // d835
// codePointAt reconoce los pares sustitutos
alert('𝒳'.codePointAt(0).toString(16)); // 1d4b3, lee ambas partes del par sustituto
Dicho esto, si tomamos desde la posición 1 (y esto es incorrecto en este caso), ambas funciones devolverán solo la segunda parte del par:
alert('𝒳'.charCodeAt(1).toString(16)); // dcb3
alert('𝒳'.codePointAt(1).toString(16)); // dcb3
// segunda parte del par, sin sentido
Encontrarás más formas de trabajar con pares sustitutos en el capítulo sobre Iterables. Probablemente haya bibliotecas especiales para ello también, pero ninguna lo suficientemente conocida como para sugerirla aquí.
En conclusión: dividir strings en un punto arbitrario es peligroso
No podemos simplemente separar un string en una posición arbitraria, por ejemplo, tomar str.slice(0, 4)
, y confiar en que sea un string válido:
alert('hi 😂'.slice(0, 4)); // hi [?]
Aquí podemos ver basura (la primera mitad del par sustituto del emoji) en la salida.
Simplemente ten esto en cuenta si quieres trabajar con confianza con los pares sustitutos. Puede que no sea un gran problema, pero al menos deberías entender lo que ocurre.
Marcas Diacríticas y Normalización
En muchos idiomas, hay símbolos compuestos, con un carácter base y una marca arriba o debajo.
Por ejemplo, la letra «a» puede ser el carácter base para estos caracteres: à, á, â, ä, ã, å, ā.
Los caracteres «compuestos» más comunes tienen su propio código en la tabla UTF-16. Pero no todos ellos, ya que hay demasiadas combinaciones posibles.
Para soportar composiciones arbitrarias, el estándar Unicode permite usar varios caracteres Unicode: el carácter base y uno o varios caracteres de «marca» que lo «decoran».
Por ejemplo, si tenemos una «S» seguida del carácter especial «punto arriba» (código \u0307), se muestra como Ṡ.
alert('S\u0307'); // Ṡ
Si necesitamos una marca adicional sobre la letra (o debajo de ella), no hay problema, simplemente se agrega el carácter de marca necesario.
Por ejemplo, si agregamos un carácter «punto debajo» (código \u0323), entonces tendremos «S con puntos arriba y abajo»: Ṩ.
Ejemplo:
alert('S\u0307\u0323'); // Ṩ
Esto proporciona una gran flexibilidad, pero también un problema interesante: dos caracteres pueden ser visualmente iguales, pero estar representados con diferentes composiciones Unicode.
Por ejemplo:
let s1 = 'S\u0307\u0323'; // Ṩ, S + punto arriba + punto debajo
let s2 = 'S\u0323\u0307'; // Ṩ, S + punto debajo + punto arriba
alert(`s1: ${s1}, s2: ${s2}`);
alert(s1 == s2); // false aunque los caracteres se ven idénticos (?!)
Para resolver esto, existe un algoritmo de «normalización Unicode» que lleva cada cadena a la forma «normal».
Esto se implementa con str.normalize()
.
alert("S\u0307\u0323".normalize() == "S\u0323\u0307".normalize()); // true
Lo curioso de esta situación particular es que normalize()
reúne una secuencia de 3 caracteres en uno: \u1e68 (S con dos puntos).
alert("S\u0307\u0323".normalize().length); // 1
alert("S\u0307\u0323".normalize() == "\u1e68"); // true
En realidad, esto no siempre es así. La razón es que el símbolo Ṩ es «bastante común», por lo que los creadores de Unicode lo incluyeron en la tabla principal y le dieron un código.
Si deseas obtener más información sobre las reglas y variantes de normalización, se describen en el apéndice del estándar Unicode. Pero para la mayoría de los propósitos prácticos, la información de esta sección es suficiente.