默认参数

如果在调用函数时没有传入参数时,我们希望使用默认值,ES5 的写法如下

function getValue(url, timeout, callback) {
timeout = timeout || 2000;
callback = callback || function(data) {
console.log(data);
}
// 其他处理逻辑
}

但是上面的代码有问题,如果 timeout 传入的是 0,那么 timeout 还是会使用默认值,因为 0 对应的 boolean 为 false,所以代码会修改如下

function getValue(url, timeout, callback) {
timeout = (typeof timeout !== 'undefined') ? timeout : 2000;
callback = (typeof callback !== 'undefined') ? callback : function(data) {
console.log(data);
}
// 其他处理逻辑
}

现在我们可以在 ES6 通过简单的写法实现上面的效果

function getValue(url, timeout = 2000, callback = function(data) {console.log(data)}) {
// 其他处理逻辑
}

上面的 timeout 和 callback 参数我们称之为默认参数,当调用函数没有传入参数,或者传入的参数为 undefiend 时,就是使用指定的默认参数。

arguments 对象

每一个函数中都会有一个 arguments 对象,其中保存的是传入的参数值。arguments 对象在严格和非严格模式下的行为是不同的。

在非严格模式下,arguments 会受到命名参数的影响,即在函数内对命名参数进行修改,也会相应的对 arguments 对象产生修改

function getName(first, second) {
console.log(first === arguments[0]); // true
console.log(second === arguments[1]); // true

first = 'a';
second = 'b';

console.log(first === arguments[0]); // true
console.log(second === arguments[1]); // true
}

在严格模式下,对命名参数的修改不会影响 arguments 对象

function getName(first, second = "hello") {
"use strict"
console.log(first === arguments[0]); // true
console.log(second === arguments[1]); // true

first = 'a';
second = 'b';

console.log(first === arguments[0]); // false
console.log(second === arguments[1]); // false
}

对于默认参数,无论是在严格模式还是在非严格模式下,对命名参数的修改都不会影响 arguments 对象

function getName(first, second = "hello") {
console.log(first === arguments[0]); // true
console.log(second === arguments[1]); // false

first = 'a';
second = 'b';

console.log(first === arguments[0]); // false
console.log(second === arguments[1]); // false
}

getName("Hello")

因为只传入了一个参数值,所以 arguments[0] === first,而 arguments[1] === undefiend ,所以 second 的值与 arguments[1] 不相等。

表达式

默认参数的取值除了原始值以外,还可以是一个表达式,例如

function getValue() {
return 5;
}

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

add(1, 1); // 2
add(1); // 6

甚至你可以将前面的参数作为后面参数的默认值

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

add(1, 2); // 3
add(2); // 4

TDZ(暂时性死区)

所谓的临时死区是指在变量声明之前,不能够访问该变量

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

add(undefined, 2); // ReferenceError: Cannot access 'second' before initialization

上面我们设置 first 的默认值为 second,当我们为 first 传入 udefiend 时会使用默认值,但是此时 second 并未初始化,所以这时访问 second 会发生错误。

匿名参数

当我们调用 JavaScript 的函数时,传入的参数个数可以与定义函数时要求的函数个数不同,可多可少。所以传入的参数都会保存在 arguments 对象中,无论是在函数签名中已经定义了的参数(命名参数)还是未定义的参数(匿名参数)

function print(arg) {
for(let i = 0; i < arguments.length; i++) {
console.log(arguments[i]);
}
}

print("a", "b", "c"); // a
// b
// c

在 ES6 中引入了剩余参数(Rest Parameters),剩余参数中只保留匿名参数的值

function print(arg, ...values) {
for(let i = 0; i < values.length; i++) {
console.log(values[i]);
}
}

print("a", "b", "c"); // b
// c,

在上面对函数进行了一点修改,在参数列表中添加了一项 ...values ,values 表示的就是剩余参数,如果调用函数时多传入了参数,那么多余的参数就会放入 values 这个数组中。

剩余参数与 arguments 参数的不同:

  1. 剩余参数是一个真正的数组,而 arguments 是一个类数组
  2. 剩余参数中只保存了匿名参数的值,而 arguments则保存了所有传入的值

使用剩余参数有两点限制:

  1. 一个函数中只能有一个剩余参数,且剩余参数必须放置在参数列表的最后

  2. 剩余参数不能用于对象的 setter 方法中

    let obj = {
    // SyntaxError: Setter function argument must not be a rest parameter
    set name(...values) {
    // do something
    }
    }

    这是因为 setter 方法被限制为只能有一个参数,但是剩余参数理论上表示的是可以接受无穷多的参数。

无论在函数的参数列表中是否定义了剩余参数,都对 arguments 对象没有影响,它始终保存着传入函数的所有参数。

Function 增强

Function 构造函数一般是用来生成函数实例的,但是我们很少用到它来声明一个函数

let add = new Function("first", "second", "return first + second");

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

在 ES6 中对 Function 构造函数进行了增强,使它能够使用默认参数和剩余参数

let add= new Function("first", "second = first", "return first + second");

console.log(add(2)); // 4
let print = new Function("...values", "console.log(values[0])");

print(1, 2, 3); // 1

展开运算符

与剩余参数密切相关就是展开运算符,剩余参数是将多个独立的参数合并为一个数组,而展开运算符则相反,向一个数组展开为一个个独立的参数。考虑 Math.max 方法,它接收一个个独立的参数

Math.max(3, 5);

当参数的个数比较少时可以还算容易使用,当需要比较的参数较多时,一般我们会将参数放置在一个数组中然后传入,但是Math.max 不接收数组,它只接收一个个独立的参数

Math.max([1, 3, 5, 4]); // ×
Math.max(1, 3, 5, 4); // √

这个时候我们通过会借助于 apply 方法,因为 apply 方法是将方法的参数合并为数组传入的

Math,max.apply(Math, [1, 3, 5, 4]);

在 ES6 中提供了展开运算符,可以直接将数组展开为一个个的独立参数进行传入

Math.max(...[1, 3, 5, 4])

name 属性

  1. 由于有多种方式创建函数,所以识别一个函数很难
  2. 匿名函数的盛行,使得调试十分困难,无法跟踪堆栈信息

出于如上原因,ES6 为所有的函数都添加了一个 name 属性。

function doSomething() {
// empty
}

const doOtherThing = function() {
// empty
}

const anotherThing = doOtherThing;

console.log(doSomething.name); // doSomething
console.log(doOtherThing.name); // doOtherThing
console.log(anotherThing.name); // doOtherThing

函数 doSomething 的 name 属性为 doSomething,因为它的函数签名为 doSomething;匿名函数 doOtherThing 的 name 属性为 doOtherThing,因为它被赋予的变量名为 doOtherThing。anotherThing 的 name 属性还是为 doOtherThing,说明匿名函数的 name 属性并不会更改。

下面看几个特例:

let doSomething= function doSomethingElse() {
// empty
}

console.log(doSomething.name); // doSomethingElse
let doSomething = function() {
// empty
}

console.log(doSomething.bind().name); // bound doSomething
console.log((new Function()).name); // anonymous

函数的多重角色

函数可以通过 new 调用,也可以不通过 new 调用。JavaScript 的函数含有两个内部可见的方法,[[Call]] 和 [[Construct]]。

当一个函数通过 new 调用时,[[Construct]] 方法就会被调用,该方法是用来创建一个新对象的,称之为实例,通过函数体内部的 this 来设置实例。拥有 [[Construct]] 方法的函数叫做构造器,并不是所有的函数都有[[Construct]] 方法,例如后面提及的箭头函数就没有。

当函数不是通过 new 调用时,就是调用函数内部的 [[Call]] 方法,它会执行函数的方法体。

那么如何分辨函数以何种方式调用呢? 在 ES5 中,常常通过 instance 来判别

function Person(name) {
if(this instanceof Person) {
this.name = name;
} else {
throw new Error("You must use new with Person");
}
}

let person = new Person("Alice");
let notAPerson = Person("Bob"); // Error: You must use new with Person

上面的方法能够有效是因为 [[Construct]] 方法会创建一个 Person 的实例,并将它赋值给 this。但是这个方法并不可靠,因为不使用 new 也可以得到 Person 的实例

function Person(name) {
if(this instanceof Person) {
this.name = name;
} else {
throw new Error("You must use new with Person");
}
}

let person = new Person("Alice");
let notAPerson = Person.call(person, "Bob"); // 没有报错

在 ES6 中提出了更好的解决办法,那就是 new.target,如果一个函数的 [[Construct]] 方法被调用,new.target 的值就会被赋值为新创建的对象,如果 [[Call]] 被调用,那么 new.target 的值就是 undefiend。所以通过 new.target 可以方便的知道是通过何种方式调用的函数

function Person(name) {
if (new.target !== undefined){
this.name = name;
} else {
throw new Error("You must use new with Person");
}
}

let person = new Person("Alice");
let notAPerson = Person.call(person, "Bob"); // Error: You must use new with Person

在函数的外部不能使用 new.target, 否则会报语法错误。

Block-Level Functions

在 ES3 或者更早以前,是不能在块级作用域中定义函数的,但是所有的浏览器都支持这一行为,于是在 ES5 中,为了修复这不兼容的行为,规定在严格模式下载块级作用域中声明函数将会导致错误

"use strict"

if (condition) {
// 在 ES5 中会导致错误
function doSomething() {
// empty
}
}

但是上面的行为在 ES6 中却是可以的,在 ES6 的严格模式下,函数的作用域只在块级作用域中,在块级作用域以外不能访问该函数

"use strict"

console.log(typeof doSomething); // undefined

if (true) {
console.log(typeof doSomething); // function
function doSomething() {
// empty
}

console.log(typeof doSomething); // function
}

console.log(typeof doSomething); // undefiend

因为对于函数的声明会被提升到作用域的最前方,所以第二个 typeof doSomething 的结果是function。

上面的行为都是在严格模式下发生的,那么在非严格模式下,函数的声明没有块级作用域的概念,所以函数的声明是在全局作用域中,即在块级作用域之外也可以访问到函数。

箭头函数

语法

箭头函数是 ES6 中的新语法,正如名字所暗示的一样,是使用箭头来声明函数的,例如

let add = (x, y) => {
return x + y;
}

add(1, 2); // 3

上面的函数等价于(当然不是完全等价,具体的区别后文详述)

let add= function(x, y) {
return x + y;
}

箭头函数由三部分组成

  • 参数列表:使用括号将参数括起,参数之间使用逗号分隔
    • (x, y)
    • (x, y = x)
  • 箭头:⇒
  • 函数体:使用花括号括起

如果参数列表只有一个参数的话,那么可以参数列表不写括号

(x) => {
return x;
}

// 等价于
x => {
return x;
}

如果函数体中只有一条语句的话,那么也可以省略花括号

x => return x;

进一步,如果这一条语句直接返回一个值的话,return 也可以省略,它会计算这条语句,然后将值返回

x => x;

如果是直接返回一个字面量对象的话,需要使用括号括起

() => ({x: 1, y: 2});

// 等价于
function() {
return {
x: 1,
y: 2
}
}

无 this 绑定

在 JavaScript 中,每一个函数都会默认的传入一个 this 参数,它的取值取决于调用方式,而不是所处的位置

function Person(name) {
this.name = name;
this.getName = function() {
return this.name;
}
}
let person = new Person("Alice");

console.log(person.getName()); // Alice
console.log(person.getName.call(global)); // undefiend

不同的调用方式,函数内部 this 的取值也不同,所以很容易就写出错误的代码。但是箭头函数内部没有 this 绑定,这意味着它会在它的作用域链中去寻找 this

function Person(name) {
this.name = name;
this.getName = () => {
return this.name;
}
}
let person = new Person("Alice");

console.log(person.getName()); // Alice
console.log(person.getName.call(global)); // Alice

上面修改 getName 为箭头函数,因为箭头函数的内部没有 this 绑定,所以它会向它的作用域链中寻找 this,即它使用的是它父级作用域的 this,这意味着箭头函数内部的 this 与调用方式无关,与它所在的位置有关,这样可以获得预期的结果,减少出 bug 的几率。

无 arguments 绑定

同 this 一样,每个函数内部都会自动传入一个 arguments 对象,但是箭头函数内部并没有 arguments 对象,所以想取得箭头函数传入的参数,就得使用剩余参数

(function() {
let add = (...values) => {
console.log(arguments); // [Arguments] {}
console.log(values); // [ 1, 2, 3 ]
let sum = 0;
for (let i = 0; i < values.length; i++) {
sum += values[i];
}

return sum;
}

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

箭头函数与一般函数的不同:

  1. 函数内部没有 this,arguments,new.target 绑定
  2. 不能通过 new 的方式被调用,上文提及过,箭头函数内部没有 [[Construct]] 方法
  3. 没有 prototype

尽管箭头函数与一般函数有所不同,但是通过 typeof 以及 instanceof 方法鉴别的行为与一般函数相同

let add = (x, y) => x + y;
console.log(typeof add); // function
console.log(add instanceof Function); // true

箭头函数也可以调用call,apply,bind 方法,但是箭头函数内部的 this 取值并不会受到影响。

尾调用优化

什么叫尾调用? 尾调用指的是函数的最后一条语句是调用一个函数,例如

function doSomething() {
return doSomethingElse();
}

我们知道每次调用函数时,都会创建一个栈帧放置到函数栈中,当函数调用较多时,特别是递归调用,可能会使得栈中的栈帧越来越多,当超过栈帧允许的最大数量时,就会报错

function doSomething(){
doSomething();
}

doSomething(); // RangeError: Maximum call stack size exceeded

那么所谓的尾调用优化指的是,当函数的最后一条语句是返回另一个函数的调用时,那么调用的函数会复用当前栈帧,而不会创建一个新的栈帧,这样栈中的栈帧的数量就不会增加,也就不会出现超过栈帧允许的最大数量。

要实现尾调用优化,被调用函数要满足以下特点:

  1. 尾调用没有访问当前栈帧中的变量
  2. 尾调用是最后一条语句
  3. 尾调用的返回值作为函数的返回值
function doSomething(){
// 无法优化,没有 return
doSomething();
}
function doSomething() {
// 无法优化,尾调用不是最后一条语句,先进行函数调用,然后进行加法
return 1 + doSomething();
}
function doSomething() {
let num = 1,
func = () => num;
// 无法优化,访问了当前栈帧中的变量
return func();
}

下面我们看一个求阶乘的例子

function factorial(n) {
if (n < 0) {
throw new Error("参数错误");
}

if (n <= 1) {
return 1;
}

return n * factorial(n - 1);
}

上面的函数无法进行尾优化,因为最后一条语句不是尾调用。现在我们换一种思路,将上面的函数转化为可以进行尾调用优化的函数

function factorial(n, result = 1) {
if (n < 0) {
throw new Error("参数错误");
}

if (n <= 1) {
return result;
}

result = result * n;

return factorial(n - 1, result);
}