0%

JavaScript 权威指南 08:函数

函数是 JavaScript 程序的一个基本组成部分,也是几乎所有编程语言共有的特性。这篇文章将学习 JavaScript 的函数。

函数简介

函数是一个 JavaScript 代码块,定义之后,可以被执行或调用任意多次:

  • JavaScript 函数是参数化的,即函数定义可以包含一组标识符,称为参数或形参(parameter)
  • 这些形参类似函数体内定义的局部变量,函数调用会为这些形参提供值或者称为实参(argument)
  • 函数返回值会成为函数调用表达式的值。
  • 除了实参,每个调用还有另外一个值,即调用上下文(invocation context),也就是 this 关键字的值

如果把函数赋值给一个对象的属性,则可以称其为该对象的方法。如果函数是在一个对象上被调用或通过一个对象被调用,这个对象就是函数的调用上下文或 this 值。

JavaScript 中的函数是对象。因为函数是对象,所以可以在函数上设置属性,甚至调用函数的方法。

JavaScript 函数可以嵌套定义在其他函数里,内嵌的函数可以访问定义在函数作用域的任何变量。这意味着JavaScript 函数是闭包(closure),基于闭包可以实现重要且强大的编程技巧。

定义函数

在 JavaScript 中定义函数最直观的方式就是使用 function 关键字,这个关键字可以用作声明或表达式。ES6定义了一种新的方式,可以不通过 function 关键字定义函数,即 箭头函数

函数声明

函数声明由 function 关键字后跟如下组件构成:

  • 命名函数的标识符:这个作为函数名的标识符对于函数声明是必需的,它作为一个变量名使用,新定义的函数对象会赋值给这个变量
  • 一对圆括号:中间包含逗号分隔的零或多个标识符。这些标识符是函数的参数名,它们就像是函数体内的局部变量
  • 一对花括号:其中包含零或多个 JavaScript 语句。这些语句构成函数体,会在函数被调用时执行
1
2
3
function square() {
return x * x;
}

这里最关键的是,理解函数的名字变成了一个变量,这个变量的值就是函数本身。函数声明语句会被 提升 到包含脚本、函数或代码块的顶部,因此调用以这种方式定义的函数时,调用代码可以出现在函数定义代码之前。

return 语句导致函数停止执行并将其表达式(如果有)的值返回给调用者。如果 return 语句没有关联的表达式,则函数的返回值是 undefined。

函数表达式

函数表达式看上去很像函数声明,但是出现在复杂表达式或者语句的上下文中,而且函数名是可选的:

1
2
3
const square = function(x) { return x * x; };
// 函数表达式中可以包含名字,这对递归有用
const f = function fact(x) { if (x <= 1) return 1; else return x * fact(x - 1); };

函数名对定义为表达式的函数而言是可选的(如果需要引用自身,也可以带函数名),前面看到的多数函数表达式都没有名字。函数声明实际上会声明一个变量,然后把函数对象赋值给它。而函数表达式不会声明变量,至于要把新定义的函数赋值给一个常量还是变量都取决于你。

如果函数表达式包含名字,则该函数的局部作用域中也会包含一个该名字与函数对象的绑定。实际上,函数名就变成了函数体内的一个局部变量

在使用声明形式时,先创建好函数对象,然后再运行包含它们的代码,而且函数的定义会被提升到顶部,因此在定义函数的语句之前就可以调用它们。但对于定义为表达式的函数就不一样了,这些函数在定义它们的表达式实际被求值以前是不存在的。因此定义为表达式的函数不能在它们的定义之前调用。

箭头函数

在 ES6 中,我们可以使用一种特别简洁的语法来定义函数,叫作 箭头函数”。它使用 箭头 分隔函数的参数和函数体。箭头函数的一般形式是圆括号中逗号分隔的参数列表,后跟箭头 =>,再跟包含在花括号中的函数体:

1
const sum = (x, y) => { return x + y; };

如果函数体只有一个 return 语句,那么可以省略 return 关键字、语句末尾的分号以及花括号,将函数体写成一个表达式,它的值将被返回:

1
const sum = (x, y) => x + y;

如果箭头函数只有一个参数,也可以省略包围参数列表的圆括号:

1
const polynomial = x => x * x + 2 * x + 3;

但是如果没有参数,箭头函数一定要把空圆括号写出来:

1
const func = () => 42;

在写箭头函数时,不能在函数参数和箭头之间放换行符,否则会出现类似于 const func = x,这本身就是合法的赋值语句。另外,如果箭头函数的函数体是一个 return 语句,但要返回的表达式是对象字面量,那必须把这个对象字面量放在一对圆括号中,以避免解释器分不清花括号到底是函数体的花括号,还是对象字面量的花括号

箭头函数的简洁语法让它们非常适合作为值传给其他函数:

1
2
> [1, null, 2, 3].filter(x => x != null)
[ 1, 2, 3 ]

它们从定义自己的环境继承 this 关键字的值,而不是像以其他方式定义的函数那样定义自己的调用上下文。箭头函数与其他函数还有一个区别,就是它们没有 prototype 属性。这意味着箭头函数不能作为新类的构造函数。

嵌套函数

在 JavaScript 中,函数可以嵌套在其他函数中。关于嵌套函数,最重要的是理解它们的变量作用域规则:它们可以访问包含自己的函数(或更外层函数)的参数和变量。下文将详细介绍。

调用函数

构成函数体的 JavaScript 代码不在定义函数的时候执行,而在调用函数的时候执行。

函数调用

函数是通过调用表达式来进行调用的,此时函数可以是常规函数或者方法。调用表达式包括求值为函数对象的函数表达式,后跟一对圆括号,圆括号中是逗号分隔的零或多个参数表达式列表。如果函数表达式是属性访问表达式,即函数是对象的属性或数组的元素,那么它是一个方法调用表达式。

在一次调用中,每个(位于括号中的)实参表达式都会被求值,求值结果会变成函数的实参。在函数体内,对形参的引用会求值为对应的实参值。

在 ES2020中,可以在函数表达式后面、左圆括号前面插入 ?.,从而只在函数不是 null 或 undefined 时调用函数。因此 f?.(x) 等价于

1
(f !== null && f !== undefined) ? f(x) : undefined

对于非严格模式下的函数调用,调用上下文(this值)是全局对象。但在严格模式下,调用上下文是 undefined。要注意的是,使用箭头语法定义的函数又有不同:它们总是继承自身定义所在环境的 this 值。

方法调用

方法其实就是 JavaScript 的函数,只不过它保存为对象的属性而已。

1
2
3
4
5
6
7
> let o = {}
undefined
> o.m = x => x*x
> o.m(10)
100
> o["m"](100)
10000

当函数表达式本身是个属性访问表达式,这意味着函数在这里是作为方法而非常规函数被调用的。方法调用的参数和返回值与常规函数调用的处理方式完全一样。但方法调用与函数调用有一个重要的区别:调用上下文。属性访问表达式由两部分构成:对象(这里的 o)和属性名(m)。在像这样的方法调用表达式中,对象 o 会成为调用上下文,而函数体可以通过关键字 this 引用这个对象

多数方法调用使用点号进行属性访问,但使用方括号的属性访问表达式也可以实现方法调用。

方法和 this 关键字是面向对象编程范式的核心。任何用作方法的函数实际上都会隐式收到一个参数,即调用它的对象。通常,方法会在对象上执行某些操作,而方法调用语法是表达函数操作对象这一事实的直观方式。

如果方法返回对象,那么基于这个方法调用的返回值还可以继续调用其他方法。这样就会得到表现为一个表达式的一系列方法调用(或方法调用链)​。

1
doStepOne().then(doStepTwo).then(doFinalAction).catch(handleError);

如果你写的方法没有自己的返回值,可以考虑让它返回 this。如果能在自己的 API 中统一这么做,那就可以支持一种被称为方法调用链(method channing)的编程风格。这样只要给对象命名,之后就可以连续调用这个对象的方法:

1
new Square().x(100).y(100).color("red").show();

注意,this 是个关键字,不是变量也不是属性名。JavaScript 语法不允许给 this 赋值。this 关键字不具有变量那样的作用域机制,除了箭头函数,嵌套函数不会继承包含函数的 this 值。这里有一个常见的错误,就是对于定义在方法中的嵌套函数,如果将其当作函数来调用,以为可以使用 this 获得这个方法的调用上下文:

  • 如果嵌套函数被当作方法来调用,那它的 this 值就是调用它的对象
  • 如果嵌套函数(不是箭头函数)被当作函数来调用,则它的 this 值要么是全局对象(非严格模式)​,要么是 undefined(严格模式)​。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let o = {
m: function() {
let self = this;
console.log(this === o); // => true

function f() {
console.log(this === o); // => false
console.log(self === o); // => true
}

f();
}
}

o.m()

上面代码也展示了如何在嵌套行数中访问得到 o 对象,即通过在外层函数中额外定义 self 变量。在 ES6 及之后的版本中,解决这个问题的另一个技巧是把嵌套函数 f 转换为箭头函数,因为箭头函数可以继承 this 值

1
2
const f = () => console.log(this === o); // => true
f();

还有一个技巧是调用嵌套函数的 bind() 方法,来定义一个在指定对象上被隐式调用的新函数:

1
2
3
const f = (function() {
this === o; // => true
}).bind(this);

构造函数调用

如果函数或方法调用前面加了一个关键字 new,那它就是构造函数调用。构造函数调用与常规函数和方法调用的区别在于参数处理、调用上下文和返回值。

  • 假如没有参数列表,构造函数调用时其实也可以省略空圆括号
  • 构造函数调用会创建一个新的空对象,这个对象继承构造函数的 prototype 属性指定的对象

在构造函数中可以通过 this 关键字引用这个新创建的对象。即使构造函数调用看起来像方法调用,构造函数的调用上下文也是新创建的对象,而不是方法调用所对应的对象。即在 new o.m o 不会用作调用上下文。

  • 构造函数正常情况下不使用 return 关键字,而是初始化新对象并在到达函数体末尾时隐式返回这个对象。此时,这个新对象就是构造函数调用表达式的值
  • 但是,如果构造函数显式使用了 return 语句返回某个对象,那该对象就会变成调用表达式的值
  • 如果构造函数使用 return 但没有返回值,或者返回的是一个原始值,则这个返回值会被忽略,仍然以新创建的对象作为调用表达式的值

间接调用

JavaScript 函数是对象,与其他 JavaScript 对象一样,JavaScript 函数也有方法。其中有两个方法 call()apply(),可以用来间接调用函数。这两个方法允许我们指定调用时的 this 值,这意味着可以将任意函数作为任意对象的方法来调用,即使这个函数实际上并不是该对象的方法。

隐式函数调用

有一些 JavaScript 语言特性看起来不像函数调用,但实际上会导致某些函数被调用。

  • 如果对象有获取方法或设置方法,则查询或设置其属性值可能会调用这些方法
  • 当对象在字符串上下文中使用时(比如当拼接对象与字符串时)​,会调用对象的 toString() 方法。类似地,当对象用于数值上下文时,则会调用它的 valueOf() 方法
  • 在遍历可迭代对象的元素时,也会涉及一系列方法调用
  • 标签模板字面量是一种伪装的函数调用
  • 代理对象的行为完全由函数控制。这些对象上的几乎任何操作都会导致一个函数被调用

函数实参与形参

JavaScript 函数定义不会指定函数形参的类型,函数调用也不对传入的实参进行任何类型检查。事实上,JavaScript 函数调用连传入实参的个数都不检查。

  • 当调用函数时传入的实参少于声明的形参时,额外的形参会获得默认值,通常是 undefined(因此可选参数一定要放到参数列表的后面,这样调用时才可以省略)
  • 在 ES6 及更高的版本中,可以在函数形参列表中直接为每个参数定义默认值。语法是在形参名后面加上等于号和默认值,这样在没有给该形参传值时就会使用这个默认值
1
2
3
4
5
6
function getPropertyNames(o, a = []) {
for (let property in o) {
a.push(property);
}
return a;
}
  • 函数的形参默认值表达式会在函数调用时求值,不会在定义时求值。因此每次调用 getPropertyNames() 函数时如果只传一个参数,都创建并传入一个新的空数组
  • 可以使用变量或函数调用计算形参的默认值。即如果函数有多个形参,则可以使用前面参数的值来定义后面参数的默认值
  • 形参默认值也可以在箭头函数中使用
1
2
3
> const rectangle = (width, height = width * 2) => ({width, height})
> rectangle(1)
{ width: 1, height: 2 }

剩余形参与可变长度实参列表

剩余形参(rest parameter)是一种允许你将不定数量的参数表示为一个数组的函数参数。这对于处理未知数量的参数非常有用,特别是在创建可变参数的函数时。

1
2
3
4
5
6
7
8
9
10
11
12
13
function max(first=-Infinity, ...rest) {
let maxValue = first;

for (let v of rest) {
if (v > maxValue) {
maxValue = v;
}
}

return maxValue;
}

console.log(max(1, 2, 3)); // => 3
  • 剩余形参前面有 3 个点,而且必须是函数声明中最后一个参数
  • 在调用有剩余形参的函数时,传入的实参首先会赋值到非剩余形参,然后所有剩余的实参(也是剩余参数)会保存在一个数组中赋值给剩余形参
  • 函数体内,剩余形参的值始终是数组。数组有可能为空,但剩余形参永远不可能是 undefined(所以不要给剩余形参定义默认值,这样既没用,也不合法)

一定要分清在函数定义中用于定义剩余形参的 ... 和扩展操作符 ...,后者可以在函数调用中使用。

Arguments 对象

剩余形参是 ES6 引入 JavaScript 的。在 ES6 之前,变长函数是基于 Arguments 对象实现的。在任何函数体内,标识符 arguments 引用该次调用的 Arguments 对象。Arguments 对象是一个类数组对象。它允许通过数值而非名字取得传给函数的参数值。

1
2
3
4
5
6
7
8
9
10
11
12
function max() {
let maxValue = -Infinity;
for (let i = 0; i < arguments.length; i++) {
if (arguments[i] > maxValue) {
maxValue = arguments[i];
}
}

return maxValue;
}

console.log(max(1, 2, 3)); // => 3

Arguments 对象可以追溯到 JavaScript 诞生之初,它效率低且难以优化,但在新写的代码中应该避免使用它。

在函数调用中使用扩展操作符

扩展操作符 ... 用于展开或 扩展 数组(或任何可迭代对象,如字符串)的元素。该操作符可以出现在函数调用中。

从求值并产生一个值的角度说,…并不是真正的操作符。应该说,它是一种可以针对数组字面量或函数调用使用的特殊 JavaScript 语法。

剩余形参和扩展操作符经常同时出现。

把函数实参解构为形参

调用函数时如果传入一个实参列表,则所有参数值都会被赋给函数定义时声明的形参。函数调用的这个初始化阶段非常类似变量赋值。因此在函数调用时可以进行解构赋值。

  • 如果我们定义了一个函数,它的形参名包含在方括号中,那说明这个函数期待对每对方括号都传入一个数组值。作为调用过程的一部分,我们传入的数组实参会被解构赋值为单独的命名形参。
  • 类似地,如果定义的函数需要一个对象实参,也可以把传入的对象解构赋值给形参
  • 在解构赋值中也可以为形参定义默认值
  • 解构数组时,可以为被展开数组中的额外元素定义一个剩余形参
  • 在 ES2018 中,解构对象时也可以使用剩余形参。此时剩余形参的值是一个对象,包含所有未被解构的属性。对象剩余形参经常与对象扩展操作一起使用,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function vectorAdd([x1, y1], [x2, y2]) {
return [x1 + x2, y1 + y2];
}
vectorAdd([1, 2], [3, 4]); // => [4, 6]

function vectorMultiply({x, y}, scalar) {
return {x: x*scalar, y: y*scalar};
}
vectorMultiply({x: 1, y: 2}, 2); // => { x: 2, y: 4 }

function vectorObjectAdd({x:x1, y:y1}, {x:x2, y:y2}) {
return {x: x1 + x2, y: y1 + y2};
}
vectorObjectAdd({x: 1, y: 2}, {x: 3, y: 4}); // => { x: 4, y: 6 }
1
2
3
4
5
function vectorMultiply({x, y, z=0, ...props}, scalar) {
return {x: x*scalar, y: y*scalar, z:z*scalar, ...props};
}

console.log(vectorMultiply({x:1, y:2, w:-1}, 2)) // { x: 2, y: 4, z: 0, w: -1 }

参数类型

JavaScript 方法的参数没有预定义的类型,在调用传参时也没有类型检查。可以用描述性强的名字作为函数参数,同时通过在注释中解释函数的参数来解决这个问题。

有时候需要函数检查参数的类型,对函数来说,在发现传入的值不对时立即失败,一定好过先执行逻辑再以出错告终,而且前者比后者更清晰。

函数作为值

在 JavaScript 中,函数不仅是语法,也是值。这意味着可以把函数赋值给变量、保存为对象的属性或者数组的元素、作为参数传给其他函数等等。

之前说过,函数声明就是创建一个新函数对象并将其赋值给一个变量,而该变量名就是函数名字。我们可以将该函数对象赋值给其他变量,之后再通过新的变量名进行函数调用。

1
2
3
4
5
6
> function square(x) { return x*x; }
> typeof square
'function'
> let s = square
> s(4)
16

除了变量,也可以把函数赋值给对象的属性。如前所述,这时候把函数称作 方法。函数甚至可以没有名字,比如可以把匿名函数作为一个数组元素:

1
2
3
> let a = [x => x*x, 20];
> a[0](a[1])
400

函数在 JavaScript 中并不是原始值,而是一种特殊的对象。这意味着函数也可以有属性。

  • 如果一个函数需要一个 静态 变量,且这个变量的值需要在函数每次调用时都能访问到,则通常把这个变量定义为函数自身的一个属性
1
2
3
4
5
6
7
8
uniqInteger.count = 0;

function uniqInteger() {
return uniqInteger.count++;
}

console.log(uniqInteger());
console.log(uniqInteger());
  • 如下代码则更加灵活,它使用 [] 语法来给函数本身添加属性,此时函数本身有点类似于数组了。可以通过该方式来缓存某些计算结果
1
2
3
4
5
6
7
8
9
10
11
> function f(x) { return f[x]; }
> f[1] = 1
1
> f[2] = 2
2
> f(1)
1
> f(2)
2
> f
[Function: f] { '1': 1, '2': 2 }

函数作为命名空间

在函数体内声明的变量在函数外部不可见。为此,有时候可以把函数用作临时的命名空间,这样可以保证在其中定义的变量不会污染全局命名空间。

例如:

1
2
3
(function() {
// your code
})()

在一个表达式中定义并调用匿名函数的技术非常常用,因此甚至有了别称,叫 立即调用函数表达式(immediately invoked function expression)。

注意,function 外围的圆括号是必须的。因为如果没有它,JavaScript 解释器会把 function 关键字作为函数声明语句来解析。有了这对括号,解释器会把它正确地识别为函数定义表达式。

闭包

与多数现代编程语言一样,JavaScript 使用词法作用域(lexical scoping)。这意味着函数执行时使用的是定义函数时生效的变量作用域,而不是调用函数时生效的变量作用域。为了实现词法作用域,JavaScript 函数对象的内部状态不仅要包括函数代码,还要包括对函数定义所在作用域的引用。这种函数对象与作用域(即一组变量绑定)组合起来解析函数变量的机制,在计算机科学文献中被称作闭包(closure)

严格来讲,所有JavaScript函数都是闭包。但由于多数函数调用与函数定义都在同一作用域内,所以闭包的存在无关紧要。闭包真正值得关注的时候,是定义函数与调用函数的作用域不同的时候。最常见的情形就是一个函数返回了在它内部定义的嵌套函数。很多强大的编程技术都是建立在这种嵌套函数闭包之上的,因此嵌套函数闭包在 JavaScript 程序中也变得比较常见。

1
2
3
4
5
6
7
8
9
10
11
let scope = "global scope";
function checkscope() {
let scope = "local scope";
function f() {
return scope;
}

return f;
}

console.log(checkscope()());

JavaScript 函数是使用定义它们的作用域来执行的。在定义嵌套函数 f() 的作用域中,变量 scope 绑定的值是 local scope​,该绑定在 f 执行时仍然有效,无论它在哪里执行。

这正是闭包惊人且强大的本质:它们会捕获自身定义所在外部函数的局部变量(及参数)绑定。闭包可以捕获一次函数调用的局部变量,可以将这些变量作为私有状态。

1
2
3
4
5
6
7
> let uniqCounter = (function() { let counter = 0; return function() { return counter++; }; })()
> uniqCounter()
0
> uniqCounter()
1
> uniqCounter()
2

类似 counter 这样的私有变量并非只能由一个闭包独享。同一个外部函数中完全可以定义两个或更多嵌套函数,而它们共享相同的作用域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function counter() {
let n = 0;
return {
count: function() { return n++; },
reset: function() { n = 0; },
};
}

let c = counter(), d = counter();
console.log(c.count());
console.log(c.count());
console.log(d.count());
c.reset();
console.log(c.count());
console.log(d.count());

每次调用 counter() 都会创建一个新作用域(与之前调用创建的作用域相互独立),作用中域中创建的一个新私有变量。因此如果调用两次 counter(),就会得到拥有两个不同私有变量的计数器对象。在一个计数器上调用count() 或 reset() 不会影响另一个计数器。

有一点需要指出的是,可以将这种闭包技术与属性获取方法和设置方法组合使用。下面的例子使用自己的参数n 保存供属性访问器方法共享的私有状态:

1
2
3
4
5
6
7
8
9
10
11
12
function count(n) {
return {
get count() { return n++; },
set count(value) { n = value },
}
}

let c = count(10)
console.log(c.count) // => 10
console.log(c.count) // => 11
c.count = 20
console.log(c.count) // => 21

与闭包关联的作用域是 活的​,嵌套函数不会创建作用域的私有副本或截取变量绑定的静态快照。再来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function funcs1() {
let funcs = [];

for (var i = 0; i < 10; i++) {
funcs[i] = () => i;
}

return funcs;
}

function funcs2() {
let funcs = [];

for (let i = 0; i < 10; i++) {
funcs[i] = () => i;
}

return funcs;
}

console.log(funcs1()[5]()) // => 10
console.log(funcs2()[5]()) // => 5

这里很容易犯错:

  • 通过 var 声明的变量在整个函数作用域内都有定义。代码中的 for 循环使用 var i 声明循环变量,因此变量 i 的作用域是整个函数体,因此所有闭包都捕获的是同一个 i 变量。当调用闭包时,它们都访问的是同一个变量 i 的最新值,即 10
  • 通过 let 声明的变量只在声明它们的块作用域内有效。因此,每次迭代都会创建一个新的、与其他循环不同的独立作用域,每个闭包捕获的都是迭代时所创建的 i 变量,该变量的值不会被后续迭代改变(因为每次迭代都会创建一个新的 i 值)

在写闭包的时候,要注意:this 是 JavaScript 关键字,不是变量。如前所述,箭头函数继承包含它们的函数中的 this 值,但使用 function 定义的函数并非如此。因此如果你要写的闭包需要使用其包含函数的 this 值,那么可以通过以下任意方式来解决:

  • 使用箭头函数
  • 调用 bind()
  • 把外部的 this 值赋给你的闭包将继承的变量
1
const self = this;

函数属性、方法与构造函数

对函数使用 typeof 操作符会返回字符串 function​,但函数实际上是一种特殊的 JavaScript 对象。由于函数是对象,因此它们也有属性和方法,甚至还有一个 Function() 构造函数可以用来创建新函数对象。

1
2
> typeof (function() {})
'function'

length 属性

函数有一个只读的 length 属性,表示函数在参数列表中声明的形参个数。如果函数有剩余形参,则这个剩余形参不包含在 length 属性内。

1
2
3
4
5
6
> let a = function(x, y) {}
> a.length
2
> a = function(x, ...rest) {}
> a.length
1

name 属性

函数有一个只读的 name 属性,表示定义函数时使用的名字。如果是未命名的函数,表示在第一次创建这个函数时赋给该函数的变量名或属性名。

1
2
3
4
5
6
> let b = function() {}
> b.name
'b'
> b = function test() {}
> b.name
'test'

prototype 属性

除了箭头函数,所有函数都有一个 prototype 属性,这个属性引用一个被称为原型对象的对象。每个函数都有自己的原型对象。当函数被作为构造函数使用时,新创建的对象从这个原型对象继承属性。

call() 和 apply() 方法

call()apply() 允许间接调用一个函数,就像这个函数是某个其他对象的方法一样:

  • call() 和 apply() 的第一个参数都是要在其上调用这个函数的对象。也就是函数的调用上下文,在函数体内它会变成 this 关键字的值
  • 箭头函数从定义它的上下文中继承 this 值。这个 this 值不能通过 call() 和 apply() 方法重写。如果对箭头函数调用这两个方法,那第一个参数实际上会被忽略
  • 除了作为调用上下文传给 call() 的第一参数,后续的所有参数都会传给被调用的函数
  • apply() 方法与 call() 方法类似,只不过要传给函数的参数需要以数组的形式提供
1
2
3
4
> function test() { console.log(this.m) }
> let o = {m: 10, n: 20}
> test.apply(o)
10

如下是一个更复杂的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function trace(o, m) {
let orignal = o[m]
o[m] = function(...args) {
console.log(new Date(), "Enterning: ", m);
let result = orignal.apply(o, args);
console.log(new Date(), "Exiting: ", m);
return result;
};
}

let o = {
m: function(x, y) {console.log(x, y, this.n)},
n: 20,
};

trace(o, "m");
o.m(1, 2);
1
2
3
4
# node trace.js
2025-01-22T03:12:21.142Z Enterning: m
1 2 20
2025-01-22T03:12:21.144Z Exiting: m

bind

bind() 方法的主要目的是把函数绑定到对象。如果在函数 f 上调用 bind() 方法并传入对象 o,则这个方法会返回一个新函数。如果作为函数来调用这个新函数,就会像 f 是 o 的方法一样调用原始函数。传给这个新函数的所有参数都会传给原始函数。

1
2
3
4
5
6
7
8
> function f(y) { return this.x + y; }
> let o = {x:1};
> let g = f.bind(o)
> g(10)
11
> let p = {x:10, g};
> p.g(10)
11

箭头函数从定义它们的环境中继承this值,且这个值不能被 bind() 覆盖。因此 bind() 对箭头函数是不起作用的。

事实上,除了把函数绑定到对象,bind() 方法还会做其他事:

  • 第一个参数之后传给bind() 的参数也会随着this值一起被绑定,从而可以实现 部分应用。部分应用是函数式编程中的一个常用技术,有时候也被称为柯里化(currying)
1
2
3
4
> let sum = (x, y) => x + y;
> let succ = sum.bind(null, 1);
> succ(2)
3

toString() 方法

与所有 JavaScript 对象一样,函数也有 toString() 方法。ECMAScript 规范要求这个方法返回一个符合函数声明语句的字符串。

  • 很多实现都是返回函数的源代码
  • 内置函数返回的字符串中通常包含 [native code]​
1
2
3
4
> sum.toString()
'(x, y) => x + y'
> sum.toString.toString()
'function toString() { [native code] }'

Function() 构造函数

因为函数是对象,所以就有一个 Function() 构造函数可以用来创建新函数:

1
2
3
> const f0 = new Function("x", "y", "return x * y");
> f0(1, 2)
2
  • Function() 构造函数可以接收任意多个字符串参数,其中最后一个参数是函数体的文本
  • 这个函数体文本中可以包含任意 JavaScript 语句,相互以分号分隔
  • 传给这个构造函数的其他字符串都用于指定新函数的参数名
  • Function() 构造函数不接收任何指定新函数名字的参数

要理解Function()构造函数,需要理解以下几点:

  • Function() 函数允许在运行时动态创建和编译 JavaScript 函数
  • Function() 构造函数每次被调用时都会解析函数体并创建一个新函数对象。如果在循环中或者被频繁调用的函数中出现了对它的调用,可能会影响程序性能。相对而言,出现在循环中的嵌套函数和函数表达式不会每次都被重新编译
  • 最后,也是关于Function()非常重要的一点,就是它创建的函数不使用词法作用域,而是始终编译为如同顶级函数一样

函数式编程

JavaScript 可以把函数作为对象来操作意味着可以在 JavaScript 中使用函数式编程技巧。像 map() 和 reduce() 这样的数组方法就特别适合函数式编程风格。

使用函数处理数组

如下使用函数式编程风格,计算数组元素的平均值和标准差:

1
2
3
4
5
6
7
8
9
const sum = (x, y) => x + y;
const square = x => x * x;

let data = [1, 1, 3, 5, 5];
let mean = data.reduce(sum) / data.length
let deviations = data.map(x => x - mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum) / (data.length - 1))

console.log(mean, stddev);

如果想把里面的一些面向对象编程风格也去除,可以再重新定义 map 和 reduce 方法。

高阶函数

高阶函数就是操作函数的函数,它接收一个或多个函数作为参数并返回一个新函数。

1
2
3
4
5
6
function mapper(f) {
return a => a.map(f);
}

const increment = x => x + 1;
console.log(mapper(increment)([1, 2, 3])); // => [2, 3, 4]

再看一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function compose(f, g) {
return function(...args) {
// f 和 g 被调用时,使用与 h 相同的 this 值
return f.call(this, g.apply(this, args));
}
}

let o = {m: 10, n: 20};
let dumpM = function() { console.log(this.m); };
let dumpN = function() { console.log(this.n); };

// 20
// 10
o.h = compose(dumpM, dumpN);
o.h();

函数的部分应用

bind 方法可以返回一个新的函数,这个新函数在指定的上下文中以指定的参数调用 f。我们称之为 把函数绑定到一个对象上并应用了部分的参数bind() 左侧部分应用参数,即传给 bind() 的参数会放在传给原始函数参数列表的开头。但是也有可能在右侧部分应用参数。

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
29
30
31
32
33
34
function partialLeft(f, ...outerArgs) {
return function(...innerArgs) {
let args = [...outerArgs, ...innerArgs];
return f.apply(this, args);
}
}

function partialRight(f, ...outerArgs) {
return function(...innerArgs) {
let args = [...innerArgs, ...outerArgs];
return f.apply(this, args);
}
}

function partial(f, ...outerArgs) {
return function(...innerArgs) {
let args = [...outerArgs];
let innerIndex = 0;

for (let i = 0; i < args.length; i++) {
if (args[i] === undefined) {
args[i] = innerArgs[innerIndex++];
}
}

args.push(...innerArgs.slice(innerIndex));
return f.apply(this, args);
}
}

const f = function(x, y, z) { return x * (y - z); }
console.log(partialLeft(f, 2)(3, 4)) // 2 * (3- 4) = -2
console.log(partialRight(f, 2)(3, 4)) // 3 * (4 - 2) = 6
console.log(partial(f, undefined, 2)(3, 4)) // 3 * (2 - 4) = -6

函数记忆

如下展示了高阶函数 memorize,它可以接受一个函数参数,并返回这个函数的记忆版本(即可以缓存之前的计算结果):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function memorize(f) {
const cache = new Map();

return function(...args) {
let key = args.length + args.join("+");
if (cache.has(key)) {
return cache.get(key);
} else {
let result = f.apply(this, args);
cache.set(key, result);
return result;
}
};
}

const factorial = memorize(function(n) {
return (n <= 1) ? 1 : n * factorial(n - 1);
});

console.log(factorial(5));