要在 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 一样,内存占用仍会随唯一行的数量增长。
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