JavaScript List集合查询:数组、Map与Set实战优化

发布时间:2026/11/5 6:01:58
JavaScript List集合查询:数组、Map与Set实战优化

JavaScript 里其实并没有一个叫List的原生类型,但几乎每个从 Java 或者 C# 转过来的朋友,第一天问的都是同一句话:JS 里的 List 集合怎么查询?他们想干的事很具体——手里攥着一坨后端返回的列表数据,想按 id 找一条、按状态筛一批、按多个条件组合过滤、再排个序分个页,最好还能像list.stream().filter(...).collect(...)那样一行写完。这篇文章就围绕这件事展开,讲清楚 JS 里用什么容器承接“List 集合”的语义、各种查询方法到底怎么选、数据量上来之后该怎么建索引、以及我在几个中后台项目里被坑过的地方。内容偏实战,适合刚接手列表页、表格页、下拉框联动这类需求的前端新手,也适合写过几年但一直靠filter打天下、没认真想过性能边界的老手当个复盘。

1. 先把概念对齐:JS 里到底有没有 List

1.1 Java List 与 JS 数组的能力对照

很多教程会说“JS 的数组就是 List”,这话对一半。从数据结构角度看,JS 的Array确实承担了有序集合的角色,能增删、能按下标访问、能遍历;但从语义和 API 设计上看,它更像 Java 里ArrayList和一部分Stream能力的混合体。Java 的List接口提供的get(index)、add、remove、contains在 JS 里对应的是arr[i]、push、splice、includes,而 Java 里真正干查询活的stream().filter().sorted().collect(),在 JS 里对应的是一串数组高阶方法。理解这个映射关系,你在读别人的 JS 代码时就不会觉得“怎么到处都在手写循环”。

真正的差异在于类型约束。Java 的List<User>里放错类型编译器会拦你,JS 的数组里想塞什么塞什么,字符串、数字、null、undefined、另一个数组都行。这个自由度的代价是:所有类型安全都得你在运行时自己兜。我见过太多线上报错堆栈指向某个filter回调,点进去一看是item.status读取时item是undefined——因为上游某次接口返回了[null]。所以第一件事不是学怎么写查询,而是在查询入口处做一次数据清洗,把非对象、缺字段的脏数据挡在外面。

能力Java List / StreamJS 数组
按下标取值list.get(i)arr[i]
按条件筛选stream().filter()filter()
找第一条stream().findFirst()find()
判断存在list.contains(x)includes(x)/some()
按字段查找需要手写循环或用 Mapfind(item => item.id === id)
去重distinct()[...new Set(arr)]
排序sort(comparator)sort((a,b) => ...),原地改
分页手动subListslice(start, end)

这张表建议贴在脑子里。尤其是最后一行,Java 的subList返回的是视图,改原列表会影响子列表;JS 的slice返回的是新数组,但里面的元素还是同一批引用。这个差别在后面讲“改了筛选结果为什么原数据也变了”的时候会重点说。

1.2 为什么项目里常说“List 查询”,而不是“数组查询”

这个叫法不是技术问题,是协作习惯问题。后端接口返回的字段经常就叫list、dataList、records,接口文档里写“返回用户列表”,产品经理说“这个列表支持按名字搜索”,久而久之“List”在前端语境里就变成了“一个装着对象的数组”的代名词。你去看很多团队封装的请求方法,返回结构清一色是{ code, msg, data: { list: [], total: 0 } }。所以当有人问你“JS 里 List 集合怎么查询”,他真正想问的是:怎么在这个对象数组上做条件检索,并且做得干净、不卡、不出错。

还有一层原因来自状态管理。很多项目里,列表数据会存在一个list变量里,然后页面上有搜索框、状态下拉、日期区间、排序按钮,每改一个条件就重新算一次展示数据。这本质上是“在内存里做一次小型数据库查询”。如果每次都用filter从原始列表里从头筛,条件一多就会出现“筛选叠加导致结果为空”的 bug——因为用户以为是在上一次结果里继续筛,代码却是在全量里筛。这个坑后面单独讲。

我个人的习惯是,只要数据结构是“一批同构对象”,就明确区分三个变量:rawList存接口原始数据,query存当前查询条件,viewList存最终渲染的数据。三者职责分开,排查问题时一眼能看出是哪一层出了问题。这个习惯值不值得学,你可以先在自己手上最乱的那个列表页试一次。

2. 选型:数组、Set、Map 谁更适合做查询

2.1 三种容器的查询复杂度与适用边界

JS 里能干“集合查询”的容器就三个:Array、Set、Map。很多人只知道数组,其实另外两个在特定场景下能把代码量和耗时都砍掉一大截。先把复杂度摊开说清楚,你才知道什么时候该换。

Array的find、filter、includes本质都是线性扫描,时间复杂度 O(n)。数据量一千条以内,这个 O(n) 你基本感知不到,一次find可能就几十微秒。但当列表涨到几万条,而且你在一个循环里对每条数据都做一次find,复杂度立刻变成 O(n²),页面就会卡住。

Set的has在引擎实现里是基于哈希的,平均 O(1)。它只存值不存键,所以适合做“存在性判断”和“去重”,比如判断某个 id 是否在已选集合里、给一批标签去重。Set不擅长按字段查对象,因为它没有“键”的概念。

Map的get、has、set平均也是 O(1),而且键可以是任意类型。它天生就是给“按某个字段快速取对象”准备的。你把id -> user建成一张 Map,之后不管查多少次,都是常数时间。代价是建索引本身要遍历一次,O(n),以及数据变更时必须维护索引,否则会读到脏数据。

容器典型查询平均复杂度适合的数据量主要代价
Arrayfilter/findO(n)千级以内量大后重复扫描慢
Sethas/ 去重O(1)任意只能存值,不能按字段查
Mapget(id)O(1)万级以上需维护索引一致性

注意:这里的“O(1)”是平均情况。如果键是对象引用,Map 用的是引用相等,两个内容相同但引用不同的对象会被当成两个键。这个问题在 4.1 节会展开。

2.2 一个真实场景下的选型推演

假设你在做一个权限配置页,左边是全部菜单树,右边是已勾选的菜单 id 列表,用户点一下勾选框,你需要判断“这个 id 是否已选”,并实时更新右侧列表。数据量:菜单大概八百个,勾选操作会很频繁。

如果右侧用数组存checkedIds,每次判断用checkedIds.includes(id),八百个菜单渲染时每个都要判断一次,那就是八百次线性扫描。虽然总量不大,但配合 React 的重复渲染,体感上会有轻微延迟。换成Set,checkedSet.has(id)一次哈希查找,渲染八百个也不会有压力。这个替换的成本极低,只需要在setState时把数组转成 Set,取值时再转回数组传给需要的组件。

再假设你在做一个订单列表,每条订单有orderNo,列表上方有个搜索框支持按订单号精确查单,同时还有一个“查看详情”按钮,点击后需要根据订单号从列表里取出完整对象。数据量五千条,搜索是用户主动触发的,不是每次渲染都跑。这种情况用find完全够,没必要为了省那几毫秒去建 Map,因为建索引的代码维护成本比这点性能收益更高。

选型的关键不是“哪个最快”,而是“查询发生在什么频率上”。高频渲染路径上的查询,值得建索引;低频、用户主动触发的查询,数组方法更省心。这个判断标准我在好几个项目里反复验证过,基本没出过错。

2.3 别急着上 Map,先看你的数据形态

有一种情况我特别想提醒:数据本身不是“扁平一批对象”,而是树形、嵌套、或者一个对象里带多个子数组。这时候上来就建 Map,往往建错了维度。

比如组织架构树,节点有id和children,你想按 id 查某个部门。正确的做法是先做一次深度优先遍历,把所有节点拍平成一个数组,再基于这个扁平数组建id -> node的 Map。如果你直接在树结构上写递归查找,代码会很难读,而且每次查都要重走一遍树。拍平这一步还能顺带解决“查某个节点的所有祖先”这类需求,因为拍平的时候可以记录parentId。

还有一种情况是数据里存在一对多关系,比如一个用户有多个角色。你想“按角色查用户”,那索引的键就不该是用户 id,而应该是角色 id,值是一个用户数组。这个时候 Map 的值类型从对象变成了数组,查询语义也跟着变。所以在建任何索引之前,先问自己一句:我最常用的查询入口是哪个字段,它的基数(不同值的个数)大概是多少。基数接近数据总量的字段(比如 id、订单号)适合做键;基数很低的字段(比如状态,只有三五个值)做键意义不大,反而会让每个键下面挂一大串数据。

3. 绝对主力:数组的查询方法族与使用边界

3.1 filter、find、findIndex、some、every 的返回值差异

这五个方法名字长得像,用错的比例高得惊人。我 review 代码时最常见的一种错,是把find的返回值当布尔用:if (list.find(x => x.id === id)),当找到的那个对象恰好是0或者空字符串时判断就翻车。虽然对象数组里不太会出现这种情况,但这个习惯很危险。

把它们的返回值类型钉死记住:

  • filter返回新数组,找不到就是空数组[],永远是真值,所以if (list.filter(...))永远是true,这是经典 bug。
  • find返回第一个匹配的元素,找不到返回undefined。判空要用!== undefined或者可选链。
  • findIndex返回下标,找不到返回-1。判断要用> -1,不能直接用if (idx),因为下标 0 是假值。
  • some返回布尔,有一个满足就true,会短路,找到就停。
  • every返回布尔,全部满足才true,遇到第一个不满足就停。空数组调用every返回true,这个反直觉的点坑过不少人。

选择逻辑很简单:要一批数据用filter,要一条数据用find,要位置用findIndex,只关心有没有用some,关心是不是全部用every。别用filter(...).length > 0去替代some,前者会把整个数组扫完并分配一个新数组,纯属浪费。

const users = [ { id: 1, name: 'A', age: 20 }, { id: 2, name: 'B', age: 30 }, { id: 3, name: 'C', age: 30 } ]; // 要一批 const adults = users.filter(u => u.age >= 30); // [B, C] // 要一条 const target = users.find(u => u.id === 2); // { id: 2, ... } // 要位置 const idx = users.findIndex(u => u.id === 3); // 2 // 只关心存在 const hasAdult = users.some(u => u.age >= 30); // true // 关心全部 const allAdult = users.every(u => u.age >= 18); // true

3.2 条件组合:多字段、区间、模糊匹配怎么写

真实需求很少只有一个条件。搜索区域通常有:关键字(模糊匹配名称)、状态(枚举等值)、时间区间(起止范围)、是否置顶(布尔)。新手容易写成四个独立的if嵌套,代码很快就没法看。我的写法是把条件收敛成一个回调函数,每个条件都是一次&&,用“条件不成立就返回 true”的方式处理空值。

function buildMatcher(query) { const { keyword, status, startTime, endTime } = query; return item => { if (keyword && !item.name.includes(keyword)) return false; if (status !== '' && status != null && item.status !== status) return false; if (startTime && item.createTime < startTime) return false; if (endTime && item.createTime > endTime) return false; return true; }; } const viewList = rawList.filter(buildMatcher(query));

这种写法的好处是提前返回,条件越多性能越好(大部分不匹配的数据在前几个判断就被拦掉了),而且新增一个条件只需要加一行,不影响其他逻辑。模糊匹配那块顺手提一句:includes区分大小写,用户搜“abc”匹配不到“ABC”会被当成 bug 报上来,稳妥的做法是两边都toLowerCase(),或者用localeCompare加sensitivity参数。中文场景还要注意不要对全角半角做过度处理,容易把本来能搜到的结果搞丢。

提示:status !== ''和status != null这两个判断看着啰嗦,但很必要。下拉框没选时值可能是''、null、undefined三种之一,只判断其中一种会漏。

3.3 链式查询的性能陷阱

filter后面接map再接filter,这种链式写法很优雅,但每接一个方法就多一次完整遍历,而且每次都分配一个新数组。五千条数据做四步链式,就是两万次回调调用加四次数组分配。在普通的点击查询场景里这不算什么,但如果这段代码在滚动、拖拽、输入框实时搜索的回调里,就会明显掉帧。

我遇到过一个典型例子:一个下拉搜索框,用户每输入一个字符就对三万条数据做filter加map加sort。用户打字快一点,主线程就卡住,输入框出现明显的延迟感。优化手段不是把filter换成手写for循环(收益有限),而是两件事:一是加防抖,把高频输入合并成低频执行;二是把map提前到初始化阶段,让后续查询只在已经清洗好的数据上跑。

还有一种隐蔽的浪费是在filter回调里做计算。比如list.filter(item => dayjs(item.time).isAfter(start)),每条数据都构造一次时间对象。如果列表能到万级,这个构造开销相当可观。解法是把start提前转成时间戳,回调里只做数值比较。这类优化的思路是“把循环不变量提到循环外”,写代码时多看一眼回调内部有没有重复计算。

3.4 排序与分页的顺序问题

sort是原地修改原数组的方法,这一点必须记牢。很多人在viewList.sort(...)之后发现rawList的顺序也变了,然后困惑为什么“取消排序”之后列表回不到初始状态。正确的写法是先slice()复制一份再排,或者用扩展运算符[...list].sort(...)。复制是浅拷贝,元素还是同一批引用,但对于排序来说足够了,因为我们只改顺序不改内容。

排序还有一个必踩的坑:默认的sort()会把元素转成字符串按字典序排。[1, 5, 10, 20].sort()得到的是[1, 10, 20, 5]。数字排序必须传比较函数(a, b) => a - b。字符串排序虽然默认行为能用,但中文场景下字典序不等于拼音序,要拼音序得用localeCompare('zh-CN')。

分页和排序的先后顺序也常被搞反。正确的逻辑是:先过滤,再排序,最后分页。如果先分页再排序,你排的只是当前页的那几条,翻页后顺序全乱。这个错误在“前端分页”的实现里特别容易出现,因为代码写起来很顺:filter完直接slice当前页,忘了sort。我建议把这三步封装成一个函数,顺序写死在里面,调用方不用操心。

function paginate(list, { page = 1, pageSize = 10 } = {}) { const start = (page - 1) * pageSize; return list.slice(start, start + pageSize); } // 使用顺序:filter -> sort -> paginate const viewList = paginate( rawList.filter(matcher).sort((a, b) => a.createTime - b.createTime), { page: 1, pageSize: 20 } );

4. 索引化查询:用 Map 把 O(n) 干成 O(1)

4.1 建索引的标准写法与主键选择

当列表数据超过万级,或者查询发生在高频路径上,就该考虑建索引了。标准写法是遍历一次原始数组,把主键作为 Map 的键,整个对象作为值。这个操作只做一次,之后所有按 id 的查询都是常数时间。

const list = [/* 大量对象 */]; const idIndex = new Map(list.map(item => [item.id, item])); const user = idIndex.get(10086); // O(1)

主键的选择有三个要求:唯一、稳定、类型一致。唯一性不用多说,重复的键会互相覆盖,Map不会报错,只会静默丢失数据,这个坑极其难查。稳定性指这个字段在对象生命周期内不会变,如果主键会变,索引就会失效。类型一致是JS 特有的坑:接口有时候返回id: 1(数字),有时候返回id: "1"(字符串),Map用严格相等比较键,数字 1 和字符串 "1" 是两个不同的键。如果同一批数据里两种类型混着来,你的索引会一半命中一半落空。

我的习惯是在建索引前统一做一次类型转换:String(item.id)。虽然多了一次转换,但换来的是查询侧不用操心类型,调用方传什么都能命中。这个决定在小项目里可能显得多余,但在多人协作、接口不稳定的项目里能省掉大量排查时间。

注意:Map的键如果是对象,用的是引用相等。idIndex.get({ id: 1 })永远返回undefined,因为这是一个新对象。这一点和 Java 里重写equals的HashMap完全不同,从 Java 过来的朋友最容易在这里翻车。

4.2 复合主键怎么拼 key

有些场景单字段不够唯一,需要“类型 + 编码”这种组合才能定位一条记录。这时候要把多个字段拼成一个字符串键。拼接看着简单,实际上有个隐蔽的坑:分隔符选择不当会导致键冲突。比如type + code直接相加,("ab", "c")和("a", "bc")都会得到"abc",两条完全不同的记录撞在一起。稳妥的做法是加一个不会出现在业务值里的分隔符,比如\u0001或者::。

const key = `${type}::${code}`; const index = new Map(list.map(item => [`${item.type}::${item.code}`, item]));

比拼接更稳的方案是用嵌套 Map:外层键是type,内层键是code。这样不存在拼接冲突,查询时多一次get而已。缺点是插入时要判断内层 Map 是否存在,代码稍微多几行。我一般在字段值可能包含任意字符(比如用户输入的编码、带分隔符的路径)时用嵌套结构,字段值格式受控时用拼接。

4.3 索引的失效与重建

建索引最大的代价不是建,是维护。只要原始列表发生增删改,索引就可能过期。常见的新增操作,如果只是往列表末尾push一条,顺手index.set(item.id, item)就行;删除操作要同时index.delete(id);修改操作如果改的是非主键字段,index.get(id)拿到的对象引用本身也变了,索引依然有效(因为存的是引用);但如果改的是主键字段,就必须先删旧键再设新键,否则会出现“按新 id 查不到、按旧 id 查到的是改过的对象”这种诡异状态。

我的经验是:不要试图手动维护索引,除非你有明确的性能瓶颈。更省心的做法是把“原始列表”和“索引”放进同一个状态里,用一个函数统一更新,任何写操作都走这个函数。在 React 里可以用useMemo基于rawList派生索引,rawList一变索引自动重建。这样虽然每次变更都重扫一遍,但换来了绝对的一致性,而且大多数项目的列表变更频率远低于查询频率,重建开销可以接受。

4.4 用 Set 做去重与存在性判断

Set在列表查询里的两大用途,一是去重,二是批量存在性判断。去重最常用的写法[...new Set(arr)]只对基本类型有效,对象数组去重不行,因为每个对象引用都不同。对象数组要去重要么按某个字段去重(配合 Map),要么序列化成字符串再比较(性能差,不推荐大数据量)。

// 基本类型去重 const uniqueIds = [...new Set(list.map(item => item.id))]; // 按字段对对象去重,保留第一次出现的 const seen = new Set(); const deduped = list.filter(item => { if (seen.has(item.id)) return false; seen.add(item.id); return true; });

第二种写法很实用,尤其是在合并多个接口返回的数据时。它只遍历一次,用Set记录已出现的 id,遇到重复就跳过,时间复杂度 O(n)。比“对每条数据再findIndex查一遍”的 O(n²) 写法快得多。

批量存在性判断的典型场景是“给列表的每一行打上是否已选的标记”。如果已选集合用数组存,每行判断是 O(n),总复杂度 O(n²);换成Set,每行判断 O(1),总复杂度 O(n)。这个替换在千级数据上就能看出差别,万级数据上差别非常明显。

5. 复杂查询的落地封装:写一个够用的 ListQuery

5.1 接口设计:where、orderBy、page、select

条件一多,代码里到处是filter和sort,可读性会急剧下降。这时候值得做一层轻封装,把常用操作串起来。设计目标很简单:链式调用、不改原数据、每一步都能单独测试。方法名参考 SQL 的语义,where过滤、orderBy排序、page分页、select投影、value取结果。

这里有个设计取舍要说清楚:要不要支持“在已有结果上继续筛选”。SQL 的WHERE是叠加的,多个条件之间是AND。但前端搜索框的语义往往是“替换”——用户改了状态筛选,应该基于全量重新筛,而不是在上一轮结果里再筛。所以我的封装默认每一步都基于当前结果继续,调用方要用“替换”语义时,重新new ListQuery(rawList)即可。这个默认值不是最优解,但它是可预测的,不容易出隐性 bug。

5.2 核心实现代码

class ListQuery { constructor(source) { this.result = Array.isArray(source) ? source.slice() : []; } where(predicate) { if (typeof predicate === 'function') { this.result = this.result.filter(predicate); } return this; } orderBy(keyOrFn, order = 'asc') { const getter = typeof keyOrFn === 'function' ? keyOrFn : item => item[keyOrFn]; const factor = order === 'desc' ? -1 : 1; this.result = this.result.slice().sort((a, b) => { const va = getter(a); const vb = getter(b); if (va === vb) return 0; if (va == null) return 1; // 空值统一排后面 if (vb == null) return -1; return va > vb ? factor : -factor; }); return this; } page(pageNo = 1, pageSize = 10) { const start = (Math.max(1, pageNo) - 1) * pageSize; this.result = this.result.slice(start, start + pageSize); return this; } select(fn) { this.result = this.result.map(fn); return this; } value() { return this.result; } count() { return this.result.length; } }

几个细节值得说明。构造函数里做了slice()浅拷贝,保证后续所有操作都不会污染入参,这个决定牺牲了一点点性能,换来的是“调用方永远不用担心自己的数组被改”。orderBy里把空值统一排到末尾,是因为实际业务中null和undefined参与比较会产生不可预测的顺序,浏览器之间的表现也可能不一致,显式处理掉更稳。page里做了Math.max(1, pageNo)的兜底,防止调用方传 0 或负数导致slice出现奇怪的结果。

count()返回的是当前结果的数量,注意它在page之后调用就变成当前页的条数了。要拿总数,得在page之前调用。这个顺序依赖是这套封装的一个小缺陷,更严谨的做法是把总数缓存下来,但那样代码复杂度上升,对大多数场景不划算。我选择在文档注释里写清楚,靠约定而不是靠机制。

5.3 分页与总数统计的配合

分页这块最容易出的问题不是分页本身,是“总数”和“当前页数据”对不上。典型症状:筛完之后总共 3 条,但页码还停在 5,页面显示空白,用户以为没数据。解决办法是在筛选条件变化时把页码重置为 1。这个逻辑必须和查询绑在一起,不能靠用户手动点,否则一定会漏。

function query(rawList, query, pagination) { const filtered = rawList.filter(buildMatcher(query)); const total = filtered.length; const sorted = filtered.slice().sort(comparator); const pageSize = pagination.pageSize; const maxPage = Math.max(1, Math.ceil(total / pageSize)); const page = Math.min(Math.max(1, pagination.page), maxPage); const list = sorted.slice((page - 1) * pageSize, page * pageSize); return { list, total, page, pageSize }; }

这段代码里maxPage和page的夹取是关键。它保证了即使外部传入的页码越界,也能自动回落到最后一页,而不是给用户一个空白页。这种“防御性夹取”在处理用户手输页码、URL 直链带参数、浏览器前进后退这些场景时特别必要。

5.4 树形数据的查询

树形数据的查询和扁平列表完全不同,不能简单套filter。需求通常有两类:一是“按关键字过滤树,保留匹配节点的祖先路径”,二是“按 id 查某个节点的完整链路”。第一类的难点在于,如果直接对每个节点判断是否匹配,父节点不匹配就会被整个剪掉,子节点就算匹配也显示不出来。

正确的思路是递归加标记:先深拷贝整棵树,然后自底向上判断,只要自己匹配或者任意子孙匹配,就保留。这样能保证匹配节点的路径完整。

function filterTree(nodes, keyword) { const result = []; for (const node of nodes) { const children = node.children ? filterTree(node.children, keyword) : []; const selfMatched = node.name.includes(keyword); if (selfMatched || children.length > 0) { result.push({ ...node, children }); } } return result; }

这个实现每次递归都创建新对象,内存开销比原地修改大,但好处是绝对不污染原始树。在菜单树、分类树这种规模(通常几百到几千节点)下,开销完全可以接受。如果树的规模上万,可以考虑先拍平成数组做一次filter拿到匹配节点集合,再回填祖先,复杂度会低一些。

提示:{ ...node, children }是浅拷贝,如果节点上还有其他嵌套对象字段,那些字段仍然是共享引用。要彻底隔离得用structuredClone,但那个性能开销大,只在确实需要修改嵌套字段时才用。

6. 踩坑实录与排查表

6.1 引用类型比较的坑

这是 JS 集合查询里最高频的 bug 来源,没有之一。[{ id: 1 }].includes({ id: 1 })返回false,因为两个对象字面量是两次不同的内存分配。indexOf同理。想按内容判断存在,只能用some配字段比较:

const exists = list.some(item => item.id === target.id);

同样的问题出现在Map的键、Set的成员、Array.prototype.includes上。我见过一个项目里用selectedList.includes(row)判断表格行是否选中,结果永远返回false,因为row是表格组件内部重新构造的对象,和selectedList里存的不是同一个引用。改成按row.id比较之后立刻正常。这个坑的隐蔽之处在于,它不会报错,只会让功能静默失效,靠肉眼 review 很难发现。

6.2 filter 返回的是浅拷贝,改元素会串

filter返回新数组,但数组里的元素还是原来的对象引用。所以下面这段代码会同时改掉原始数据:

const selected = rawList.filter(item => item.checked); selected[0].status = 'done'; // rawList 里对应的那条也被改了

如果你的页面有“编辑后取消”的功能,用户点了取消却发现数据变了,大概率就是这个原因。解法有两个:一是编辑时用{ ...item }复制一份再改,二是整个列表在进入编辑态时做一次深拷贝。前者更轻量,后者更省心,看你的编辑逻辑复杂度选。我一般选择在“打开编辑弹窗”的那一刻复制单条数据,这样影响面最小。

6.3 排序稳定性与数字排序

老版本浏览器(以及某些引擎的特定实现)的sort不保证稳定排序,相同键值的元素顺序可能被打乱。现代引擎已经普遍稳定了,但如果你的项目要兼容老环境,排序键最好加上一个唯一的次级键做 tiebreaker,比如先按状态排、状态相同再按 id 排,这样结果就是确定的。

数字排序不传比较函数是另一个经典错误,前面提过一次。这里补充一个变体:按字符串形式的数字排序。"10" < "9"在字符串比较里是true,因为逐字符比较"1"和"9"。如果接口返回的金额、数量是字符串,排序前必须先Number()转一下,或者用localeCompare配numeric: true参数。

6.4 常见问题速查表

现象可能原因排查动作解决方式
筛选后结果为空但数据明明存在条件叠加,在上一轮结果上继续筛打印每步filter后的长度每次查询基于rawList重建
判断存在永远为false用includes比较对象引用检查是否对象字面量改用some按字段比
改了筛选结果,原数据也变了filter浅拷贝,元素共享引用对比修改前后rawList修改前复制元素
数字排序结果错乱sort默认按字符串比较打印排序后的数组传(a, b) => a - b
空值排序位置飘忽null/undefined参与比较检查数据里是否有空值比较函数里显式处理空值
页码越界显示空白筛选后总数变少,页码没重置打印total和page页码夹取到maxPage
树形筛选后子节点丢失父节点不匹配被整体剪掉检查递归是否自底向上子孙匹配则保留父节点
按 id 查不到但数据里有索引键类型不一致(数字 vs 字符串)打印typeof键建索引时统一转字符串

这张表建议存下来,遇到问题先对一遍,能省掉不少瞎猜的时间。尤其是第一行和最后一行,我几乎每个项目都会遇到至少一次。

7. 性能实测与经验

7.1 不同数据量下的方法耗时对比

我在一台普通办公本上做过一组粗略对比,数据是结构相同的对象数组,查询是“按 id 找一条”,每种方式跑一千次取平均。这里给的是量级,不是精确基准,你的机器和浏览器不同,数字会有出入,但相对关系基本一致。

数据量find单次Map.get单次建索引耗时盈亏平衡点
1,000约 0.005ms约 0.0002ms约 0.3ms约 60 次查询
10,000约 0.05ms约 0.0002ms约 3ms约 60 次查询
100,000约 0.5ms约 0.0002ms约 30ms约 60 次查询

这组数字说明一个很实用的结论:建索引的固定成本相当于六十次左右的线性查找。只要你的查询次数超过这个量级,建索引就是赚的。而实际项目里,“列表渲染时每行都要按 id 查一次关联数据”这种场景,查询次数等于行数,随便就上千次,建索引的收益非常明显。反过来,如果只是用户点一下按钮查一次,建索引纯属给自己找麻烦。

7.2 什么时候该考虑虚拟列表或后端查询

前端查询的性能天花板其实不在算法,在渲染。十万条数据哪怕你用 O(1) 查到,渲染十万个 DOM 节点也会让浏览器卡死。这时候优化查询毫无意义,该做的是虚拟列表,只渲染可视区域内的几十个节点。虚拟列表和查询是两件独立的事,查询负责算数据,虚拟列表负责渲染数据,两者可以叠加使用。

另一条路是把查询推给后端。当数据量超过几万条,或者需要做全文搜索、多表关联,前端内存查询就力不从心了。判断标准很朴素:如果数据本身需要分页从接口拉,那筛选条件就应该作为请求参数传给后端。前端全量拉下来自己筛,只在数据量可控、且需要极快响应的场景下才合理,比如下拉框选项、本地缓存的字典数据。

7.3 我个人的几条经验

第一条,别过早优化。我见过太多项目在列表只有两百条的时候就搞了一套复杂的索引管理层,结果每次数据变更都要同步三个地方,bug 比性能问题多得多。先用filter写清楚,等真的卡了再优化,而且优化前先用performance.now()量一下,别凭感觉。

第二条,把查询逻辑和渲染逻辑分开。查询函数只负责输入列表和条件、输出结果列表,不碰任何 UI 状态。这样你可以单独给它写测试,也可以在控制台里直接调它验证边界情况。这个习惯一旦养成,排查“为什么筛出来是空”这类问题的时间会大幅缩短。

第三条,给查询条件做一次“归一化”。空字符串、null、undefined、[]在业务上都表示“没填”,但代码里如果不统一处理,每个条件判断都要写三种情况,漏一个就是一个 bug。在入口处把它们统一成undefined,后面的判断只需要判一次。

第四条,也就是我踩得最疼的一条:永远不要假设接口返回的字段类型是稳定的。同一个人字段今天返回数字明天返回字符串,在跨团队协作里太常见了。所有参与比较和索引的字段,进来先转一次类型,宁可多写一行String(),也别在三个月后的某个深夜对着一个查不到数据的 bug 发呆。