现今各种框架、工具‘横行’,到处在讲原理和源码,更有跨端技术需要我们去探索,但如果基本功不好,学什么都是事倍功半,效果很不好,花费时间的同时打击自信心。此篇文章,为我所计划的【轻聊前端】系列第(九)篇,旨在系统地、逻辑性地把原生JavaScript知识分享给大家,帮助各位较为轻松地理清知识体系,更好地理解和记忆,我尽力而为,望不负期待。
上一篇我们聊了对象,对象是属性和方法的集合体,代表包含某些信息或能力的个体。
这篇我们来聊另外一种集合体——数组,它代表一系列的数据。
数组同样无处不在,每当你需要多于一个数据的传输或者展示的时候,就会用到数组。
我们从定义说起。
数组定义
数组,即一组数据,表现形如:
1 | [1,2,3,4] |
JavaScript中,数组元素可以是任意类型,但通常,同一类型的一组数据更常见,也更有实际意义。
可以用下面几种方式定义数组:
- 字面量
- Array()构造函数
- 对可迭代对象使用…扩展操作符
- 工厂方法Array.of()和Array.from()
字面量
最简单直观,也是最常用之一。
1 | let arr = [1,2,3] |
这里有两个需要注意的现象:
1 | let arr = [1,,3] //访问arr[1]会是undefined |
很多时候,我们会初始化一个空数组,然后根据业务逻辑向其中添加元素,后面“方法”段落会讲到。
Array()构造函数
构造函数创建数组是最合理的方式之一。就像下面这样。
1 | let arr1 = new Array(1,2,3,4) //[1, 2, 3, 4] |
看似简单的形式中暗藏玄机:
- 当构造函数的参数是多项时,会将每一项作为元素创建出来。
- 当仅有一个数字时,表示数组的长度,元素并未填充,访问也会是undefined。
- 仅有一个非数字,则又会创建一个单个元素的数组。
扩展可迭代对象
迭代在编程中是个老概念,表示可使用某些方法访问集合中的所有元素,但在JavaScript中,可迭代对象是ES6后引入的新概念——iterable。
哪些类型可迭代?比如:数组、Map、Set,类数组对象——arguments对象,DOM NodeList对象,Generator对象,字符串等。
我们一个个试一下。
string
直接看代码。
1 | let str1 = 'hello world' |
前面章节我们聊字符串的时候,讲过一个split()方法,可以将字符串以某种字符作为分界创建一个数组,而这里,可以直接使用扩展运算符进行转换,只是这种“粉碎性”的效果使用场景不多。
Map
Map是ES6新引入的一种对象,用于保存键值对,并且能够记住键的原始插入顺序。
咦,保存键值对,这不是我们熟悉的Object?
确实,在Map出现之前,很多相似的需求都是使用Object来实现的,但它们有几点重要区别:
- Map默认不包含任何键,只包含显式插入的键,而Object有原型, 也就包含原型上的键。
- Map的键可以是任意值,Object的键必须是一个String或Symbol。
- Map中的key是有序的,Object的键无序。
- Map的键值对个数可通过size属性获取,Object只能手动计算。
先创建一个Map简单认识一下:
1 | let map1 = new Map(); |
从上面Map对象操作和访问元素的情况可以更明显地看出它和Object的区别了。
怎样用Map转换数组呢,只需使用扩展运算符。
1 | let arr6 = [...map1] //[ [ 'a', 1 ], [ 'b', 2 ] ] |
用扩展运算符轻松地将map转换成了数组,但是,它并不是对象数组,而是个以原对象的键和值为元素的二维数组,需要格外注意。
Set
和Map一样,Set也是ES6提供的新的集合工具,跟数组类似,也是包含一组数据,只是它不允许添加重复的数据。看代码:
1 | let set1 = new Set(); |
以上代码我们创建了一个包含两个元素的Set,然后同样使用扩展运算符轻松地将其转化为了数组。
类数组
类数组,是和数组形似,但不具备全部数组特性的数据形式,较为常见的,就是arguments对象和DOM NodeList对象。
arguments可用来获取函数的参数列表。
1 | function func(){ |
而NodeList则是我们使用js获取DOM节点时拿到的,比如说,页面上有两个类名为‘item’的DOM元素。
1 | <div class="item"></div> |
如上所示,使用getElementsByClassName就会拿到的一个HTMLCollection,包含两个类名为item的div。
它们都长得像数组,但除了能访问length属性之外,就没有其他数组的能力了,如果想利用那些能力,就要进行转换,在ES6之前,通常通过Array在原型上的方法来做到这一点。
1 | function func(){ |
ES6之后就简洁一些1
2
3
4function func(){
let arsArray = [...arguments]
// [1, 2, 3, 4, 5]
}
Array.of()和Array.from()
Array.from()的作用效果和扩展运算符类似,但又有所区别。
Array.of()——创建一个具有可变数量的新数组实例。
Array.from()——从一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例。
我们只举一个例子就好。
1 | function func(){ |
Array.from()将函数的参数转换成了一个真正的数组。
而Array.of()更像Array(),比如我们可以这样:
1 | Array.of(1); // [1] |
可看出,它跟Array()的一点明显不同是,当只传入一个数字时,是当做单个元素处理,而不是元素个数。
好了,到此,创建数组的介绍先告一段落。
创建数组只是第一步,我们或许会从头创建,或者会将类数组转换为数组,再或者,会将其他类型转换为数组(比如字符串),这些操作的目的,大都是利用数组所具备的方法为具体的目标服务的,接下来,我们就来看看数组有哪些强大的方法,以及如何使用。
常见数组方法及应用
数组的方法多且用途广,它们是数组强大的关键。
如果你是初学者,不要指望靠看就能记住,想当年,笔者把《高程》3 的数组章节看了几遍都没记住,不过,并不代表没有帮助记忆的方法,将其和实际应用场景相结合,就可更好地记忆。
我们逐一清点。
类型判断——Array.isArray()
有些时候,我们要判断拿到的数据是不是数组,否则可能出现方法调用报错的情况,以前判断数组不是很方便,会选用以下几种方式:
- instanceof
判断运算符的左侧是否是右侧类型的一个实例,即 a instanceof b。此时我们将b设为Array即可,返回布尔值。
- constructor
前面我们聊创建对象的时候,提到过constructor,引用类型都有对应的constructor,数组实例的constructor是Array,所以,可用a.constructor == Array是否为true来检查a是否为数组实例。
- Object.prototype.toString.call()
这个方法,旨在将实例的类型转为字符串然后输出,如果实例是数组类型,则会输出’[object Array]’,进而可以做出判断。因为前两个方法可能存在一些不确定的问题,这个方法,曾被认为是最准确和可靠的方法,判断其他引用类型也同样。
ES6的出现提供了新的判断方法——isArray(),只需要Array.isArray(obj)即可判断obj是否为数组,提供了极大的便利。
添加及删除——push()/pop()、unshift()/shift()
数组被创建后,可能有元素,也可能没有元素,这两组方法,常被用来动态地向数组中添加或者删除元素,只是它们的方式有所局限,只能从数组的两端进行操作,但对于适合的场景来说够用了。
什么是适合的场景?只要求符合条件的元素,不讲究顺序,也没有其他附加条件,就可以这样简单粗暴地处理。
1 | const arr = [],ele; |
另外一对同理,不再赘述。
任意位置添加或删除——splice()/slice()
既然上面的方法有局限,这组就更灵活,它的灵活体现在不再局限位置,可在任意位置进行添加、删除、替换,至于是哪一种,取决于传参的情况。
参数格式:splice(index,nums,item1,…..,itemX)
它们分别代表:
index——开始位置
nums——空出位置数
item1,…..,itemX——从空出的位置添加进哪些元素
前两个参数必填,第三个选填。由此可得出:
只要给index赋一个合法的值,就可以选定操作位置,第二位如果是0,则不删除元素,此时若第三个参数有值,则往指定位置添加元素。
如果第二个参数是非0的正整数,则删除指定数量的元素,此时第三个参数如有数据,则填到删除了元素的位置,起到元素替换的效果。
那么slice()又是什么,它有何不同?
slice看起来跟splice很像,只差一个字母,但用途大不同,主要两点区别:
一、slice接收两个参数,begin 和 end,决定了截取源数组的哪些部分,截取出的部分包括begin,但不包括end
二、slice返回一个新数组,这个新数组是源数组的一个浅拷贝,源数组不受影响
所以,这两种方法的使用可简单概括为:如果想要在源数组的基础上做处理,截取某部分,但不改变源数组,用slice,其他情况用splice。
特定元素的索引——indexOf()/findIndex()
前面的方法中,我们提到了“元素”和‘位置’,很多时候并不知道某元素所在的位置,要动态获取,这时候查找索引就派上了用场。
这两种方法所得结果类似,但用法存在差异。
- indexOf(searchElement[, fromIndex])
indexOf()方法需要传入具体的查找元素,和起始索引(可选)。
1 | const nums = [1, 2, 3, 4, 5]; |
- findIndex()方法则是传入一个回调函数
函数支持三个可选参数:元素、索引、数组本身。通常,使用前两个,甚至一个参数就够了。像下面这样:
1 | const nums = [1, 2, 3, 4, 5]; |
要特别注意的是,有时候结果可能跟期望不同,即当数组中有多个相同目标元素的时候,它们都只会返回第一个目标元素的位置。
1 | const nums = [1, 2, 3, 3, 5]; |
这是正常情况,如果异常,比如元素不存在,二者均会返回-1。
查找元素——includesOf()/find()
上一组方法,是找到某元素在数组中的位置,当然,顺便可以通过返回值是不是-1来判断元素是否存在,而这一组方法,则是直接得到元素是否存在于数组中。
- includesOf()——返回布尔值
1 | const nums = [1, 2, 3, 3, 5]; |
- find()-返回目标值本身
1 | const nums = [1, 2, 3, 3, 5]; |
填充——fill()
上面讲创建数组的时候,说可以创建一个空数组,然后往里添加,也可使用字面量创建现成的数组,也可使用splice对数组进行增、删、改,但还有一种方式可以用来改变数组——fill()。
看看用法。
1 | const arr = new Array() |
哦豁~好像翻车了,说好的填充呢,怎么还是空数组?
且慢,fill()方法不是这么用滴,使用它的前提是,数组中已有一定数量的元素。比如:
可以这样:
1 | const arr = new Array(1,2,3,4) |
也可以这样
1 | const arr = new Array(4) |
由此,能够得出一个快速建立具备某数量的非空数组的方法。
现在来看看完整语法:arr.fill(value[, start[, end]])
似曾相识吧,它也接收两个位置参数,一个起始位置,一个结束位置,上面我们没有传的时候,它们默认是从头到尾,我们可以设定试试看。
1 | const arr = [1,2,3,4,5,6] |
但是,fill()方法有个易犯的错误,当填充的元素是引用类型时,其填充的值都会是同一个引用,比如,初始化一个商品列表。
1 | const goods = { |
此时的商品列表数据会是这样:
1 | [ |
然后我们编辑第一个商品,将价格改为8
1 | goodsList[0].price = 8 |
却发现每个商品的价格都被改变了。
1 | [ |
这显然不是想要的效果。
不仅如此,别忘了数组也是引用类型,所以,在初始化二维数组时同样会有这个问题,因此,如果有这样一个需求——在页面初始化时,需要准备好一组待编辑/修改的数据项,就不适合用这种方法来创建了。
排序——sort()
排序是个常见需求,凡涉及列表,定有排序,按时间、按价格、按销量等。
最简单的,给一组数字排序。
1 | const numSort = [3,2,8,6,5,7,9,1,4].sort() |
这么简单,有什么可说?
当然有,一个小例子就成功欺骗了我们,它是按照数字大小从小到大排列?非也,不信再看。
我们将上面的数组改一下:
1 | const numSort = [3,2,8,10,6,20,5,7,9,1,4].sort() |
神奇的事情发生了,10比2小?20比3小?
注意了,sort()方法实际接收一个函数,以函数的返回值来指定按某种顺序排列,如果省略函数,则按照将元素转为字符串的各字符的Unicode位点进行排序。所以,如果这里想要按照数字的真实大小排序,可以这样写。
1 | [3,2,8,10,6,20,5,7,9,1,4].sort((a,b) => a-b) |
依据是什么?是排序函数的算法规则:
- 如果 a - b 小于 0 ,a 会被排列到 b 之前;
- 如果 a - b 等于 0 ,a 和 b 的相对位置不变;
- 如果 a - b 大于 0 ,b 会被排列到 a 之前。
如果比较对象是字符串,方法也一样,所以,一般情况下,不要偷懒,我们可以充分运用这个特点,对需要的规则进行定制。
上面讨论的是对数字或者字符串进行排序,日常需求中,往往不会这么简单,可能会对一列包含多个属性的对象数组进行排序,比如开始提到的:价格、销量等。
怎样根据某个属性对数组排序。
其实也不难,同样道理,拿上面的商品列表(goodsList)为例,如果以价格排序,只需要这样:
1 | goodList.sort((a,b)=>{ |
就可以了。
说了排序,顺便说下反转(reverse),反转也是一种排序,只是它没什么规则可言,直接将一组元素首尾颠倒,[1,2,3]会变成[3,2,1],[‘a’,’f’,’c’]变成[ ‘c’, ‘f’, ‘a’ ]。
合并——concat()/扩展运算
理想情况下,我们获取一个数组,操作一个数组是最好,但有时数据来源不止一个,可能是两个或多个,在展示或传递的时候,又需要合为一个,就要用合并方法,传统方法是concat()。
1 | const primary = [1,2,3,4,5,6], |
但是,如果觉得仅此而已,就又错了。
- concat()不仅可以用来合并数组,还可以合并一个数组和其他类型的值,比如数字、字符串等。
1 | primary.concat(7,8) // [1, 2, 3, 4, 5, 6, 7, 8] |
- concat()在合并数组时,不改变原数组,而是返回新数组,但是,新数组包含的是原数组的浅拷贝。
1 | const primary = [{a:1}], |
引用类型总是带给我们“惊喜”,在使用时要多加注意。
当然,扩展运算符依然是简洁。上面的操作只需要这样就可以:
1 | const grade = [...primary,...middle,...high] |
返回新数组——map()/filter()
新数组是什么意思?
大部分情况下,我们拿到的数据都是由对象组成的数组,对象是集合,会包含很多东西,它本身的数据、它关联的其他数据等,少则几条,多则几十条,但在传递或者展示的时候并不需要把它们都带着,或者,我们需要在原有基础上进行处理,这时候就可以按需返回处理后的新数组。
比如下面这样:
1 | [ |
我们得到一个商品列表,但只需要把name拿出来用,就可以这样。
1 | let nameList = goodsList.map(item=>{ |
又或者,我们需要在原价的基础上,对所有商品进行打折处理,就可以这样:
1 | let priceDiscountList = goodsList.map(item=>{ |
当然,实际项目中这个环节不会这么干,不然每有变动都要改JS逻辑代码,从易用性、效率和维护上都不利,只是借此说明用法。
介绍完map,再看filter,从字面意思很好理解,过滤,符合条件才会被筛选出来,它同样是接收一个函数。
比如我们将价格超过500的找出来。
1 | let priceLowList = goodsList.filter(item=>{ |
这两个方法在实际项目中极为常见,唯独需要注意的是它们的工作方式,它们都是生成新的数组,map需要返回的是数组元素,fliter则是筛选条件,千万记得”return“哦!
迭代处理——forEach()
这个方法,和上面两个极为相似,从底子上,都是可以访问到数组的每个元素,然后进行相应处理,区别在于,此方法仅用于迭代,好比以前常用的for循环,当然,功能的简单意味着可操作空间更大。
比如,我们可以这样实现类似map的效果。
1 | let nameList = [] |
也可以这样实现类似filter的效果。
1 | let priceLowList = [] |
是的,你可以写任何想要的逻辑,且它的执行效率比for循环更高,也更符合函数式编程范式。
元素判断——some()/every()
同样用于检查,接收回调函数,写入判断逻辑,区别在于,some()是“存在符合”即为true,而every()是“所有符合”才为true,类似 || 和 &&。比较简单,不再赘述。
应用拓展
不论学什么,人们习惯于追求“知”、“会”,缺少往“活”的方向继续挖掘,但往往“活”用的东西能带来更多益处和惊喜。
接下来看看数组是怎样在更多应用方案中施展拳脚的。
去重
当用户的操作是大量的、不确定的,难免有重复,有时候我们只需要知道某个值是否存在,而不是多个重复的值,这时就需要对数组进行去重(当然,还有其他方法保证单一值,这里重点是去重)。
去重方法有很多,原理是类似的——通过遍历数组做比较,保证值唯一。列三种大家参考:
- includes
1 | function unique(arr) { |
- filter
1 | function unique(arr) { |
- Set
1 | function unique (arr) { |
求和
求和的需求不是特别常见,但也是要掌握的。
- 递归
不考虑复杂度的情况下,递归是能比较容易想到的。
1 | function sum(arr) { |
- 循环
1 | function sum(arr) { |
- reduce()
上面介绍方法时本该包含这个方法,但放在这里更加合适,先看看它是什么。
完整参数列表:
reduce(accumulator, currentValue, index, array)
previousValue:上一次调用回调返回的值,或者是提供的初始值(initialValue)
currentValue:当前被处理的元素
index:当前元素的索引
array:调用reduce的数组
只看前两项,就可以看出答案,previousValue不就是循环方案中设置的s?而currentValue就是arr[i],那么求和只需要这样:
1 | function sum(arr) { |
reduce能做的事还有很多,甚至有些可称为“奇技淫巧”,鉴于篇幅,这里不多做介绍,等系列文章结束后,还会跟大家详聊。
拍平
这个词儿比较接地气,拍平,意味着操作之前是不平的,怎么叫不平?比如:
1 | [1,[2,3],4,[5,[6,7]]] |
这个数组中既有元素,又有数组,数组还套数组,是有层次嵌套的,这时候,如果我们想把它们当做一维或者二维数组处理,显然是会出错的,就要进行降维。
ES5之前,“拍平”不是那么容易,而ES6出现了新的“真香”方法——flat(),这个方法,前面也没说,嗯~
怎么拍呢,如果上面的数组,想变成二维数组,就这样:
1 | arrFlat.flat(1) |
彻底拍平就这样:
1 | arrFlat.flat(2) |
默认情况是只处理一层的,即flat()等效于flat(1)。
”高级“数据结构
现在大家出去面试,比较紧张两样东西,一是”原理“,二是”算法“。
算法离不开数据结构,JavaScript原生数据结构很少,语言本身只提供了基础能力,但由基础能力可衍生出更复杂或者有特殊用途的结构。
树
上面已经提到数组套数组,其实,较为常见的”树“,就是数组套数组,一个父节点包含多个子节点,子节点可能也包含子节点,就成了树。
最常见的,行政区域树,组织架构树等,每涉及到树,基本上都会有增、删、改,或者级联选择,先知道就好,暂不赘述。
队列
队列是有先进先出(FIFO)限制的数组,正像平时我们排队,只能队尾进,队头出,不能插队。
队列的应用也是处理有序的不能插队的任务,比如:叫号系统、购票系统等。
栈
栈和队列类似,但它是后进先出(LIFO),可想象为叠盘子,放的时候从下往上,拿的时候从上往下。
应用有函数调用栈,括号匹配问题等。
总结
数组是大话题,虽然听起来没有那么高大上,应用非常多,也是某些”高级数据结构“的基石。
本文尽量做到繁简得当,但难免会有一些东西没介绍,也会有些朋友觉得内容多,都正常,大家掌握程度不同嘛,有什么问题需要深入交流的,欢迎踊跃留言。
此文之后,系列进行到第九篇,先是介绍简单类型,再是对象,然后是数组,不知读者朋友是否看到了此中的逻辑,能猜到下一篇聊什么吗?~