资讯动态

数组清洗:安全过滤含images节点及其前一项的两种方法

发布时间:2026/10/19 13:34:58 来源:尧图企业网站定制
上个月在处理一批文章列表数据时遇到一个需求一句话就能说完把所有带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]是undefinedhasImages(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、BC 的前一项最终删除集合是{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 掉再手动把索引往前跳一格跳过已经被删除的前一项。看起来没什么问题直到测试跑出下面这个 caseconst 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以及它的前一项AC含images应该删掉C以及它的前一项B。所以A一定是要被删的。但流式 splice 给出的结果里A赫然还在。4.2 漏删的根因分析删除原因被“前一项删除”掩盖把错误代码的执行过程一步步拆开看原始数组为[A, B(有图片), C(有图片), D]索引 0 到 3。倒序遍历到i 3D不含图片跳过。倒序遍历到i 2C含图片。执行splice(2, 1)删除C数组变为[A, B, D]接着执行splice(1, 1)删除原来的B数组变为[A, D]。随后i--变成 1。下一轮循环i 1此时位置 1 上的元素是D跳过再到i 0A不含图片跳过。最终结果是[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的对象#2img表示含images的对象输入数组期望输出说明[][]空数组[#1][#1]单元素不含图片[#1img][]单元素含图片[#1, #2img][]尾元素含图片删除它和前一项[#1, #2img, #3][#3]删除含图片项及其前一项[#1, #2img, #3img, #4][#4]连续两个含图片项前两项都删[#1img, #2img, #3][#3]首元素就含图片且连续命中[#1, #2, #3img][#1]删除尾元素和前一项[#1, #2img, #3, #4img, #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 filterOutImagesAndPrevT( 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 标记法基本不会再被边界情况绊倒。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑