数组和字符串的方法,前端日常开发里几乎天天在碰。但很多时候我们是"用到了查一下",查完就忘,下次遇到类似需求还是得翻文档。这篇文章我想换个思路,把这些高频方法重新梳理一遍——每个方法都挂一个真实场景,再把我实际使用中的体会和踩过的坑一并写出来。不管你是刚入行还是写了两三年,如果能顺着场景把这些方法串起来记,以后写代码会顺手很多。
先说清楚这篇会覆盖到什么程度。数组部分重点讲:map、filter、reduce、forEach、some、every、find、findIndex、includes、indexOf、push、pop、unshift、shift、splice、slice、concat、sort、flat、join、Array.from。字符串部分重点讲:slice、substring、substr、indexOf、lastIndexOf、includes、startsWith、endsWith、replace、replaceAll、split、trim、toLowerCase、toUpperCase、padStart、padEnd、charAt、match、repeat。看到这个清单你先别慌,虽然数量不少,但真正需要死记硬背的没几个,大部分都是同一套逻辑在不同场景下的变形。
1. 数组方法怎么分类才记得住
数组方法多,难记,根源在于没有分类意识。如果你按"能做什么"去分类,而不是按方法名去硬背,会发现整个体系非常清晰。
1.1 按操作意图划分的五大类
我把数组高频方法分成五组:
- 遍历类:forEach、map、filter、some、every、find、findIndex,这一组都是"遍历数组、对每一项做点什么"。
- 增删类:push、pop、unshift、shift、splice,这一组直接改变数组本身,要特别留心。
- 截取拼接类:slice、concat、join,这一组不影响原数组,返回新结果。
- 查找类:indexOf、lastIndexOf、includes、find、findIndex,前三个适合查原始值,后两个适合查对象。
- 排序处理类:sort、reverse、flat,各有各的坑,后面细说。
这样一分,你会发现find和findIndex把它们放在查找类更合理,但它们本质也是遍历。没关系,分类只是辅助记忆的手段,关键是脑子里要有这张地图。
1.2 方法选型的三个判断顺序
实际开发里遇到一个数组操作需求,我会按三个问题走:
第一,要不要改变原数组?需要改变就用push、splice这类,不需要就尽量用slice、map、filter、concat,保持不可变,代码更安全。
第二,返回值是什么?forEach返回undefined,map返回等长新数组,filter返回子集,reduce返回任意你想返回的值。返回值决定了你这个方法能不能继续链式调用。
第三,回调函数的参数顺序是什么?绝大多数方法回调是(value, index, array)。这一点新手经常搞混,等会儿实际操作里会专门验证。
这三个顺序想清楚,大部分"不知道该用哪个方法"的问题都能解决。所谓高频方法,其实就是在这些判断框架里反复出现的几个固定答案。
2. 数组高频方法逐一拆解
这部分我会配合真实场景来讲,每个方法都有自己的"主场优势"。
2.1 遍历三兄弟:forEach、map、filter
forEach是最朴素的遍历方法。它不返回任何值,就是单纯地对每个元素执行一次回调。适合什么场景?适合"遍历只是为了做一件事"的场景,比如把数据渲染进页面、打印日志、给外部变量赋值。
const list = [{ name: '苹果', price: 3 }, { name: '香蕉', price: 5 }]; list.forEach((item) => { console.log(`${item.name} 的价格是 ${item.price} 元`); });forEach有个特点:回调里return并不会终止循环,break和continue也不生效。需要提前结束遍历的话,优先考虑for...of。我一开始也经常踩这个坑,想在forEach里return跳过某一项,结果发现完全没用。
map的功能是"一一映射"。它遍历数组,把每次回调的返回值收集成一个新数组。新数组和原数组长度一定相等。
const prices = list.map((item) => item.price); // [3, 5]map最常见的错误是"回调函数里没有return",或者把箭头函数的简写忘了。箭头函数写成item => item.price会隐式返回price;写成item => { item.price }就返回undefined了。这个细节我在Code Review里看到过很多次。
filter的作用是"筛选子集"。回调返回true就保留这一项,返回false就过滤掉,新数组长度可能比原数组短。
const cheapItems = list.filter((item) => item.price < 5); // 只有苹果filter的坑在于:回调函数里如果直接return一个对象,很容易写错成item => item.price > 5,结果返回boolean没问题;但如果你判断的是数组里套数组的情况,比如arr.filter(item => item.includes('关键词')),就要注意item本身可能存在null或者undefined。给filter的回调函数加一层空值判断,能避免很多线上隐藏bug。
2.2 归并与判定:reduce、some、every
reduce是数组方法里最"万能"也最容易被绕晕的一个。它把数组"归纳"成一个值,这个值是什么类型完全由你决定。
const total = list.reduce((acc, item) => acc + item.price, 0); // 8reduce的回调有四个参数:accumulator(累加器)、currentValue(当前值)、currentIndex(当前索引)、array(原数组)。initialValue即第二个参数,它决定了第一次循环时acc的值。不传initialValue的话,第一次循环acc会取数组第一项,currentValue取第二项。
一个常见的坑:不传初始值,遇到空数组会直接报错。
[].reduce((acc, item) => acc + item, 0); // 0,正常 [].reduce((acc, item) => acc + item); // TypeError所以只要数组是动态数据,reduce务必传初始值。我自己的习惯是:求和、求积这类聚合操作,初始值传0或1;拼对象、拼Map这类操作,初始值传空对象或空的Map。
reduce还能用来实现"分组""去重""扁平化"这些看起来很高端的操作。去重最简单的方式之一:
const arr = [1, 2, 2, 3, 3, 4]; const unique = arr.reduce((acc, cur) => { if (!acc.includes(cur)) { acc.push(cur); } return acc; }, []);some和every是一对"判定"方法。some代表"是否存在至少一个满足条件",every代表"是否全部满足条件"。两个方法都返回布尔值,而且都有"短路"机制,找到结果之后就不会继续遍历。
const hasExpensive = list.some((item) => item.price > 10); const allCheap = list.every((item) => item.price < 10);实际开发中,some常用来做"是否包含权限项""是否已经有重复名字"之类的判断。every常用来做表单校验,比如数组里每一项是否都填写了必填字段。这两个方法性能上比forEach加flag变量好得多,代码也更语义化。
2.3 查找与确认:find、findIndex、includes、indexOf
find返回第一个满足条件的元素本身,findIndex返回第一个满足条件的元素索引。它们最大的价值是能查找对象数组,这是includes和indexOf做不到的。
const target = list.find((item) => item.name === '香蕉'); // { name: '香蕉', price: 5 } const targetIndex = list.findIndex((item) => item.name === '香蕉'); // 1找不到时,find返回undefined,findIndex返回-1。判断"是否找到"最容易出错的地方,就是直接用if (target)去判断,万一找到的元素本身是{ }空对象或者0,这个判断就不准确了。稳妥的写法是判断target !== undefined。
includes用来判断"数组里是否存在某个值",内部用的是严格相等(===)比较。它对NaN的处理比indexOf好,[NaN].includes(NaN)返回true,而[NaN].indexOf(NaN)返回-1。这个细节很有意思,底层原因是includes用的是SameValueZero比较算法。
const fruits = ['苹果', '香蕉']; fruits.includes('苹果'); // true fruits.indexOf('苹果'); // 0 fruits.indexOf('葡萄'); // -1indexOf和lastIndexOf可以传第二个参数作为起始查找位置。实际场景里,我常用它做"从第几个开始找""找最后出现的位置"这类操作。比如要判断一个数组里"苹果"出现的位置和次数,indexOf配合循环就能实现。
2.4 增删改与变化统计:push、pop、unshift、shift、splice
这五个方法都会修改原数组。push在尾部加一项,pop在尾部取出一项,unshift在头部加一项,shift在头部取出一项。push和pop组合出栈行为,unshift和shift组合出队行为。
const stack = []; stack.push(1); // 尾部加 stack.push(2); stack.pop(); // 尾部取出,栈:先入后出 const queue = []; queue.push('a'); queue.unshift('b'); queue.shift(); // 头部取出,队:先入先出push的返回值是新数组的长度,pop的返回值是被弹出的元素。这个细节偶尔会用来写链式逻辑。
splice是数组方法里的"瑞士军刀",能增能删能改。它接收多个参数:起始位置、删除几个、要插入的元素。
const arr = ['a', 'b', 'c', 'd']; arr.splice(1, 2); // 从索引1开始删2个,结果是 ['a', 'd'] arr.splice(1, 0, 'x'); // 在索引1位置插入,不删除,结果是 ['a', 'x', 'd'] arr.splice(1, 1, 'y'); // 从索引1开始删1个,再插入 'y'splice的返回值是被删除的元素组成的新数组。它和slice相比,最大的区别就是splice改变原数组,slice不改变。日常编码里,如果不想动原始数据,优先用slice和concat,不要随手写splice。
2.5 提取与拼接:slice、concat、join
slice是截取数组的一个副本。它接收两个参数,起始索引和结束索引(不包含结束索引)。负数索引从末尾倒数。
const arr = [1, 2, 3, 4, 5]; arr.slice(0, 2); // [1, 2] arr.slice(2); // [3, 4, 5] arr.slice(-2); // [4, 5]slice最常用的场景是"浅拷贝数组"。arr.slice()什么参数都不传,就是复制一份数组。另一个场景是"截取列表数据做分页展示"。
concat拼接多个数组或元素,返回一个新数组,不影响原数组。
const a = [1, 2]; const b = [3, 4]; const c = a.concat(b, 5); // [1, 2, 3, 4, 5]需要注意concat是浅拷贝。如果数组里元素是对象,拷贝的是引用。修改原数组里对象的内容,新数组里的对象也会跟着变。要真正深拷贝,简单写法是JSON.parse(JSON.stringify(arr)),但要小心函数、undefined、Date等特殊值会被处理掉。
join把数组拼成字符串,传入分隔符。
const arr = ['前端', 'JavaScript', '数组']; arr.join('-'); // '前端-JavaScript-数组' arr.join(''); // '前端JavaScript数组' arr.join(); // '前端,JavaScript,数组',默认逗号实际场景里,我常用它来拼路径、拼URL参数、拼显示文案。反过来字符串split可以拆回数组,两者是一对天然的搭档。
2.6 排序与扁平化:sort、reverse、flat
sort默认会把元素转成字符串再按Unicode码点排序,所以[1, 12, 2].sort()得到的是[1, 12, 2]。想数字升序,必须传比较函数。
[1, 12, 2].sort(); // [1, 12, 2] [1, 12, 2].sort((a, b) => a - b); // [1, 2, 12],升序 [1, 12, 2].sort((a, b) => b - a); // [12, 2, 1],降序比较函数的原理是:a - b 小于0,a放前面;大于0,b放前面。这个规则可以推广到对象数组排序:
list.sort((a, b) => a.price - b.price);sort的坑还有两个。第一,它会改变原数组。如果要保留原数组的顺序,先arr.slice().sort()。第二,中文排序默认按Unicode编码,不是按拼音。需要按拼音排中文的话,可以用localeCompare。
reverse是反转数组顺序,同样改变原数组。配合sort逆序,其实可以直接用arr.sort().reverse(),但注意reverse也会改原数组。
flat用来扁平化嵌套数组。可以传一个深度参数。
[1, [2, [3, [4]]]].flat(2); // [1, 2, 3, [4]] [1, [2, [3, [4]]]].flat(Infinity); // [1, 2, 3, 4]flat默认深度是1。如果数组里全是非嵌套的,flat()就等价于浅拷贝一层。如果想批量扁平化多层嵌套,最简单的方式是flat(Infinity),但要注意如果数组里元素本身包含循环引用,会出问题。
3. 字符串高频方法实操
字符串和数组很多方法长得像,但字符串有个铁律:所有操作都是不可变的,原字符串永远不变,每次操作都会返回一个新字符串。理解了这一点,很多"为什么这样写"的问题就通了。
3.1 截取与切分:slice、substring、substr、split
slice用于字符串和数组都一样,接收起始索引和结束索引,结束索引不包含。
const str = 'hello world'; str.slice(0, 5); // 'hello' str.slice(6); // 'world' str.slice(-5); // 'world'substring和slice的区别是:substring会把负数转换为0,并且如果start大于end,会自动交换位置。这两个"自动纠正"的行为有时是好事,但更容易掩盖你的逻辑错误,所以我日常更倾向slice。
substr曾经也很常用,接收起始索引和长度。但它属于废弃规范,新项目不建议继续用。遇到老代码里出现substr,改为slice或substring更稳妥。
split把字符串拆成数组,是join的逆操作。它接收分隔符,分隔符可以是字符串,也可以是正则。
const csv = '苹果,香蕉,葡萄'; csv.split(','); // ['苹果', '香蕉', '葡萄'] csv.split(',', 2); // ['苹果', '香蕉'],限制结果长度 const str2 = 'a1b2c3'; str2.split(/\d/); // ['a', 'b', 'c'],用正则拆split的坑是空字符串拆出来的数组和预期可能不一致:'abc'.split('')得到['a','b','c'],这个没问题;但''.split('')得到的是[]而不是['']。如果字符串末尾刚好是分隔符,也会产生一个空字符串元素,比如'a,'.split(',')得到['a', '']。
3.2 查找定位:indexOf、lastIndexOf、includes、startsWith、endsWith
字符串的indexOf返回第一次出现的位置,找不到返回-1;lastIndexOf从末尾往前找。
const str = 'hello world'; str.indexOf('o'); // 4 str.lastIndexOf('o'); // 7 str.indexOf('x'); // -1includes判断是否包含子串,返回布尔值。
startsWith和endsWith分别判断是否以指定字符串开头或结尾。这两个方法日常很常用:做路由前缀匹配、文件名后缀校验、URL域名判断都特别好使。
const fileName = 'report.pdf'; fileName.endsWith('.pdf'); // true const url = '/api/user/list'; url.startsWith('/api'); // true这三个方法都可以接收第二个参数:indexOf和includes用它来表示"从哪个位置开始往后找",startsWith用它来表示"从哪个位置开始判断",endsWith的第二个参数则是"只看从开头到该位置的内容"。
3.3 替换与清洗:replace、replaceAll、trim
replace接收两个参数:要匹配的内容(字符串或正则)和替换内容(字符串或函数)。注意replace在使用字符串匹配时,只替换第一次出现的位置。
const str = 'color color'; str.replace('color', 'colour'); // 'colour color' str.replace(/color/g, 'colour'); // 'colour colour'replaceAll是相对较新的方法,直接替换所有匹配的字符串,不需要用正则的g标志。
'color color'.replaceAll('color', 'colour'); // 'colour colour'replace的回调函数会在匹配时被调用,常见的场景是做动态替换。比如把文本里的变量名替换成对应值:
const data = { name: '张三', age: 18 }; const template = '我是{name},今年{age}岁'; const result = template.replace(/\{(.*?)\}/g, (match, key) => data[key] || ''); // '我是张三,今年18岁'trim去掉字符串两端的空白字符。注意它不改变原字符串,返回新字符串。需要去掉首尾指字特定字符,不能用trim,得用自定义逻辑或正则。它在处理表单输入时几乎是必用的。
const input = ' hello '; input.trim(); // 'hello'3.4 转换与对齐:toLowerCase、toUpperCase、padStart、padEnd、charAt、repeat
toLowerCase和toUpperCase做大小写转换。实际场景里,大小写转换常配合includes做不区分大小写的搜索:
const keyword = 'JAVASCRIPT'; const content = 'JavaScript 是世界上最好的语言'; content.toLowerCase().includes(keyword.toLowerCase()); // truepadStart和padEnd用于补齐字符串长度。它们接收目标长度和填充内容。
'5'.padStart(2, '0'); // '05' '2024-1-1'.padStart(10, '0'); // '2024-01-01'? 这里要算下长度,其实不一定对实际场景里常用它做编号补零、时间格式补位、金额显示对齐。比如订单号需要8位,不满就补零。要注意填充内容如果过长,只会取前面的一部分。
charAt返回指定位置的字符。它和str[index]的区别是,越界访问时charAt返回空字符串,str[index]返回undefined。
const str = 'abc'; str.charAt(1); // 'b' str.charAt(99); // '' str[99]; // undefinedrepeat返回新字符串,表示把原字符串重复指定次数。批量生成占位符、画分隔线,它很合适。
'-'.repeat(20); // '--------------------'4. 真实场景组合拳
方法单独记住是一回事,放进场景里能不能顺手组合出来是另一回事。我整理了四个前端日常一定会碰到的场景,每写一个都会解释我自己的思考顺序。
4.1 列表数据筛选与统计
最典型的需求:后端给了一批用户数据,前端要展示满足条件的用户,同时统计总价或平均价。
const users = [ { name: '张三', age: 20, active: true }, { name: '李四', age: 17, active: false }, { name: '王五', age: 30, active: true }, ]; // 筛选成年且激活的用户 const activeAdults = users.filter((user) => user.age >= 18 && user.active); // 提取名字列表 const names = activeAdults.map((user) => user.name); // 计算激活用户平均年龄 const avgAge = activeAdults.reduce((sum, user) => sum + user.age, 0) / activeAdults.length;这一串其实就是一个"filter + map + reduce"的组合链。我自己在拆需求时的心法是:先想清楚最终要什么,是数组、布尔值还是数值。要数组且等长,考虑map;要数组但可能变短,考虑filter;要数值或聚合结果,考虑reduce。这个顺序基本能覆盖七八成的列表处理场景。
4.2 搜索框实时过滤与高亮
搜索框输入关键词,实时过滤列表,并且把命中的文字高亮。这个场景会同时用到数组和字符串方法。
const goods = ['苹果手机', '香蕉牛奶', '苹果汁', '葡萄干']; function searchGoods(keyword) { const kw = keyword.trim().toLowerCase(); if (!kw) return goods; return goods.filter((name) => name.toLowerCase().includes(kw)); } function highlight(name, keyword) { const kw = keyword.trim(); if (!kw) return name; const index = name.toLowerCase().indexOf(kw.toLowerCase()); if (index === -1) return name; const prefix = name.slice(0, index); const match = name.slice(index, index + kw.length); const suffix = name.slice(index + kw.length); return `${prefix}<mark>${match}</mark>${suffix}`; }这里最容易踩的坑是大小写匹配时用indexOf定位,和原字符串的截取位置对不上。解决办法是:indexOf用转小写后的字符串去定位,slice用原字符串去截,因为转小写不会改变字符串长度,所以索引位置是一致的。
4.3 数据去重与差异比较
两组数据合并去重,再找出新增和删除的项。
const oldIds = [1, 2, 3, 4]; const newIds = [3, 4, 5, 6]; const added = newIds.filter((id) => !oldIds.includes(id)); // [5, 6] const removed = oldIds.filter((id) => !newIds.includes(id)); // [1, 2] const merged = Array.from(new Set([...oldIds, ...newIds])); // [1, 2, 3, 4, 5, 6]使用includes做差异比较,时间复杂度是O(n²)。数据量小无所谓,但如果达到万级,建议先把数组转成Set,用set.has(id)做判断,性能会好非常多。这是个很容易被忽略的优化点。
const oldSet = new Set(oldIds); const addedFast = newIds.filter((id) => !oldSet.has(id));4.4 对象数组按字段分组
给一批订单按状态分组,是相当经典的需求。reduce在这里体现得淋漓尽致。
const orders = [ { id: 1, status: 'pending' }, { id: 2, status: 'done' }, { id: 3, status: 'pending' }, { id: 4, status: 'canceled' }, ]; const groupByStatus = orders.reduce((acc, order) => { (acc[order.status] = acc[order.status] || []).push(order); return acc; }, {}); // { // pending: [订单1, 订单3], // done: [订单2], // canceled: [订单4] // }这里要注意括号的使用。(acc[order.status] = acc[order.status] || []).push(order)这行代码里,先给当前状态键初始化一个空数组,再push进去。第一次遇到pending时,acc.pending是undefined,undefined || []得到空数组并赋值给acc.pending,然后对这个新数组push。这段逻辑看起来很巧妙,但也是reduce里最容易写错的地方——忘了把返回值return acc,分组就会失败。
5. 容易踩的坑,帮你提前避开
方法本身不难,难的是细节。下面这些坑我基本都踩过,写出来希望你一次避开。
5.1 误以为map会改变原数组
map返回的是新数组,回调里对原数组做push或者修改,确实会影响原数组,但"map本身修改原数组"这个理解是错的。很多人写const newArr = arr.map(item => item.name = 'xxx'),其实回调返回的是赋值表达式的值,也就是'xxx',会得到一堆'xxx'组成的新数组,同时原数组被一起改了。这种副作用写出来,代码很难维护。map的回调函数应该保持纯函数风格:只读取,不修改。
5.2 sort中比较函数的正负号记反了
每次写sort我都要自言自语一遍:a - b是升序还是降序。其实想清楚就行:返回值小于0,表示a要排在b前面;返回值大于0,表示b要排在a前面。所以a - b是升序,b - a是降序。如果还记不住,可以只记一个口诀:"升序用a减b"。实际项目里我建议把它封装成一个工具函数,比如sortByNumber(arr, key),避免每次调用都重写一遍比较逻辑。
5.3 split、join、replaceAll弄混分隔符和替换内容
split的参数是分割符,join的参数是连接符,两者方向相反但概念相似。replaceAll的参数是匹配内容和替换内容,没有分隔符的概念。最容易出错的场景是从URL里拼接参数:用join拼接用split拆分,一对组合,方向千万别搞反。
5.4 字符串方法不可变,链式调用别忘了接收结果
字符串的replace、trim、slice、toUpperCase全部返回新字符串。有时候一偷懒写成:
let name = ' zhangsan '; name.trim(); name = name.toUpperCase();第一行trim的结果被丢弃了,name还是原来的带空格的小写字符串。这个场景在真实代码里很常见。要么重新赋值,要么使用链式写法。
5.5 find和filter的区别没想清楚
find只返回第一个匹配项,filter返回所有匹配项。一个小需求"找到满足条件的第一个",用find会比filter再取[0]更高效,而且语义更清晰。反过来如果"要所有匹配项",用find就会漏数据。这个区别不大,但能体现编码意识。
6. 我自己的高频使用习惯
方法学完,最后分享一点我个人的编码习惯。算不上标准答案,但确实是这些年用出来的经验。
6.1 能从语义看出意图,少写注释
我写代码时尽量让方法名自己表达意图。要选"是否有符合条件的人",用some;要判断"全都通过",用every;要取第一个匹配对象,用find;要拿索引去做后续操作,用findIndex。方法选对了,代码基本不需要注释,读起来就像自然语言。反过来说,如果一个函数里出现10行for循环加if判断,完全可以用some或every替代,代码可读性会提升一个档次。
6.2 优先不可变操作
能不改原数组,尽量不改。这不只是风格问题,更是排错效率问题。项目里的数据状态一旦被一个操作意外改动,bug会顺着引用关系到处串。slice、map、filter、concat这类返回新数组的操作是首选。需要修改时,我通常会先复制再修改,或者用展开运算符:
const newArr = [...arr]; newArr.push('x');6.3 合理使用reduce,但别滥用
reduce很强大,但有些场景它并不是最优解。比如"遍历数组改成另一个数组",map更简单;"筛选子集",filter更好读。只有在"需要聚合出一个复杂对象或数字"的时候,reduce才是最优选。我见过有人用reduce实现filter和map的功能,代码又长又难懂。工具用对了才是好工具。
6.4 数据量大时,注意性能
数组方法大多都是线性遍历,数据量小不会有什么问题。但如果数组达到数万甚至数十万级别,就需要留心了。includes和indexOf在数组里做查找是O(n),改成Set的has之后是O(1)。sort默认排序和自定义排序的性能差异也客观存在。实际项目里,我的习惯是:上百条数据不考虑性能,几千条数据开始想优化思路,上万条数据一定会用Set或Map。
6.5 链式调用,读起来像流水账
map、filter、reduce可以串成一条链,但链别太长。超过三步,我就倾向拆开写或用中间变量。太长的链虽然"一行搞定",但调试起来非常痛苦。比如下面这样我是不推荐的:
const result = arr.filter(...).map(...).filter(...).sort(...).reduce(...);每一步都返回一个新数组,中间值如果和预期不一致,你很难知道是哪一环出了问题。拆成几步,每步console.log一下,排查效率高很多。这个习惯在团队协作里尤其重要,因为别人看你的代码时,也会被一条超长链搞糊涂。
数组和字符串的方法学起来不难,难的是在不同场景下做出合适的选择。希望这篇把方法、场景、坑点写清楚的文章,能让你在下次写代码时少查几次文档。如果你有自己觉得特别好用、但容易被忽视的方法或技巧,也欢迎在评论区分享出来,我也长长见识。