深入理解JavaScript函数

对于 JavaScript 来说,最核心的部分莫过于函数和对象,这篇文章就通过对函数的展开介绍,提供了一份全面的关于 JavaScript 函数的指南。

函数定义

到目前为止,定义普通函数总共有四种方式(加一种特殊方式):

  • 函数声明
  • 函数表达式
  • 箭头函数
  • Function 构造函数
  • Generator 函数

函数声明

对于函数声明语句,需要注意如下几点:

  • 函数声明语句必须定义函数名称,函数的名称为函数内部的一个局部变量,指代该函数对象本身
  • 函数声明语句不能出现在循环、判断,或者 try/cache/finally 以及 with 语句中;
  • 函数声明语句定义的函数,会被提前到外部脚本或者外部函数作用域的顶端,因此可以在定义之前使用。
1
2
3
4
5
// 函数声明语句
function name(param[, ...param]) {
statements
[return xxx]
}

大部分函数中会包含一条 return 语句,该语句用来停止函数的执行,如果一个函数不包含 return 语句或者 return 语句没有一个与之相关的表达式,则函数默认返回 undefined

函数表达式

与函数声明语句相比,函数表达式具有以下特点:

  • 函数表达式可以省略函数名称;
  • 函数表达式可以出现在任何地方;
  • 函数表达式定义的函数,由于变量提升作用的存在,该表达式变量被提前,但是函数本身并未提前,因此无法在定义前调用。
1
2
3
4
5
// 函数表达式
var temp = function [name](param[, ...param]) {
statements
[return xxx]
}

箭头函数

使用箭头函数需要注意:

  • 箭头函数没有自己的 thisargumentssupernew.target 关键字绑定;
  • 函数体内的 this 对象,就是定义时所在的对象,而不是使用时所在的对象,因此箭头函数不适合作为方法;
  • 无法当作构造函数使用,也就是说,不可以使用 new 命令,否则会抛出一个错误;
  • 不可以使用 yield 命令,因此箭头函数不能用作 Generator 函数。
1
2
3
4
5
6
7
8
9
10
11
12
// 基本用法
(param1, param2, …, paramN) => {statements}
(param1, param2, …, paramN) => expression // 等同于:=> {return expression;}

// 当只有一个参数时括号是可选的
(singleParam) => {statements} // 等同于:singleParam => {statements}

// 没有参数时括号是不可以省略的
() => {statements}

// 将函数体用大括号括起来返回对象字面量
params => ({foo: bar})

Function 构造函数

使用 Function 定义函数时可以传入任意数量的字符串实参,最后一个实参就是函数体:

1
2
3
var fn = new Function("x", "y", "return x + y;");
// 等同于
var fn = function(x, y) {return x + y;}

使用 Function 构造函数定义函数时只需要注意一个问题:由 Function 构造函数定义的函数只继承全局作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var foo = 1;

function myFunc() {
var foo = 2;

function decl() {
console.log(foo);
}

var expr = function() {
console.log(foo);
};

var cons = new Function('\tconsole.log(foo);');

decl(); // 2
expr(); // 2
cons(); // 1
}

myFunc();

Function() 构造函数允许 JavaScript 在运行时动态地创建并编译函数,每次调用都会解析函数体并创建新的函数对象,而使用函数声明语句和表达式只会解析一次,很明显使用构造函数的效率比较低,因此,通常应尽可能避免使用 Function 构造函数。

函数调用

在 JavaScript 中,函数调用总共有四种方式:

  • 作为函数
  • 作为方法
  • 作为构造函数
  • 通过 call()apply() 方法间接调用

其中作为函数调用是最基本的形式,只需注意以这种方式调用函数通常不使用 this 关键字,但是此时 this 可以用来判断当前是否为严格模式:

1
var strict = (function() {return !this;}());

方法调用

方法指的是保存在对象属性中的 JavaScript 函数,使用方法调用有两种形式,与访问对象的属性访问方法一致:

  • 使用“.”:obj.f(argu)
  • 使用“[]”:obj["f"](argu)

方法调用的参数和返回值处理和函数调用一致,但是方法调用有一个重要的特点:调用上下文(context),即函数体可以使用 this 引用该对象。

构造函数调用

如果函数和方法调用之前带有关键字 new,它便构成构造函数调用。构造函数调用和函数调用以及方法调用在实参处理、调用上下文和返回值方便都有不同。

如果构造函数没有形参,则可以省略实参列表。构造函数创建的是一个新的对象,这个对象继承自构造函数的 prototype 属性,因此此时的调用上下文是生成的新对象而非构造函数。如果在构造函数中使用 return 语句并返回一个对象,那么这个新对象将作为调用结果,这一特性可用来实现私有属性、方法。

this

任何函数只要作为方法调用都会传入一个隐式实参——方法调用的母体对象。可以使用 this 关键字访问该母体对象的任意属性。this 关键字没有作用域的限制,嵌套的函数不会从调用它的函数中继承 this,因此如果需要在嵌套的函数中使用 this,需要先使用一个变量来保存外部的 this

需要注意的一点是,无论是否在严格模式下,在全局执行环境中(在任何函数体外部)this 都指向全局对象。在严格模式下,如果进入执行环境时没有设置 this 的值,this 会保持为 undefined

可以使用 call()apply() 显式指定函数的调用上下文,即 this 的值。

1
2
3
4
5
6
7
8
9
10
var obj = {a: 'Custom'};
var a = 'Global';

function whatsThis() {
return this.a;
}

whatsThis(); // 'Global'
whatsThis.call(obj); // 'Custom'
whatsThis.apply(obj); // 'Custom'

使用 bind() 方法绑定函数的调用上下文,绑定后无论如何调用该函数,不会改变其调用上下文。

1
2
3
4
5
6
7
8
9
function f() {
return this.a;
}

var g = f.bind({a: 'azerty'});
console.log(g()); // azerty

var h = g.bind({a: 'yoo'}); // 绑定只生效一次
console.log(h()); // azerty

闭包

一般来说,只有在函数运行时,子函数才能访问父函数内定义的局部变量,但是利用闭包的特性,调用父函数中返回的子函数也能访问到父函数所有局部变量。闭包是由函数以及创建该函数的词法环境(作用域链)组合而成,这个环境包含了这个闭包创建时所能访问的所有局部变量。闭包常被用来实现私有变量和方法。

要理解闭包,首先需要理解嵌套函数的作用域规则。正常情况下,局部变量定义在 CPU 的栈中,因此函数返回后这些局部变量就不存在了。但是在 JavaScript 中,作用域链是以一个对象列表形式存在,而并非直接添加到栈中,因此只要有引用到这个对象的部分存在,该作用域就一直存在,否则就会被当作垃圾回收掉。

1
2
3
4
5
6
7
8
9
var foo = 1;

function f() {
var foo = 2;
function show() {return foo;}
return show();
}

f(); // 2

因此,只需要把私有变量方法放到父函数中,公共变量和方法放到嵌套函数中,即可实现变量方法的私有化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function addPrivateProperty(o, name, predicate) {
var value;

// getter 方法
o["get" + name] = function() {return value;}

// setter 方法
o["set" + name] = function(v) {
if (predicate && !predicate(v)) {
throw Error("set" + name + ": invalid value " + v);
} else {
value = v;
}
}
}

需要注意的是,记住关联到闭包的作用域链都是“活动的”,嵌套的函数不会将作用域内的私有成员复制一份,如下面的例子所示:

1
2
3
4
5
6
7
8
9
10
11
function f() {
var funcs = [];
for (var i = 0; i < 10; i++) {
funcs[i] = function() {return i;}
}
return funcs;
}

var funcs = f();

f[5](); // 返回值为10

函数参数

在 JavaScript 中,参数的传入是非常灵活的。当调用函数的时候传入的实参比函数声明时指定的形参个数少,则剩下的形参都会被设置为 undefined。

arguments 对象

在函数体内,标识符 arguments 指向实参对象的引用,是一个类数组对象,可以直接使用访问数组元素的方法访问对应位置的实参,也可以使用 length 属性来获取参数的个数。利用这个特性可以实现让函数操作任意数量的参数。

1
2
3
4
5
6
7
function max() {
var max = Number.NEGATIVE_INFINITY;
for (var i = 0; i < arguments.length; i++) {
if (arguments[i] > max) max = arguments[i];
}
return max;
}

尽管 arguments 并非真正的数组,但是可以通过一定的方法把它转为真正的数组:

1
2
3
var args = Array.prototype.slice.call(arguments);
// 等效于
var args = [].slice.call(arguments);

实参对象还有 calleecaller 两个属性,它们类似两个指针,callee 指向当前正在执行的函数,caller 指向当前正在执行的函数的函数,即调用栈。

callee 属性在递归中的应用:

1
2
3
4
var factorial = function(x) {
if (x <= 1) return 1;
return x * arguments.callee(x - 1);
}

Rest 参数

ES6 引入 rest 参数(形式为 ...变量名 ),用于获取函数的多余参数,这样就不需要使用 arguments 对象了。rest 参数搭配的变量是一个数组,该变量将多余的参数放入数组中。需要注意的是,rest 参数只能作为函数的最后一个参数,否则会报错。同时,函数的 length 属性,不包括 rest 参数。

1
2
3
4
5
6
7
function sum(...theArgs) {
return theArgs.reduce((previous, current) => {
return previous + current;
});
}

console.log(sum(1, 2, 3)); // expected output: 6

参数默认值

ES6 之前,想要为函数指定默认值只能采用如下的办法:

1
2
3
4
5
function f(a, b) {
a = a || 0;
b = typeof b == "undefined" ? 0 : b;
return a + b;
}

ES6 允许为函数的参数设置默认值,即直接写在参数定义的后面。

1
2
3
function f(a = 0, b = 0) {
return a + b;
}

无论是上面说到的 rest 参数,还是参数默认值,都可以使用 ES6 的解构赋值。

1
2
3
4
5
6
7
8
function foo({x = 0, y = 0} = {}) {
console.log(x, y);
}

foo() // 0 0
foo({}) // 0 0
foo({x: 1}) // 1 0
foo({x: 1, y: 2}) // 1 2

函数的属性和方法

length 属性

函数的 length 属性不同于 arguments.length,后者表示传入函数的实参个数,而前者表示函数定义时所需的实参个数,可以通过这个特性来判断传入函数的参数个数是否满足要求:

1
2
3
4
5
function check(args) {
if (args.length != args.callee.length) {
throw Error('Expected ' + args.callee.length + " arguments");
}
}

call、apply 和 bind

call()apply() 的功能是一样的,可以将函数作为某个对象的方法调用,以此来改变调用上下文(context),即 this 的指向。它们的第一个参数是用来绑定上下文的对象,之后的参数是传入函数的实参,二者唯一的不同便是传入实参的形式,call() 直接传入实参,类似 call(o, 1, 2, 3),而 apply() 传入一个实参的数组或者类数组对象(arguments),比如 apply(o, [1, 2, 3])

1
2
3
4
5
6
7
8
9
10
11
12
var obj = {
x: 1,
y: 2
}

function sum() {
return this.x + this.y;
}

console.log(sum()); // NaN
console.log(sum.call(obj)); // 3
console.log(sum.apply(obj)); // 3

apply() 对于任意参数的函数或者将一个函数的参数传给另一个函数的场景会非常好用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function calcu(m, nums) {
this.method = m.name;
return m.apply(this, nums);
}

function sum() {
let total = 0;
Array.prototype.slice.apply(arguments).forEach(n => total += n);
return {method: this.method, result: total};
}

function cumprod() {
let total = 1;
Array.prototype.slice.apply(arguments).forEach(n => total *= n);
return {method: this.method, result: total};
}

let op1 = new calcu(sum, [1, 2, 3, 4]);
let op2 = new calcu(cumprod, [1, 2, 3, 4]);

console.log("The result of " + op1.method + " is: " + op1.result);
console.log("The result of " + op2.method + " is: " + op2.result);

bind() 是 ECMAScript5 中新增的方法,用于将函数绑定至某个对象,返回值是一个新的函数。

1
2
3
4
5
6
7
8
9
10
11
var obj = {
x: 1,
y: 2
}

function sum() {
return this.x + this.y;
}

var temp = sum.bind(obj);
console.log(temp()) // 3

在 ECMAScript3 中可以通过已有的方法来模拟 bind() 方法:

1
2
3
4
5
6
7
8
9
10
11
if (!Function.prototype.bind) {
Function.prototype.bind = function(o/*, args */) {
let self = this, boundArgs = arguments;
return function() {
let args = [], i;
for (i = 1; i < boundArgs.length; i++) args.push(boundArgs[i]);
for (i = 0; i < arguments.length; i++) args.push(arguments[i]);
return slef.apply(o, args);
}
}
}

其他

  • prototype 属性:指向原型对象,当函数作为构造函数时,新创建的对象会从原型对象继承属性;

  • toString() 方法:以字符串的形式返回函数的完整源码。

参考资料

[1] 《JavaScript权威指南》第六版. 机械工业出版社,2015.
[2] 《JavaScript Guide》MDN web docs.
[3] 《ECMAScript 6 入门教程》阮一峰.


深入理解JavaScript函数
https://infiniture.cn/2019/09/29/深入理解JavaScript函数/
作者
NickHopps
发布于
2019年9月29日
许可协议