1. 需求拆解与边界分析
先说说这个需求本身。一句话版本是:数组里只要某个数据项带 images 字段,那么它和它紧挨着的前一项都要被过滤掉。很多人第一反应都是“filter 一下不就完了”,但真写起来才会发现,真正难的不是过滤动作,而是对数组边界的理解。什么时候前一项不存在?连续多个带 images 的项会不会导致连锁删除?这些问题不提前想清楚,测试数据一压上来代码就会翻车。
1.1 这个需求在什么场景下会出现
我最早遇到这个需求是在做一个内容流展示页。后端返回的每一条内容可能带图片,也可能不带,业务规则是带图片的条目必须和它上一条普通条目成对出现,用来展示“文字描述 + 图片素材”的聚合卡片。后来上游数据混入了一些脏数据,这些脏数据恰好都带 images 字段,业务方的要求很明确:带图条目不能再出现在列表里,它前面那条文字描述也必须一起消失,否则页面上会出现一张没有说明的图片卡,或者一条指向无效内容的文字卡。
这类“成对剔除”的需求在数据处理里其实很常见。比如做日志采集时,某些带图片资源地址的记录可能是爬虫扫描或异常探测,需要把这条记录连同它前面的上下文请求一起丢弃,避免人为打断用户访问序列。再比如处理 CSV 导入时,某些行带有图片标记,按照规则需要连同上一个数据行一起跳过。虽然业务场景八竿子打不着,但落到代码上抽象出来的问题完全一致:数组里满足某个条件的项,连同它前一项,一起过滤。
1.2 动手前先盘一遍边界条件
这个需求最大的坑在边界。我整理了下,至少要考虑下面几类情况:
- 数组本身是空的
- 第一项就带 images,它没有前一项
- 带 images 的项连续出现
- 最后一项带 images
- 全部项都带 images
第一项带 images 时,要剔除的只有它自己,代码里如果直接写i - 1就会得到一个负数下标。好在数组的 splice 遇到负数下标并不会立刻报错,而是会从数组末尾往前数,这就导致完全不可预期的结果。所以必须显式判断i > 0。
连续带 images 的情况最容易漏删。比如数组下标 1 和 2 都带 images,那要删的是:下标 1 这个项(自身带)、下标 0 这个项(下标 1 的前一项)、下标 2 这个项(自身带)。而下标 1 同时是下标 2 的前一项,已经在删除清单里,不能因为重复处理而漏掉下标 0。最后一项带 images 时的处理比较直白,它只需要删自己和前一项,不涉及后一项。
把这些边界罗列清楚之后,实现方案就有底了。我一直觉得这类数组操作题,最值钱的部分不是主逻辑,而是 edge case 的处理。
2. 三种实现方案对比
写代码最忌讳一上来就动手。我先在脑子里过一遍可行方案,列出了三种写法,挨个分析它们的优缺点之后再选型。
2.1 标记索引法(最推荐)
先给结论:如果你只记住一种方案,记这个就够了。
思路分两步。第一遍遍历数组,把所有带 images 的项的下标,以及它前一项的下标,全部收集到一个 Set 里。第二遍用 filter 过滤,只要下标在 Set 里的项就丢掉。
function filterImages(arr) { const removeIndexes = new Set(); for (let i = 0; i < arr.length; i++) { if (arr[i].images) { removeIndexes.add(i); if (i > 0) { removeIndexes.add(i - 1); } } } return arr.filter((item, index) => !removeIndexes.has(index)); }这段代码为什么稳?因为“哪些项要删”在第一遍遍历时就已经完全确定下来,第二遍只是执行剔除动作,整个过程中数组索引没有发生任何变化。带 images 的项即使连续出现,Set 也会自动去重,不会重复删。第一项没有前一项,if (i > 0)天然防住越界。
性能方面也不用担心。Set 的 add 和 has 都是近似 O(1) 的操作,整体时间复杂度是 O(n),空间上只多了一个下标集合。就算数组有几十万条数据,跑起来也很快。
2.2 反向遍历剔除法(看着巧妙但坑不少)
我第一版其实是用反向遍历加 splice 写的,想着从后往前删,删除后面的元素不会影响前面元素的索引,应该很稳。原代码长这样,注意这段代码是有问题的。
function filterImagesInPlace(arr) { const result = [...arr]; for (let i = result.length - 1; i >= 0; i--) { if (result[i].images) { result.splice(i, 1); if (i > 0) { result.splice(i - 1, 1); i--; } } } return result; }孤立地看这个函数似乎挺合理,但一上测试就露馅。我拿连续带 images 的数据跑了一下,数组记为 [A, B, C, D],其中 B 和 C 都带 images。从末尾往前遍历:
- i=3,D 不带 images,跳过
- i=2,C 带 images,删除下标 2 的 C 和下标 1 的 B,数组变成 [A, D],i 从 2 变成 1,再减变成 0
- i=0,A 不带 images,跳过
按正确逻辑,B 带 images,A 作为 B 的前一项应该被删;C 带 images,B 作为 C 的前一项也应该被删,最终应该只剩 D。但上面这段代码却把 A 留下来了。
原因在于我删除 C 的时候顺手把 B 也删了,等循环推进到 B 原本所在的位置时,B 已经不存在,于是“B 带 images,所以 A 也要删”这条规则永远不会被触发。这就是典型的“一边遍历一边删”带来的连锁反应。
所以这类“按索引关系剔除”的需求,优先用标记法收集下标,再统一执行过滤,才是最稳妥的。不是说反向遍历绝对写不对,而是你对索引变化和连锁删除没有十足把握时,别拿业务数据去赌。
2.3 filter + 前项探测法(代码短但会漏删)
还有一种比较有迷惑性的写法,直接在当前项的 filter 回调里检查自己和前一项:
const result = arr.filter((item, index, array) => { const prev = array[index - 1]; if (item.images || (prev && prev.images)) { return false; } return true; });这个写法看起来非常优雅,甚至很多人一眼看不出问题。问题在于 filter 回调是按索引从前往后执行的,当前项只能看到前一项,看不到后一项。如果当前项本身不带 images,但它的后一项带 images,按规则当前项应该被删(因为它是“带 images 项的前一项”),可 filter 判断当前项时并不知道后一项的情况,只能保留它。
继续用 [A, B, C, D],B 和 C 带 images 的例子跑一遍:
- A:自身不带,前一项不存在,保留
- B:自身带 images,删除
- C:自身带 images,删除
- D:自身不带,前一项 C 带 images,删除
结果只删了 B、C、D,A 被错误地留下了。filter 是纯粹的“单向向前看”结构,它做不了“根据后面项的情况决定当前项去留”这种操作,除非你提前做一次下标标记。
所以我后来总结了一条经验:凡是要根据相邻项关系做数组过滤,别试图在 filter 回调里靠前后探测取巧,老老实实分两步走,先标记再过滤,代码长一点但正确性有保障。
3. 核心细节与判断逻辑
方案选定之后,还有一个问题不能绕开:到底怎么判断“包含 images”?这个看似基础的问题,在真实数据里反而最容易踩坑。
3.1 "包含 images"到底怎么判断
实际项目中 images 字段长什么样完全取决于后端接口。我归纳了一下,常见的取值情况有下面这些:
| 数据情况 | 典型值 | 是否算“包含 images” |
|---|---|---|
| 字段不存在 | undefined | 否 |
| 字段存在但为 null | null | 视需求,通常算无 |
| 空数组 | [] | 通常算无图 |
| 非空数组 | ['http://img.xxx.com/a.png'] | 是 |
| 空字符串 | '' | 通常算无 |
| 非空字符串 | 'http://img.xxx.com/a.png' | 是 |
如果只在代码里写if (item.images)做真值判断,null、空数组、空字符串这些值会被统一当成“没有 images”。但有些后端接口对无图资源会返回空字符串,有些返回 null,还有些干脆不返回这个字段,直接真值判断很容易误判。
我一般会把判断逻辑抽成一个小函数单独维护:
function hasImages(item) { return Array.isArray(item.images) ? item.images.length > 0 : typeof item.images === 'string' ? item.images.trim() !== '' : false; }这样“什么是带 images 的项”就集中在一个函数里。如果后续需求改成“images 数组长度大于 2 才算命中”或者“只要这个字段存在就算”,只需要改这一处,过滤函数主体完全不用动。
3.2 连续命中时的去重细节
连续命中时,如果不用 Set 而用一个普通数组来收集待删除下标,你可能会写出类似这样的代码:
const removeIndexes = []; // 伪代码示意 if (removeIndexes.indexOf(i - 1) === -1) { removeIndexes.push(i - 1); }倒也不是不能跑,但 indexOf 是线性查找,如果数组很长,整体复杂度会退化成 O(n^2),这个代价在数据量上来之后非常明显。用 Set 的 add 和 has 都是 O(1),而且天然去重,所以从效率和简洁性两个角度看,Set 都是更好的选择。
另一个小细节是:如果某个带 images 的项恰好是数组第一项,i > 0的判断会阻止加入 -1 这个下标。如果它是数组第二项,那么下标 0 会被加入删除集合,这是完全正常的,说明它前面那条普通描述也要一起清掉。
3.3 不可变性与原数组保护
清洗函数最好设计成不修改原始数组。用标记法很容易做到:第一遍只是收集下标,第二遍 filter 返回一个新数组,原数组完全不受影响。如果你用 splice 方案,要么先const result = [...arr]拷贝一份,要么接受它修改原数组的事实。
有些同事喜欢原地处理以节省内存,但在 React 或 Vue 的渲染层,直接修改原始数据可能导致状态难以追踪,甚至造成视图异常。我个人的习惯是:默认返回新数组,特殊情况再单独做原地版本,函数命名上也区分一下,比如filterImages和filterImagesInPlace。
4. 常见问题与排查技巧
这部分是我真正踩过的坑,比方案本身更有参考价值。我把几个典型问题和排查思路整理如下。
4.1 连续 images 项导致漏删
现象很典型:数据里只有偶发一个 images 项时,函数跑得好好的;一旦出现连续两三个 images 项,结果就开始不对劲,有时候多留了一项,有时候删错了项。
排查方法很简单:先检查代码是不是“边遍历边 splice”。如果是,改成标记索引法:第一遍把所有应删下标收集到 Set,第二遍再统一过滤。连续命中时的连锁删除,只有预先收集下标才能真正覆盖到。
4.2 第一项带 images 时越界
新手很容易写出arr.splice(i - 1, 1)这种代码,而且不会立刻报错。数组的 splice 方法传负数时,会从数组末尾往前数,比如splice(-1, 1)实际删除的是最后一个元素,这在业务里会直接造成严重且隐蔽的错误。
排查方法也直接:看一眼所有i - 1之前有没有if (i > 0)的保护。没有的话,第一项带 images 时就必炸。
4.3 原数组被意外修改
有的实现会在过滤过程中直接操作参数数组,调用方拿到结果后发现原数据也变了,排查半天才发现是函数内部动了引用类型的数据。这种副作用非常讨厌,尤其当数组里的元素是对象时,即便用 filter 返回新数组,新数组里的对象仍然指向原对象的引用,修改对象属性照样会影响原数组。
安全做法是把“是否修改原始数据”当成函数设计的一等考量:纯函数优先,不要直接 splice 参数数组;对象如果还需要修改,就结合业务决定是否需要浅拷贝或深拷贝。
4.4 大数据量下的性能隐患
假设数组规模是十万条,标记法第一遍遍历全量收集下标,第二遍 filter 再遍历一次,总共两次遍历,时间复杂度是 O(n),空间上多了一个 Set,完全能接受。
但如果用filter(item => removeIndexes.includes(index))这种方式判断下标是否在待删除集合里,includes 每次都是 O(n),整体退化成 O(n^2),数据一多就会明显卡顿。判断是否待删,必须用Set.has()。这个差别在小数组上感知不出来,在大数组上能差出几个数量级。
5. 完整封装与通用化
基础功能写到这里已经没问题,但一个函数如果只能处理 images,复用性还是太差。我通常会再扩展一层,让它能处理更通用的“命中项及其前 N 项”过滤场景。
5.1 支持自定义判断条件的通用函数
把“命中判断”和“前向剔除数量”抽成参数,封装成下面的通用清洗函数:
function filterByRule(arr, isHit, count = 1) { const removeIndexes = new Set(); for (let i = 0; i < arr.length; i++) { if (isHit(arr[i])) { removeIndexes.add(i); for (let j = 1; j <= count; j++) { if (i - j >= 0) { removeIndexes.add(i - j); } } } } return arr.filter((item, index) => !removeIndexes.has(index)); }isHit 表示“当前项是否命中删除规则”,count 表示要连带删掉前面多少项。原来的需求可以写成:
const result = filterByRule(arr, item => hasImages(item), 1);如果哪天产品说“带图片的项以及它前两项都不要了”,只要把 count 改成 2。如果需求变成“删除带图片项及其后一项”,那就把内层循环里的i - j改成i + j,再处理一下上界判断即可。这个思路一旦通了,扩展起来很快。
5.2 测试用例清单
这种清洗逻辑不写测试的话,改起来太容易回归。我把典型用例整理成一张表格,并且给出了对应的输入输出预期,你可以直接拿去跑。
| 测试场景 | 输入数据 | 预期输出 |
|---|---|---|
| 空数组 | [] | [] |
| 没有任何带 images 的项 | [{ id: 1 }, { id: 2 }] | [{ id: 1 }, { id: 2 }] |
| 第一项带 images | [{ id: 1, images: ['a'] }, { id: 2 }] | [{ id: 2 }] |
| 中间项带 images | [{ id: 1 }, { id: 2 }, { id: 3, images: ['a'] }, { id: 4 }] | [{ id: 1 }, { id: 4 }] |
| 连续两个带 images 的项 | [{ id: 1 }, { id: 2, images: ['a'] }, { id: 3, images: ['b'] }, { id: 4 }] | [{ id: 4 }] |
| 最后一项带 images | [{ id: 1 }, { id: 2 }, { id: 3, images: ['a'] }] | [{ id: 1 }] |
| 所有项都带 images | [{ id: 1, images: ['a'] }, { id: 2, images: ['b'] }, { id: 3, images: ['c'] }] | [] |
这里再解释两个容易理解错的点。第一项带 images 时,因为它没有前一项,所以只删它自己。连续两个带 images 的项,除了删它们自身,第一条 images 项前面的普通项也会被连带删掉,所以最终结果往往比直觉上删得更多。
如果用 Jest 之类的测试框架,可以写成一串参数化用例,断言函数返回的数组长度和 id 顺序是否符合预期。加上这些用例之后,后续无论是改了判断逻辑还是调整了带删数量,心里都会踏实很多。
5.3 后续还能怎么扩展
实际上你还可以把前向剔除和后向剔除组合起来,封装成“命中后删除前 M 项和后 N 项”的通用工具。更进一步,如果要对数组中的元素做更复杂的上下文清洗,比如“删除命中项所在分组的所有项”,那就要从纯数组操作升级到分组逻辑,本质上已经超出这个函数本身的范围。
就这个需求来说,我的建议是不要在工具函数里塞太多业务规则,文件里只保留hasImages和filterByRule,各司其职。业务方下次提再奇怪的需求,你只需要改调用参数,而不是重写核心逻辑。
回到我开头说的那个需求,最后我选的就是标记索引法,并且在项目里补上了前面那一组测试用例。后来连续 images 项的脏数据混进来,函数依然稳定,没再出过漏删。要说个人体会,我最大的感受是:这种数据清洗函数看起来不起眼,但边界处理决定了它能不能上生产。把命中规则独立成函数、把待删下标先用 Set 收集、再统一过滤,这三步组合基本能应对绝大多数同类需求。下次你要是也遇到“删除某项及其前后项”的场景,不妨直接拿这个思路去套,应该能省下不少调试时间。