在使用Javascript进行开发的过程中,数组(Array)的操作在开发工作中是一个非常常见,高频率使用的功能。如果我们能够深入理解其操作方式和了解相关的原理,可以大幅度提升开发的效率,也能使你编写的代码更加简洁优雅。
可能有人可能会质疑使用这些代码后,程序的可读性,这里笔者的想法是应该保持一个平衡,当然首先要做好备注,但是作为一个合格的开发人员,应该有相应的编程语言的认知和运用能力,并具备基础的代码阅读和理解能力,而且不能永远满足于最基础的代码组织和编写。
笔者认为,经过多年的应用和发展,一个编程语言的框架和底层逻辑,已经非常成熟,而且都趋向于相同的发展方向。很多其新版本提供的功能,要么是将一些通用常用的需求实现抽象处理,内置在语言和平台之中(比如密码学、网络模块),再者就是通过语法增强(本质上属于“语法糖”的范畴)提升编程和实现的便利性,最终提升软件开发和运维的效率。
笔者相信,通过后续的内容和讨论,能够让读者认识和理解到这些特点和优势,并且通过消化吸收,并且在实践中的熟练运用,来大幅度提高编程能力和应用开发的效率。
创建和构造
在JS中,数组本身是没有类型限制的(当然可能最好限制数据类型,可能会影响到执行引擎对代码的优化),所以这里的数组完全是一个抽象概念。JS的数组创建有很多方法,如使用Array()方法就可以创建一个数组;还可以指定数组的初始大小,但要注意Array(n)创建的数组,并不能真正可以操作(空数组,没有成员),可以使用fill()方法初始化数组成员。
let a1 = Array();
let a2 = Array(10);
let a3 = Array(10).fill(0); // 创建一个包括10个0的数组
现实场景中,更常见的需求是需要从一些具体元素创建数组;或者从另一个数组创建新数组的情况,如复制整个或者一部分数组元素(裁剪);或者进行数组和合并或者连接的情况,都可以方便的完成。
let a4 = [0,1,2,3]; // 直接从元素创建数组
let a5 = [...a4]; // 从一个数组复制数组
let a6 = [...a4,...a5]; // 展开并连接两个数组
let a7 = a6.slice(0,3); // 裁切获得新数组
这里...,是JS的解构方法,笔者的理解是它可以将一个数组拆解成一个个的元素,然后进行处理。类似的对于对象,也可以使用:
let o1 = { a:1, b:2 };
let o2 = { ...o1, c: 3};
字符串转换
应用开发过程中,经常遇到数组和字符串之间进行转换的场景。如我们需要将一个逗号分隔的元素字符串,转换为数组进行处理,或者将一个数组,连接成字符串来进行保存,等等。相关的方法是split和join。
看看下面的示例代码:
let cities = "北京,上海,广州".split(","); // 字符串分隔成数组
let strcities = cities.join(";"); // 数组连接组合成字符串
split是字符串的对象方法,可以使用指定的分隔符,将字符串分割转换为一个字符串数组。 split参数分割所依据的字符,通常不能省略(将直接转换为只有一个元素的数组);如果使用空格,它会分割所有的字符。
join是一个数组方法,可以将数组元素连接起来,也可以指定连接用的符号。join可以指定参数,作为连接字符,默认为逗号。我们经常使用join来处理SQL参数。也可以使用字符串作为数组参数的保存形式。
数组元素修改
当我们已有一个数组的时候,通常可以对数组进行的修改操作包括增加、去除、修改、替换等等操作。JS提供了一系列函数来帮助我们完成这些操作,但需要注意,有些操作是创建一个新数组,而有些操作则是直接修改原理的数组。
- 在数组头尾增加或删除元素
先来看一些代码:
let a1 = [4,5,6];
a1.push(7); // 添加元素到数组尾部
a1.push(8,9); // 多个元素
a1.unshift(3); // 添加到头部
a1.unshift(1,2); // 添加多个,注意次序
let n9 = a1.pop(); // 弹出尾部元素,返回值为此元素或空,同时修改数组
console.log(a1, n9);
let n1 = a1.shift(); // 弹出头部元素,返回值为此元素,并修改数组
console.log(a1, n1);
这四个方法(push,unshift,pop,shift)都可以在数组的头尾增加(多个)或者删除元素。都是在数组上直接进行操作。
- 子数组
如果我们希望从一个数组获取其一部分(截断数组),可以使用slice方法:
let a2 = a1.slice(1,3); // 获取第一到第三的子数组
a2 = a1.slice(3); // 获取第二个以后的子数组
a2 = a1.slice(-2); // 获取倒数第二个之后的子数组,数组后n个元素
a2 = a1.slice(1,-2); // 从第二个元素到倒数第二的元素
a2 = a1.slice(0,3); // 数组前3个元素
a2 = a1.slice(-4,-1); // 后四个到后一个元素
slice方法会返回一个新的数组作为处理结果(子数组),同时不影响原数组。slice可以有两个参数,而且每个参数都可以是整数和负数,可以非常灵活的处理各种数组的截断操作。slice还有一个比较强大的地方是,如果参数在逻辑上超界,通常不影响操作,也不会产生错误。
- 插入或替换元素
如果希望在截断数组的同时,增加元素,达到类似替换或者插入数据的效果,可以使用splice方法。
a1.splice(2,1,"a","b");
a1.splice(3,1,...["c","d"]);
splice方法有三个以上的参数,我们需要很好的理解,才能用好这个功能。
splice方法是直接在原数组上操作的,不会产生新数组。第一个参数是要操作的位置;第二个参数是要删除的元素的数量,当为0时,不会删除任何元素;后面的参数就是要在操作位置上插入的元素了,当然我们也可以插入一个数组,使用前面提到的解构方法展开就可以了。
使用splice操作有两个比较常见的特定的操作场景,如果要从数组中删除一个元素,可以使用: a.splice(n,1); 如果要替换一个元素,可以使用 a.splice(n,1,v)。
- 数组赋值
有时候我们会遇到这一的需求,需要将数组中的一些元素赋予一些变量(快速拆解数组),这一有一个简单的写法:
let [beij,shangh] = "京沪广深成渝".split("");
// beij = "京", shangh = "沪"
遍历和查找
数组产生了之后,是必然要进行遍历的。传统的遍历方式就是 for 循环:
a1 = Array(5).fill(100);
for (let i = 0,l = a1.length; i<l; i++) {
console.log(a1[i]+i);
}
上面是所谓for循环语句块的"完整"形式。在大的数组中,先取出数组长度作为一个变量,可以减少操作。这里我们稍微深入探讨一下这个for语句的构成。它的参数其实包括三个部分(使用分号分开),必须有,但可以为空,比如:
for (;;) 就是一个无限循环。
第一个部分初始化用于声明在循环体里面使用的变量;第二部分是循环条件,如果为假则结束循环,默认值为真;第三部分有些文章中说这个地方是变量的步长,个人觉得这样的表述不准确,觉得这个地方语法中保留可以用于修改循环中的变量,这样的理解可能更合适一点,那样这个地方也是可以忽略的(操作可以放在循环体里面)。
所以,如果不关心循环顺序,或者想要从后往前操作的话,可以使用简化的写法:
for (let i = a1.length; i--; ) {
console.log(a1[i]+i);
}
除了标准经典的for之外,起码还有两种可用的方法来对数组进行遍历:
for (let v of a1) {
console.log(v);
}
for..of是ES6提供的数组迭代遍历功能,它不使用索引,可用直接获得当前的数组元素,更加简洁方便。还有就是函数式编程风格的forEach方法,在后面的集合操作中会详细解释。
查找和访问数组元素,也是一个非常常用的操作,相关涉及的方法包括:
- 使用位置索引, l[i], 可以获得指定位置的元素,这是最常用的方式
- .indexOf: 这是最常用的简单元素查找方法,通常通过判断>-1来判断存在性,一般我们可能忽略的是,indexOf可以指定一个搜索开始位置
- .find: index只能查找简单元素,如果需要使用条件查找,可以使用find(详见集合操作相关内容)
数组去重
对于简单数组,有个简单的方法可以进行去重操作,利用了另一个数据类型Set。
let a = [1,2,3,4,5,4,3,2,1,6];
a = Array.from(new Set(a));
集合操作
前面我们提到了使用for来进行数组遍历的方法,它确实也是一种通用抽象的处理方式。但在笔者看来,JS提供的相关集合操作的方法,才是这个语言的精髓所在。笔者认为其原因有三:第一,和常规数组操作通常使用"遍历"方式,而且将数组元素当成单个的对象不同,集合操作将数组当成一个整体的"对象"来进行处理;第二,这些操作方法的主体都是数组,也就是这个数组"对象"的方法,简单一致;第三,在集合操作方法调用时,可以将一个自定义的函数,作为一个参数注入,调用时数组对象会遍历数组成员,将当前元素作为参数注入到前面定义的方法中,从而完成某些业务处理的要求,这样可以极大的提高代码的灵活性和简练性。
常见的JS Array集合操作方法包括map、reduce、filter、sort等等。我们通过一些示例代码来进行理解。
- 操作函数
在集合操作中,通常需要设置一个匿名的操作函数作为调用的参数。这样可以在抽象的数组元素遍历的过程中,来实现具体的操作处理和业务要求。几乎所有需要注入处理函数的集合操作模式都是如此,它的标准形式如下(箭头函数代码块):
(c,v,i,l)=> { // some code; return value; }
函数回调的参数名可以自己定义,这里的定义只是笔者的习惯。其中,c是当前归约值,只在reduce方法中有效,后面会详细解释;v是当前数组元素,通常都必须指定,用作业务处理;i是当前元素的索引值,如果处理和索引有关,需要引入这个值;l就是当前这个数组,按需要进入,比如要参考当前元素前面那个值,可以结合使用l和i,即l[i-1]。理论上这个参数名,只在函数内部有效,所以不会影响外部的代码。
操作函数一般需要有一个返回值,不同的集合操作,对于返回值形式的约定不同,具体参见个操作方法的讨论,按照要求来处理即可。
如果处理的操作比较简单,可以不需要使用大括号代码块和明示返回值如return,而直接使用一个计算值,如将其简化为如下形式:
v=>f(v)
根据API文档,参数注入还可以使用解构方法(笔者很少用到),如
({ age })=> age+500
集合操作执行时,调用过程会开始遍历数组,使用当前的遍历到的元素作为参数,来执行这个函数;在这个函数中,就可以包括我们可以定义的业务功能。然后按照调用操作的类型和要求,设置相应的返回值,来达成集合操作的效果。
当然,我们也可以先在外部定义好这个操作函数,然后作为参数设置在集合操作的调用过程中,从而达到业务解耦和功能复用的目的。
- forEach (循环遍历)
// 一个人员列表,包括id,年龄,性别,工资
let peoples = [
{ id:1, age: 40, gender: 1, salary: 1000 },
{ id:2, age: 29, gender: 1, salary: 1800 },
{ id:3, age: 30, gender: 2, salary: 1500 },
{ id:4, age: 58, gender: 1, salary: 2000 },
];
// 遍历打印
peoples.forEach(v=>console);
这是简单函数式编程风格的数组遍历方式。效果等同于for或者for..of,但注意这个遍历不能中断,所以虽然使用简单方便,但也要注意应用的场景。
- map (映射)
Map按照字面意思就很好理解,它是种映射处理,其输出可以是另一个数组,其成员和原数组成员有一对一的映射关系,当然也可以对数组自己的元素进行一一处理。
下面的示例代码可以帮助我们更好的理解这一过程,我们首先定义了一个职员的数组,然后对来增加每个职员的工资:
// 给所有人员的工资都加500
peoples.map(v=>v.salary+=500)
// 返回工资的数组
let salaries = peoples.map(v=>v.salary+=500);
这里操作函数的参数需要使用一个v,代表当前数组的元素,我们的操作可以直接修改当前元素,也可以返回一个值来构造新的数组。具体而言,第一个操作可以给每个职员增加工资;第二个操作在在增加工资的同时,可以返回一个当前工资的数组。
- reduce (归约)
一般我们对map都比较熟悉,但刚开始接触到reduce函数,理解起来需要再深入一个层次。reduce这里的译名"归约"是两个意思。归是归纳,也就是这个方法最终的返回值是一个值,而非map一样的数组;约是约化,就是需要一个迭代计算的过程,从而从一个数组中计算出这个值来。具体实现的过程大体是这样的,首先需要一个初始值作为迭代当前值,然后开始循环遍历数组,遍历每个元素时,都会将迭代当前值和数组元素作为参数,进行处理后的返回值,作为下一个循环的迭代当前值,直到循环结束,这个迭代值就是最后reduce处理的返回值。
我们可以举个例子更容易理解一点。比如如果要计算人员工资的总和,使用reduce方法的代码可以如下:
let sum = peoples.reduce((c,v)=>c+v.salary);
// 其实它的完整形式是
sum = peoples.reduce((c,v)=>{ c+=v.salary; return c;}, 0);
首先,reduce的调用参数有两个,第二个是一个迭代的初始值,可以省略,默认为0;第一个参数是一个函数,它将会在数组元素迭代时调用,它会自动在调用时传入参数,这里的c(第一个参数)是当前值,如果是初始调用,就是初始值;其他调用时是上次调用的返回值;迭代完成后这个值就是最后reduce的结果;v是数组迭代调用的当前数组的元素(这里是一个职员对象)。
在示例程序中,需求为求职员工资的总和,就需要循环访问职员列表,然后累加每个职员的工资项目。通常如果使用for循环的话,需要设置一个变量,然后遍历职员列表,累加其每个职员的工资项目,循环完成后,这个变量就是工资之和。reduce的表达,实现了相同的功能,但显然更加简洁优雅。
其实除了当前迭代值和当前元素之外,reduce还可选两个参数;笔者一般使用i(就是当前数组的索引序号)和l(就是数组本身)。灵活组合运用初始值和这些参数,通过发挥想象力,我们可以使用reduce完成几乎所有简单或者复杂的数组操作。
为了更好的说明这一点,我们可以考虑以下代码,可以将一个数组进行分组,也是比较常用的需求:
const s = "京沪广深蓉渝津宁汉西兰";
// 每三个一组,分成若干组
let r1 = s.split("")
.reduce((c,v,i,l)=> (i = 0|i/3, c[i] = c[i] ? c[i] + v : v, c),[])
.join(",");
// 仅可能平均的分成三组
let r2 = s.split("")
.reduce((c,v,i,l)=>(c[i%3] += v,c),Array(3).fill(""))
.join(",");
- filter (过滤)
这个比较好理解,就是可以通过条件判断,从一个数组中过滤出满足条件的元素。输出的结果是一个新的数组。操作方法参数的返回是一个bool类型,为真时保留当前元素,否则不保留。
// 从人员列表中查找所有男性 gender == 1
let mans = peoples.filter(v=>v.gender == 1);
-find (查找)
这显然是indexOf的增强版本,因为indexOf只能进行精确的值的匹配,而find可以使用条件检查方法来查找符合条件的元素。和filter方法不同的是,它只会返回第一个找到的元素(filter是数组)。
相关联的方法还包括findIndex-查找满足条件的第一个元素的索引值; findLast/findLastIndex-查找最后满足条件的元素和索引等等。
- sort (排序)
对一个整数的数组排序通常处理比较简单,但如果对一个对象组成的数据,根据每个元素的某些属性,来进行排序,一般的处理就比较麻烦了。但使用sort方法,可以简化到开发者只需要关心排序逻辑的实现,就可以了。
// 人员按年龄排序
let mans = peoples.sort((a,b)=>a.age > b.age);
- reverse (倒排) JS Array提供了简单的倒排方法,可以对数组进行倒排:
// 人员按年龄排序
let plist2= peoples.reverse();
-
some (一些) 这个操作方法,可以通过设置检查条件,判断数组中是否包括满足条件的元素。
-
链式操作
JS语言的另一个强大的功能是提供了链式操作,有点像Unix系统中的管道符合,即一个操作的结果,可以作为另一个操作的输入,从而简化分步骤处理的表达和实现。
还是以员工列表为例,如果我们需要在涨工资后,统计所有男性员工的总和,可以使用下面的方法:
let mensum = peoples
.filter(v=>v.gender==1)
.map(v=>v.salary+=500)
.reduce((c,v)=>c+v.salary);
这个处理的逻辑非常清晰,就是先过滤筛选男性列表,然后修改工资,最后进行累加。
集合操作虽然非常简练,但也不是所有场景都适合。比如集合操作它因为是集合的方法,需要遍历数组,其实是不能"中断"的,这点在某些情况下就不太合适,使用for可以随时中断,可以减少计算量。此外,由于,通常认为集合操作的性能,不如简单的for循环,所以如果遇到数据集很大,或者对性能特别敏感的应用场合,在充分评估其对性能的影响后,可能还是需要选择使用传统的for循环来处理。
Buffer
Buffer是Nodejs的一个数据类型,我们可以简单的将其理解为一个字节数组,所以作为数组,它就可以使用到我们前面提到的一些集合操作。如下面的代码,就是将一个Buffer转换为hex字符串(只是示例,buffer有原生的hex转换方法)
let hex = Buffer.from("China中国").reduce((c,v)=>c+v.toString(16).padStart(2,"0"),"")
要特别小心,这里虽然可以使用reduce方法,但好像不能直接使用map方法(可能nodejs并不认为buffer是标准数组)。当然这个问题也不大,有很多变通的处理方法可以使用。比如使用Array.from方法,将buffer转换为标准整形数组,或者使用reduce生成数组等等,比如下面的代码:
let hex_array1 = Buffer.from("China中国")
.reduce((c,v)=>(c.push(v.toString(16).padStart(2,"0")),c),[])
.join();
let hex_array2 = Array.from(Buffer.from("China中国"))
.map(v=>v.toString(16).padStart(2,"0"))
.join();
上面讨论的Buffer虽然操作非常方便,但它只能在nodejs环境中使用。浏览器环境中没有buffer,但有一个类似的数据类型Uint8Array,基本也可以使用相同的逻辑进行使用,当然可能没有那么丰富完善的操作功能,这里篇幅有限,不再累述。
杂项
其实,JS Array提供的功能,上面讨论只涉及到了一小部分,是笔者觉得相对比较重要和有趣的。要获取更完整的信息,可以参考这个API文档:
developer.mozilla.org/en-US/docs/…
建议如果读者有比较特殊的数组操作需求,可以先不着急实现,先看看这些文档,说不定会有收获。这里再举几点比较有意思的:
- Array.isArray(): 判断是否是一个数组
- .at(): 获取特定索引位置的元素,参数可以是负数,如倒数第二个
- .flat()/flatMap(): 展平数组,还可以指定展平深度,也可以使用map处理当前元素
- .concat(): 连接数组,注意如果是Buffer,需要用Buffer.contact方法
- .reduceRight(): 反向归约,从后往前归约计算
- .keys(),.values(), entries(): 键、值和项目的迭代器
- .fill(): 填充,还可以指定位置和数量,奇怪的是不能使用随机数填充
- .toSorted(),.toSliced(): 可以返回处理后的数组
- .toString(): 数组的字符串表示形式,应该是同 .join()
- .with(i,v): 这个方法可以直接在指定的位置设置值,会产生新数组
性能考量
JS提供了丰富的功能用于数组的操作,甚至可以使用不同的方法来达到相同的目的和结果。对于各种数组操作方法,笔者并没有做详细严格的性能测试,这里简单的借助一个网络上性能测试的结果图表来让读者感受一下,得到一个定性的概念:
大体上,我们可以看出for和for..of基本上一样的操作,都可以提供比集合操作更好的性能。所以如果数据集比较大,或者对性能特别敏感(计算和操作频率非常高),就应该使用for。其他的情况,主要考量的是开发的方便和业务场景的需求。