上个月在处理一批文章列表数据时遇到一个需求,一句话就能说完:把所有带images字段的节点过滤掉,同时把它前面紧挨着的那一项也一并过滤掉。听起来就是写个循环的小事,但我第一版代码交出去后,测试同事直接甩回一个 case——当数组中两个带images的节点连续出现时,前面本该被删除的普通节点没删干净。我盯着调试输出看了好一会儿,才意识到问题出在我边遍历边删除,导致某些节点作为“另一个节点的前一项”被删掉之后,它自己作为“含 images 节点”所关联的更前一项,永远没有机会被处理了。这篇文章把完整思路整理出来,包括正确解法、踩坑过程和一套边界测试,给正在做数组清洗、列表预处理、数据去重过滤的朋友参考。
1. 先看清需求:这里真正容易被忽略的三条潜规则
1.1 “包含 images”怎么判定,得先定标准
标题里的“包含 images”看着直白,但落到代码里至少有三种理解口径:
- 数据项是一个对象,只要
item.images这个字段存在,就算“包含”,哪怕它的值是null或空数组。 - 数据项是一个对象,
item.images必须是一个非空数组(或非空字符串),才算真的“有图片”。 - 数据项本身是一个字符串或对象字符串,需要判断
'images'是否作为子串出现。
我默认按第二种处理,因为在真实业务里,images: []通常表示这条数据没有图片,不应该被过滤。如果后端在下发数据时把images字段补成空数组,而你用“字段存在即命中”的口径,会误删一大片正常数据。
function hasImages(item) { if (item == null) return false; if (Array.isArray(item.images)) { return item.images.length > 0; } if (typeof item.images === 'string') { return item.images.length > 0; } return Boolean(item.images); }这个函数一定要做空值保护。因为后面判断“前一项”的时候,会访问arr[index + 1],如果当前项是最后一个元素,arr[index + 1]是undefined,hasImages(undefined)必须返回false,否则直接item.images会抛异常。
1.2 “前一项”指的是原数组里的紧邻前一项,不是删除后的前一项
这是整道题最核心的语义。给定数组[A, B, C],如果B含images,要删除的是B和A。这里的“前一项”是基于原始数组的顺序关系,而不是基于“删除B之后临时数组”的顺序关系。
举个例子:原数组是[A, B, C, D],索引关系是A(0)、B(1)、C(2)、D(3)。如果删除B和C,临时数组变成[A, D],此时D看起来排在了A后面,但D并不是B处理完之后需要被删除的“前一项”。所有删除决策都应该以最开始的数组索引为唯一依据,谁被删、谁的前一项被删,都必须在初始数组上定义。
这一点听起来简单,但如果你使用“边遍历边 splice”的写法,很容易在不知不觉中把“临时数组里的前一个”当成“原始数组里的前一个”,从而产生逻辑偏差。
1.3 连续命中时,一个项可能被多个原因同时删除
当两个含images的项相邻出现,比如[A, B(有图片), C(有图片), D]:
B自身含images,应删除。C自身含images,应删除。B是C的前一项,所以B又会因为“是C的前一项”被删除一次。A是B的前一项,所以A也会因为“是B的前一项”被删除。
最终正确结果应该是[D]。这里B同时有两个删除原因,但一个元素被删除一次和两次,结果没有区别,所以并不会出现“重复删除报错”的问题。真正的隐患在于:如果使用流式 splice 删除,删除B时可能只把它当作C的前一项处理掉了,而没有机会触发出“B自身也含images,所以还要删A”这条规则。这就是我开头提到的那次 bug 的根因。
2. 最优解法:用 filter 检查“自身和后一项”
2.1 核心判断条件怎么推出来
要理解这个解法,可以换个视角看“保留什么”,而不是“删除什么”。一个元素最终能被保留,必须同时满足两个条件:
- 它自己不含
images。 - 它不能是任何含
images元素的前一项。
第二个条件怎么判断?一个元素arr[i]是“某个含 images 元素的前一项”,只可能是它的后一项arr[i + 1]含images。因为“前一项”只对应索引i - 1,所以某元素要被删除,要么它是arr[i](含 images),要么它是arr[i - 1](其前一项含 images)。等价地,保留arr[i]需要arr[i]不含 images,且arr[i + 1]不含 images。
这就是我推荐的核心判断公式:
!hasImages(arr[i]) && !hasImages(arr[i + 1])2.2 完整代码与配套 hasImages 判断
把上面的公式套进filter回调即可:
function filterOutImagesAndPrev(arr) { return arr.filter((item, index) => { if (hasImages(item)) return false; if (index + 1 < arr.length && hasImages(arr[index + 1])) return false; return true; }); }由于hasImages内部已经对undefined做了保护,可以简化成:
function filterOutImagesAndPrev(arr) { return arr.filter( (item, index) => !hasImages(item) && !hasImages(arr[index + 1]) ); }这里的关键细节是:filter回调里的index是原数组的索引,即使在过滤过程中某些元素已经被判定为删除,index依然指向原数组位置。因此arr[index + 1]读取的一定是原始数据中的后一项,而不是“临时结果里的后一项”。这个特性让filter实现天然安全。
2.3 为什么 filter 回调里的 index 是原数组索引
很多人第一次接触filter会误以为回调里的index是“结果数组”的索引,其实不是。filter是遍历原始数组并筛选,每次回调都会传入当前元素在原始数组中的位置。这意味着你可以在回调里安全地访问arr[index - 1]和arr[index + 1],不需要提前复制数组。
这也是它比“边遍历边 splice”更适合这类需求的原因:splice 会实时修改数组,导致后续索引全部错位;而 filter 自始至终基于原始数组做决策,不会出现“索引越界”“跳过元素”这类问题。
2.4 复杂度与适用范围
时间复杂度是O(n):只遍历一次数组,每次判断是O(1)的hasImages调用。额外空间复杂度是O(n),因为filter会返回一个新数组。如果原始数组很大,这个新数组的内存开销是不可避免的;如果业务要求原地修改,则需要切换到标记法。
这个实现适用于绝大多数业务场景:数据清洗、列表预处理、渲染前过滤等。它不会修改原数组,符合函数式编程的不可变理念,也方便在其他逻辑中复用。
3. 按字面定义实现:Set 标记法最稳妥
3.1 先把要删的索引全部标记出来
如果你希望代码读起来和需求完全一一对应,可以用Set标记法:先遍历一遍,把所有需要删除的索引收集起来,再统一过滤。这样做虽然多了一次遍历,但逻辑最直观,几乎不可能出错。
function filterOutImagesAndPrevBySet(arr) { const removeIndexes = new Set(); for (let i = 0; i < arr.length; i++) { if (hasImages(arr[i])) { removeIndexes.add(i); if (i - 1 >= 0) { removeIndexes.add(i - 1); } } } return arr.filter((_, index) => !removeIndexes.has(index)); }这个实现和需求描述逐条对应:
hasImages(arr[i])命中“包含 images 的数据项”。removeIndexes.add(i - 1)命中“包含 images 的数据项的前一项”。- 最后统一过滤,保证即使一个元素同时满足两个条件,也只会被删除一次。
3.2 统一过滤,避免边删边遍历
Set标记法的核心优势是:把“标记阶段”和“删除阶段”完全分离。标记阶段基于原始数组建立删除集合,删除阶段不再依赖数组的实时状态,所以不会出现流式 splice 那种“删着删着索引对不上”的问题。
在处理连续命中场景时,Set标记法尤其稳健。回到[A, B(有图片), C(有图片), D]的例子:第一遍遍历会标记B、A、C、B(C 的前一项),最终删除集合是{0, 1, 2},过滤后只剩D。而流式 splice 在这个例子上会漏删A,后面专门有一章讲这个坑。
3.3 原地修改的需求怎么做
有时候面试或业务要求直接修改传入的数组,而不是返回新数组。这时可以先用Set标记要删除的索引,再从后往前遍历并 splice。
function filterOutImagesAndPrevInPlace(arr) { const removeIndexes = new Set(); for (let i = 0; i < arr.length; i++) { if (hasImages(arr[i])) { removeIndexes.add(i); if (i > 0) removeIndexes.add(i - 1); } } for (let i = arr.length - 1; i >= 0; i--) { if (removeIndexes.has(i)) { arr.splice(i, 1); } } return arr; }为什么要从后往前 splice?因为 splice 会改变数组长度,正序遍历时删除一个元素后,后面的元素会集体前移,导致你原计划要访问的下一个索引错位。从后往前删除时,被删除元素后面的部分已经处理过了,前面未处理元素的索引完全不受影响。这是原地修改数组时的基本原则。
4. 我在真实数据上踩过的坑:流式 splice 为什么删不干净
4.1 错误示范代码与复现
第一版我给测试同事的代码长这样:
function wrongFilter(arr) { for (let i = arr.length - 1; i >= 0; i--) { if (hasImages(arr[i])) { arr.splice(i, 1); if (i > 0) { arr.splice(i - 1, 1); } i--; } } return arr; }当时我的想法是:倒序遍历,每遇到一个含images的项,就把它和前一项一起 splice 掉,再手动把索引往前跳一格,跳过已经被删除的前一项。看起来没什么问题,直到测试跑出下面这个 case:
const data = [ { id: 'A' }, { id: 'B', images: ['1.jpg'] }, { id: 'C', images: ['2.jpg'] }, { id: 'D' } ]; const result = wrongFilter(data); // 期望:[{ id: 'D' }] // 实际:[{ id: 'A' }, { id: 'D' }]A被错误地保留了。按照需求,B含images,应该删掉B以及它的前一项A;C含images,应该删掉C以及它的前一项B。所以A一定是要被删的。但流式 splice 给出的结果里,A赫然还在。
4.2 漏删的根因分析:删除原因被“前一项删除”掩盖
把错误代码的执行过程一步步拆开看:
- 原始数组为
['A', 'B(有图片)', 'C(有图片)', 'D'],索引 0 到 3。 - 倒序遍历到
i = 3,D不含图片,跳过。 - 倒序遍历到
i = 2,C含图片。执行splice(2, 1)删除C,数组变为['A', 'B', 'D'];接着执行splice(1, 1)删除原来的B,数组变为['A', 'D']。随后i--变成 1。 - 下一轮循环
i = 1,此时位置 1 上的元素是D,跳过;再到i = 0,A不含图片,跳过。最终结果是['A', 'D']。
问题出在第二步:当C触发删除时,它的前一项恰好是B。于是B被当作“C的前一项”删除,整个过程里代码完全没有机会再次检查B本身是否含images。如果B不含images,这样的删除没有问题;但B含images,它作为“含 images 节点”时,还应该触发“删除它的前一项A”的规则。这条规则因为B已经被提前删掉而永远没有执行机会。
更本质地说,流式 splice 把“问题描述时的数组状态”和“执行过程中的数组状态”混为一谈。所有删除判断都应该基于最初的数组完成,而不是基于“已经删了一些元素之后的新数组”。当你依赖实时数据判断“前一项”时,某些元素的位置和存在性已经发生了不可逆的变化,逻辑就悄悄偏离了原始需求。
4.3 现场排查过程:打印每一步,别靠肉眼猜
当时定位这个问题,不是我一眼看出来的。我做了两件事:
第一,把输入数据换成带编号的对象,在每次 splice 前后打印完整数组:
const trace = (arr, step) => { console.log(step, arr.map((item, index) => `${index}:${item.id}`).join(' | ')); };第二,在纸上画一个“删除原因”表格。先列出所有原始索引,再逐个标注:这个索引是因为自身含图片被删,还是因为后一项含图片被删。结果发现A的删除原因写的是“因为B含图片”,但程序实际根本没有走到判断B含图片这一轮。
这个排查思路可以复制到其他类似问题上:当数组操作结果和预期不符时,不要急着改循环条件,先把你脑子里的逻辑写成表格或注释,然后对照代码一行行核对。大多数这类 bug 都出在“你心里想的是原数组,代码操作的却是临时数组”。
5. 测试用例与常见误读
5.1 覆盖全部边界的测试表
我整理了一份覆盖各种边界的测试用例表,用#1表示不含images的对象,#2+img表示含images的对象:
| 输入数组 | 期望输出 | 说明 |
|---|---|---|
[] | [] | 空数组 |
[#1] | [#1] | 单元素,不含图片 |
[#1+img] | [] | 单元素,含图片 |
[#1, #2+img] | [] | 尾元素含图片,删除它和前一项 |
[#1, #2+img, #3] | [#3] | 删除含图片项及其前一项 |
[#1, #2+img, #3+img, #4] | [#4] | 连续两个含图片项,前两项都删 |
[#1+img, #2+img, #3] | [#3] | 首元素就含图片,且连续命中 |
[#1, #2, #3+img] | [#1] | 删除尾元素和前一项 |
[#1, #2+img, #3, #4+img, #5] | [#3, #5] | 两组不连续的删除区域 |
[{id:1}, {id:2, images:[]}, {id:3}] | [{id:1}, {id:3}] | images为空数组不算包含 |
最后一行尤其重要。images: []是否算“包含”,取决于业务定义。我这里的hasImages实现把空数组视为“没有图片”,所以不触发删除。如果你的需求是“只要存在images字段就删除”,那hasImages应该简化为:
const hasImages = (item) => item != null && 'images' in item;5.2 三个最容易写歪的变体
第一个变体是hasImages写得太信任字段类型。比如item.images可能是null、undefined、空数组、非空数组、字符串,直接用Boolean(item.images)会在空数组时误判为 true(因为空数组是 truthy),在后续业务里很容易造成意外。稳妥的做法是显式判断非空。
第二个变体是使用filter之后,新数组和原数组索引混淆。有些写法会在 filter 中先定义const newArr = []再手动 push,然后根据newArr的索引去判断arr,这属于自己给自己挖坑。filter 回调里的index已经是原数组索引,不需要再造一层映射。
第三个变体是正序 splice 时因为没有跳过删除项,导致每次删除后都会漏掉本该检查的元素。所有 splice 场景都建议优先考虑倒序,或者用标记法先收集再统一删除。
5.3 扩展到其他语言和数据结构
这个过滤逻辑不只是 JavaScript 才有。Python 里可以借用列表推导式实现同样的效果:
def has_images(item): return bool(item.get('images')) if isinstance(item, dict) else False result = [ item for i, item in enumerate(arr) if not has_images(item) and not has_images(arr[i + 1] if i + 1 < len(arr) else None) ]SQL 中如果数据存在仓库表里,可以用窗口函数LAG判断“前一行”是否包含图片,再统一过滤:
WITH marked AS ( SELECT *, CASE WHEN has_images_flag = 1 THEN 1 WHEN LAG(has_images_flag) OVER (ORDER BY id) = 1 THEN 1 ELSE 0 END AS need_delete FROM ( SELECT *, CASE WHEN images IS NOT NULL AND array_length(images, 1) > 0 THEN 1 ELSE 0 END AS has_images_flag FROM article_nodes ) t ) SELECT * FROM marked WHERE need_delete = 0;这里的思路和Set标记法完全一致:先把所有需要删除的行标记出来,再统一过滤。无论什么语言,核心都是“标记 + 过滤”,而不是“边遍历边删除”。
TypeScript 泛型版本也很简单,只需给函数加上泛型约束:
function filterOutImagesAndPrev<T>( arr: T[], hasImages: (item: T) => boolean ): T[] { return arr.filter( (item, index) => !hasImages(item) && !hasImages(arr[index + 1]) ); }把hasImages作为参数传入,过滤逻辑与具体数据类型解耦。业务中可能从判断images字段变成判断attachments、files、media等字段,只需要替换判定函数,其余代码不用改。
我在实际项目里踩过几次坑之后,养成了一个习惯:遇到“删除数组中某类元素以及它相邻元素”这类需求,会先在心里回答三个问题——判定函数是什么、需要删除的索引范围是哪些、允许原地修改还是返回新数组。想清楚再动手写代码,宁可多写两行标记逻辑,也不要在 splice 里面玩花活。如果你也正在处理类似的数据清洗任务,直接套用 filter 后项检查或 Set 标记法,基本不会再被边界情况绊倒。