JavaScript学习笔记(8):函数

函数基础

函数实际上是对象,每个函数都是Function类型的实例,因此函数和其他引用类型一样,也有属性和方法。

因此函数名就是指向函数对象的指针,不一定与函数本身紧密绑定,即一个函数名作为一个名称,可以替换它所指向的函数。

定义函数

一共有四种函数的定义方式。

函数声明

最常见的、中规中矩的函数定义的方式:

1
2
3
4
function sum(a, b) {
return a + b;
}
console.log(sum(2,3)); // 5

函数声明的末尾不加分号。

函数表达式

将一个匿名函数赋给一个变量:

1
2
3
4
let sum = function (a, b) {
return a+b;
};
console.log(sum(2,3)); // 5

箭头函数

简单的函数定义方式:

1
2
let sum = (a, b) => a + b;
console.log(sum(2,3)); // 5

如果函数内容较为复杂可以把函数体写成代码块。

创建函数对象

通过构造函数创建函数对象:

1
2
let sum = new Function("a","b","return a+b;");
console.log(sum(2,3)); // 5

最后一个字符串作为函数体的内容,前面的字符串是参数名。

不推荐使用这种方法创建函数。

函数表达式

函数表达式与函数声明

函数表达式和函数声明的唯一不同之处就是它会不会被提升:函数声明会被提升,在声明之前也可以使用函数,而函数表达式则只能在表达式赋值之后才可以使用函数

除了这一点区别外,函数声明和函数表达式完全等价。

就像var变量提升可以多次定义一样,函数声明被提升也是可以多次定义的,但是这里的多次定义并非重载,JS是不支持重载函数的,多次声明时后面的声明将会覆盖前面的。

必需使用函数表达式替代函数声明的情况之一是需要利用函数表达式的“表达式”特性时,如条件定义函数时:

1
2
3
4
5
6
7
8
9
10
11
12
let char = "a";
let process;
if(char >= "a" && char <= "z") {
process = function (c) {
return c.toUpperCase();
}
} else if(char >= "A" && char <= "Z") {
process = function (c) {
return c.toLowerCase();
}
}
console.log(process(char)); // A

这样可以在不同的情况下让函数process获得不同的定义,但是使用函数声明就做不到。使用函数声明浏览器大概率不会报错,因为浏览器会尝试将其纠正为合理的声明,但最后的结果一定是不符合预期的!

立即调用的函数表达式

即IIFE,最常见的用途是用来模拟块作用域。在还没有块作用域和let声明的时候,使用IIFE可以模拟出这样的行为。

在“语言基础”章节提到了var变量的一种反常行为:

1
2
3
for (var i = 0; i < 5; ++i) {
setTimeout(() => console.log(i), 0)
} // 5 5 5 5 5

我们希望它按照预期地输出,之前说要使用let声明变量,但是在解决历史遗留问题的时候以及在某些不支持ES6语法的地方,只能使用IIFE来模拟let变量的行为了:

1
2
3
4
5
for (var i = 0; i < 5; ++i) {
setTimeout((function (j) {
return () => console.log(j);
})(i));
} // 0 1 2 3 4

这里通过闭包加上立即调用的函数表达式,实现了变量锁定

此外由于函数作用域能够关住var变量,所以可以模拟出块作用域的行为。

注意:在支持ES6语法的环境下,应当尽量不去使用IIFE了!

箭头函数

定义

ECMAScript 6新增了使用胖箭头(=>)语法定义函数表达式的能力,任何使用到函数的地方,都可以使用箭头函数来替代。

箭头函数的完整定义为:

1
(a,b) => { return a+b; }

其中,如果参数只有一个,就可以省略小括号,参数有0个或多个时,不能省略小括号:

1
a => { return a+3; }

如果函数的返回值是一个表达式,并且没有其他处理过程,可以将函数体直接写成返回的表达式:

1
a => a+3

限制

箭头函数不能使用argumentssupernew.target,也不能用作构造函数。

此外,箭头函数也没有prototype属性。

函数结构

函数名

函数名需要遵循JS标识符的规则。

函数实际上就是一个对象,可以像其他对象一样给变量赋值,因此一个对象可以被传来传去,多个名称指向一个函数也是可以的,因此,一个函数不一定只有一个名称:

1
2
3
4
5
6
7
function sum(a, b) {
return a + b;
}
let add = sum;
console.log(sum(2,3)); // 5
console.log(add(2,3)); // 5
console.log(sum === add); // true

ECMAScript 6的所有函数对象都会暴露一个只读的name属性,它就是这个函数最原始的名称:

  • 对于函数声明来说,name属性就是声明它时给出的名称;
  • 对于函数表达式和箭头函数来说,它们定义时是没有名称的,即name属性为空,但是如果使用它们来初始化一个变量,那么这个变量名就会成为它们的函数名,即name属性的值;
  • 使用Function构造函数创建的函数的函数名name都为"anonymous"
  • 函数名name属性是只读的,因此一旦确定下来,就不可能再改变了,因此赋值操作不会改变函数的name字段,最多认为赋值操作是为函数指定了“别名”。

有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function sum1(a, b) {
return a + b;
}
let sum2 = function (a, b) {
return a + b;
};
let sum3 = (a, b) => a + b;
let sum4 = new Function("a", "b", "return a + b;");
let sum5 = sum1;
let sum6 = sum2;
let sum7 = sum3;
let sum8 = sum4;
console.log(sum1.name, sum5.name); // sum1 sum1
console.log(sum2.name, sum6.name); // sum2 sum2
console.log(sum3.name, sum7.name); // sum3 sum3
console.log(sum4.name, sum8.name); // anonymous anonymous
let funArr = [
function (a, b) {
return a + b;
},
(a, b) => a + b
];
console.log(funArr[0].name === ""); // true
console.log(funArr[1].name === ""); // true
let sum9 = funArr[0];
let sum10 = funArr[1];
console.log(funArr[0].name === ""); // true
console.log(funArr[1].name === ""); // true

特别地,如果函数是一个getter或者setter,或者使用bind方法来实例化,那么函数标识符前会有一个前缀,如:

1
2
3
4
5
6
7
8
9
10
11
let obj = {
name: "Three",
age_: 18,
get age() {
return this.age_;
}
};
let ageFun = Object.getOwnPropertyDescriptor(obj, "age");
console.log(ageFun.get.name); // get age
function fun() {}
console.log(fun.bind(null).name); // bound fun

注意这里的age方法无法直接通过obj.age来获得,通过obj.age获得的是它返回的age值,因此需要使用Object.getOwnPropertyDescriptor()静态方法来获取函数本体。

参数

随意的参数

JS的函数是非常随意的,因此参数也是非常随意的。

函数随意在于它根本没有签名,只是一个个对象,函数的定义格式对函数的调用格式没有任何要求

参数随意在于函数定义中写的有多少参数并不代表调用时就一定需要传多少参数,函数声明时写明了两个参数,调用时传递1个或3个,甚至不传,语法上都不会报错,只是函数可能不会按照预期来正常执行。

JS函数(暂时只说除了箭头函数以外的函数)的机制为:

  • 函数调用时传递任意个参数都会原封不动地进入一个名为arguments的类数组对象中进入函数体供函数使用。
  • arguments对象可以像数组一样通过数字下标访问,传入函数的参数都依次存储在里面,但它不是数组,使用Array.isArray()测试会返回false。
  • 函数定义时指定的形参只是个“别名”,这些别名会获得arguments中的前若干个值,以能够在函数中以更方便的方式来访问它们,因此这些形参的个数与调用时传入的实参的个数没有任何约束关系:
    • 传入的实参个数多了,就都在arguments对象中存着呢,仍然可以通过arguments读取;
    • 传入的实参个数少了,只不过是后面若干个形参没有对应的值,为undefined罢了,也不会有什么语法上的错误。

严格模式

考虑:如果在函数内部修改arguments的内容呢?有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function fun(a, b) {
console.log(a, b);
arguments[1] = 99;
console.log(a, b);
b = 66;
console.log(arguments[1]);
}
fun(1, 2);
// 1 2
// 1 99
// 66
fun(1);
// 1 undefined
// 1 undefined
// 99

可以认为给函数传递了两个参数时,函数的两个命名参数都获得了值,那么它们都处于活跃的状态,就会建立它们与arguments之间的联系,使得它们的值互相同步修改;而如果某些命名参数没有在一开始获得值,他们就不处于激活的状态,就不会与arguments建立起联系。

注意这里并非arguments和命名参数使用的是同一块存储空间,只是它们互相同步修改而已。

而在严格模式下情况就不一样了,上述的同步修改的情况根本不会发生:

1
2
3
4
5
6
7
8
9
10
11
12
function fun(a, b) {
"use strict"
console.log(a, b);
arguments[1] = 99;
console.log(a, b);
b = 66;
console.log(arguments[1]);
}
fun(1, 2);
// 1 2
// 1 2
// 99

还有一点是严格模式下重写arguments对象会报语法错误,非严格模式下不会报错。

箭头函数

箭头函数中是没有arguments对象的,因此只能通过命名参数来访问参数

除非使用包装函数通过闭包来使得箭头函数获得arguments对象,但这显得非常麻烦,违背了使用箭头函数简洁的初衷。

默认参数

默认参数是从ES6开始支持的,在以前,实现默认参数的一种常用方式就是检测某个参数是否等于undefined,如果是则意味着没有传这个参数,那就给它赋一个值。

有以下例子:

1
2
3
4
5
function sum(a, b=2) {
return a + b;
}
console.log(sum(5,5)); // 10
console.log(sum(5)); // 7

注意:

  • 使用默认参数时arguments对象中不会反映参数的默认值,只存储的是调用函数传进来的参数。
  • 一旦使用了默认参数,arguments和命名参数之间的联系就被打断了,修改其一另一个就不会被同步修改了。
  • 默认参数可以使用任何合法的表达式,但是表达式在调用且未传递相关参数时求值,并不在定义时求值。

既然参数可以使用任何表达式,那么表达式一定是在某个作用域中求值的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function f() {
console.log("f this :", this);
return 1;
}
function s() {
console.log("second");
return 2;
}
function fun(first=f(), second=s(), third=second*2, t=this) {
console.log("fun this :", t);
return `${first} ${second} ${third}`;
}
let obj = { fun };
console.log(obj.fun());
// f this : window
// second
// fun this : obj
// 1 2 4

可以看到表达式求值的作用域为函数作用域,而函数作为默认参数则在全局作用域上运行,并且后求值的参数可以访问到前面的参数,但前面的参数无法访问到后面的参数。

箭头函数也可使用默认参数,但这时如果只有一个参数就不能省略小括号了。

参数的收集和扩展

使用ES6的扩展操作符,可以方便地将数组展开为函数的参数来传递给函数,也可以在形参处将若干参数收集起来成为一个数组:

1
2
3
4
5
6
function print(name="unknow", age, ...others) {
console.log(`name: ${name}, age: ${age}, others: ${others}`);
}
print(); // name: unknow, age: undefined, others:
let p = ["Dasen", 22];
print(...p, "other1", "other2"); // name: Dasen, age: 22, others: other1,other2

在传递参数时由于数组长度已知,因此在扩展前后都可以传递其他参数,而在收集时,由于不知道要收集多少参数到数组中,因此收集参数只能作为最后一个形参,表示收集其余全部参数。

箭头函数也支持参数收集。

函数内部对象

函数内部对象就是在函数内部可以使用的对象,它们在进入函数运行时会自动创建,并可以在函数内部直接使用。内部对象主要有四个:ES5以前规定的thisarguments和ES6规定的new.target以及几乎所有浏览器都支持的caller

this

在标准函数中,this引用的是把函数当成方法调用的上下文对象。在全局作用域下,this就是window对象。

在箭头函数中,this引用的是箭头函数定义所在的上下文,即箭头函数没有它自己的this,它的this实际是通过闭包获得的上级上下文。

arguments

在上文已经讨论过很多关于arguments对象的内容了,我们知道它是一个类数组对象。

之所以是类数组对象,是因为我们可以通过数字下标来访问它的元素,但它又有和数组不一样的东西,其中之一就是callee属性,是一个指向本函数的指针。

如果你之前学过C语言可能会觉得它没啥用,但它其实有很大用,原因就在于像C语言这样的语言的函数名是固定的,你也许能通过函数指针来使函数获得一个别名,但是函数的原本的名字不可能改变,不如看这样的例子:

1
2
3
4
5
6
7
function factorial(num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
}

这是一个典型的求阶乘的函数,通过递归来实现的,如果它是个C语言函数那没有任何问题,因为它的函数名factorial不可能被改变,永远可以通过这个名称调用到这个函数,但是在JS中使用函数表达式定义的函数就不是了,一个函数可能永远抛弃了它原本的名字而改用新名,那么在函数内部递归地调用factorial这个名称在函数名改变之后就会出现问题了——找不到这个函数了:

1
2
3
4
5
6
7
8
9
10
11
let factorial = function (num) {
if (num <= 1) {
return 1;
} else {
return num * factorial(num - 1);
}
};
console.log(factorial(5)); // 120
let anotherFun = factorial; // 改变函数名
factorial = null; // 废弃掉原来的函数名
console.log(anotherFun(5)); // TypeError: factorial is not a function

这种情况是C语言不可能发生的,因为你无法在C语言中废弃掉一个函数最原本的函数名,这就是不同语言特性的差异导致的在不同语言下应当有不同的设计思想。

在JS中很多关系只是名称与引用的关系,名称在大多情况下是可以改变的,那就要在设计时尽可能地不依赖名称,即名称与逻辑解耦。在本例中,就可以使用arguments.callee来改进,这样函数的逻辑就不依赖于函数名了:

1
2
3
4
5
6
7
8
9
10
11
let factorial = function (num) {
if (num <= 1) {
return 1;
} else {
return num * arguments.callee(num - 1);
}
};
console.log(factorial(5)); // 120
let anotherFun = factorial; // 改变函数名
factorial = null; // 废弃掉原来的函数名
console.log(anotherFun(5)); // 120

但是,这种解耦虽然似乎更好,但会带来安全性的问题,因此在严格模式下是不允许使用callee和下文介绍的caller的,因为它们可能会泄露函数的代码。因此这种模式不应该在生产环境下使用,生产环境下代码的安全性是很重要的,因此应当使用其他的约束来避免发生不合适的函数名改变。

可是如果我希望在严格模式下也能做到这样的名称和逻辑解耦呢?答案是使用带有默认名称的函数表达式:

1
2
3
4
5
6
7
8
9
"use strict"
let factorial = (function f(num) {
if (num <= 1) {
return 1;
} else {
return num * f(num - 1);
}
});
console.log(factorial(5)); // 120

本来函数表达式声明的是个匿名函数,但我们可以给它一个原始名称,这个原始名称无法被解绑,其他的都只是它的别名,这就解决了问题。

new.target

ECMAScript中的函数始终可以作为构造函数实例化一个新对象,也可以作为普通函数被调用。

ECMAScript 6新增了检测函数是否使用new关键字调用的new.target属性:

  • 如果函数是正常调用的,则new.target的值是undefined;
  • 如果是使用new关键字调用的,则new.target将引用被调用的构造函数。

caller

caller属性是在函数对象上的,用来标识调用它的那个主调函数:

1
2
3
4
5
6
7
8
function inner() {
console.log(inner.caller);
}
function outer() {
inner();
}
inner(); // 结果不一定
outer(); // ƒ outer()

直接在全局作用域下调用函数的caller属性在不同实现中的结果不一样,在Chrome中是null。

属性与方法

属性

函数有两个属性:length和prototype。

其中,length属性表示函数拥有几个命名参数,prototype属性则是函数对象的原型,一般是用不到函数对象的prototype属性的。

有以下例子:

1
2
3
4
5
6
function sum(a, b) {
return a + b;
}
let add2 = (a) => a + 2;
console.log(sum.length); // 2
console.log(add2.length); // 1

方法

函数有三个方法:apply()call()bind()

前两个方法都会以指定的this值来调用函数,即会设置调用函数时函数体内this对象的值。

apply()方法接收两个参数:函数内this的值和一个参数数组,第二个参数可以是Array的实例,也可以是arguments对象。

call()方法与apply()的作用一样,只是传参的形式不同:第一个参数跟apply()一样,也是this值,而后面若干个参数是要传递给函数的参数,必须将参数一个一个地列出来。

bind()方法则只需要一个this参数,它是基于原函数构建出一个新的函数,这个新函数是以提供的参数作为this对象的。

有以下例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function addAll(a, b, c, d) {
console.log(this);
return a + b + c + d;
}
let obj = {};
console.log(addAll.apply(obj, [1,2,3,4]));
// obj: {}
// 10
console.log(addAll.call(obj, 1, 2, 3, 4));
// obj: {}
// 10
let fun = addAll.bind(obj);
console.log(fun(1, 2, 3, 4));
// obj: {}
// 10

此外:之前说函数在全局上下文并且没有指定上下文对象时,函数的this是指向window全局对象的,然而在严格模式下并不是这样的,严格模式下没有指定this值时函数不会自动获得全局对象作为上下文,而会是undefined值

此外还有继承的方法toLocaleString()toString()始终返回函数的代码,返回代码的具体格式因浏览器而异:有的返回源代码,包含注释;而有的只返回代码的内部形式,会删除注释,甚至代码可能被解释器修改过。这种方法最好只用来调试。

尾调用优化

形如如下调用方式就是尾调用:

1
2
3
function outerFunction() {
return innerFunction();
}

另一个函数的调用是当前函数的最后一步操作

在常规的函数调用中,函数调用是通过函数调用栈来进行的,一个函数调用另一个函数时,将会把被调函数的压入调用栈,返回时将函数弹栈并返回值。

如果是尾调用的情况则可以进行尾调用优化:

  • 执行到outerFunction函数体,第一个栈帧被推到栈上。
  • 执行outerFunction函数体,到达return语句。为求值返回语句,必须先求值innerFunction。
  • 引擎发现把第一个栈帧弹出栈外也没问题,因为innerFunction的返回值也是outerFunction的返回值。
  • 弹出outerFunction的栈帧。
  • 执行到innerFunction函数体,栈帧被推到栈上。
  • 执行innerFunction函数体,计算其返回值。
  • 将innerFunction的栈帧弹出栈外。

但一般情况下尾调用优化是不会生效的,尾调用优化生效的条件为:

  • 代码在严格模式下执行;
  • 外部函数的返回值是对尾调用函数的调用;
  • 尾调用的函数返回后不需要执行额外的逻辑
  • 尾调用的函数不是引用外部函数作用域中自由变量的闭包

合理使用尾调用将大大提升程序的效率,并且对于尾递归,由于进行下一次递归时前一次递归的函数调用已经被弹出栈了,因此调用栈中永远都只有一个函数的调用栈,这样的经过尾调用优化的递归永远不会出现栈溢出的问题。

闭包与私有成员

闭包

概念

闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。

在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链,然后用arguments和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象,这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。

而闭包就是在一个函数中返回了另一个函数,而返回的这个函数中包含了父函数作用域中某些值的引用,因此在这种情况下,尽管父函数已经返回了,结束了运行,但上下文不会被销毁,因为还有返回的函数内部保持着对父函数上下文内对象的引用。

this

引入了闭包之后,考虑以下例子的this值的指向:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function outer() {
console.log(this);
}
let obj = {
inner() {
console.log(this);
outer(); // 在全局定义,内部调用
let fun = function () {
console.log(this);
}
fun(); // 在内部定义,内部调用
return [
function () {
console.log(this);
},
() => console.log(this),
fun
];
}
};
outer(); // window - 在全局定义,全局调用
let [fun, afun, innerfun] = obj.inner();
// obj - obj.inner()的this
// window - 在全局定义,内部调用
// window - 在内部定义,内部调用
fun(); // window - (返回的匿名函数)
afun(); // obj - (返回的箭头函数)
innerfun(); // window - 在内部定义,全局调用

可以看到,非箭头函数无论在哪里声明、哪里调用,其this值永远是全局对象,函数内返回的匿名函数,也相当于是在函数内部定义、函数外部调用的函数了,所以其this值也是全局对象;而箭头函数没有自己的this,它永远引用的是它定义时的外层的this,因此箭头函数只要使用了this,就一定引用了外层作用域中的内容,就一定形成了闭包。

内存泄漏

在“值、作用域与内存”这一章节中提到过闭包导致的内存泄漏,尤其是在IE9之前的浏览器中,如果把HTML元素保存在了闭包中,则它们永远不会被回收。建议的解决方法是在闭包中只保存能够唯一标识HTML元素的ID,并在保存了这个ID之后将保存HTML元素的变量设置为null,手动解除引用,这样就能够解决这一问题了。

闭包模式的私有成员

任何定义在函数或块中的变量,都可以认为是私有的,因为在这个函数或块的外部无法访问其中的变量。私有变量包括函数参数、局部变量,以及函数内部定义的其他函数(包括函数内部的其他函数声明)。

ES语法是不支持对象的私有成员的,而使用闭包的特性,可以创建出具有私有成员的对象。

简单闭包模式

使用内部变量、命名参数等只能在函数内部访问的名称来存储私有属性,然后通过暴露出的特权方法来访问:

1
2
3
4
5
6
7
8
9
10
11
12
function Person(name) {
this.getName = function () {
return name;
};
this.setName = function (personName) {
name = personName;
};
}
let person = new Person("Dasen");
console.log(person.getName()); // Dasen
person.setName("name");
console.log(person.getName()); // name

类私有成员

上述方法通过构造函数的闭包模拟了带有私有成员的对象,而如果需要给类创建私有的静态成员,使得类的实例之间拥有共享的私有静态成员,则需要在类的外面包一层闭包,使用立即调用函数表达式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
let User = (function () {
// 类(静态)私有成员
let userCounter = 0;
// 构造函数
let User = function (username) {
this.name = username;
userCounter++;
};
// 类(静态)公共方法
User.prototype.countUser = function () {
return userCounter;
}
return User;
})();
let user1 = new User("Dasen");
console.log(user1.countUser()); // 1
console.log(user1.name); // Dasen
let user2 = new User("Three");
console.log(user2.countUser()); // 2
console.log(user2.name); // Three

模块模式

在模块模式中,单例对象作为一个模块,经过初始化可以包含某些私有的数据,而这些数据又可以通过其暴露的公共方法来访问。

单例对象是通过字面量来创建的,结合IIFE可以创建带有私有成员的单例对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let rooms = function () {
// 私有成员
let roomList = [];
// 初始化私有成员
roomList.push("Dasen's room");
// 公共接口
return {
getRooms() {
return roomList.join(", ");
},
getRoomNumber() {
return roomList.length;
},
createRoom(roomName) {
roomList.push(roomName);
}
}
}();
console.log(rooms.getRoomNumber()); // 1
console.log(rooms.getRooms()); // Dasen's room
rooms.createRoom("New room");
console.log(rooms.getRoomNumber()); // 2
console.log(rooms.getRooms()); // Dasen's room, New room



* 你好,我是大森。如果文章内容帮到了你,你可通过下方付款二维码支持作者 *