12k
All articles

如何在 JavaScript 中删除重复的文本行

使用 JavaScript 的 Set 删除重复行,并处理大小写、空格、空行、换行符,以及保留首次或末次出现的内容。

OpenReplay Team
OpenReplay Team
如何在 JavaScript 中删除重复的文本行

要在 JavaScript 中删除重复行,可以按 /\r?\n/ 拆分文本,将各行传入 Set,然后重新拼接:[...new Set(text.split(/\r?\n/))].join('\n')。

以下场景都会用到它:批量导入前清理邮箱或 ID 列表、整理日志文件、合并两份 CSV 导出文件,或去除词表中的重复项。这行代码可以处理完全相同的重复行。如果只需要结果,可以使用 删除重复行工具 直接在浏览器中完成。对于仅在大小写、空白字符、换行符或空行上有差异的近似重复行,本文提供了一个可直接复制使用的函数,并支持“保留首次出现”和“保留最后出现”两种模式。

核心要点

  • [...new Set(lines)] 可删除完全相同的重复行,并让每行保留在首次出现的位置,因为 Set 按插入顺序迭代。
  • Set 对字符串做精确比较,因此 Apple、apple 和 apple (带尾随空格)会被视为三个不同的行。
  • 基于规范化后的键(去除首尾空白,可选转为小写)去重,但输出原始行,这样可以保留原有的大小写和缩进。
  • 对 Map 中已存在的键调用 set() 会替换其值,但键的原始位置不变。先调用 delete() 才能将该键移到末尾。
  • sort -u 会对输出排序,因此会丢失原始顺序;awk '!seen[$0]++' 则会保持输入顺序。

在 JavaScript 中删除重复行的单行写法

在 JavaScript 中删除重复行,最简洁的方法是在 split 和 join 之间加一个 Set:

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

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

这种写法之所以有效,是因为 Set 按插入顺序迭代其中的值。每一行都保留在首次出现的位置,后续的副本会被丢弃。ES2015 规范 定义了这一顺序:add 会将每个新值追加到集合内部列表的末尾。对于干净的、由程序生成的输入,这就足够了。关于 Set API 的其余内容,请参阅 JavaScript Set 方法 指南。

简单的 Set 写法会在哪些地方出错?

Set 对字符串做精确比较(使用 SameValueZero 算法),因此哪怕只差一个字符,两行也会被视为不同。下面每个输入中,都会保留一个你很可能想删除的行:

  • 大小写: 'Apple\napple' 两行都会保留。
  • 尾随空白: 'apple \napple' 两行都会保留,因为其中一行以空格结尾。
  • CRLF 换行符: 'apple\r\napple'.split('\n') 得到的是 ['apple\r', 'apple']。仅按 '\n' 拆分时,除了末尾没有换行符的最后一行外,每一行末尾都会残留一个 \r,因此最后一行永远无法与前面的副本匹配。在 split 中使用正则分隔符 /\r?\n/ 即可解决这个问题。如果输入中可能包含经典 Mac OS 换行符(单独的 \r),请使用 /\r\n|\r|\n/。
  • 空行: 'a\n\nb\n\nc' 会变成 'a\n\nb\nc'。第二个空行被视为第一个空行的重复项,导致段落 b 和 c 连在了一起。
  • 末尾换行符: 'a\n\nb\n' 会变成 'a\n\nb'。末尾换行符之后的空字符串与前面的空行匹配,因而被删除。

避免使用 lines.filter((l, i, a) => a.indexOf(l) === i)。indexOf 会线性搜索数组,对每一行都执行一次,会使整个处理过程的复杂度达到 O(n²)。规范要求 Set 的查找平均快于线性时间,因此使用 Set 可以避免这种重复扫描。若用普通对象记录已出现的行,也有其自身的问题:内容为 __proto__ 或 hasOwnProperty 的行可能会导致其出错。

基于规范化键去重,输出原始行

要将近似重复的行视为相同,可以用规范化后的键(去除首尾空白后的行,如需不区分大小写匹配,再转为小写)在 Set 中进行比较,但在输出中加入原始行。

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'

removeDuplicateLines 各选项的行为如下:

  • trim 使用 String.prototype.trim,它还会去除不换行空格和开头的 BOM。输出会保留原始缩进。
  • caseSensitive: false 使用 toLowerCase,在任何区域设置下结果都相同。
  • blankLines: 'keep' 让空行不参与去重,因此空行保持原位。'collapse' 将每组连续空行合并为一个;它检查的是最后一个输出行,因此即使两个空行之间有一个被丢弃的重复行,它们仍会被合并。'remove' 删除所有空行。
  • 该函数会保留输入末尾的换行符。只要输入中出现任何 \r\n,输出的每一行都会使用 \r\n。对于混用换行符的文件,这是一种简化处理。

用从键映射到行的 Map 也能实现同样的效果:if (!map.has(key)) map.set(key, line)。关于各种数据结构的适用场景,请参阅 Map、Set 与 Object 的对比。Unicode 规范化、智能引号及其他粘贴带来的杂质应在单独的步骤中处理,详见 清理用户粘贴到应用中的文本。

保留首次出现与保留最后出现

删除重复行时,保留首次出现和保留最后出现会得到不同的输出。而保留最后出现又有两种在顺序上不同的变体。以不区分大小写匹配的输入 ['apple', 'banana', 'Apple', 'cherry'] 为例:

策略结果
保留首次出现(键的 Set)apple, banana, cherry
保留最后出现,仅覆盖(map.set)Apple, banana, cherry
保留最后出现,先删除再设置banana, 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()];
}

Map 按键首次添加的顺序保存键,对已存在的键调用 set() 只会更改其值,而不会移动键的位置。因此,仅做覆盖的处理会返回每行的最后一个版本,但位置仍是该行首次出现的位置。若要让每行位于其最后出现的位置,应在 set(key, line) 之前调用 delete(key),使该键重新添加到末尾。

在终端中删除大文件中的重复行

对于大到无法粘贴的文件,可以在 shell 中使用 sort -u 或 awk 删除重复行。sort -u 通过排序来去重,因此会丢失原始顺序。此外,它使用当前区域设置的排序规则比较各行,在非 C 区域设置下可能会合并字节并不完全相同的行,因此如需精确匹配,请使用 LC_ALL=C sort -u。

awk '!seen[$0]++' 会在每行首次出现时将其输出并保持输入顺序,但它会把每个唯一行都存储在内存中的 关联数组 里。awk '!seen[tolower($0)]++' 可忽略大小写。CRLF 文件中的 \r 会被带入这两个工具,因此请先用 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

总结

对于完全相同的重复行,使用 new Set 即可。当输入来自用户或多个来源时,应基于规范化键去重并输出原始行。要有意识地选择空行处理策略以及“保留首次出现”或“保留最后出现”的规则,而不是直接接受默认行为。复制 removeDuplicateLines,根据你的数据设置选项;如果你想对相关的行进行分组而不是删除,可以使用 Object.groupBy。

常见问题

如何在 JavaScript 中只保留恰好出现一次的行?

先统计出现次数,再进行过滤。遍历一次,构建一个从行映射到次数的 Map,然后用 lines.filter((line) => counts.get(line) === 1) 保留次数为 1 的行。这与去重不同:去重会为每个重复行保留一份副本,而这里任何出现两次及以上的行都会被完全删除。如果近似重复的行应视为同一行,请基于规范化键进行计数。

如何从一个列表中删除已在另一个列表中出现的行?

将第二个列表加载到 Set 中,然后据此过滤第一个列表:const exclude = new Set(listB); listA.filter((line) => !exclude.has(line))。每次检查都是一次 Set 查找,从而避免了对每一行都在数组上调用 includes 或 indexOf 所带来的二次方开销。请先按 /\r?\n/ 拆分两段文本,并对两侧应用相同的 trim 或小写转换键。

为什么两行看起来完全相同,去重后却都保留了下来?

它们通常包含不同的 Unicode 码点。像 é 这样带重音的字母,既可以存储为一个预组合字符,也可以存储为普通字母 e 加一个组合重音符。Set 会精确比较码元,因此会将这两行视为不同。在键函数中加入 line.normalize('NFC') 即可让它们匹配。trim 不会删除零宽空格(U+200B),需要单独去除。

在 Node.js 中,如何删除大文件中的重复行,而不必将整个文件读入一个字符串?

使用 node:readline 模块以流的方式读取文件,并用 Set 检查每一行。通过 createInterface({ input: createReadStream(path), crlfDelay: Infinity }) 创建接口,用 for await 遍历,仅当 Set 中尚不包含某行时才将其写出。crlfDelay: Infinity 选项会让 readline 将每个 CRLF 视为单个换行符。与 awk 一样,内存占用仍会随唯一行的数量增长。

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.