2

由于JavaScript开发者多年的不断抱怨和呼吁,ES6终于大力度地更新了函数特性,在ES5基础上进行了许多改进。

函数形参的默认值

ES5形参默认值的实现

ES5中,你很可能通过以下这种方式为函数赋予默认值:

function makeRequest(url, timeout, callback) {
    timeout = timeout || 2000;
    callback = callback || function() {};
    
    // 函数的其余部分
}

对于函数的命名参数,如果不显式传值,则其默认值为undefined。因此经常是使用逻辑或操作符来为缺失的参数提供默认值。然而这个方式有缺陷,如果给第二个形参timeout传入值0,尽管这个值是合法的,也会被视为false值,对函数调用方来说,timeout非预期的被修改为2000。

更安全的选择是通过typeof检查参数类型,就像这样:

function makeRequest(url, timeout, callback) {
    timeout = (typeof timeout !== 'undefined') ? timeout : 2000;
    callback = (typeof callback !== 'undefined') ? callback : function() {};
    
    // 函数的其余部分
}

在流行的JavaScript库中均使用类似的模式进行默认值补全。

ES6形参默认值的实现

ES6简化了为形参提供默认值的过程,定义形参时即可指定初始值:

function makeRequest(url, timeout = 2000, callback) {
    // 函数的其余部分
}

这种情况下,只有当不为第二个参数传值或主动为第二个参数传入undefined时才会使用timeout的默认值,就像这样:

// 使用timeout的默认值
makeRequest("/foo", undefined, function(body) {
    doSomething(body);
});

// 使用timeout的默认值
makeRequest("/foo");

// 不使用timeout的默认值
makeRequest("foo", null, function(body){ // null是一个合法值
    doSomething(body);
});

ES6默认参数表达式

ES6中,关于默认参数值,还可以是非原始值传参,就像这样:

function getValue() {
    return 5;
}

function add(first, second = getValue()) {
    return first + second;
}

console.log(add(1, 1));  // 2
console.log(add(1));     // 6

尤其注意,初次解析函数声明时是不会调用getValue()方法的,只有当调用add()函数且不传入第二个参数时才会被调用。另外,second = getValue()这里的小括号()不要忘记掉,不然最终传入的是对函数的引用,而不是函数调用的结果。

正因为默认参数是在函数调用时求值,所以可以使用先定义的参数作为后定义的参数的默认值,就像这样:

function add(first, second = first) {
    return first + second;
}

console.log(add(1, 1));   // 2
console.log(add(1));      // 2

也可以将参数first传入一个函数来获得参数second的值,就像这样:

function getValue(value) {
    return value + 5;
}

function add(first, second = getValue(first)) {
    return first + second;
}

console.log(add(1, 1));  // 2
console.log(add(1));     // 7

默认参数的临时死区

在引用参数默认值的时,只允许引用前面参数的值,即先定义的参数不能访问后定义的参数:

function add(first = second, second) {
    return first + second;
}

console.log(add(1, 1));   // 2
console.log(add(undefined, 1));   // 抛出错误

在讲解letconst时介绍了临时死区(TDZ),其实默认参数也有同样的临时死区。上面这个示例,调用add(1, 1)和add(undefined, 1)相当于引擎在背后做了如下事情:

// 表示调用add(1, 1)时的JavaScript代码
let first = 1;
let second = 1;

// 表示调用add(undefined, 1)时的JavaScript代码
let first = second;
let second = 1;

这个示例中,调用add(undefined, 1)函数,因为当first初始化时second尚未初始化,所以会导致程序抛出错误,此时second(已声明,未初始化)尚处于临时死区中,正如讨论let绑定时所说的那样,任何引用临时死区中的绑定的行为都会报错。

函数参数有自己的作用域和临时死区,其与函数体的作用域是各自独立的,也就是说参数的默认值不可访问函数体内声明的变量。

ES6形参默认值不再影响arguments对象

当使用默认参数值时,arguments对象的行为与以往有所不同。在ES5非严格模式下,函数命名参数的变化会体现在arguments对象中:

function mixArgs(first, second) {
    console.log(first === arguments[0]);
    console.log(second === arguments[1]);
    first = "c";
    second = "d";
    console.log(first === arguments[0]);
    consolo.log(second === arguments[1]);
}

mixArgs("a", "b");

这段代码会输出:

true
true
true
true

ES5非严格模式下,命名参数的变化会同步更新到arguments对象中,所以当firstsecond被赋予新值时,arguments[0]和arguments[1]相应地也就更新了,最终所有===全等结果都为true

ES5严格模式下,取消了arguments对象的这个令人感到困惑的行为,无论参数如何变化,arguments对象不再随之改变。ES6中,arguments对象的行为与ES5严格模式下保持一致。arguments对象保持与命名参数分离,这个微妙的细节将影响你使用arguments对象的方式,请看以下代码:

function mixArgs(first, second = "b") {
    console.log(arguments.length);
    console.log(first === arguments[0]);
    console.log(second === arguments[1]);
    first = "c";
    second = "d";
    console.log(first === arguments[0]);
    console.log(second === arguments[1]);
}

mixArgs("a");

这段代码会输出以下内容:

1
true
false
false
false
false

改变firstsecond并不会影响arguments对象。总是可以通过arguments对象将参数恢复为初始值。

处理无命名参数

JavaScript的函数语法规定,无论函数已定义的命名参数有多少,都不限制调用时传入的实际参数数量。当传入更少数量的参数时,默认参数值的特性可以有效简化函数的声明;当传入更多数量的参数时,ES6同样也提供了更好的方案。

ES5中的无命名参数

早先,我们用JavaScript提供的arguments对象来检查函数的所有参数,从而不必定义每一个要用的参数,看下面的例子:

function pick(object) {
    let result = Object.create(null);
    
    // 从第二个参数开始
    for(let i = 1, len = arguments.length; i < len; i++) {
        result[arguments[i]] = object[arguments[i]];
    }
    
    return result;
}

let book = {
    title: "ECMAScript 6",
    author: "Nicholas",
    year: 2016
};

let bookData = pick(book, "author", "year");

console.log(bookData.author);   // "Nicholas"
console.log(bookData.year);     // 2016

这个函数模仿了Underscore.js库中的pick()方法,返回一个给定对象的副本,包含原始对象属性的特定子集。在这个示例中只定义了一个参数,第一个参数传入的是被复制的源对象,其他参数为被复制属性的名称。

关于pick()函数应该注意这样几件事:首先,并不容易发现这个函数可以接受任意数量的参数,当然,可以定义更多的参数,但是怎么也达不到要求;其次,因为第一个参数为命名参数并已被使用,当你要查找需要拷贝的属性名称时,不得不从索引1而不是索引0开始遍历arguments对象。牢记真正的索引位置并不难,但这总归是我们需要牵挂的问题。

ES6中,通过引入不定参数(rest parameters)的特性可以轻易解决这些问题。

ES6的不定参数

在函数的命名参数前添加三个点(...)就表明这是一个不定参数,该参数为一个数组,包含着自它之后传入的所有参数。使用不定参数重写pick()函数:

function pick(object, ...keys) {
    let result = Object.create(null);
    
    for(let i = 0, len = keys.length; i < len; i++) {
        result[keys[i]] = object[keys[i]];
    }
    
    return result;
}

只需要看一眼就知晓该函数可以处理的参数数量。
注意一下,函数的length属性统计的是函数命名参数的数量,不定参数的加入不会影响函数length属性。在本示例中pick()函数的length值为1,因为只会计算object。另外每个函数最多只能声明一个不定参数,并且一定要放在末尾。

不定参数对arguments对象的影响

不定参数的设计初衷是代替JavaScriptarguments对象的。但是arguments对象依然存在。如果声明函数时定义了不定参数,则在函数被调用时,arguments对象包含了所有传入函数的参数,就像这样:

function checkArgs(...args) {
    console.log(args.length);
    console.log(arguments.length);
    console.log(args[0], arguments[0]);
    console.log(args[1], arguments[1]);
}

checkArgs("a", "b");

调用checkArgs(),输出以下内容:

2
2
a a
b b

无论是否使用不定参数,arguments对象总是包含所有传入函数的参数。

展开运算符配合不定参数

展开运算符可以让你指定一个数组,将它们打散后作为各自独立的参数传入函数。正好,不定参数希望的就是指定多个各自独立的参数,并通过整合后的数组来访问:

function max(...args) {
    // 计算并返回最大值
}

let values = [10, 20, 30, 40];
// 使用展开运算符打散
max(...values);
// 等价于
// max(10, 20, 30, 40)

构造函数

ES5及早期版本中的函数具有多重功能,可以结合new使用,函数内的this值将指向一个新对象,函数最终会返回这个新对象:

function Person(name) {
    this.name = name;
}

var person = new Person("Nicholas");
var notAPerson = Person("Nicholas");

console.log(person);      // "[Object Object]"
console.log(notAPerson);  // "undefined"

JavaScript函数有两个不同的内部方法:[[Call]]和[[Construct]]。当通过new关键字调用函数时,执行的是[[Construct]]函数,它负责创建一个通常被称作实例的新对象,然后再执行函数体,将this绑定到实例上,并返回这个对象;如果不通过new关键字调用函数,则执行[[Call]]函数,从而直接执行代码中的函数体。具有[[Construct]]方法的函数被统称为构造函数。

不是所有函数都有[[Construct]]方法,因此不是所有函数都可以通过new来调用,比如ES6的箭头函数就没有这个[[Construct]]。

ES5判断函数是否用new调用

ES5中,如果想判断一个函数是否通过new关键字被调用,最流行的方式是使用instanceof

function Person(name) {
    if(this instanceof Person) {
        this.name = name;  // 如果通过new关键字调用
    } else {
        throw new Error("必须通过new关键字来调用Person。");
    }
}

var person = new Person("Nicholas");
var notAPerson = Person("Nicholas");  // 抛出错误

但是这个方法也不完全可靠,因为有一种不通过new关键字的方法也可以将this绑定到Person的实例上:

function Person(name) {
    if(this instanceof Person) {
        this.name = name;
    } else {
        throw new Error("必须通过new关键字来调用Person。");
    }
}

var person = new Person("Nicholas");
var notAPerson = Person.call(person, "Michael");  // 有效!

调用Person.call()时将变量person传入作为第一个参数,相当于在Person函数里将this设为了person实例。对于函数本身,无法区分是通过Person.call()(或者是Person.apply())还是new关键字调用得到的Person的实例。

ES6判断函数是否用new调用

为了解决判断函数是否通过new关键字调用的问题,ES6引入了new.target这个元属性。当调用函数的[[Construct]]方法时,new.target被赋值为新创建对象实例;如果调用[[Call]]方法,则new.target的值为undefined

可以通过检查new.target是否被定义过来安全的检测一个函数是否是通过new关键字调用的,就像这样:

function Person(name) {
    // typeof new.target === Person 可以检查是否被某个特定的构造函所调用
    if(typeof new.target !== "undefined") {
        this.name = name;
    } else {
        throw new Error("必须通过new关键字来调用Person。");
    }
}

var person = new Person("Nicholas");
var notAPerson = Person.call(person, "Michael");  // 抛出错误!

箭头函数

ES6中,箭头函数是最有趣的新增特性。它与传统的JavaScript函数有些许不同,主要集中在以下方面:

  • 没有this、super、arguments和new.target绑定
    箭头函数中的this、super、arguments及new.target这些值由外围最近一层非箭头函数决定。

  • 不能通过new关键字调用
    箭头函数没有[[Construct]]方法,所以不能被用作构造函数,如果通过new关键字调用箭头函数,程序会抛出错误。

  • 没有原型
    由于不可以通过new关键字调用箭头函数,因而没有构建原型的需求,所以箭头函数不存在prototype这个属性。

  • 不可以改变this的绑定
    函数内部的this值不可被改变,在函数的生命周期内始终保持一致。

  • 不支持arguments对象
    箭头函数没有arguments绑定,所以你必须通过命名参数和不定参数这两种形式访问函数的参数。

  • 不支持重复的命名参数
    箭头函数不支持重复的命名参数;而在传统函数的规定中,只有在严格模式下才不能有重复的命名参数。

this绑定是JavaScript程序中常见的错误来源,在函数内很容易就对this的值失去控制,其经常导致程序出现意想不到的行为,箭头函数消除了这方面的烦恼。

箭头函数的语法

单一参数:

let reflect = value => value;

// 相当于
let reflect = function(value) {
    return value;
};

两个以上参数:

let sum = (num1, num2) => num1 + num2;

// 相当于
let sum = function(num1, num2) {
    return num1 + num2;
};

没有参数:

let getName = () => "Nicholas";

// 相当于
let getName = function(){
    return "Nicholas";
};

多表达式组成的更传统的函数体:

let sum = (num1, num2) => {
    return num1 + num2;
};

// 相当于
let sum = function(num1, num2) {
    return num1 + num2;
};

空函数:

let doNothing = () => {};

// 相当于
let doNothing = function(){};

返回对象字面量:

let getTempItem = id => ({ id: id, name: "Temp" });

// 相当于
let getTempItem = function(id){
    return {
        id: id,
        name: "Temp"
    };
};

将对象字面量包裹在小括号中是为了将其与函数体区分开来。


zhutianxiang
1.5k 声望327 粉丝