一、函数参数的默认值
1.1、基本用法
ES6 允许为函数的参数设置默认值,直接写在参数定义的后面
function log(x, y = 'World') {
console.log(x, y)
}
log('Hello') // Hello World
log('Hello', 'China') // Hello China
log('Hello', '') // Hello
ES6 的写法还有两个好处:
- 阅读代码的人可以l立刻意识到哪些参数是可以省略的,不用查看函数体或文档;
- 有利于将来的代码优化,即使未来的版本彻底拿掉这个参数,也不会导致以前的代码无法运行
使用默认参数有以下几点需要注意:
- 参数变量是默认声明的,所以不能用 let 或 const 再次声明
function fn(x = 5) {
let x = 1 //error
const x = 1 // error
}
- 使用参数默认值时,函数不能有同名参数。
function fn(x, x, y = 1) {
// todo
}
// Uncaught SyntaxError: Duplicate parameter name not allowed in this context
- 参数默认值不是传值的,而是每次都重新计算默认值表达式的值。也就是说,参数默认值是惰性求值的。
lett x = 99
function fn(p = x + 1) {
console.log(p)
}
fn() // 100
x = 100
fn() // 101
1.2、与解构赋值默认值结合使用
参数默认值可以与结构赋值的默认值结合起来使用
function fn({x, y = 5}) {
console.log(x, y)
}
fn({}) // undefined, 5
fn({x: 1}) // 1, 5
fn({x: 1, y: 2}) // 1, 2
fn() // TypeError: Cannot destructure property `x` of 'undefined' or 'null'.
如果函数 fn 调用时参数不是对象,变量 x 和 y 就不会生成,从而报错。如果结合函数参数默认值,就可以省略这个参数,这是,就出现了双重默认值。
function fn({x, y = 5} = {}) {
console.log(x, y)
}
fn() // undefined, 5
当没有参数时,函数参数的默认值就会生效,然后才是解构赋值的默认值生效。
对比下面两种写法有什么差别:
// 写法一
function m1({x = 0, y = 0} = {}) {
return [x, y]
}
// 写法二
function m2({x, y} = {x: 0, y: 0}) {
return [x, y]
}
上面两种写法的区别在于:
- 写法一中 函数参数的默认值是空对象,但是设置了对象结构赋值的默认值;
- 写法二中 函数参数的默认值是一个有具体属性的函数,但是没有设置对象结构赋值的默认值
下面是几种不同的调用方式:
// 函数没有参数的情况下
m1() // [0, 0]
m2() // [0, 0]
// x 和 y都有值的情况下
m1({x: 3, y: 8}) // [3, 8]
m2({x: 3, y: 8}) // [3, 8]
// x有值,y无值的情况
m1({x: 3}) // [3, 0]
m2({x: 3}) // [3, undefined]
// x 和 y都无值的情况
m1({}) // [0, 0]
m2({}) // [undefined, undefined]
m1({z: 3}) // [0, 0]
m2({z: 3}) // [undefined, undefined]
1.3、参数默认值的位置
通常情况下,定义了默认值的参数应该是函数的尾参数。韹为这样比较容易看出到底省略了那些参数。如果非尾部的参数设置默认值,实际上这个参数是无法省略的。
function fn(x = 5, y) {
return [x, y]
}
fn(undefined, 1) // [5, 1]
fn(, 1) // 报错
如果传入 undefined,将触发该参数等于默认值,null没有这个效果
1.4、函数的 length 属性
指定了默认值以后,函数的 length 属性将返回没有指定默认值的参数个数。也就是说,指定了默认值后,length 属性将失真。
(function (a) {}).length // 1
(function (a = 5) {}).length // 0
(function (a, b, c = 3) {}).length // 2
这是因为 length 属性的含义是该函数预期传入的参数个数。某个参数指定默认值后,预期传入的参数个数就不包括这个参数了。同理,rest 参数也不会计入 length 属性。
(function (...args) {}).length // 0
如果设置了默认值的参数不是尾参数,那么length 属性也不再计入后面的参数。
(function (a = 0, b , c) {}).length // 0
(function (a, b = 1, c) {}).length // 1
1.5、作用域
一旦设置了参数的默认值,函数进行声明初始化时,参数会形成一个单独的作用域(context)。等到初始化结束,这个作用域就会消失。这种语法行为在不设置参数默认值时是不会出现的。
var x = 1
function fn(x, y = x) {
console.log(y)
}
fn(2) // 2
上面的代码中,参数 y 的默认值等于变量x。调用函数f 时,参数形成一个单独的作用域。在这个作用域里面,默认值变量x 指向第一个参数x,而不是全局变量x,所以输入 2
let x = 1
function fn(y = x) {
let x = 2
console.log(y)
}
fn() // 1
上面的代码中,函数 fn 调用时,参数 y = x 形成了一个单独的作用域。在这个作用域里面,变量 x 本身没有定义,所以指向外层的全局变量x。函数调用时,函数体内部的局部变量 x 影响不到默认值变量 x。
如果全局变量 x 不存在,就会报错
如果参数的默认值是一个函数,该函数的作用域也遵守这个规则。
let foo = 'outer'
function bar(func = x => foo) {
let foo = 'inner'
console.log(func())
}
bar() // outer
函数参数形成的单独作用域里面并没有定义变量 foo,所以 foo 指向外层的全局变量 foo,因此输出 outer。
因此写成下面这样,就会报错
function bar(func = () => foo) {
let foo = 'inner'
console.log(func())
}
bar() // ReferenceError: foo is not defined
下面是一个更为复杂的例子:
var x = 1
function foo(x, y = function() {x = 2}) {
var x = 3
y()
console.log(x)
}
foo() // 3
x // 1
y 的默认值是一个匿名函数,这个匿名函数内部的变量 x 指向同一个作用域的第一个参数 x,函数 foo 内部又声明了一个内部变量x,改变量与第一个参数 x 由于不是同一个作用域,所以不是同一个变量,因此执行 y 后,内部变量 x 和外部变量 x 的值都没有变。
如果将 var x = 3 的var 去除,函数 foo 的内部变量 x 就指向第一个参数x,与匿名函数内部的 x 是一致的。
var x = 1
function foo(x, y = function() {x = 2}) {
x = 3
y()
console.log(x)
}
foo() // 3
x // 1
最后输出的就是2,而外层的全局变量x 依然不受影响。
1.6、应用
利用参数默认值可以指定某一个参数不得省略,如果省略就抛出一个错误
function throwIfMissing() {
throw new Error('Missing parameter')
}
function foo(mustBeProvided = throwIfMissing() ) {
return mustBeProvided
}
foo()
另外,可以将参数默认值设为 undefined,表明这个参数是可以省略的
function foo(optional = undefined) {...}
二、rest 参数
ES6 引入 rest 参数(形式为“...变量名”),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入其中。
function add(...values) {
let sum = 0
for (var val of values) {
sum += val
}
return sum
}
add(2 ,3, 4, 5, 5)
下面是一个 rest 参数 代替 arguments 变量的例子。
// arguments 变量的写法
function sortNumbers() {
return Array.prototype.slice.call(arguments).sort()
}
// rest 参数的写法
const sortNumbers = (...numbers) => numbers.sort()
下面是关于 rest 参数的注意事项:
- rest 参数中的变量代表一个数组,所以数组特有的方法都可以用于这个变量。
- rest 参数之后不能再有其他参数(即只能是最后一个参数),否则会报错。
- 函数的 length 属性不包括 rest 参数
三、严格模式
从 ES5 开始,函数内部可以设定为严格模式。
function doSometing(a, b) {
'use strict'
// todo
}
ES2016 做了一点修改,规定只要函数参数使用了默认值、解构赋值或者扩展运算符,那么函数内部就不能显示设定为 严格模式,否则会报错。
// 报错
function doSomething(a, b = a) {
'use strict'
// todo
}
// 报错
function doSomething ({a, b}) {
'use strict'
// todo
}
// 报错
const doSomething = (...a) => {
'use strict'
// todo
}
这样规定的原因是,函数内部的严格模式同时适用于 函数体 和 函数参数。但是,函数执行时,先执行函数参数,然后再执行函数体。这样就有一个不合理的地方:只有从函数体之中才能知道参数是否应该以严格模式执行,但是参数却应该先于函数体执行。
// 报错
function doSomething(value = 070) {
'use strict'
return value
}
上面代码中,参数 value 的默认值是 八进制数 070,但是严格模式下不能用前缀 0 表示八进制,所以应该报错。但实际上,JavaScript 引擎会先成功执行 value = 070,然后进入函数体内部,发现需要用严格模式时才会报错。
虽然可以先解析函数体代码,再执行参数代码,但是这样无疑增加了复杂性。因此,标准索性禁止了这种写法,只要参数使用了默认值、解构赋值、扩展运算符,就不能够显示指定严格模式
有两种方法可以规避这种限制:
1. 设定全局的严格模式
'use strict'
function doSomething(a, b = a) {
// todo
}
2. 把函数抱在一个无参数的立执行函数里面
const doSomething = (function() {
'use strict'
return function(value = 42) {
return value
}
} ())
四、name 属性
函数的 name 属性返回该函数的函数名
function foo() {}
foo.name // "foo"
ES6 对这个属性的行为做出了一些修改。如果将一个匿名函数赋值给一个变量,ES5 的 name 属性会返回空字符串,而ES6 的 name 属性会返回实际的函数名
var f = function() {}
// ES5
f.name // ""
// ES6
f.name // "f"
如果将一个具名函数赋值给一个变量,则ES5 和 ES6 的name属性都返回这个具名函数原本的名字
const bar = function baz() {}
// ES5
bar.name // "baz"
// ES6
bar.name // "baz"
Function 构造函数返回的函数实例,name 属性的值为 anonymous
(new Function).name // "anonymous"
bind 返回的函数,name 属性值 会加上 bound 前缀
function foo() {}
foo.bind({}).name // "bound foo"
(function() {}.bind({}) ).name // "bound"
五、箭头函数
5.1、 基本用法
ES6 允许使用 “箭头”(=>) 定义函数
var f = v => v
等同于以下代码
var f = function(v) {
return v
}
下面是箭头函数的一些写法:
- 如果箭头函数不需要或需要多个参数,就使用圆括号代表参数部分
var f = () => 5
- 如果箭头函数的代码块多余一条语句,就要失业大括号将其括起来,并使用 return 语句返回
var sum = (num1, num2) => {return num1 + num2}
- 由于大括号被解释为代码块,所以如果箭头函数直接返回一个对象,必须在对象外面加上括号
var getTempItem = id => ({id: id, name: 'Temp'})
- 箭头函数可以与变量解构结合使用
const full = ({first, last}) => first + ' ' + last
- 一个参数时,如果是rest参数,需要加上括号
// 报错
const fn = ...number => 1
// 正确写法
const fn = (...numbers) => 1
5.2、注意事项
箭头函数有以下几个使用注意事项:
- 函数体内的 this 对象就是定义时所在的对象,而不是使用时所在的对象。
- 不可以当作构造函数。也就是说,不可以使用 new 命令符,否则会抛出一个错误。
- 不可以使用 arguments 对象,该对象在函数体内部不存在。如果要用,可以用 rest 参数代替。
- 不可以使用 yield 命令,因为箭头函数不能用作 Generator 函数。
其中,第一点尤其值得注意。this 对象的指向是可以变的,但在箭头函数中它是固定的。
箭头函数可以让 this 指向固定化,这种特性非常有利于封装回调函数。
var handler = {
id: '123456',
init: function() {
document.addEventListener('click', event => this.doSomething(event.type), false)
},
doSomething: function(type) {
console.log('Handling ' + type + ' for ' + this.id)
}
}
this指向的固定化并不是因为 箭头函数内部有绑定 this 的机制,实际原因是箭头函数根本没有自己的this,导致内部的 this 就是外层代码块的 this。正是因为 它没有 this,所以不能用作构造函数。
除了 this,以下 3 个变量在 箭头函数中也是不存在的,分别指向外层函数的对于变:arguments、super、new.target
function foo() {
setTimeout(() => {
console.log('args:', arguments)
}, 100)
}
foo([2, 3, 4, 5])
// args: [2, 3, 4, 5]
上面的代码中,箭头函数内部的变量 arguments 其实是 函数 foo 的arguments 变量。
另外,由于箭头函数没有自己的this,当然也就不能用 call()、apply()、bind() 这些方法去改变 this 的指向。
(function () {
return [
(() => this.x).bind({x: 'inner'})()
]
}).call({x: 'outer'})
// ["outer"]
长期以来,JavaScript 语言的 this 对象一直是一个令人头痛的问题,在对象方法中使用 this 必须非常小心。箭头函数“绑定” this,很大程度上解决了这个困扰。
5.3、嵌套的箭头函数
箭头函数内部还可以再使用箭头函数。
下面是一个 部署管道机制(pipeline)的例子,即前一个函数的输出是后一个函数的输入。
const pipeline = (...funcs) =>
val => funcs.reduce((a, b) => b(a), val)
const plus1 = a => a + 1
const mult2 = a => a * 2
const addThenMult = pipeline(plus1, mult2)
addThenMult(5)
// 12
如果觉得上面的写法可读性比较差,可以采用下面的写法
const plus1 = a => a + 1
const mult2 = a => a * 2
mult2(plus1(5))
// 12
箭头函数 还有一个功能,就是可以很方便的地改写 λ 演算
六、绑定 this
箭头函数可以绑定 this 对象,大大减少了显示绑定 this 对象的写法(call、apply、bind)。但是,箭头函数并非适合于所有场合,所以 ES7 提出了 “函数绑定”(function bind)运算符,用来取代 call、apply、bind调用。虽然该语法还是 ES7 的一个提案,但是Babel转码器已经支持。
函数绑定运算符是并排的 双冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边对象作为上下文环境(即this 对象)绑定到右边的函数上。
foo::bar
// 等同于
bar.bind(foo)
如果双冒号左边为空,右边是一个对象的方法,则等于该方法绑定在该对象上。
var method = obj::obj.foo
// 等同于
var method = ::obj.foo
var log = ::console.log
// 等同于
var log = console.log.bind(console)
由于 双冒号运算符返回的还是原对象,因此可以采用链式写法
let { find, html } = jake
document.querySelectorAll('div.myClass')
::find('p')
::html('hahha')
七、尾调用优化
7.1、什么是尾调用
尾调用(Tail Call)是函数式编程的一个重要概念,本身非常简单,一句话就能说清楚,就是指某个函数的最后一步是调用另一个函数。
function f(x) {
return g(x)
}
如果在尾调用之后还要赋值操作,就不属于尾调用,即使语义完全一样。
尾调用不一定出现在函数尾部,只要是最后一步操作即可。
function f(x) {
if (x > 0) {
return m(x)
}
return n(x)
}
7.2、尾调用优化
函数调用会在内存形成一个“调用记录”,又称“调用帧”(call frame),保存调用位置和 内部变量等信息。如果在函数 A 的内部调用函数 B,那么 在 A 的调用帧上方还会形成一个 B 的调用帧。等到 B 运行结束,将结果返回到 A,B的调用帧才会消失。如果函数 B 内部还调用 函数C,那就还有一个 C 的调用帧,以此类推。所有的调用帧就形成一个“调用栈”(call stack)
尾调用由于是函数的最后一步操作,所以不需要保留外层函数的调用帧,因为调用位置内部变量等信息都不会再用到了,直接用内存函数的调用帧取代外层函数的即可。
function f() {
let m = 1
let n = 2
return g(m + n)
}
f()
// 等同于
function f() {
return g(3)
}
f()
// 等同于
g(3)
由于调用 g 之后,函数 f 就结束了,所以执行到最后一步,完全可以删除 f(x) 的调用帧,只保留 g(3) 的调用帧
这就叫作“尾调用优化”(Tail CallOptimization),即在保留内层函数的调用帧。如果所有函数都是尾调用,那么完全可以做到每次执行时调用帧只有一项,这将大大节省内存。这就是“尾调用优化”的意义。
注意:只有不再用到外层函数的内部变量,内层函数的调用帧才会取代外层函数的调用帧,否则就无法进行“尾调用优化”
。
function addOne(a) {
var one = 1
function inner(b) {
return b + one
}
return inner(a)
}
上面的函数不会进行尾调用优化,因为内层函数 inner 用到了外层函数 addOne 的内部变量 one
7.3、尾递归
函数调用自身称为递归。如果尾调用自身就成为尾递归
递归非常消耗内存,因为需要同时保存成百上千调用帧,很容易发生“栈溢出”错误(stack overflow)。但对于尾递归来说,由于只存在一个调用帧,所以永远不会发生“栈溢出”错误。
function factorial(n) {
if (n===1) return 1
return n * factorial(n - 1)
}
factorial(5) // 120
上面是一个阶乘函数,计算 n 的阶乘,最多需要保存 n 个调用记录,复杂度为 O(n)。如果改写成尾递归,只保留一个调用记录,则复杂度为 O(1)。
function factorial(n, total) {
if (n === 1) return total
return factorial(n - 1, n * total)
}
// factorial(5, 1) // 120
还有一个比较著名的例子——计算Fibonacci 数列,也能充分说明尾递归优化的重要性。
非尾递归的 Fibonacci 数列实现如下:
function Fibonacci(n) {
if (n <= 1) return 1
return Fibonacci(n - 1) + Fibonacci(n - 2)
}
Fibonacci(10) // 89
Fibonacci(100) // 堆栈溢出
Fibonacci(500) // 堆栈溢出
尾递归优化的 Fibonacci 数列:
function Fibonacci(n, ac1 = 1, ac2 = 1) {
if ( n <= 1) return ac2
return Fibonacci(n -1, ac2, ac1 + ac2)
}
Fibonacci(100) // 573147844013817200000
Fibonacci(1000) // 7.0330367711422765e+208
由此可见,“尾调用优化” 对递归操作意义重大,所以一些函数式编程语言将其写入了语言规格。ES6 也是如此,第一次明确规定,所有 ECMAScript 的实现都必须部署“尾调用 优化”。也就是说,在ES6 中,只要使用尾递归。就不会发生栈溢出,相对节省空间。
7.4、递归函数的改写
尾递归的实现往往需要改写递归函数,却白最后一步只调用自身。做到这一点的方法,就是把所有用到的内部变量改写成函数的参数。
比如上面的励志。阶乘函数 factorial 需要用到第一个中间变量 total,那就把这个中间变量改写成函数的参数。这样做的缺点是不太直观,第一眼很难看出来,为什么计算 5 的阶乘需要传入两个参数 5 和 1
有两个方法可以解决这个问题。
1. 在尾递归函数之外再提供一个正常形式的函数
function tailFactorial(n, total) {
if (n === 1) return total
return tailFactorial(n - 1, n * total)
}
function factorial(n) {
return tailFactorial(n, 1)
}
factorial(5) // 120
函数柯里化
函数式编程有一个概念,叫做柯里化(currying),意思就是将多参数的函数转换成单参数的形式。这里也可以使用柯里化。
function currying(fn, n) {
return function(m) {
return fn.call(this, m, n)
}
}
function tailFactorial(n, total) {
if (n === 1) return total;
return tailFactorial(n -1, n * total)
}
const factorial = currying(tailFactorial, 1)
factorial(5)
2. 采用ES6 的函数默认值
function factorial(n, total = 1) {
if (n === 1) return total
return factorial(n - 1, n * total)
}
factorial(5)
递归本质上是一种循环操作。纯粹的函数式编程语言没有循环操作命令,所有的循环都用递归实现,这就是为什么尾递归对这些语言极其重要。对于其他支出“尾调用优化” 的语言(比如 Lua、ES6),只需要知道循环可以用递归代替,而一旦使用递归,就最好使用尾递归。
7.5、严格模式
ES6 的尾调用优化只在严格模式下开启,正常模式下无效的。
这是因为,在正常模式下函数内部有两个变量,可以跟踪函数的调用栈
- func.arguments:返回调用时函数的参数
- func.caller:返回调用当前函数的那个函数。
尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
function restricted() {
'use strict'
restricted.caller // 报错
restricted.arguments // 报错
}
restricted()
7.6、尾递归优化的实现
尾递归优化只在严格模式下生效,那么在正常模式下,或者在那些不支持该功能的环境中,要靠自己实现尾递归优化。
实现原理:
尾递归之所以需要优化,原因是调用栈太多造成溢出,那么只要减少调用栈就不会溢出。方法就是 采用 “循环” 替换 “递归”
下面是一个正常的递归函数:
function sum(x, y) {
if (y > 0) {
return sum(x + 1, y -1)
} else {
return x
}
}
sum(1, 100000)
// RangeError: Maximum call stack size exceeded
上面的代码中,sum 是一个递归函数,参数 x 是需要累积的值,参数 y 控制递归次数。一旦指定 sum 递归100000 次,就会报错,提示超出调用栈的最大次数。
蹦床函数(trampoline)可以将递归执行转为循环执行
function trampoline(f) {
while(f && f instanceof Function) {
f = f()
}
return f
}
以上代码就是蹦床函数的一个实现,它接受函数 f 作为参数。只要 f 执行后返回一个 函数就继续执行。
这里是返回一个函数,然后执行函数,而不是在函数里面调用函数,这样就避免了递归执行,从而消除了调用栈过大的问题。
然后要做的是将原来的递归函数改写为每一步返回另一个函数
function sum(x, y) {
if (y > 0) {
return sum.bind(null, x + 1, y -1)
} else {
return x
}
}
上面代码中,sum 函数的每次执行都会返回自身的另一个版本。
使用蹦床函数执行 sum 就不会发生调用栈溢出。
trampoline(sum(1, 100000))
蹦床函数并不是真正的尾递归优化,下面的实现才是
function tco(f) {
var value,
active = false,
accumulated = []
return function accumulator() {
accumulated.push(arguments)
if (!active) {
active = true
while(accumulated.length) {
value = f.apply(this, accumulated.shift())
}
active = false
return value
}
}
}
var sum = tco(function (x, y) {
if ( y > 0) {
return sum(x + 1, y -1)
} else {
return x
}
})
sum(1, 100000) // 100001
上面的代码中,tco 函数时尾递归优化的实现,它的奥妙就在于状态变量 active。默认情况下,这个变量是不激活的。一旦进入尾递归优化过程,这个变量就被激活了。然后,每一轮递归 sum 返回的都是 undefined,所以就避免了递归执行;而 accumulated 数组存放每一轮 sum 执行的参数,总是有值的,这就保证了 accumulator 函数内部的 while 循环总会执行,很巧妙地将“递归” 改成了 “循环”,而后一轮的参数会取代前一轮的参数,保住了调用栈只有一层
八、函数参数的尾逗号
ES2017 中有一个提案,允许函数的最后一个参数有尾逗号(trailing comma)
function clownsEverywhere(
param1,
param2
) { /* .... */}
clownsEverywhere(
'foo',
'bar'
)
像上面这样,修改代码时,若想为函数添加参数,或者调整参数的次序,势必要在原来最后一个参数后面添加一个逗号。这对于版本管理系统来说,就会显示添加逗号的那一行发生了变动,看上去有点冗余,因此新天允许定义和调用时尾部有一个逗号
function clownsEverywhere(
param1,
param2,
) { /* .... */}
clownsEverywhere(
'foo',
'bar',
)
这样的规定也使得函数参数与数组和对象的尾逗号规则可以保持一致。
网友评论