12k
All articles

Cómo eliminar líneas de texto duplicadas en JavaScript

Elimina líneas duplicadas en JavaScript con Set y gestiona mayúsculas, espacios, líneas en blanco, saltos de línea y qué aparición conservar.

OpenReplay Team
OpenReplay Team
Cómo eliminar líneas de texto duplicadas en JavaScript

Para eliminar líneas duplicadas en JavaScript, divide el texto con /\r?\n/, pasa las líneas por un Set y vuelve a unirlas: [...new Set(text.split(/\r?\n/))].join('\n').

Esto resulta útil para depurar una lista de correos electrónicos o IDs antes de una importación masiva, limpiar un archivo de log, combinar dos exportaciones CSV o eliminar repeticiones de una lista de palabras. La solución de una línea cubre los duplicados exactos, y la herramienta para eliminar líneas duplicadas lo hace directamente en el navegador si solo necesitas el resultado. Para los casi duplicados, que solo difieren en mayúsculas y minúsculas, espacios en blanco, finales de línea o líneas en blanco, este artículo ofrece una función lista para copiar con la opción de conservar la primera o la última aparición.

Puntos clave

  • [...new Set(lines)] elimina las líneas duplicadas exactas y mantiene cada línea en la posición donde apareció por primera vez, porque un Set se recorre en orden de inserción.
  • Un Set compara las cadenas de forma exacta, así que Apple, apple y apple (con un espacio final) cuentan como tres líneas distintas.
  • Deduplica a partir de una clave normalizada (recortada y, opcionalmente, en minúsculas), pero devuelve la línea original para conservar las mayúsculas y la sangría.
  • Llamar a set() sobre una clave existente de un Map reemplaza el valor, pero mantiene la posición original de la clave. Llama primero a delete() para mover la clave al final.
  • sort -u ordena la salida y, por tanto, pierde el orden original. awk '!seen[$0]++' conserva el orden de entrada.

La solución de una línea para eliminar líneas duplicadas en JavaScript

La forma más breve de eliminar líneas duplicadas en JavaScript es colocar un Set entre split y join:

const dedupeLines = (text) => [...new Set(text.split(/\r?\n/))].join('\n');

dedupeLines('one\nthree\ntwo\nthree\none');
// 'one\nthree\ntwo'

Esto funciona porque un Set recorre sus valores en orden de inserción. Cada línea se queda en la posición de su primera aparición y las copias posteriores se descartan. La especificación ES2015 define este orden: add añade cada valor nuevo al final de la lista interna del conjunto. Para una entrada limpia generada por máquina, no necesitas nada más. La guía de métodos de Set en JavaScript cubre el resto de la API de Set.

¿Dónde falla la versión ingenua con Set?

Un Set compara las cadenas de forma exacta (mediante SameValueZero), por lo que basta con que difieran en un solo carácter para que dos líneas se consideren distintas. Cada una de las siguientes entradas conserva una línea que probablemente querías eliminar:

  • Mayúsculas y minúsculas: 'Apple\napple' conserva ambas líneas.
  • Espacios en blanco finales: 'apple \napple' conserva ambas, porque una de las líneas termina en un espacio.
  • Finales de línea CRLF: 'apple\r\napple'.split('\n') devuelve ['apple\r', 'apple']. Dividir solo por '\n' deja un \r al final de cada línea, salvo en la última si no va seguida de un salto de línea, de modo que esa última línea nunca coincide con sus copias anteriores. El separador de expresión regular en split /\r?\n/ lo soluciona. Si esperas texto con finales de línea del Mac OS clásico (un \r aislado), usa /\r\n|\r|\n/.
  • Líneas en blanco: 'a\n\nb\n\nc' se convierte en 'a\n\nb\nc'. La segunda línea en blanco cuenta como duplicado de la primera, así que los párrafos b y c quedan pegados.
  • Salto de línea final: 'a\n\nb\n' se convierte en 'a\n\nb'. La cadena vacía que sigue al último salto de línea coincide con la línea en blanco anterior y se elimina.

Evita lines.filter((l, i, a) => a.indexOf(l) === i). indexOf recorre el array de forma lineal, así que hacerlo para cada línea convierte el recorrido completo en O(n²). La especificación exige que las búsquedas en un Set sean, de media, más rápidas que lineales, por lo que un Set evita ese recorrido repetido. Usar un objeto plano como registro de elementos vistos tiene sus propios problemas: una línea con el texto __proto__ o hasOwnProperty puede romperlo.

Deduplica con una clave normalizada y devuelve la línea original

Para tratar los casi duplicados como iguales, compara contra el Set una clave normalizada (la línea recortada, en minúsculas si quieres una comparación que no distinga mayúsculas), pero añade a la salida la línea original.

function removeDuplicateLines(text, options = {}) {
  const {
    caseSensitive = true,
    trim = true,
    collapseWhitespace = false,
    blankLines = 'collapse', // 'keep' | 'collapse' | 'remove'
  } = options;

  const eol = text.includes('\r\n') ? '\r\n' : '\n';
  const trailingNewline = /\r?\n$/.test(text);
  const lines = text.replace(/\r?\n$/, '').split(/\r?\n/);

  const toKey = (line) => {
    let key = trim ? line.trim() : line;
    if (collapseWhitespace) key = key.replace(/\s+/g, ' ');
    return caseSensitive ? key : key.toLowerCase();
  };

  const isBlank = (line) => line.trim() === '';
  const seen = new Set();
  const out = [];

  for (const line of lines) {
    if (isBlank(line)) {
      if (blankLines === 'remove') continue;
      if (blankLines === 'collapse' && out.length && isBlank(out[out.length - 1])) continue;
      out.push(line);
      continue;
    }
    const key = toKey(line);
    if (seen.has(key)) continue;
    seen.add(key);
    out.push(line);
  }

  return out.join(eol) + (trailingNewline ? eol : '');
}

removeDuplicateLines('Apple\napple \n\n\nbanana\nBanana\n', { caseSensitive: false });
// 'Apple\n\nbanana\n'

Así se comportan las opciones de removeDuplicateLines:

  • trim usa String.prototype.trim, que también elimina los espacios de no separación y un BOM inicial. La salida conserva la sangría original.
  • caseSensitive: false usa toLowerCase, que produce el mismo resultado en cualquier configuración regional.
  • blankLines: 'keep' excluye las líneas en blanco de la deduplicación, de modo que permanecen en su sitio. 'collapse' reduce cada secuencia de líneas en blanco a una sola; comprueba la última línea de la salida, así que dos líneas en blanco con un duplicado descartado entre ellas también se reducen a una. 'remove' elimina todas las líneas en blanco.
  • La función conserva el salto de línea final de la entrada. Si aparece algún \r\n en la entrada, la salida usa \r\n en todas las líneas. Se trata de una simplificación para archivos con finales de línea mixtos.

Un Map de clave a línea funciona igual: if (!map.has(key)) map.set(key, line). Consulta Map vs Set vs Object para saber cuándo conviene cada estructura. La normalización Unicode, las comillas tipográficas y otros residuos del pegado de texto corresponden a un paso aparte, que se trata en Cleaning Up Text People Paste Into Your App.

Primera frente a última aparición

Al eliminar líneas duplicadas, conservar la primera aparición o la última produce resultados distintos. Además, conservar la última tiene dos variantes que difieren en el orden. Para la entrada ['apple', 'banana', 'Apple', 'cherry'], comparada sin distinguir mayúsculas y minúsculas:

EstrategiaResultado
Gana la primera (Set de claves)apple, banana, cherry
Gana la última, solo sobrescribiendo (map.set)Apple, banana, cherry
Gana la última, con delete y luego setbanana, Apple, cherry
function keepLast(lines, toKey = (l) => l.trim().toLowerCase(), { moveToEnd = true } = {}) {
  const byKey = new Map();
  for (const line of lines) {
    const key = toKey(line);
    if (moveToEnd) byKey.delete(key);
    byKey.set(key, line);
  }
  return [...byKey.values()];
}

Un Map conserva sus claves en el orden en que se añadieron por primera vez, y llamar a set() sobre una clave que ya contiene cambia el valor sin mover la clave. Por eso, un recorrido que solo sobrescribe devuelve la última versión de cada línea, pero en la posición donde la línea apareció por primera vez. Para colocar cada línea donde apareció por última vez, llama a delete(key) antes de set(key, line), de modo que la clave se vuelva a añadir al final.

Eliminar líneas duplicadas de archivos grandes desde la terminal

Para archivos demasiado grandes como para pegarlos, elimina las líneas duplicadas desde la shell con sort -u o awk. sort -u elimina los duplicados ordenando, por lo que se pierde el orden original. Además, compara las líneas según la intercalación de la configuración regional activa, lo que en configuraciones distintas de C puede fusionar líneas que no son idénticas byte a byte; usa LC_ALL=C sort -u para obtener coincidencias exactas.

awk '!seen[$0]++' imprime cada línea la primera vez que aparece y conserva el orden de entrada, pero almacena cada línea única en un array asociativo que se mantiene en memoria. awk '!seen[tolower($0)]++' ignora las mayúsculas y minúsculas. En los archivos CRLF, el \r llega a ambas herramientas, así que elimínalo primero con tr -d '\r'.

LC_ALL=C sort -u input.txt > output.txt
awk '!seen[$0]++' input.txt > output.txt
tr -d '\r' < input.txt | awk '!seen[$0]++' > output.txt

Conclusión

Usa new Set para los duplicados exactos. Cuando la entrada proviene de personas o de varias fuentes, deduplica con una clave normalizada y devuelve la línea original. Elige de forma deliberada una política para las líneas en blanco y una regla de primera o última aparición, en lugar de aceptar los valores por defecto. Copia removeDuplicateLines, ajusta las opciones a tus datos y, si lo que quieres es agrupar líneas relacionadas en lugar de eliminarlas, usa Object.groupBy.

Preguntas frecuentes

¿Cómo conservo solo las líneas que aparecen exactamente una vez en JavaScript?

Cuenta primero las apariciones y después filtra. Construye en un solo recorrido un Map de línea a recuento y luego conserva las líneas cuyo recuento sea 1 con lines.filter((line) => counts.get(line) === 1). Esto no es deduplicación, que conserva una copia de cada línea repetida. Aquí, cualquier línea que aparezca dos o más veces se elimina por completo. Cuenta a partir de una clave normalizada si los casi duplicados deben considerarse la misma línea.

¿Cómo elimino de una lista las líneas que ya aparecen en otra?

Carga la segunda lista en un Set y filtra la primera contra él: const exclude = new Set(listB); listA.filter((line) => !exclude.has(line)). Cada comprobación es una búsqueda en un Set, así que evitas el coste cuadrático de llamar a includes o indexOf sobre un array para cada línea. Divide antes ambos textos con /\r?\n/ y aplica la misma clave recortada o en minúsculas a ambos lados.

¿Por qué sobreviven a la deduplicación dos líneas que parecen idénticas?

Normalmente contienen puntos de código Unicode distintos. Una letra acentuada como é puede almacenarse como un único carácter precompuesto o como una e simple seguida de un acento combinable. Un Set compara las unidades de código de forma exacta, así que trata ambas líneas como distintas. Añade line.normalize('NFC') a la función de clave para que coincidan. trim no elimina los espacios de ancho cero (U+200B), así que debes quitarlos por separado.

¿Cómo elimino líneas duplicadas de un archivo grande en Node.js sin leer el archivo completo en una cadena?

Procesa el archivo como stream con el módulo node:readline y comprueba cada línea contra un Set. Crea la interfaz con createInterface({ input: createReadStream(path), crlfDelay: Infinity }), recórrela con for await y escribe una línea solo si el Set aún no la contiene. La opción crlfDelay: Infinity hace que readline trate cada CRLF como un único salto de línea. Al igual que con awk, la memoria sigue creciendo con el número de líneas únicas.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.