JavaScript 知识总结
1 简介
定义:JavaScript 一种直译式脚本语言,是一种动态类型、弱类型、基于原型的语言。
产生原因:网页没有复杂交互行为。
2 语法
2.1 变量类型
- number
- string
- boolean
- object
- function
- null
- undefined
2.2 关键字
-
流程控制
- return
- continue
- break
- do
- while
- for
- if
- else
- switch
- case
-
定义
- var :定义变量
- let : 不可重复声明、块级作用域(ES6+)
- const : 不可重复声明、块级作用域(ES6+)
- function
- new
-
异常处理
- try :测试代码块的错误
- catch:处理错误
- finally
- throw :创建自定义错误
-
类型判断
- typeof
- instanceof
-
其他
- this
- in : 与 for 一起使用用于遍历对象的属性名或判断某个对象是否具有某个属性
- delete : 删除对象的某个属性,只能删除自身定义的公有属性,即"this.属性名"定义的属性,而不能删除私有属性或通过 proptotype 定义的公有属性(已实践)。此外可删除直接在对象上添加的属性,如
var a = new Object();a.name = "name"; delete a.name;
- with : 引用一个对象,使访问属性与方法更加方便(只能访问与修改属性,不能增加属性与方法)
2.3 运算符
- 数学运算符 : +,-,*,/,%
- 比较运算符 : >,<,==,!=,>=,<=,===,!==
- 逻辑运算符 : &&,||,!
- 位操作符 : ^,~,&,|,«,»,»>
2.4 函数的定义
- 正常方式
function mysum(num1,num2){return num1+num2;}
- 直接量/匿名方式
var mysum = function(num1,num2){return num1+num2;}
- 构造器方式
new Function("num1","num2","return num1+num2;")
- 箭头函数(ES6)
3 常用对象
3.1 内建对象
- Math
- Date
- getTime() : 获取毫秒值
- String
- length : 字符串的长度
- indexOf() : 检索字符串。
- substr() : 从起始索引号提取字符串中指定数目的字符。
- split() : 把字符串分割为字符串数组。
- Array
- length : 设置或返回数组中元素的数目。
- join() : 把数组的所有元素放入一个字符串。元素通过指定的分隔符进行分隔。
- push() : 向数组的末尾添加一个或更多元素,并返回新的长度。
- splice() : 删除元素,并返回删除元素
- RegExp
- 修饰符
- i : 执行对大小写不敏感的匹配。
- g : 执行全局匹配(查找所有匹配而非在找到第一个匹配后停止)。
- 中括号
- [abc] : 查找方括号之间的任何字符。
- [^abc] : 查找任何不在方括号之间的字符。
- [0-9] : 查找任何从 0 至 9 的数字。
- [a-z] : 查找任何从小写 a 到小写 z 的字符。
- [A-Z] : 查找任何从大写 A 到大写 Z 的字符。
- [A-z] : 查找任何从大写 A 到小写 z 的字符。
- 元字符
- . : 查找单个字符,除了换行和行结束符。
- \w : 查找任意一个字母、数字或下划线字符。
- \W : 查找非\w 字符。
- \d : 查找数字。
- \D : 查找非数字字符。
- \s : 查找空白字符。
- \S : 查找非空白字符。
- \b : 匹配单词边界。
- \B : 匹配非单词边界。
- 量词
- n+ : 匹配任何包含至少一个 n 的字符串。
- n* : 匹配任何包含零个或多个 n 的字符串。
- n? : 匹配任何包含零个或一个 n 的字符串。
- n{X} : 匹配包含 X 个 n 的序列的字符串。
- n{X,Y} : 匹配包含 X 至 Y 个 n 的序列的字符串。
- n{X,} : 匹配包含至少 X 个 n 的序列的字符串。
- n$ : 匹配任何结尾为 n 的字符串。
- ^n : 匹配任何开头为 n 的字符串。
- ?=n : 匹配任何其后紧接指定字符串 n 的字符串。
- ?!n : 匹配任何其后没有紧接指定字符串 n 的字符串。
- 修饰符
3.2 HTML DOM 对象
- document
- document.getElementById() : 返回对拥有指定 id 的第一个对象的引用。
- element
- id : 设置或者返回元素的 id。
- className : 设置或返回元素的 class 属性
- style : 设置或返回元素的样式属性
- title : 设置或返回元素的 title 属性
- getAttribute() : 返回指定元素的属性值
- setAttribute() : 设置或者改变指定属性并指定值
- innerHTML : 设置或者返回元素的内容
- outerHTML : 设置或返回元素的全部内容包括标签部分
- clientHeight : 在页面上返回内容的可视高度(不包括边框,边距或滚动条)
- clientWidth : 在页面上返回内容的可视宽度(不包括边框,边距或滚动条)
- scrollHeight : 返回整个元素的高度(包括带滚动条的隐蔽的地方)
- scrollTop : 返回滚动的高度
3.3 HTML BOM 对象
- window
- alert() : 显示带有一段消息和一个确认按钮的警告框。
- setInterval(函数,毫秒数) : 每隔一段时间执行函数
- clearInterval(定时器对象) : 结束定时器
- setTimeout(函数,毫秒数) : 一段时间后执行函数
- clearTimeout(超时器对象) : 结束超时器
- location
- href : 返回完整的 URL(直接使用 location 就相当于调用这个属性)
- history
- back() : 加载 history 列表中的前一个 URL
- forward() : 加载 history 列表中的下一个 URL
- go() : 加载 history 列表中的某个具体页面
- screen
- availHeight : 返回屏幕的高度(不包括 Windows 任务栏)
- availWidth : 返回屏幕的宽度(不包括 Windows 任务栏)
- height : 返回屏幕的总高度
- width : 返回屏幕的总宽度
- navigator
- userAgent : 返回由客户机发送服务器的 user-agent 头部的值
3.4 AJAX 对象
- ActiveXObject
- XMLHttpRequest
4 常用全局方法
- parseInt() : 字符串转换整型
- eval() : 执行字符串表达式,如果是变量需要加上括号:eval("("+var+")")
- encodeURI(string):对 URI 字符串进行 UTF-8 的编码
5 扩展
5.1 ES5 数组新增方法
5.1.1 一、前言-索引
ES5 中新增的不少东西,了解之对我们写 JavaScript 会有不少帮助,比如数组这块,我们可能就不需要去有板有眼地for
循环了。
ES5 中新增了写数组方法,如下:
- forEach (js v1.6)
- map (js v1.6)
- filter (js v1.6)
- some (js v1.6)
- every (js v1.6)
- indexOf (js v1.6)
- lastIndexOf (js v1.6)
- reduce (js v1.8)
- reduceRight (js v1.8)
浏览器支持
- Opera 11+
- Firefox 3.6+
- Safari 5+
- Chrome 8+
- Internet Explorer 9+
对于让人失望很多次的 IE6-IE8 浏览器,Array 原型扩展可以实现以上全部功能,例如forEach
方法:
|
|
5.1.2 二、一个一个来
5.1.2.1 forEach
forEach
是 Array 新方法中最基本的一个,就是遍历,循环。例如下面这个例子:
|
|
等同于下面这个传统的for
循环:
|
|
Array 在 ES5 新增的方法中,参数都是function
类型,默认有传参,这些参数分别是?见下面:
|
|
显而易见,forEach
方法中的function
回调支持 3 个参数,第 1 个是遍历的数组内容;第 2 个是对应的数组索引,第 3 个是数组本身。
因此,我们有:
|
|
对比 jQuery 中的$.each
方法:
|
|
会发现,第 1 个和第 2 个参数正好是相反的,大家要注意了,不要记错了。后面类似的方法,例如$.map
也是如此。
现在,我们就可以使用forEach
卖弄一个稍显完整的例子了,数组求和:
|
|
再下面,更进一步,forEach
除了接受一个必须的回调函数参数,还可以接受一个可选的上下文参数(改变回调函数里面的this
指向)(第 2 个参数)。
|
|
例子更能说明一切:
|
|
如果这第 2 个可选参数不指定,则使用全局对象代替(在浏览器是为window
),严格模式下甚至是undefined
.
另外,forEach 不会遍历纯粹“占着官位吃空饷”的元素的,例如下面这个例子:
|
|
综上全部规则,我们就可以对 IE6-IE8 进行仿真扩展了,如下代码:
|
|
现在拿上面“张含韵”的例子测下我们扩展的forEach
方法,您可能狠狠地点击这里:兼容处理的 forEach 方法 demo
例如 IE7 浏览器下:
5.1.2.2 map
这里的 map
不是“地图”的意思,而是指“映射”。
|
|
基本用法跟 forEach
方法类似:
|
|
callback
的参数也类似:
|
|
map
方法的作用不难理解,“映射”嘛,也就是原数组被“映射”成对应新数组。下面这个例子是数值项求平方:
|
|
callback
需要有return
值,如果没有,就像下面这样:
|
|
结果如下图,可以看到,数组所有项都被映射成了undefined
:
在实际使用的时候,我们可以利用map
方法方便获得对象数组中的特定属性值们。例如下面这个例子(之后的兼容 demo 也是该例子):
|
|
Array.prototype
扩展可以让 IE6-IE8 浏览器也支持map
方法:
|
|
您可以狠狠地点击这里:兼容 map 方法测试 demo
结果显示如下图,IE6 浏览器:
5.1.2.3 filter
filter
为“过滤”、“筛选”之意。指数组过滤后,返回过滤后的新数组。用法跟 map
极为相似:
|
|
filter
的callback
函数需要返回布尔值true
或false
. 如果为true
则表示,恭喜你,通过啦!如果为false
, 只能高歌“我只能无情地将你抛弃……”)。
可能会疑问,一定要是Boolean
值吗?我们可以简单测试下嘛,如下:
|
|
有此可见,返回值只要是弱等于== true/false
就可以了,而非非得返回 === true/false
.
因此,我们在为低版本浏览器扩展时候,无需关心是否返回值是否是纯粹布尔值(见下黑色代码部分):
|
|
接着上面map
筛选邮件的例子,您可以狠狠地点击这里:兼容处理后 filter 方法测试 demo
主要测试代码为:
|
|
实际上,存在一些语法糖可以实现
map+filter
的效果,被称之为“数组简约式(Array comprehensions)”。目前,仅 FireFox 浏览器可以实现,展示下又不会怀孕:
1 2 3
var zhangEmails = [user.email for each (user in users) if (/^zhang/.test(user.email)) ]; console.log(zhangEmails); // [zhang@email.com]
5.1.2.4 some
some
意指“某些”,指是否“某些项”合乎条件。与下面的 every
算是好基友,every
表示是否“每一项”都要靠谱。用法如下:
|
|
例如下面的简单使用:
|
|
结果弹出了“朕准了”文字。 some
要求至少有 1 个值让callback
返回true
就可以了。显然,8 > 7
,因此scores.some(higherThanCurrent)
值为true
.
我们自然可以使用forEach
进行判断,不过,相比some
, 不足在于,some
只有有true
即返回不再执行了。
IE6-IE8 扩展如下:
|
|
于是,我们就有了“朕准了”的 demo,您可以狠狠地点击这里:兼容处理后的 some 方法 demo
5.1.2.5 every
跟 some
的基友关系已经是公开的秘密了,同样是返回 Boolean 值,不过,every
需要每一个妃子都要让朕满意,否则——“来人,给我拖出去砍了!”
IE6-IE8 扩展(与some
相比就是true
和false
调换一下):
|
|
还是那个朕的例子,您可以狠狠地点击这里:是否 every 妃子让朕满意 demo
|
|
结果是:
5.1.2.6 indexOf
indexOf
方法在字符串中自古就有,
|
|
。数组这里的 indexOf
方法与之类似。
|
|
返回整数索引值,如果没有匹配(严格匹配),返回-1
. fromIndex
可选,表示从这个位置开始搜索,若缺省或格式不合要求,使用默认值0
,我在 FireFox 下测试,发现使用字符串数值也是可以的,例如"3"
和3
都可以。
|
|
兼容处理如下:
|
|
一个路子下来的,显然,轮到 demo 了,您可以狠狠地点击这里:兼容处理后 indexOf 方法测试 demo
下图为 ietester IE6 下的截图:
5.1.2.7 lastIndexOf
lastIndexOf
方法与 indexOf
方法类似:
|
|
只是lastIndexOf
是从字符串的末尾开始查找,而不是从开头。还有一个不同就是fromIndex
的默认值是array.length - 1
而不是0
.
IE6 等浏览器如下折腾:
|
|
于是,则有:
|
|
懒得截图了,结果查看可狠狠地点击这里:lastIndexOf 测试 demo
5.1.2.8 reduce
reduce
是 JavaScript 1.8 中才引入的,中文意思为“减少”、“约简”。不过,从功能来看,我个人是无法与“减少”这种含义联系起来的,反而更接近于“迭代”、“递归(recursion)”,擦,因为单词这么接近,不会是 ECMA-262 5th 制定者笔误写错了吧~~
此方法相比上面的方法都复杂,用法如下:
|
|
callback
函数接受 4 个参数:之前值、当前值、索引值以及数组本身。initialValue
参数可选,表示初始值。若指定,则当作最初使用的previous
值;如果缺省,则使用数组的第一个元素作为previous
初始值,同时current
往后排一位,相比有initialValue
值少一次迭代。
|
|
说明:
- 因为
initialValue
不存在,因此一开始的previous
值等于数组的第一个元素。 - 从而
current
值在第一次调用的时候就是2
. - 最后两个参数为索引值
index
以及数组本身array
.
以下为循环执行过程:
|
|
有了reduce
,我们可以轻松实现二维数组的扁平化:
|
|
兼容处理 IE6-IE8:
|
|
然后,测试整合,demo 演示,您可以狠狠地点击这里:兼容 IE6 的 reduce 方法测试 demo
IE6 浏览器下结果如下图:
5.1.2.9 reduceRight
reduceRight
跟 reduce
相比,用法类似:
|
|
实现上差异在于reduceRight
是从数组的末尾开始实现。看下面这个例子:
|
|
结果0
是如何得到的呢?
我们一步一步查看循环执行:
|
|
为使低版本浏览器支持此方法,您可以添加如下代码:
|
|
您可以狠狠地点击这里:reduceRight 简单使用 demo
对比 FireFox 浏览器和 IE7 浏览器下的结果:
5.1.3 三、更进一步的应用
我们还可以将上面这些数组方法应用在其他对象上。
例如,我们使用 forEach 遍历 DOM 元素。
|
|
可以输出页面所有div
的类名,您可以狠狠地点击这里:Array 新方法 forEach 遍历 DOM demo
结果如下,IE6 下,demo 结果:
等很多其他类数组应用。
5.1.4 四、最后一点点了
本文为低版本 IE 扩展的 Array 方法我都合并到一个 JS 中了,您可以轻轻的右键这里或下载或查看:es5-array.js
以上所有未 IE 扩展的方法都是自己根据理解写的,虽然多番测试,难免还会有细节遗漏的,欢迎指出来。
5.2 ES6
5.2.1 let 和 const
5.2.1.1 let 命令
5.2.1.1.1 基本用法
ES6 新增了let
命令,用来声明变量。它的用法类似于var
,但是所声明的变量,只在let
命令所在的代码块内有效。
|
|
上面代码在代码块之中,分别用let
和var
声明了两个变量。然后在代码块之外调用这两个变量,结果let
声明的变量报错,var
声明的变量返回了正确的值。这表明,let
声明的变量只在它所在的代码块有效。
for
循环的计数器,就很合适使用let
命令。
|
|
上面代码中,计数器i
只在for
循环体内有效,在循环体外引用就会报错。
下面的代码如果使用var
,最后输出的是10
。
|
|
上面代码中,变量i
是var
命令声明的,在全局范围内都有效,所以全局只有一个变量i
。每一次循环,变量i
的值都会发生改变,而循环内被赋给数组a
的函数内部的console.log(i)
,里面的i
指向的就是全局的i
。也就是说,所有数组a
的成员里面的i
,指向的都是同一个i
,导致运行时输出的是最后一轮的i
的值,也就是 10。
如果使用let
,声明的变量仅在块级作用域内有效,最后输出的是 6。
|
|
上面代码中,变量i
是let
声明的,当前的i
只在本轮循环有效,所以每一次循环的i
其实都是一个新的变量,所以最后输出的是6
。你可能会问,如果每一轮循环的变量i
都是重新声明的,那它怎么知道上一轮循环的值,从而计算出本轮循环的值?这是因为 JavaScript 引擎内部会记住上一轮循环的值,初始化本轮的变量i
时,就在上一轮循环的基础上进行计算。
另外,for
循环还有一个特别之处,就是设置循环变量的那部分是一个父作用域,而循环体内部是一个单独的子作用域。
|
|
上面代码正确运行,输出了 3 次abc
。这表明函数内部的变量i
与循环变量i
不在同一个作用域,有各自单独的作用域(同一个作用域不可使用 let
重复声明同一个变量)。
5.2.1.1.2 不存在变量提升
var
命令会发生“变量提升”现象,即变量可以在声明之前使用,值为undefined
。这种现象多多少少是有些奇怪的,按照一般的逻辑,变量应该在声明语句之后才可以使用。
为了纠正这种现象,let
命令改变了语法行为,它所声明的变量一定要在声明后使用,否则报错。
|
|
上面代码中,变量foo
用var
命令声明,会发生变量提升,即脚本开始运行时,变量foo
已经存在了,但是没有值,所以会输出undefined
。变量bar
用let
命令声明,不会发生变量提升。这表示在声明它之前,变量bar
是不存在的,这时如果用到它,就会抛出一个错误。
5.2.1.1.3 暂时性死区
只要块级作用域内存在let
命令,它所声明的变量就“绑定”(binding)这个区域,不再受外部的影响。
|
|
上面代码中,存在全局变量tmp
,但是块级作用域内let
又声明了一个局部变量tmp
,导致后者绑定这个块级作用域,所以在let
声明变量前,对tmp
赋值会报错。
ES6 明确规定,如果区块中存在let
和const
命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
总之,在代码块内,使用let
命令声明变量之前,该变量都是不可用的。这在语法上,称为“暂时性死区”(temporal dead zone,简称 TDZ)。
|
|
上面代码中,在let
命令声明变量tmp
之前,都属于变量tmp
的“死区”。
“暂时性死区”也意味着typeof
不再是一个百分之百安全的操作。
|
|
上面代码中,变量x
使用let
命令声明,所以在声明之前,都属于x
的“死区”,只要用到该变量就会报错。因此,typeof
运行时就会抛出一个ReferenceError
。
作为比较,如果一个变量根本没有被声明,使用typeof
反而不会报错。
|
|
上面代码中,undeclared_variable
是一个不存在的变量名,结果返回“undefined”。所以,在没有let
之前,typeof
运算符是百分之百安全的,永远不会报错。现在这一点不成立了。这样的设计是为了让大家养成良好的编程习惯,变量一定要在声明之后使用,否则就报错。
有些“死区”比较隐蔽,不太容易发现。
|
|
上面代码中,调用bar
函数之所以报错(某些实现可能不报错),是因为参数x
默认值等于另一个参数y
,而此时y
还没有声明,属于“死区”。如果y
的默认值是x
,就不会报错,因为此时x
已经声明了。
|
|
另外,下面的代码也会报错,与var
的行为不同。
|
|
上面代码报错,也是因为暂时性死区。使用let
声明变量时,只要变量在还没有声明完成前使用,就会报错。上面这行就属于这个情况,在变量x
的声明语句还没有执行完成前,就去取x
的值,导致报错”x 未定义“。
ES6 规定暂时性死区和let
、const
语句不出现变量提升,主要是为了减少运行时错误,防止在变量声明前就使用这个变量,从而导致意料之外的行为。这样的错误在 ES5 是很常见的,现在有了这种规定,避免此类错误就很容易了。
总之,暂时性死区的本质就是,只要一进入当前作用域,所要使用的变量就已经存在了,但是不可获取,只有等到声明变量的那一行代码出现,才可以获取和使用该变量。
5.2.1.1.4 不允许重复声明
let
不允许在相同作用域内,重复声明同一个变量。
|
|
因此,不能在函数内部重新声明参数。
|
|
5.2.1.2 块级作用域
5.2.1.2.1 为什么需要块级作用域?
ES5 只有全局作用域和函数作用域,没有块级作用域,这带来很多不合理的场景。
第一种场景,内层变量可能会覆盖外层变量。
|
|
上面代码的原意是,if
代码块的外部使用外层的tmp
变量,内部使用内层的tmp
变量。但是,函数f
执行后,输出结果为undefined
,原因在于变量提升,导致内层的tmp
变量覆盖了外层的tmp
变量。
第二种场景,用来计数的循环变量泄露为全局变量。
|
|
上面代码中,变量i
只用来控制循环,但是循环结束后,它并没有消失,泄露成了全局变量。
5.2.1.2.2 ES6 的块级作用域
let
实际上为 JavaScript 新增了块级作用域。
|
|
上面的函数有两个代码块,都声明了变量n
,运行后输出 5。这表示外层代码块不受内层代码块的影响。如果两次都使用var
定义变量n
,最后输出的值才是 10。
ES6 允许块级作用域的任意嵌套。
|
|
上面代码使用了一个五层的块级作用域,每一层都是一个单独的作用域。第四层作用域无法读取第五层作用域的内部变量。
内层作用域可以定义外层作用域的同名变量。
|
|
块级作用域的出现,实际上使得获得广泛应用的匿名立即执行函数表达式(匿名 IIFE)不再必要了。
|
|
5.2.1.2.3 块级作用域与函数声明
函数能不能在块级作用域之中声明?这是一个相当令人混淆的问题。
ES5 规定,函数只能在顶层作用域和函数作用域之中声明,不能在块级作用域声明。
|
|
上面两种函数声明,根据 ES5 的规定都是非法的。
但是,浏览器没有遵守这个规定,为了兼容以前的旧代码,还是支持在块级作用域之中声明函数,因此上面两种情况实际都能运行,不会报错。
ES6 引入了块级作用域,明确允许在块级作用域之中声明函数。ES6 规定,块级作用域之中,函数声明语句的行为类似于let
,在块级作用域之外不可引用。
|
|
上面代码在 ES5 中运行,会得到“I am inside!”,因为在if
内声明的函数f
会被提升到函数头部,实际运行的代码如下。
|
|
ES6 就完全不一样了,理论上会得到“I am outside!”。因为块级作用域内声明的函数类似于let
,对作用域之外没有影响。但是,如果你真的在 ES6 浏览器中运行一下上面的代码,是会报错的,这是为什么呢?
|
|
上面的代码在 ES6 浏览器中,都会报错。
原来,如果改变了块级作用域内声明的函数的处理规则,显然会对老代码产生很大影响。为了减轻因此产生的不兼容问题,ES6 在附录 B里面规定,浏览器的实现可以不遵守上面的规定,有自己的行为方式。
- 允许在块级作用域内声明函数。
- 函数声明类似于
var
,即会提升到全局作用域或函数作用域的头部。 - 同时,函数声明还会提升到所在的块级作用域的头部。
注意,上面三条规则只对 ES6 的浏览器实现有效,其他环境的实现不用遵守,还是将块级作用域的函数声明当作let
处理。
根据这三条规则,浏览器的 ES6 环境中,块级作用域内声明的函数,行为类似于var
声明的变量。上面的例子实际运行的代码如下。
|
|
考虑到环境导致的行为差异太大,应该避免在块级作用域内声明函数。如果确实需要,也应该写成函数表达式,而不是函数声明语句。
|
|
另外,还有一个需要注意的地方。ES6 的块级作用域必须有大括号,如果没有大括号,JavaScript 引擎就认为不存在块级作用域。
|
|
上面代码中,第一种写法没有大括号,所以不存在块级作用域,而let
只能出现在当前作用域的顶层,所以报错。第二种写法有大括号,所以块级作用域成立。
函数声明也是如此,严格模式下,函数只能声明在当前作用域的顶层。
|
|
5.2.1.3 const 命令
5.2.1.3.1 基本用法
const
声明一个只读的常量。一旦声明,常量的值就不能改变。
|
|
上面代码表明改变常量的值会报错。
const
声明的变量不得改变值,这意味着,const
一旦声明变量,就必须立即初始化,不能留到以后赋值。
|
|
上面代码表示,对于const
来说,只声明不赋值,就会报错。
const
的作用域与let
命令相同:只在声明所在的块级作用域内有效。
|
|
const
命令声明的常量也是不提升,同样存在暂时性死区,只能在声明的位置后面使用。
|
|
上面代码在常量MAX
声明之前就调用,结果报错。
const
声明的常量,也与let
一样不可重复声明。
|
|
5.2.1.3.2 本质
const
实际上保证的,并不是变量的值不得改动,而是变量指向的那个内存地址所保存的数据不得改动。对于简单类型的数据(数值、字符串、布尔值),值就保存在变量指向的那个内存地址,因此等同于常量。但对于复合类型的数据(主要是对象和数组),变量指向的内存地址,保存的只是一个指向实际数据的指针,const
只能保证这个指针是固定的(即总是指向另一个固定的地址),至于它指向的数据结构是不是可变的,就完全不能控制了。因此,将一个对象声明为常量必须非常小心。
|
|
上面代码中,常量foo
储存的是一个地址,这个地址指向一个对象。不可变的只是这个地址,即不能把foo
指向另一个地址,但对象本身是可变的,所以依然可以为其添加新属性。
下面是另一个例子。
|
|
上面代码中,常量a
是一个数组,这个数组本身是可写的,但是如果将另一个数组赋值给a
,就会报错。
如果真的想将对象冻结,应该使用Object.freeze
方法。
|
|
上面代码中,常量foo
指向一个冻结的对象,所以添加新属性不起作用,严格模式时还会报错。
除了将对象本身冻结,对象的属性也应该冻结。下面是一个将对象彻底冻结的函数。
|
|
5.2.1.3.3 ES6 声明变量的六种方法
ES5 只有两种声明变量的方法:var
命令和function
命令。ES6 除了添加let
和const
命令,后面章节还会提到,另外两种声明变量的方法:import
命令和class
命令。所以,ES6 一共有 6 种声明变量的方法。
5.2.1.4 顶层对象的属性
顶层对象,在浏览器环境指的是window
对象,在 Node 指的是global
对象。ES5 之中,顶层对象的属性与全局变量是等价的。
|
|
上面代码中,顶层对象的属性赋值与全局变量的赋值,是同一件事。
顶层对象的属性与全局变量挂钩,被认为是 JavaScript 语言最大的设计败笔之一。这样的设计带来了几个很大的问题,首先是没法在编译时就报出变量未声明的错误,只有运行时才能知道(因为全局变量可能是顶层对象的属性创造的,而属性的创造是动态的);其次,程序员很容易不知不觉地就创建了全局变量(比如打字出错);最后,顶层对象的属性是到处可以读写的,这非常不利于模块化编程。另一方面,window
对象有实体含义,指的是浏览器的窗口对象,顶层对象是一个有实体含义的对象,也是不合适的。
ES6 为了改变这一点,一方面规定,为了保持兼容性,var
命令和function
命令声明的全局变量,依旧是顶层对象的属性;另一方面规定,let
命令、const
命令、class
命令声明的全局变量,不属于顶层对象的属性。也就是说,从 ES6 开始,全局变量将逐步与顶层对象的属性脱钩。
|
|
上面代码中,全局变量a
由var
命令声明,所以它是顶层对象的属性;全局变量b
由let
命令声明,所以它不是顶层对象的属性,返回undefined
。
5.2.1.5 globalThis 对象
JavaScript 语言存在一个顶层对象,它提供全局环境(即全局作用域),所有代码都是在这个环境中运行。但是,顶层对象在各种实现里面是不统一的。
- 浏览器里面,顶层对象是
window
,但 Node 和 Web Worker 没有window
。 - 浏览器和 Web Worker 里面,
self
也指向顶层对象,但是 Node 没有self
。 - Node 里面,顶层对象是
global
,但其他环境都不支持。
同一段代码为了能够在各种环境,都能取到顶层对象,现在一般是使用this
关键字,但是有局限性。
- 全局环境中,
this
会返回顶层对象。但是,Node.js 模块中this
返回的是当前模块,ES6 模块中this
返回的是undefined
。 - 函数里面的
this
,如果函数不是作为对象的方法运行,而是单纯作为函数运行,this
会指向顶层对象。但是,严格模式下,这时this
会返回undefined
。 - 不管是严格模式,还是普通模式,
new Function('return this')()
,总是会返回全局对象。但是,如果浏览器用了 CSP(Content Security Policy,内容安全策略),那么eval
、new Function
这些方法都可能无法使用。
综上所述,很难找到一种方法,可以在所有情况下,都取到顶层对象。下面是两种勉强可以使用的方法。
|
|
ES2020 在语言标准的层面,引入globalThis
作为顶层对象。也就是说,任何环境下,globalThis
都是存在的,都可以从它拿到顶层对象,指向全局环境下的this
。
垫片库global-this
模拟了这个提案,可以在所有环境拿到globalThis
。
5.2.2 解构赋值
5.2.2.1 数组的解构赋值
5.2.2.1.1 基本用法
ES6 允许按照一定模式,从数组和对象中提取值,对变量进行赋值,这被称为解构(Destructuring)。
以前,为变量赋值,只能直接指定值。
|
|
ES6 允许写成下面这样。
|
|
上面代码表示,可以从数组中提取值,按照对应位置,对变量赋值。
本质上,这种写法属于“模式匹配”,只要等号两边的模式相同,左边的变量就会被赋予对应的值。下面是一些使用嵌套数组进行解构的例子。
|
|
如果解构不成功,变量的值就等于undefined
。
|
|
以上两种情况都属于解构不成功,foo
的值都会等于undefined
。
另一种情况是不完全解构,即等号左边的模式,只匹配一部分的等号右边的数组。这种情况下,解构依然可以成功。
|
|
上面两个例子,都属于不完全解构,但是可以成功。
如果等号的右边不是数组(或者严格地说,不是可遍历的结构,参见《Iterator》一章),那么将会报错。
|
|
上面的语句都会报错,因为等号右边的值,要么转为对象以后不具备 Iterator 接口(前五个表达式),要么本身就不具备 Iterator 接口(最后一个表达式)。
对于 Set 结构,也可以使用数组的解构赋值。
|
|
事实上,只要某种数据结构具有 Iterator 接口,都可以采用数组形式的解构赋值。
|
|
上面代码中,fibs
是一个 Generator 函数(参见《Generator 函数》一章),原生具有 Iterator 接口。解构赋值会依次从这个接口获取值。
5.2.2.1.2 默认值
解构赋值允许指定默认值。
|
|
注意,ES6 内部使用严格相等运算符(===
),判断一个位置是否有值。所以,只有当一个数组成员严格等于undefined
,默认值才会生效。
|
|
上面代码中,如果一个数组成员是null
,默认值就不会生效,因为null
不严格等于undefined
。
如果默认值是一个表达式,那么这个表达式是惰性求值的,即只有在用到的时候,才会求值。
|
|
上面代码中,因为x
能取到值,所以函数f
根本不会执行。上面的代码其实等价于下面的代码。
|
|
默认值可以引用解构赋值的其他变量,但该变量必须已经声明。
|
|
上面最后一个表达式之所以会报错,是因为x
用y
做默认值时,y
还没有声明。
5.2.2.2 对象的解构赋值
5.2.2.2.1 简介
解构不仅可以用于数组,还可以用于对象。
|
|
对象的解构与数组有一个重要的不同。数组的元素是按次序排列的,变量的取值由它的位置决定;而对象的属性没有次序,变量必须与属性同名,才能取到正确的值。
|
|
上面代码的第一个例子,等号左边的两个变量的次序,与等号右边两个同名属性的次序不一致,但是对取值完全没有影响。第二个例子的变量没有对应的同名属性,导致取不到值,最后等于undefined
。
如果解构失败,变量的值等于undefined
。
|
|
上面代码中,等号右边的对象没有foo
属性,所以变量foo
取不到值,所以等于undefined
。
对象的解构赋值,可以很方便地将现有对象的方法,赋值到某个变量。
|
|
上面代码的例一将Math
对象的对数、正弦、余弦三个方法,赋值到对应的变量上,使用起来就会方便很多。例二将console.log
赋值到log
变量。
如果变量名与属性名不一致,必须写成下面这样。
|
|
这实际上说明,对象的解构赋值是下面形式的简写(参见《对象的扩展》一章)。
|
|
也就是说,对象的解构赋值的内部机制,是先找到同名属性,然后再赋给对应的变量。真正被赋值的是后者,而不是前者。
|
|
上面代码中,foo
是匹配的模式,baz
才是变量。真正被赋值的是变量baz
,而不是模式foo
。
与数组一样,解构也可以用于嵌套结构的对象。
|
|
注意,这时p
是模式,不是变量,因此不会被赋值。如果p
也要作为变量赋值,可以写成下面这样。
|
|
下面是另一个例子。
|
|
上面代码有三次解构赋值,分别是对loc
、start
、line
三个属性的解构赋值。注意,最后一次对line
属性的解构赋值之中,只有line
是变量,loc
和start
都是模式,不是变量。
下面是嵌套赋值的例子。
|
|
如果解构模式是嵌套的对象,而且子对象所在的父属性不存在,那么将会报错。
|
|
上面代码中,等号左边对象的foo
属性,对应一个子对象。该子对象的bar
属性,解构时会报错。原因很简单,因为foo
这时等于undefined
,再取子属性就会报错。
注意,对象的解构赋值可以取到继承的属性。
|
|
上面代码中,对象obj1
的原型对象是obj2
。foo
属性不是obj1
自身的属性,而是继承自obj2
的属性,解构赋值可以取到这个属性。
5.2.2.2.2 默认值
对象的解构也可以指定默认值。
|
|
默认值生效的条件是,对象的属性值严格等于undefined
。
|
|
上面代码中,属性x
等于null
,因为null
与undefined
不严格相等,所以是个有效的赋值,导致默认值3
不会生效。
5.2.2.2.3 注意点
(1)如果要将一个已经声明的变量用于解构赋值,必须非常小心。
|
|
上面代码的写法会报错,因为 JavaScript 引擎会将{x}
理解成一个代码块,从而发生语法错误。只有不将大括号写在行首,避免 JavaScript 将其解释为代码块,才能解决这个问题。
|
|
上面代码将整个解构赋值语句,放在一个圆括号里面,就可以正确执行。关于圆括号与解构赋值的关系,参见下文。
(2)解构赋值允许等号左边的模式之中,不放置任何变量名。因此,可以写出非常古怪的赋值表达式。
|
|
上面的表达式虽然毫无意义,但是语法是合法的,可以执行。
(3)由于数组本质是特殊的对象,因此可以对数组进行对象属性的解构。
|
|
上面代码对数组进行对象解构。数组arr
的0
键对应的值是1
,[arr.length - 1]
就是2
键,对应的值是3
。方括号这种写法,属于“属性名表达式”(参见《对象的扩展》一章)。
5.2.2.3 字符串的解构赋值
字符串也可以解构赋值。这是因为此时,字符串被转换成了一个类似数组的对象。
|
|
类似数组的对象都有一个length
属性,因此还可以对这个属性解构赋值。
|
|
5.2.2.4 数值和布尔值的解构赋值
解构赋值时,如果等号右边是数值和布尔值,则会先转为对象。
|
|
上面代码中,数值和布尔值的包装对象都有toString
属性,因此变量s
都能取到值。
解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象。由于undefined
和null
无法转为对象,所以对它们进行解构赋值,都会报错。
|
|
5.2.2.5 函数参数的解构赋值
函数的参数也可以使用解构赋值。
|
|
上面代码中,函数add
的参数表面上是一个数组,但在传入参数的那一刻,数组参数就被解构成变量x
和y
。对于函数内部的代码来说,它们能感受到的参数就是x
和y
。
下面是另一个例子。
|
|
函数参数的解构也可以使用默认值。
|
|
上面代码中,函数move
的参数是一个对象,通过对这个对象进行解构,得到变量x
和y
的值。如果解构失败,x
和y
等于默认值。
注意,下面的写法会得到不一样的结果。
|
|
上面代码是为函数move
的参数指定默认值,而不是为变量x
和y
指定默认值,所以会得到与前一种写法不同的结果。
undefined
就会触发函数参数的默认值。
|
|
5.2.2.6 解构赋值圆括号问题
解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。
由此带来的问题是,如果模式中出现圆括号怎么处理。ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。
但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。
5.2.2.6.1 不能使用圆括号的情况
以下三种解构赋值不得使用圆括号。
(1)变量声明语句
|
|
上面 6 个语句都会报错,因为它们都是变量声明语句,模式不能使用圆括号。
(2)函数参数
函数参数也属于变量声明,因此不能带有圆括号。
|
|
(3)赋值语句的模式
|
|
上面代码将整个模式放在圆括号之中,导致报错。
|
|
上面代码将一部分模式放在圆括号之中,导致报错。
5.2.2.6.2 可以使用圆括号的情况
可以使用圆括号的情况只有一种:赋值语句的非模式部分,可以使用圆括号。
|
|
上面三行语句都可以正确执行,因为首先它们都是赋值语句,而不是声明语句;其次它们的圆括号都不属于模式的一部分。第一行语句中,模式是取数组的第一个成员,跟圆括号无关;第二行语句中,模式是p
,而不是d
;第三行语句与第一行语句的性质一致。
5.2.2.7 解构赋值的用途
变量的解构赋值用途很多。
(1)交换变量的值
|
|
上面代码交换变量x
和y
的值,这样的写法不仅简洁,而且易读,语义非常清晰。
(2)从函数返回多个值
函数只能返回一个值,如果要返回多个值,只能将它们放在数组或对象里返回。有了解构赋值,取出这些值就非常方便。
|
|
(3)函数参数的定义
解构赋值可以方便地将一组参数与变量名对应起来。
|
|
(4)提取 JSON 数据
解构赋值对提取 JSON 对象中的数据,尤其有用。
|
|
上面代码可以快速提取 JSON 数据的值。
(5)函数参数的默认值
|
|
指定参数的默认值,就避免了在函数体内部再写var foo = config.foo || 'default foo';
这样的语句。
(6)遍历 Map 结构
任何部署了 Iterator 接口的对象,都可以用for...of
循环遍历。Map 结构原生支持 Iterator 接口,配合变量的解构赋值,获取键名和键值就非常方便。
|
|
如果只想获取键名,或者只想获取键值,可以写成下面这样。
|
|
(7)输入模块的指定方法
加载模块时,往往需要指定输入哪些方法。解构赋值使得输入语句非常清晰。
|
|
5.2.3 函数的扩展
5.2.3.1 函数参数的默认值
5.2.3.1.1 基本用法
ES6 之前,不能直接为函数的参数指定默认值,只能采用变通的方法。
|
|
上面代码检查函数log()
的参数y
有没有赋值,如果没有,则指定默认值为World
。这种写法的缺点在于,如果参数y
赋值了,但是对应的布尔值为false
,则该赋值不起作用。就像上面代码的最后一行,参数y
等于空字符,结果被改为默认值。
为了避免这个问题,通常需要先判断一下参数y
是否被赋值,如果没有,再等于默认值。
|
|
ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。
|
|
可以看到,ES6 的写法比 ES5 简洁许多,而且非常自然。下面是另一个例子。
|
|
除了简洁,ES6 的写法还有两个好处:首先,阅读代码的人,可以立刻意识到哪些参数是可以省略的,不用查看函数体或文档;其次,有利于将来的代码优化,即使未来的版本在对外接口中,彻底拿掉这个参数,也不会导致以前的代码无法运行。
参数变量是默认声明的,所以不能用let
或const
再次声明。
|
|
上面代码中,参数变量x
是默认声明的,在函数体中,不能用let
或const
再次声明,否则会报错。
使用参数默认值时,函数不能有同名参数。
|
|
另外,一个容易忽略的地方是,参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。
|
|
上面代码中,参数p
的默认值是x + 1
。这时,每次调用函数foo()
,都会重新计算x + 1
,而不是默认p
等于 100。
5.2.3.1.2 与解构赋值默认值结合使用
参数默认值可以与解构赋值的默认值,结合起来使用。
|
|
上面代码只使用了对象的解构赋值默认值,没有使用函数参数的默认值。只有当函数foo()
的参数是一个对象时,变量x
和y
才会通过解构赋值生成。如果函数foo()
调用时没提供参数,变量x
和y
就不会生成,从而报错。通过提供函数参数的默认值,就可以避免这种情况。
|
|
上面代码指定,如果没有提供参数,函数foo
的参数默认为一个空对象。
下面是另一个解构赋值默认值的例子。
|
|
上面代码中,如果函数fetch()
的第二个参数是一个对象,就可以为它的三个属性设置默认值。这种写法不能省略第二个参数,如果结合函数参数的默认值,就可以省略第二个参数。这时,就出现了双重默认值。
|
|
上面代码中,函数fetch
没有第二个参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效,变量method
才会取到默认值GET
。
注意,函数参数的默认值生效以后,参数解构赋值依然会进行。
|
|
上面示例中,函数f()
调用时没有参数,所以参数默认值{ a: 'hello' }
生效,然后再对这个默认值进行解构赋值,从而触发参数变量b
的默认值生效。
作为练习,大家可以思考一下,下面两种函数写法有什么差别?
|
|
5.2.3.1.3 参数默认值的位置
通常情况下,定义了默认值的参数,应该是函数的尾参数。因为这样比较容易看出来,到底省略了哪些参数。如果非尾部的参数设置默认值,实际上这个参数是没法省略的。
|
|
上面代码中,有默认值的参数都不是尾参数。这时,无法只省略该参数,而不省略它后面的参数,除非显式输入undefined
。
如果传入undefined
,将触发该参数等于默认值,null
则没有这个效果。
|
|
上面代码中,x
参数对应undefined
,结果触发了默认值,y
参数等于null
,就没有触发默认值。
5.2.3.1.4 函数的 length 属性
指定了默认值以后,函数的length
属性,将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length
属性将失真。
|
|
上面代码中,length
属性的返回值,等于函数的参数个数减去指定了默认值的参数个数。比如,上面最后一个函数,定义了 3 个参数,其中有一个参数c
指定了默认值,因此length
属性等于3
减去1
,最后得到2
。
这是因为length
属性的含义是,该函数预期传入的参数个数。某个参数指定默认值以后,预期传入的参数个数就不包括这个参数了。同理,后文的 rest 参数也不会计入length
属性。
|
|
如果设置了默认值的参数不是尾参数,那么length
属性也不再计入后面的参数了。
|
|
5.2.3.1.5 作用域
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为,在不设置参数默认值时,是不会出现的。
|
|
上面代码中,参数y
的默认值等于变量x
。调用函数f
时,参数形成一个单独的作用域。在这个作用域里面,默认值变量x
指向第一个参数x
,而不是全局变量x
,所以输出是2
。
再看下面的例子。
|
|
上面代码中,函数f
调用时,参数y = x
形成一个单独的作用域。这个作用域里面,变量x
本身没有定义,所以指向外层的全局变量x
。函数调用时,函数体内部的局部变量x
影响不到默认值变量x
。
如果此时,全局变量x
不存在,就会报错。
|
|
下面这样写,也会报错。
|
|
上面代码中,参数x = x
形成一个单独作用域。实际执行的是let x = x
,由于暂时性死区的原因,这行代码会报错。
如果参数的默认值是一个函数,该函数的作用域也遵守这个规则。请看下面的例子。
|
|
上面代码中,函数bar
的参数func
的默认值是一个匿名函数,返回值为变量foo
。函数参数形成的单独作用域里面,并没有定义变量foo
,所以foo
指向外层的全局变量foo
,因此输出outer
。
如果写成下面这样,就会报错。
|
|
上面代码中,匿名函数里面的foo
指向函数外层,但是函数外层并没有声明变量foo
,所以就报错了。
下面是一个更复杂的例子。
|
|
上面代码中,函数foo
的参数形成一个单独作用域。这个作用域里面,首先声明了变量x
,然后声明了变量y
,y
的默认值是一个匿名函数。这个匿名函数内部的变量x
,指向同一个作用域的第一个参数x
。函数foo
内部又声明了一个内部变量x
,该变量与第一个参数x
由于不是同一个作用域,所以不是同一个变量,因此执行y
后,内部变量x
和外部全局变量x
的值都没变。
如果将var x = 3
的var
去除,函数foo
的内部变量x
就指向第一个参数x
,与匿名函数内部的x
是一致的,所以最后输出的就是2
,而外层的全局变量x
依然不受影响。
|
|
5.2.3.1.6 应用
利用参数默认值,可以指定某一个参数不得省略,如果省略就抛出一个错误。
|
|
上面代码的foo
函数,如果调用的时候没有参数,就会调用默认值throwIfMissing
函数,从而抛出一个错误。
从上面代码还可以看到,参数mustBeProvided
的默认值等于throwIfMissing
函数的运行结果(注意函数名throwIfMissing
之后有一对圆括号),这表明参数的默认值不是在定义时执行,而是在运行时执行。如果参数已经赋值,默认值中的函数就不会运行。
另外,可以将参数默认值设为undefined
,表明这个参数是可以省略的。
|
|
5.2.3.2 rest 参数
ES6 引入 rest 参数(形式为...变量名
),用于获取函数的多余参数,这样就不需要使用arguments
对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。
|
|
上面代码的add
函数是一个求和函数,利用 rest 参数,可以向该函数传入任意数目的参数。
下面是一个 rest 参数代替arguments
变量的例子。
|
|
上面代码的两种写法,比较后可以发现,rest 参数的写法更自然也更简洁。
arguments
对象不是数组,而是一个类似数组的对象。所以为了使用数组的方法,必须使用Array.from
先将其转为数组。rest 参数就不存在这个问题,它就是一个真正的数组,数组特有的方法都可以使用。下面是一个利用 rest 参数改写数组push
方法的例子。
|
|
注意,rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
|
|
函数的length
属性,不包括 rest 参数。
|
|
5.2.3.3 严格模式
从 ES5 开始,函数内部可以设定为严格模式。
|
|
ES2016 做了一点修改,规定只要函数参数使用了默认值、解构赋值、或者扩展运算符,那么函数内部就不能显式设定为严格模式,否则会报错。
|
|
这样规定的原因是,函数内部的严格模式,同时适用于函数体和函数参数。但是,函数执行的时候,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方,只有从函数体之中,才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。
|
|
上面代码中,参数value
的默认值是八进制数070
,但是严格模式下不能用前缀0
表示八进制,所以应该报错。但是实际上,JavaScript 引擎会先成功执行value = 070
,然后进入函数体内部,发现需要用严格模式执行,这时才会报错。
虽然可以先解析函数体代码,再执行参数代码,但是这样无疑就增加了复杂性。因此,标准索性禁止了这种用法,只要参数使用了默认值、解构赋值、或者扩展运算符,就不能显式指定严格模式。
两种方法可以规避这种限制。第一种是设定全局性的严格模式,这是合法的。
|
|
第二种是把函数包在一个无参数的立即执行函数里面。
|
|
5.2.3.4 name 属性
函数的name
属性,返回该函数的函数名。
|
|
这个属性早就被浏览器广泛支持,但是直到 ES6,才将其写入了标准。
需要注意的是,ES6 对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5 的name
属性,会返回空字符串,而 ES6 的name
属性会返回实际的函数名。
|
|
上面代码中,变量f
等于一个匿名函数,ES5 和 ES6 的name
属性返回的值不一样。
如果将一个具名函数赋值给一个变量,则 ES5 和 ES6 的name
属性都返回这个具名函数原本的名字。
|
|
Function
构造函数返回的函数实例,name
属性的值为anonymous
。
|
|
bind
返回的函数,name
属性值会加上bound
前缀。
|
|
5.2.3.5 箭头函数
5.2.3.5.1 基本用法
ES6 允许使用“箭头”(=>
)定义函数。
|
|
如果箭头函数不需要参数或需要多个参数,就使用一个圆括号代表参数部分。
|
|
如果箭头函数的代码块部分多于一条语句,就要使用大括号将它们括起来,并且使用return
语句返回。
|
|
由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号,否则会报错。
|
|
下面是一种特殊情况,虽然可以运行,但会得到错误的结果。
|
|
上面代码中,原始意图是返回一个对象{ a: 1 }
,但是由于引擎认为大括号是代码块,所以执行了一行语句a: 1
。这时,a
可以被解释为语句的标签,因此实际执行的语句是1;
,然后函数就结束了,没有返回值。
如果箭头函数只有一行语句,且不需要返回值,可以采用下面的写法,就不用写大括号了。
|
|
箭头函数可以与变量解构结合使用。
|
|
箭头函数使得表达更加简洁。
|
|
上面代码只用了两行,就定义了两个简单的工具函数。如果不用箭头函数,可能就要占用多行,而且还不如现在这样写醒目。
箭头函数的一个用处是简化回调函数。
|
|
另一个例子是
|
|
下面是 rest 参数与箭头函数结合的例子。
|
|
5.2.3.5.2 使用注意点
箭头函数有几个使用注意点。
(1)箭头函数没有自己的this
对象(详见下文)。
(2)不可以当作构造函数,也就是说,不可以对箭头函数使用new
命令,否则会抛出一个错误。
(3)不可以使用arguments
对象,该对象在函数体内不存在。如果要用,可以用 rest 参数代替。
(4)不可以使用yield
命令,因此箭头函数不能用作 Generator 函数。
上面四点中,最重要的是第一点。对于普通函数来说,内部的this
指向函数运行时所在的对象,但是这一点对箭头函数不成立。它没有自己的this
对象,内部的this
就是定义时上层作用域中的this
。也就是说,箭头函数内部的this
指向是固定的,相比之下,普通函数的this
指向是可变的。
|
|
上面代码中,setTimeout()
的参数是一个箭头函数,这个箭头函数的定义生效是在foo
函数生成时,而它的真正执行要等到 100 毫秒后。如果是普通函数,执行时this
应该指向全局对象window
,这时应该输出21
。但是,箭头函数导致this
总是指向函数定义生效时所在的对象(本例是{id: 42}
),所以打印出来的是42
。
下面例子是回调函数分别为箭头函数和普通函数,对比它们内部的this
指向。
|
|
上面代码中,Timer
函数内部设置了两个定时器,分别使用了箭头函数和普通函数。前者的this
绑定定义时所在的作用域(即Timer
函数),后者的this
指向运行时所在的作用域(即全局对象)。所以,3100 毫秒之后,timer.s1
被更新了 3 次,而timer.s2
一次都没更新。
箭头函数实际上可以让this
指向固定化,绑定this
使得它不再可变,这种特性很有利于封装回调函数。下面是一个例子,DOM 事件的回调函数封装在一个对象里面。
|
|
上面代码的init()
方法中,使用了箭头函数,这导致这个箭头函数里面的this
,总是指向handler
对象。如果回调函数是普通函数,那么运行this.doSomething()
这一行会报错,因为此时this
指向document
对象。
总之,箭头函数根本没有自己的this
,导致内部的this
就是外层代码块的this
。正是因为它没有this
,所以也就不能用作构造函数。
下面是 Babel 转箭头函数产生的 ES5 代码,就能清楚地说明this
的指向。
|
|
上面代码中,转换后的 ES5 版本清楚地说明了,箭头函数里面根本没有自己的this
,而是引用外层的this
。
请问下面的代码之中,this
的指向有几个?
|
|
答案是this
的指向只有一个,就是函数foo
的this
,这是因为所有的内层函数都是箭头函数,都没有自己的this
,它们的this
其实都是最外层foo
函数的this
。所以不管怎么嵌套,t1
、t2
、t3
都输出同样的结果。如果这个例子的所有内层函数都写成普通函数,那么每个函数的this
都指向运行时所在的不同对象。
除了this
,以下三个变量在箭头函数之中也是不存在的,指向外层函数的对应变量:arguments
、super
、new.target
。
|
|
上面代码中,箭头函数内部的变量arguments
,其实是函数foo
的arguments
变量。
另外,由于箭头函数没有自己的this
,所以当然也就不能用call()
、apply()
、bind()
这些方法去改变this
的指向。
|
|
上面代码中,箭头函数没有自己的this
,所以bind
方法无效,内部的this
指向外部的this
。
长期以来,JavaScript 语言的this
对象一直是一个令人头痛的问题,在对象方法中使用this
,必须非常小心。箭头函数”绑定”this
,很大程度上解决了这个困扰。
5.2.3.5.3 不适用场合
由于箭头函数使得this
从“动态”变成“静态”,下面两个场合不应该使用箭头函数。
第一个场合是定义对象的方法,且该方法内部包括this
。
|
|
上面代码中,cat.jumps()
方法是一个箭头函数,这是错误的。调用cat.jumps()
时,如果是普通函数,该方法内部的this
指向cat
;如果写成上面那样的箭头函数,使得this
指向全局对象,因此不会得到预期结果。这是因为对象不构成单独的作用域,导致jumps
箭头函数定义时的作用域就是全局作用域。
再看一个例子。
|
|
上面例子中,obj.m()
使用箭头函数定义。JavaScript 引擎的处理方法是,先在全局空间生成这个箭头函数,然后赋值给obj.m
,这导致箭头函数内部的this
指向全局对象,所以obj.m()
输出的是全局空间的21
,而不是对象内部的42
。上面的代码实际上等同于下面的代码。
|
|
由于上面这个原因,对象的属性建议使用传统的写法定义,不要用箭头函数定义。
第二个场合是需要动态this
的时候,也不应使用箭头函数。
|
|
上面代码运行时,点击按钮会报错,因为button
的监听函数是一个箭头函数,导致里面的this
就是全局对象。如果改成普通函数,this
就会动态指向被点击的按钮对象。
另外,如果函数体很复杂,有许多行,或者函数内部有大量的读写操作,不单纯是为了计算值,这时也不应该使用箭头函数,而是要使用普通函数,这样可以提高代码可读性。
5.2.3.5.4 嵌套的箭头函数
箭头函数内部,还可以再使用箭头函数。下面是一个 ES5 语法的多重嵌套函数。
|
|
上面这个函数,可以使用箭头函数改写。
|
|
下面是一个部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。
|
|
如果觉得上面的写法可读性比较差,也可以采用下面的写法。
|
|
箭头函数还有一个功能,就是可以很方便地改写 λ 演算。
|
|
上面两种写法,几乎是一一对应的。由于 λ 演算对于计算机科学非常重要,这使得我们可以用 ES6 作为替代工具,探索计算机科学。
5.2.3.6 尾调用优化
5.2.3.6.1 什么是尾调用?
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
|
|
上面代码中,函数f
的最后一步是调用函数g
,这就叫尾调用。
以下三种情况,都不属于尾调用。
|
|
上面代码中,情况一是调用函数g
之后,还有赋值操作,所以不属于尾调用,即使语义完全一样。情况二也属于调用后还有操作,即使写在一行内。情况三等同于下面的代码。
|
|
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
|
|
上面代码中,函数m
和n
都属于尾调用,因为它们都是函数f
的最后一步操作。
5.2.3.6.2 尾调用优化
尾调用之所以与其他调用不同,就在于它的特殊的调用位置。
我们知道,函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和内部变量等信息。如果在函数A
的内部调用函数B
,那么在A
的调用帧上方,还会形成一个B
的调用帧。等到B
运行结束,将结果返回到A
,B
的调用帧才会消失。如果函数B
内部还调用函数C
,那就还有一个C
的调用帧,以此类推。所有的调用帧,就形成一个“调用栈”(call stack)。
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置、内部变量等信息都不会再用到了,只要直接用内层函数的调用帧,取代外层函数的调用帧就可以了。
|
|
上面代码中,如果函数g
不是尾调用,函数f
就需要保存内部变量m
和n
的值、g
的调用位置等信息。但由于调用g
之后,函数f
就结束了,所以执行到最后一步,完全可以删除f(x)
的调用帧,只保留g(3)
的调用帧。
这就叫做“尾调用优化”(Tail call optimization),即只保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时,调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
注意,只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”。
|
|
上面的函数不会进行尾调用优化,因为内层函数inner
用到了外层函数addOne
的内部变量one
。
注意,目前只有 Safari 浏览器支持尾调用优化,Chrome 和 Firefox 都不支持。
5.2.3.6.3 尾递归
函数调用自身,称为递归。如果尾调用自身,就称为尾递归。
递归非常耗费内存,因为需要同时保存成千上百个调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
|
|
上面代码是一个阶乘函数,计算n
的阶乘,最多需要保存n
个调用记录,复杂度 O(n) 。
如果改写成尾递归,只保留一个调用记录,复杂度 O(1) 。
|
|
还有一个比较著名的例子,就是计算 Fibonacci 数列,也能充分说明尾递归优化的重要性。
非尾递归的 Fibonacci 数列实现如下。
|
|
尾递归优化过的 Fibonacci 数列实现如下。
|
|
由此可见,“尾调用优化”对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 亦是如此,第一次明确规定,所有 ECMAScript 的实现,都必须部署“尾调用优化”。这就是说,ES6 中只要使用尾递归,就不会发生栈溢出(或者层层递归造成的超时),相对节省内存。
5.2.3.6.4 递归函数的改写
尾递归的实现,往往需要改写递归函数,确保最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。比如上面的例子,阶乘函数 factorial 需要用到一个中间变量total
,那就把这个中间变量改写成函数的参数。这样做的缺点就是不太直观,第一眼很难看出来,为什么计算5
的阶乘,需要传入两个参数5
和1
?
两个方法可以解决这个问题。方法一是在尾递归函数之外,再提供一个正常形式的函数。
|
|
上面代码通过一个正常形式的阶乘函数factorial
,调用尾递归函数tailFactorial
,看起来就正常多了。
函数式编程有一个概念,叫做柯里化(currying),意思是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
|
|
上面代码通过柯里化,将尾递归函数tailFactorial
变为只接受一个参数的factorial
。
第二种方法就简单多了,就是采用 ES6 的函数默认值。
|
|
上面代码中,参数total
有默认值1
,所以调用时不用提供这个值。
总结一下,递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支持“尾调用优化”的语言(比如 Lua,ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
5.2.3.6.5 严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式是无效的。
这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
func.arguments
:返回调用时函数的参数。func.caller
:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
|
|
5.2.3.6.6 尾递归优化的实现
尾递归优化只在严格模式下生效,那么正常模式下,或者那些不支持该功能的环境中,有没有办法也使用尾递归优化呢?回答是可以的,就是自己实现尾递归优化。
它的原理非常简单。尾递归之所以需要优化,原因是调用栈太多,造成溢出,那么只要减少调用栈,就不会溢出。怎么做可以减少调用栈呢?就是采用“循环”换掉“递归”。
下面是一个正常的递归函数。
|
|
上面代码中,sum
是一个递归函数,参数x
是需要累加的值,参数y
控制递归次数。一旦指定sum
递归 100000 次,就会报错,提示超出调用栈的最大次数。
蹦床函数(trampoline)可以将递归执行转为循环执行。
|
|
上面就是蹦床函数的一个实现,它接受一个函数f
作为参数。只要f
执行后返回一个函数,就继续执行。注意,这里是返回一个函数,然后执行该函数,而不是函数里面调用函数,这样就避免了递归执行,从而就消除了调用栈过大的问题。
然后,要做的就是将原来的递归函数,改写为每一步返回另一个函数。
|
|
上面代码中,sum
函数的每次执行,都会返回自身的另一个版本。
现在,使用蹦床函数执行sum
,就不会发生调用栈溢出。
|
|
蹦床函数并不是真正的尾递归优化,下面的实现才是。
|
|
上面代码中,tco
函数是尾递归优化的实现,它的奥妙就在于状态变量active
。默认情况下,这个变量是不激活的。一旦进入尾递归优化的过程,这个变量就激活了。然后,每一轮递归sum
返回的都是undefined
,所以就避免了递归执行;而accumulated
数组存放每一轮sum
执行的参数,总是有值的,这就保证了accumulator
函数内部的while
循环总是会执行。这样就很巧妙地将“递归”改成了“循环”,而后一轮的参数会取代前一轮的参数,保证了调用栈只有一层。
5.2.3.7 函数参数的尾逗号
ES2017 允许函数的最后一个参数有尾逗号(trailing comma)。
此前,函数定义和调用时,都不允许最后一个参数后面出现逗号。
|
|
上面代码中,如果在param2
或bar
后面加一个逗号,就会报错。
如果像上面这样,将参数写成多行(即每个参数占据一行),以后修改代码的时候,想为函数clownsEverywhere
添加第三个参数,或者调整参数的次序,就势必要在原来最后一个参数后面添加一个逗号。这对于版本管理系统来说,就会显示添加逗号的那一行也发生了变动。这看上去有点冗余,因此新的语法允许定义和调用时,尾部直接有一个逗号。
|
|
这样的规定也使得,函数参数与数组和对象的尾逗号规则,保持一致了。
5.2.3.8 Function.prototype.toString()
ES2019 对函数实例的toString()
方法做出了修改。
toString()
方法返回函数代码本身,以前会省略注释和空格。
|
|
上面代码中,函数foo
的原始代码包含注释,函数名foo
和圆括号之间有空格,但是toString()
方法都把它们省略了。
修改后的toString()
方法,明确要求返回一模一样的原始代码。
|
|
5.2.3.9 catch 命令的参数省略
JavaScript 语言的try...catch
结构,以前明确要求catch
命令后面必须跟参数,接受try
代码块抛出的错误对象。
|
|
上面代码中,catch
命令后面带有参数err
。
很多时候,catch
代码块可能用不到这个参数。但是,为了保证语法正确,还是必须写。ES2019 做出了改变,允许catch
语句省略参数。
|
|
5.2.4 数组的扩展
5.2.4.1 扩展运算符
5.2.4.1.1 含义
扩展运算符(spread)是三个点(...
)。它好比 rest 参数的逆运算,将一个数组转为用逗号分隔的参数序列。
|
|
该运算符主要用于函数调用。
|
|
上面代码中,array.push(...items)
和add(...numbers)
这两行,都是函数的调用,它们都使用了扩展运算符。该运算符将一个数组,变为参数序列。
扩展运算符与正常的函数参数可以结合使用,非常灵活。
|
|
扩展运算符后面还可以放置表达式。
|
|
如果扩展运算符后面是一个空数组,则不产生任何效果。
|
|
注意,只有函数调用时,扩展运算符才可以放在圆括号中,否则会报错。
|
|
上面三种情况,扩展运算符都放在圆括号里面,但是前两种情况会报错,因为扩展运算符所在的括号不是函数调用。
5.2.4.1.2 替代函数的 apply() 方法
由于扩展运算符可以展开数组,所以不再需要apply()
方法将数组转为函数的参数了。
|
|
下面是扩展运算符取代apply()
方法的一个实际的例子,应用Math.max()
方法,简化求出一个数组最大元素的写法。
|
|
上面代码中,由于 JavaScript 不提供求数组最大元素的函数,所以只能套用Math.max()
函数,将数组转为一个参数序列,然后求最大值。有了扩展运算符以后,就可以直接用Math.max()
了。
另一个例子是通过push()
函数,将一个数组添加到另一个数组的尾部。
|
|
上面代码的 ES5 写法中,push()
方法的参数不能是数组,所以只好通过apply()
方法变通使用push()
方法。有了扩展运算符,就可以直接将数组传入push()
方法。
下面是另外一个例子。
|
|
5.2.4.1.3 扩展运算符的应用
(1)复制数组
数组是复合的数据类型,直接复制的话,只是复制了指向底层数据结构的指针,而不是克隆一个全新的数组。
|
|
上面代码中,a2
并不是a1
的克隆,而是指向同一份数据的另一个指针。修改a2
,会直接导致a1
的变化。
ES5 只能用变通方法来复制数组。
|
|
上面代码中,a1
会返回原数组的克隆,再修改a2
就不会对a1
产生影响。
扩展运算符提供了复制数组的简便写法。
|
|
上面的两种写法,a2
都是a1
的克隆。
(2)合并数组
扩展运算符提供了数组合并的新写法。
|
|
不过,这两种方法都是浅拷贝,使用的时候需要注意。
|
|
上面代码中,a3
和a4
是用两种不同方法合并而成的新数组,但是它们的成员都是对原数组成员的引用,这就是浅拷贝。如果修改了引用指向的值,会同步反映到新数组。
(3)与解构赋值结合
扩展运算符可以与解构赋值结合起来,用于生成数组。
|
|
下面是另外一些例子。
|
|
如果将扩展运算符用于数组赋值,只能放在参数的最后一位,否则会报错。
|
|
(4)字符串
扩展运算符还可以将字符串转为真正的数组。
|
|
上面的写法,有一个重要的好处,那就是能够正确识别四个字节的 Unicode 字符。
|
|
上面代码的第一种写法,JavaScript 会将四个字节的 Unicode 字符,识别为 2 个字符,采用扩展运算符就没有这个问题。因此,正确返回字符串长度的函数,可以像下面这样写。
|
|
凡是涉及到操作四个字节的 Unicode 字符的函数,都有这个问题。因此,最好都用扩展运算符改写。
|
|
上面代码中,如果不用扩展运算符,字符串的reverse()
操作就不正确。
(5)实现了 Iterator 接口的对象
任何定义了遍历器(Iterator)接口的对象(参阅 Iterator 一章),都可以用扩展运算符转为真正的数组。
|
|
上面代码中,querySelectorAll()
方法返回的是一个NodeList
对象。它不是数组,而是一个类似数组的对象。这时,扩展运算符可以将其转为真正的数组,原因就在于NodeList
对象实现了 Iterator。
|
|
上面代码中,先定义了Number
对象的遍历器接口,扩展运算符将5
自动转成Number
实例以后,就会调用这个接口,就会返回自定义的结果。
对于那些没有部署 Iterator 接口的类似数组的对象,扩展运算符就无法将其转为真正的数组。
|
|
上面代码中,arrayLike
是一个类似数组的对象,但是没有部署 Iterator 接口,扩展运算符就会报错。这时,可以改为使用Array.from
方法将arrayLike
转为真正的数组。
(6)Map 和 Set 结构,Generator 函数
扩展运算符内部调用的是数据结构的 Iterator 接口,因此只要具有 Iterator 接口的对象,都可以使用扩展运算符,比如 Map 结构。
|
|
Generator 函数运行后,返回一个遍历器对象,因此也可以使用扩展运算符。
|
|
上面代码中,变量go
是一个 Generator 函数,执行后返回的是一个遍历器对象,对这个遍历器对象执行扩展运算符,就会将内部遍历得到的值,转为一个数组。
如果对没有 Iterator 接口的对象,使用扩展运算符,将会报错。
|
|
5.2.4.2 Array.from()
Array.from()
方法用于将两类对象转为真正的数组:类似数组的对象(array-like object)和可遍历(iterable)的对象(包括 ES6 新增的数据结构 Set 和 Map)。
下面是一个类似数组的对象,Array.from()
将它转为真正的数组。
|
|
实际应用中,常见的类似数组的对象是 DOM 操作返回的 NodeList 集合,以及函数内部的arguments
对象。Array.from()
都可以将它们转为真正的数组。
|
|
上面代码中,querySelectorAll()
方法返回的是一个类似数组的对象,可以将这个对象转为真正的数组,再使用filter()
方法。
只要是部署了 Iterator 接口的数据结构,Array.from()
都能将其转为数组。
|
|
上面代码中,字符串和 Set 结构都具有 Iterator 接口,因此可以被Array.from()
转为真正的数组。
如果参数是一个真正的数组,Array.from()
会返回一个一模一样的新数组。
|
|
值得提醒的是,扩展运算符(...
)也可以将某些数据结构转为数组。
|
|
扩展运算符背后调用的是遍历器接口(Symbol.iterator
),如果一个对象没有部署这个接口,就无法转换。Array.from()
方法还支持类似数组的对象。所谓类似数组的对象,本质特征只有一点,即必须有length
属性。因此,任何有length
属性的对象,都可以通过Array.from()
方法转为数组,而此时扩展运算符就无法转换。
|
|
上面代码中,Array.from()
返回了一个具有三个成员的数组,每个位置的值都是undefined
。扩展运算符转换不了这个对象。
对于还没有部署该方法的浏览器,可以用Array.prototype.slice()
方法替代。
|
|
Array.from()
还可以接受一个函数作为第二个参数,作用类似于数组的map()
方法,用来对每个元素进行处理,将处理后的值放入返回的数组。
|
|
下面的例子是取出一组 DOM 节点的文本内容。
|
|
下面的例子将数组中布尔值为false
的成员转为0
。
|
|
另一个例子是返回各种数据的类型。
|
|
如果map()
函数里面用到了this
关键字,还可以传入Array.from()
的第三个参数,用来绑定this
。
Array.from()
可以将各种值转为真正的数组,并且还提供map
功能。这实际上意味着,只要有一个原始的数据结构,你就可以先对它的值进行处理,然后转成规范的数组结构,进而就可以使用数量众多的数组方法。
|
|
上面代码中,Array.from()
的第一个参数指定了第二个参数运行的次数。这种特性可以让该方法的用法变得非常灵活。
Array.from()
的另一个应用是,将字符串转为数组,然后返回字符串的长度。因为它能正确处理各种 Unicode 字符,可以避免 JavaScript 将大于\uFFFF
的 Unicode 字符,算作两个字符的 bug。
|
|
5.2.4.3 Array.of()
Array.of()
方法用于将一组值,转换为数组。
|
|
这个方法的主要目的,是弥补数组构造函数Array()
的不足。因为参数个数的不同,会导致Array()
的行为有差异。
|
|
上面代码中,Array()
方法没有参数、一个参数、三个参数时,返回的结果都不一样。只有当参数个数不少于 2 个时,Array()
才会返回由参数组成的新数组。参数只有一个正整数时,实际上是指定数组的长度。
Array.of()
基本上可以用来替代Array()
或new Array()
,并且不存在由于参数不同而导致的重载。它的行为非常统一。
|
|
Array.of()
总是返回参数值组成的数组。如果没有参数,就返回一个空数组。
Array.of()
方法可以用下面的代码模拟实现。
|
|
5.2.4.4 实例方法:copyWithin()
数组实例的copyWithin()
方法,在当前数组内部,将指定位置的成员复制到其他位置(会覆盖原有成员),然后返回当前数组。也就是说,使用这个方法,会修改当前数组。
|
|
它接受三个参数。
- target(必需):从该位置开始替换数据。如果为负值,表示倒数。
- start(可选):从该位置开始读取数据,默认为 0。如果为负值,表示从末尾开始计算。
- end(可选):到该位置前停止读取数据,默认等于数组长度。如果为负值,表示从末尾开始计算。
这三个参数都应该是数值,如果不是,会自动转为数值。
|
|
上面代码表示将从 3 号位直到数组结束的成员(4 和 5),复制到从 0 号位开始的位置,结果覆盖了原来的 1 和 2。
下面是更多例子。
|
|
5.2.4.5 实例方法:find(),findIndex(),findLast(),findLastIndex()
数组实例的find()
方法,用于找出第一个符合条件的数组成员。它的参数是一个回调函数,所有数组成员依次执行该回调函数,直到找出第一个返回值为true
的成员,然后返回该成员。如果没有符合条件的成员,则返回undefined
。
|
|
上面代码找出数组中第一个小于 0 的成员。
|
|
上面代码中,find()
方法的回调函数可以接受三个参数,依次为当前的值、当前的位置和原数组。
数组实例的findIndex()
方法的用法与find()
方法非常类似,返回第一个符合条件的数组成员的位置,如果所有成员都不符合条件,则返回-1
。
|
|
这两个方法都可以接受第二个参数,用来绑定回调函数的this
对象。
|
|
上面的代码中,find()
函数接收了第二个参数person
对象,回调函数中的this
对象指向person
对象。
另外,这两个方法都可以发现NaN
,弥补了数组的indexOf()
方法的不足。
|
|
上面代码中,indexOf()
方法无法识别数组的NaN
成员,但是findIndex()
方法可以借助Object.is()
方法做到。
find()
和findIndex()
都是从数组的 0 号位,依次向后检查。ES2022 新增了两个方法findLast()
和findLastIndex()
,从数组的最后一个成员开始,依次向前检查,其他都保持不变。
|
|
上面示例中,findLast()
和findLastIndex()
从数组结尾开始,寻找第一个value
属性为奇数的成员。结果,该成员是{ value: 3 }
,位置是 2 号位。
5.2.4.6 实例方法:fill()
fill
方法使用给定值,填充一个数组。
|
|
上面代码表明,fill
方法用于空数组的初始化非常方便。数组中已有的元素,会被全部抹去。
fill
方法还可以接受第二个和第三个参数,用于指定填充的起始位置和结束位置。
|
|
上面代码表示,fill
方法从 1 号位开始,向原数组填充 7,到 2 号位之前结束。
注意,如果填充的类型为对象,那么被赋值的是同一个内存地址的对象,而不是深拷贝对象。
|
|
5.2.4.7 实例方法:entries(),keys() 和 values()
ES6 提供三个新的方法——entries()
,keys()
和values()
——用于遍历数组。它们都返回一个遍历器对象(详见《Iterator》一章),可以用for...of
循环进行遍历,唯一的区别是keys()
是对键名的遍历、values()
是对键值的遍历,entries()
是对键值对的遍历。
|
|
如果不使用for...of
循环,可以手动调用遍历器对象的next
方法,进行遍历。
|
|
5.2.4.8 实例方法:includes()
Array.prototype.includes
方法返回一个布尔值,表示某个数组是否包含给定的值,与字符串的includes
方法类似。ES2016 引入了该方法。
|
|
该方法的第二个参数表示搜索的起始位置,默认为0
。如果第二个参数为负数,则表示倒数的位置,如果这时它大于数组长度(比如第二个参数为-4
,但数组长度为3
),则会重置为从0
开始。
|
|
没有该方法之前,我们通常使用数组的indexOf
方法,检查是否包含某个值。
|
|
indexOf
方法有两个缺点,一是不够语义化,它的含义是找到参数值的第一个出现位置,所以要去比较是否不等于-1
,表达起来不够直观。二是,它内部使用严格相等运算符(===
)进行判断,这会导致对NaN
的误判。
|
|
includes
使用的是不一样的判断算法,就没有这个问题。
|
|
下面代码用来检查当前环境是否支持该方法,如果不支持,部署一个简易的替代版本。
|
|
另外,Map 和 Set 数据结构有一个has
方法,需要注意与includes
区分。
- Map 结构的
has
方法,是用来查找键名的,比如Map.prototype.has(key)
、WeakMap.prototype.has(key)
、Reflect.has(target, propertyKey)
。 - Set 结构的
has
方法,是用来查找值的,比如Set.prototype.has(value)
、WeakSet.prototype.has(value)
。
5.2.4.9 实例方法:flat(),flatMap()
数组的成员有时还是数组,Array.prototype.flat()
用于将嵌套的数组“拉平”,变成一维的数组。该方法返回一个新数组,对原数据没有影响。
|
|
上面代码中,原数组的成员里面有一个数组,flat()
方法将子数组的成员取出来,添加在原来的位置。
flat()
默认只会“拉平”一层,如果想要“拉平”多层的嵌套数组,可以将flat()
方法的参数写成一个整数,表示想要拉平的层数,默认为 1。
|
|
上面代码中,flat()
的参数为 2,表示要“拉平”两层的嵌套数组。
如果不管有多少层嵌套,都要转成一维数组,可以用Infinity
关键字作为参数。
|
|
如果原数组有空位,flat()
方法会跳过空位。
|
|
flatMap()
方法对原数组的每个成员执行一个函数(相当于执行Array.prototype.map()
),然后对返回值组成的数组执行flat()
方法。该方法返回一个新数组,不改变原数组。
|
|
flatMap()
只能展开一层数组。
|
|
上面代码中,遍历函数返回的是一个双层的数组,但是默认只能展开一层,因此flatMap()
返回的还是一个嵌套数组。
flatMap()
方法的参数是一个遍历函数,该函数可以接受三个参数,分别是当前数组成员、当前数组成员的位置(从零开始)、原数组。
|
|
flatMap()
方法还可以有第二个参数,用来绑定遍历函数里面的this
。
5.2.4.10 实例方法:at()
长久以来,JavaScript 不支持数组的负索引,如果要引用数组的最后一个成员,不能写成arr[-1]
,只能使用arr[arr.length - 1]
。
这是因为方括号运算符[]
在 JavaScript 语言里面,不仅用于数组,还用于对象。对于对象来说,方括号里面就是键名,比如obj[1]
引用的是键名为字符串1
的键,同理obj[-1]
引用的是键名为字符串-1
的键。由于 JavaScript 的数组是特殊的对象,所以方括号里面的负数无法再有其他语义了,也就是说,不可能添加新语法来支持负索引。
为了解决这个问题,ES2022 为数组实例增加了at()
方法,接受一个整数作为参数,返回对应位置的成员,并支持负索引。这个方法不仅可用于数组,也可用于字符串和类型数组(TypedArray)。
|
|
如果参数位置超出了数组范围,at()
返回undefined
。
|
|
5.2.4.11 实例方法:toReversed(),toSorted(),toSpliced(),with()
很多数组的传统方法会改变原数组,比如push()
、pop()
、shift()
、unshift()
等等。数组只要调用了这些方法,它的值就变了。现在有一个提案,允许对数组进行操作时,不改变原数组,而返回一个原数组的拷贝。
这样的方法一共有四个。
Array.prototype.toReversed() -> Array
Array.prototype.toSorted(compareFn) -> Array
Array.prototype.toSpliced(start, deleteCount, ...items) -> Array
Array.prototype.with(index, value) -> Array
它们分别对应数组的原有方法。
toReversed()
对应reverse()
,用来颠倒数组成员的位置。toSorted()
对应sort()
,用来对数组成员排序。toSpliced()
对应splice()
,用来在指定位置,删除指定数量的成员,并插入新成员。with(index, value)
对应splice(index, 1, value)
,用来将指定位置的成员替换为新的值。
上面是这四个新方法对应的原有方法,含义和用法完全一样,唯一不同的是不会改变原数组,而是返回原数组操作后的拷贝。
下面是示例。
|
|
5.2.4.12 实例方法:group(),groupToMap()
数组成员分组是一个常见需求,比如 SQL 有GROUP BY
子句和函数式编程有 MapReduce 方法。现在有一个提案,为 JavaScript 新增了数组实例方法group()
和groupToMap()
,它们可以根据分组函数的运行结果,将数组成员分组。
group()
的参数是一个分组函数,原数组的每个成员都会依次执行这个函数,确定自己是哪一个组。
|
|
group()
的分组函数可以接受三个参数,依次是数组的当前成员、该成员的位置序号、原数组(上例是num
、index
和array
)。分组函数的返回值应该是字符串(或者可以自动转为字符串),以作为分组后的组名。
group()
的返回值是一个对象,该对象的键名就是每一组的组名,即分组函数返回的每一个字符串(上例是even
和odd
);该对象的键值是一个数组,包括所有产生当前键名的原数组成员。
下面是另一个例子。
|
|
上面示例中,Math.floor
作为分组函数,对原数组进行分组。它的返回值原本是数值,这时会自动转为字符串,作为分组的组名。原数组的成员根据分组函数的运行结果,进入对应的组。
group()
还可以接受一个对象,作为第二个参数。该对象会绑定分组函数(第一个参数)里面的this
,不过如果分组函数是一个箭头函数,该对象无效,因为箭头函数内部的this
是固化的。
groupToMap()
的作用和用法与group()
完全一致,唯一的区别是返回值是一个 Map 结构,而不是对象。Map 结构的键名可以是各种值,所以不管分组函数返回什么值,都会直接作为组名(Map 结构的键名),不会强制转为字符串。这对于分组函数返回值是对象的情况,尤其有用。
|
|
上面示例返回的是一个 Map 结构,它的键名就是分组函数返回的两个对象odd
和even
。
总之,按照字符串分组就使用group()
,按照对象分组就使用groupToMap()
。
5.2.4.13 数组的空位
数组的空位指的是,数组的某一个位置没有任何值,比如Array()
构造函数返回的数组都是空位。
|
|
上面代码中,Array(3)
返回一个具有 3 个空位的数组。
注意,空位不是undefined
,某一个位置的值等于undefined
,依然是有值的。空位是没有任何值,in
运算符可以说明这一点。
|
|
上面代码说明,第一个数组的 0 号位置是有值的,第二个数组的 0 号位置没有值。
ES5 对空位的处理,已经很不一致了,大多数情况下会忽略空位。
forEach()
,filter()
,reduce()
,every()
和some()
都会跳过空位。map()
会跳过空位,但会保留这个值join()
和toString()
会将空位视为undefined
,而undefined
和null
会被处理成空字符串。
|
|
ES6 则是明确将空位转为undefined
。
Array.from()
方法会将数组的空位,转为undefined
,也就是说,这个方法不会忽略空位。
|
|
扩展运算符(...
)也会将空位转为undefined
。
|
|
copyWithin()
会连空位一起拷贝。
|
|
fill()
会将空位视为正常的数组位置。
|
|
for...of
循环也会遍历空位。
|
|
上面代码中,数组arr
有两个空位,for...of
并没有忽略它们。如果改成map()
方法遍历,空位是会跳过的。
entries()
、keys()
、values()
、find()
和findIndex()
会将空位处理成undefined
。
|
|
由于空位的处理规则非常不统一,所以建议避免出现空位。
5.2.4.14 Array.prototype.sort() 的排序稳定性
排序稳定性(stable sorting)是排序算法的重要属性,指的是排序关键字相同的项目,排序前后的顺序不变。
|
|
上面代码对数组arr
按照首字母进行排序。排序结果中,straw
在spork
的前面,跟原始顺序一致,所以排序算法stableSorting
是稳定排序。
|
|
上面代码中,排序结果是spork
在straw
前面,跟原始顺序相反,所以排序算法unstableSorting
是不稳定的。
常见的排序算法之中,插入排序、合并排序、冒泡排序等都是稳定的,堆排序、快速排序等是不稳定的。不稳定排序的主要缺点是,多重排序时可能会产生问题。假设有一个姓和名的列表,要求按照“姓氏为主要关键字,名字为次要关键字”进行排序。开发者可能会先按名字排序,再按姓氏进行排序。如果排序算法是稳定的,这样就可以达到“先姓氏,后名字”的排序效果。如果是不稳定的,就不行。
早先的 ECMAScript 没有规定,Array.prototype.sort()
的默认排序算法是否稳定,留给浏览器自己决定,这导致某些实现是不稳定的。ES2019 明确规定,Array.prototype.sort()
的默认排序算法必须稳定。这个规定已经做到了,现在 JavaScript 各个主要实现的默认排序算法都是稳定的。
5.3 构造 Date 对象的字符串格式问题
JavaScript 构造 Date 对象时要传的字符串标准格式为 yyyy/MM/dd HH:mm:ss
,正常情况下日期字符串的格式为 yyyy-MM-dd HH:mm:ss
,无法直接使用(Chrome 可以,IE 不可以)。需要字符串替换后使用 replace(/-/g,"/")
。