# JavaScript教程 - 11 函数进阶

# 11.1 高阶函数

什么是高阶函数?

满足以下两种情况之一的函数就是高阶函数:

  1. 函数作为参数传入
  2. 函数作为返回值返回

高阶函数有什么作用呢?

高阶函数能让你写出更简洁、可复用、模块化的代码,它是回调函数、事件处理、函数组合等场景的核心。

下面慢慢讲。

# 1 函数作为参数

例如,我们现在有一个学生列表:

class Student {
  constructor(name, chinese, math, english) {
    this.name = name;
    this.chinese = chinese;
    this.math = math;
    this.english = english;
  }
}

// 学生列表
const allStuList = [
  new Student("zhangsan", 87, 48, 92),
  new Student("lisi", 47, 92, 71),
  new Student("wangwu", 58, 46, 38),
  new Student("zhangliu", 95, 91, 99),
];
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

现在分别想查询列表中:语文成绩不及格的同学、所有课程不及格的同学、所有课程都是优秀的学生,那么我们需要定义三个函数,如下:

// 学生列表
const allStuList = [
  new Student("zhangsan", 87, 48, 92),
  new Student("lisi", 47, 92, 71),
  new Student("wangwu", 58, 46, 38),
  new Student("zhangliu", 95, 91, 99),
];

// 获取语文成绩不及格的同学
function getChineseFlunkList() {
  const stuList = [];
  for (let stu of allStuList) {
    if (stu.chinese < 60) {
      stuList.push(stu);
    }
  }
  return stuList;
}

// 获取所有课程不及格的同学
function getFlunkList() {
  const stuList = [];
  for (let stu of allStuList) {
    if (stu.chinese < 60 && stu.math < 60 && stu.english < 60) {
      stuList.push(stu);
    }
  }
  return stuList;
}

// 获取所有课程都是优秀的学生
function getExcellentList() {
  const stuList = [];
  for (let stu of allStuList) {
    if (stu.chinese >= 90 && stu.math >= 90 && stu.english >= 90) {
      stuList.push(stu);
    }
  }
  return stuList;
}

// -------------方法调用---------------------
console.log("语文不及格的同学:");
const chineseFlunkList = getChineseFlunkList();
for (let stu of chineseFlunkList) {
  console.log(stu);
}

console.log("所有不及格的同学:");
const flunkList = getFlunkList();
for (let stu of flunkList) {
  console.log(stu);
}

console.log("所有成绩都是优秀的同学:");
const excellentList = getExcellentList();
for (let stu of excellentList) {
  console.log(stu);
}
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
  • 在上面的代码中,定义了3个函数 getChineseFlunkList()getFlunkList()getExcellentList(),但是三个函数中只有其中的判断条件不同,代码比较冗余, 能否抽出判断条件,将判断条件作为参数传递呢?

我们先抽出一个公共的函数,根据传递的参数条件获取学生列表的函数:

// 根据条件获取学生列表
function filter(funCondition) {
  const stuList = [];
  for (let stu of allStuList) {
    if (funCondition(stu)) {
      stuList.push(stu);
    }
  }
  return stuList;
}
1
2
3
4
5
6
7
8
9
10
  • 上面的函数的参数也是一个函数,在函数中,调用传递的函数,根据函数的结果判断 stu 是否满足条件。
  • 这样就可以传递一个函数,具体判断的条件,在传递的函数中实现就可以了,返回 true 就是满足条件。

下面来调用上面的函数:

// 使用示例
console.log("语文不及格的同学:");
const chineseFlunkList = filter((stu) => {return stu.chinese < 60});
for (let stu of chineseFlunkList) {
  console.log(stu);
}
1
2
3
4
5
6
  • 调用函数,并传递一个函数作为参数,为了简单,直接传递一个箭头函数就可以了。

  • stu.chinese < 60 表示如果学生的中文成绩大于60就返回true,那么在 filter() 函数中调用传递的函数,就可以根据结果筛选学生了。

箭头函数还可以再简化一下,获取三个学习列表,可以这样写:

// 使用示例
console.log("语文不及格的同学:");
const chineseFlunkList = filter((stu) => stu.chinese < 60);
for (let stu of chineseFlunkList) {
  console.log(stu);
}

console.log("所有不及格的同学:");
const flunkList = filter(
  (stu) => stu.chinese < 60 && stu.math < 60 && stu.english < 60
);
for (let stu of flunkList) {
  console.log(stu);
}

console.log("所有成绩都是优秀的同学:");
const excellentList = filter(
  (stu) => stu.chinese >= 90 && stu.math >= 90 && stu.english >= 90
);
for (let stu of excellentList) {
  console.log(stu);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

通过函数式编程,代码的冗余度降低了,代码更简洁了。

这种方式更符合编程的 OCP (Open-Closed Principle)原则,对扩展开放,对修改关闭。如果有其他的查询学生的条件,直接扩展即可,不用修改 filter() 函数。

# 2 函数作为返回值

函数作为返回值也经常被用到。

举个栗子:

你开了一家咖啡店,需要根据顾客的不同身份生成问候语:

// 高阶函数 - 返回定制化的问候函数
function createGreeter(role) {
  return function (name) {
    if (role === "VIP") {
      return `尊贵的${name}会员,欢迎光临!`;
    } else if (role === "regular") {
      return `${name},您好!今天想喝点什么?`;
    } else {
      return `欢迎${name}~`;
    }
  };
}

// 生成特定角色的问候函数
const greetVIP = createGreeter("VIP");
const greetRegular = createGreeter("regular");

// 使用示例
console.log(greetVIP("张先生")); // "尊贵的张先生会员,欢迎光临!"
console.log(greetRegular("李女士")); // "李女士,您好!今天想喝点什么?"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
  • 在上面的代码中,首先调用 createGreeter() 函数,函数也返回一个函数。
  • 通过调用返回的函数,就可以实现不同的结果了。

函数作为返回值,经常是创建闭包最常见的方式,待会讲一下闭包。

# 11.2 内置高阶函数

JavaScript 中的内置高阶函数主要是 数组方法,它们都支持 传入函数作为参数。下面介绍一下常用的高阶函数,可以参考适用场景进行使用。

常用内置高阶函数

方法 作用 是否返回新数组 是否常用
forEach() 遍历数组,执行副作用
map() 映射数组,返回新数组 ✅✅
filter() 过滤数组,返回符合条件的项 ✅✅
reduce() 聚合数组成单个值 否(返回值) ✅✅✅
find() 查找第一个符合条件的元素 否(返回元素)
some() 至少一个符合条件返回 true
every() 所有都符合条件返回 true
sort() 排序,接收比较函数 原地修改
flatMap() 类似 mapflat 一层 ⚠️

# 1 forEach

作用:一般就是用来遍历数组的。

前面遍历数组的时候已经用到了,举个栗子:

const fruits = ["apple", "banana", "cherry"];
fruits.forEach((item, index) => {
  console.log(`${index + 1}. ${item}`);
});

// 执行结果:
1. apple
2. banana
3. cherry
1
2
3
4
5
6
7
8
9

# 2 map

作用:对每一项进行转换处理,返回一个新数组(不会改变原数组)。

举个栗子,将数组中的数据乘以2倍,返回一个新的数组:

const nums = [1, 2, 3];
const newArray = nums.map((item) => item * 2);
console.log(newArray); // [2, 4, 6]
1
2
3
  • 给 map 传递一个函数,函数的参数 item 是每个元素,可以对每个元素进行处理,回调函数的返回值就是新数组的元素
  • 适用于对数组中的数据进行转换;
  • 回调函数还有两个参数,第二个是index,第三个是被遍历的数组本身。

# 3 filter

作用:筛选符合条件的元素,返回新数组。

举个栗子,筛选数组中的偶数,组成一个新的数组。

const nums = [1, 2, 3, 4, 5];
const evens = nums.filter(item => item % 2 === 0);  // 判断item是否能被2整除
console.log(evens); // [2, 4]
1
2
3
  • 回调函数的返回值为 true,就表示将该元素添加到新数组中
  • 可以用来根据条件筛选元素,和前面我们编写的 filter 函数类似。
  • 回调函数还有两个参数,第二个是index,第三个是被遍历的数组本身。

# 4 reduce

作用:将数组归纳为一个值(例如求和等)。

举个栗子:求和

const nums = [1, 2, 3, 4];
const sum = nums.reduce((acc, cur) => acc + cur, 0);
console.log(sum); // 10
1
2
3
  • 在上面的代码中,reduce 函数有两个参数,第一个是回调函数,第二个是初始值,也就是第一次调用回调函数时候的 acc 的值。acc 表示累计的值,cur 表示当前正在处理的元素。
  • 上面是将数组的各个元素求和。

其实回调函数有四个值,第三个为当前元素的索引,第四个为调用 reduce 函数的数组本身。

再举个栗子,将数组中的元素变为一个对象的属性:

const arr = ['a', 'b', 'c'];
const obj = arr.reduce((acc, cur, i) => {
  acc[cur] = i;
  return acc;
}, {});
console.log(obj); // { a:0, b:1, c:2 }
1
2
3
4
5
6
  • 在上面的代码中,初始值就是一个空对象 {} ,每次调用就给这个空对象添加属性和赋值 acc[cur] = i
  • 注意,需要返回归纳的结果值 acc

reduce 函数适合将数组变为单个值。

# 5 find

作用:返回第一个符合条件的元素(不是数组)。

举个栗子:

const users = [
  { id: 1, name: "Tom" },
  { id: 2, name: "Jerry" }
];
const jerry = users.find(user => user.name === "Jerry");
console.log(jerry); // { id: 2, name: "Jerry" }
1
2
3
4
5
6
  • 就是查找数组中满足条件的第一个元素。

# 6 some

作用:判断是否至少有一个元素符合条件,返回布尔值。

举个栗子:

const nums = [1, 3, 5];
let hasEle = nums.some((x) => x % 2 === 0);  // 判断数组中是否有偶数
console.log(hasEle); // false
1
2
3
  • 用来判断数组中是否有一个元素满足条件。

# 7 every

作用:判断是否所有元素都符合条件。

举个栗子:

const nums = [2, 4, 6];
console.log(nums.every(x => x % 2 === 0)); // true,判断数组中是否都是偶数
1
2

# 8 sort

作用:对数组排序,将修改原数组

讲解数组的时候,也举过例子了:

let arr = [10, 2, 30];
arr.sort(); // 排序
console.log(arr); // [10, 2, 30] → ["10", "2", "30"] → 按字符排序

arr.sort((a, b) => a - b); // 排序,可以传递一个函数,按照数字比较两个元素的大小,进行排序,实现升序或者降序
console.log(arr);
1
2
3
4
5
6
  • 注意:如果不传递参数,默认是按字符排序。
  • 可以传递参数,实现按照元素大小排序,返回 a-b 结果是升序,返回 b-a 结果是降序。
  • 注意:会修改原始数组!

# 9 flat

作用:对数组进行扁平化处理,减少数组的嵌套层级。

前面讲解数组的时候,就是数组中放元素,但是元素其实还可以是数组,也就是进行嵌套,而 flat() 函数就是对数组进行扁平化处理。

举个栗子:

const arr1 = [1, 2, [3, 4]];
console.log(arr1.flat());  // 对数组进行扁平化处理,默认只会扁平化一层
// [1, 2, 3, 4] (默认 depth=1)

const arr2 = [1, 2, [3, 4, [5, 6]]];
console.log(arr2.flat());
// [1, 2, 3, 4, [5, 6]] (只扁平化一层)

console.log(arr2.flat(2));
// [1, 2, 3, 4, 5, 6] (扁平化两层)
1
2
3
4
5
6
7
8
9
10
  • 对数组进行扁平化处理,如果不指定参数,默认只会扁平化一层。

# 10 flatMap

作用:先使用 map 映射,然后再 flat 一层

举个栗子:

const sentences = ["Hello world", "Goodbye universe"];
const words = sentences.flatMap((sentence) => sentence.split(" "));
console.log(words); // ["Hello", "world", "Goodbye", "universe"]
1
2
3
  • 在上面的代码中,sentence.split(" ") 表示将每个元素,使用空格 " " 进行分割,那么数组变为:[["Hello", "world"], ["Goodbye", "universe"]] ,每个元素被被变为数组了,那么数组就变为两层了。
  • 然后在使用 flat() 函数进行扁平化处理。

# 11.3 闭包

# 1 闭包简介

如果要实现一个计数器的功能,每调用一次函数,打印的数值加1,那么该如何实现呢?

let count = 0;
function counter() {
  count++;
  console.log(count);
}

counter();  // 1
counter();  // 2
counter();  // 3
1
2
3
4
5
6
7
8
9

你可能像上面这样实现,但是这样实现会有一个问题,计数的变量是全局变量,谁都可以修改,那么就会出问题。

修改一下代码,如下:

function outer() {
  let count = 0;    // 外部函数的变量

  function inner() {
    count++;  // 内部访问外部函数变量
    console.log(count);
  }

  return inner;   // 返回内部函数
}

let counter = outer();  // 调用外部函数,返回内部函数
counter(); // 1
counter(); // 2
counter(); // 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 既然全局变量大家都能修改,那么就把变量放在函数中,内部函数引用了外部函数的变量,所以这个变量在全局是无法访问和修改的;
  • 然后将内部函数作为外部函数的返回值,进行返回;

上面的实现就形成了一个闭包,所以说**什么是闭包?**闭包必须满足三个条件:

  • 在一个外部函数中有一个内部函数;
  • 内部函数必须引用外部函数中的变量;
  • 外部函数的返回值必须是内部函数;

当我们想要隐藏一些数据,不希望被别人访问时,就可以使用闭包。

# 2 闭包的本质

先看一下下面的代码:

let count = 2;

function func1() {
    console.log(count);
}

function func2() {
  let count = 3;    // 外部函数的变量

  func1();
}

func1();  // 2
func2();  // 2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  • 在上面的代码中,调用 func1()func2() 都打印 2,也就是说在 func1() 函数中访问的 count 变量就是全局的 count,不会因为在不同的调用调用结果就不同。

也就是 JavaScript 中的作用域是词法作用域,在函数定义的时候就决定了能访问哪些变量。

# 3 闭包注意事项

在上面使用闭包的时候,每调用一次外部的函数就会产生一个闭包。而闭包会使函数的外部变量始终保存内存中,不会被作为垃圾回收,容易造成内存泄漏。

闭包中的变量只要它们“能被访问”,就不会被回收;一旦所有对闭包的引用都消失了,闭包和它的变量也就会被垃圾回收器清理。

# 11.4 函数的递归

什么是函数的递归?

函数的递归,就是在一个函数内部,又调用了这个函数自己,这个就是函数的递归。

举个例子:

function funA() {
    console.log("----");
    funA();
}

funA();
1
2
3
4
5
6

上面的函数,执行 funA() ,然后在 funA() 内部又调用了自己,那么会重新又调用了 funA() 函数,然后又调用了自己,这样就会一直无限调用,变成了无限循环调用,执行的时候很快就报Stack Overflow的错误,栈溢出。

所以函数的递归有时候是很危险的,很容易无限调用,造成栈溢出,程序崩溃。所以函数的递归调用一定要注意 结束或跳出递归 的条件。


例如我们写一个用递归求阶乘的函数:

/**
 * 求阶乘
 */
function factorial(num) {
    if (num <= 1) {
        return 1;
    }

    return num * factorial(num - 1);
}

let num = 5;
let result = factorial(num);
console.log(num + "的阶乘为:" + result);  // 120
1
2
3
4
5
6
7
8
9
10
11
12
13
14

以 5 举例,求 5 的阶乘,调用了 factorial() 函数 ,则计算 5 乘以 4 的阶乘,然后求 4 的阶乘,重新调用了 factorial() 函数,然后计算 4 乘以 3 的阶乘,依次类推,一直得到1的阶乘,然后向上返回。

递归函数一定得有结束的条件,否则就会无限递归导致栈溢出错误。

这里暂时理解不了,可以不纠结,慢慢领悟,平时也很少用到。

# 11.5 可变参数

“可变参数”是指函数能够接收 不定数量的参数。可以根据需要传入任意数量的参数值。

可变参数主要有两种方式:

  • 传统方式:arguments 对象;
  • 现代推荐方式: ...rest 参数。

# 1 arguments对象

  • arguments 是一个 类数组对象(和数组的操作很像,但是不是数组),包含了传入函数的所有参数;
  • 只在非箭头函数中有效;
  • 它是函数中的隐藏参数,没有显式声明,但可用,名称是固定的,就是arguments

举个栗子:

function sum() {
  let sum = 0;
  for (let i = 0; i < arguments.length; i++) { // 可以通过arguments获取到传入的所有参数
    sum += arguments[i]; // 可以通过数组一样访问到传入的参数
  }
  return sum;
}

console.log(sum(1, 2));  // 3
console.log(sum(1, 2, 3, 4));  // 10
1
2
3
4
5
6
7
8
9
10

需要注意:

  • arguments 不是数组,所以无法直接使用如 map, filter, forEach 等方法;
  • 但是可以通过 Array.from(arguments)[...arguments] 转换为数组;

arguments 对象总是接收到函数的所有参数,不管函数有没有其他的参数:

function sum(a, b) {
  console.log(a);  // 1
  console.log(b);  // 2
  console.log(arguments);  // [1, 2, 3, 4]
}

console.log(sum(1, 2, 3, 4));  // 10
1
2
3
4
5
6
7

# 2 ...方式

...rest 参数方式的参数就真的是一个数组了。

举个栗子:

function sum(...numbers) {
  return numbers.reduce((acc, val) => acc + val, 0);  // 使用 reduce() 方法进行求和
}

console.log(sum(1, 2)); // 输出 3
console.log(sum(1, 2, 3, 4)); // 输出 10
1
2
3
4
5
6
  • 上面的 numbers 就真的是一个数组,可以使用数组的方法;
  • 参数名称是可以自定义的

...rest 可变参数参数可以和固定参数一起使用,但是需要注意,可变参数最多只能有一个,而且必须作为最后一个参数。

function sum(a, b, ...numbers) {
  return a + b + numbers.reduce((acc, val) => acc + val, 0); // 使用 reduce() 方法进行求和
}

console.log(sum(1, 2)); // 输出 3
console.log(sum(1, 2, 3, 4)); // 输出 10
1
2
3
4
5
6
  • 在上面的代码中,优先传递参数给前面的固定参数,剩余参数才会传递给可变参数。

为什么更推荐使用 ...rest 参数?

  1. arguments 不是数组,...rest 是数组 ;

  2. arguments 不支持箭头函数,...rest 支持 ;

  3. arguments 在严格模式下行为有陷阱;

    举个栗子,在非严格模式下,arguments 和命名参数是绑定的

    function test(a) {
      arguments[0] = 99;  // 传递的a的值为1,这里修改了第一个参数的值为99
      console.log(a); // a的值也发生了变化,为99
    }
    test(1);
    
    1
    2
    3
    4
    5

    在严格模式下不再绑定:

    "use strict";
    function test(a) {
      arguments[0] = 99;  // 修改arguments的值
      console.log(a); // 输出 1(未变)
    }
    
    test(1)
    
    1
    2
    3
    4
    5
    6
    7

    这种不一致性会导致潜在 bug,不如 ...rest 安全可靠。

# 11.6 call和apply

函数除了可以使用 () 进行调用,还可以使用 call()apply() 方法进行调用。

举个栗子:

function test() {
  console.log('Hello')
}

test.call();
1
2
3
4
5
  • 对象会有属性和方法,而函数也是对象,而 call()apply() 其实是函数对象的方法。

上面这样调用函数,有点脱裤子放屁的感觉!

前面在讲函数中的 this 指向谁,基本可以归纳为以下几种:

  • 以函数方式调用,this指向window对象;
  • 以对象方式调用,指向当前调用方法的对象;
  • 构造函数中的this,指向的是当前创建的对象;
  • 箭头函数中没有自己的this,this 指向的是外层作用域。

其实 call()apply() 的主要作用是改变函数内部的 this 指向

举个栗子:

function test(a, b, c) {
  console.log(this, a, b, c)
}

let person = {name:'Doubi'}
test.call(person, 1, 2, 3);  // {name: 'Doubi'}  1 2 3
1
2
3
4
5
6
  • call 的第一个参数,会成为函数中的 this,后面的参数会成为函数的参数。

apply() 函数也是一样的,只是 call()apply() 传递参数的方式不同。call() 函数传递参数的方式是一个一个传递,apply() 函数是通过数组传递。

举个栗子:

function test(a, b, c) {
  console.log(this, a, b, c)
}

let person = {name:'Doubi'}
test.call(person, 1, 2, 3);  // {name: 'Doubi'}  1 2 3
test.apply(person, [1, 2, 3]);  // {name: 'Doubi'}  1 2 3
1
2
3
4
5
6
7

# 11.7 bind

bind() 方法的作用,也是可以改变函数中 this 的指向,但是和 callapply 的不同, 它不会立即执行函数,而是返回一个新的函数,将这个新函数中的 this 绑定。

举个栗子:

function test(a, b, c) {
  console.log(this, a, b, c)
}

let person = {name:'Doubi'}
let newFunc = test.bind(person, 1, 2);

newFunc(4, 5, 6);
1
2
3
4
5
6
7
8
  • test.bind(person, 1, 2) 会返回一个新的函数,函数中的 this 指向 bind() 方法的第一个参数,也就是 person
  • bind() 方法后面的参数是对参数进行绑定,也就是这个新函数的第一个参数被绑定为1,第二个参数被绑定为2了,调用新函数时传的参数将无效。

执行结果:

{name: 'Doubi'} 1 2 4
1
  • 新函数的前两个参数被绑定为1和2 了,调用的时候传递的参数无效。

所以 bind() 可以创建一个新函数,为新函数绑定 this 和参数

需要注意,箭头函数中没有this,所以不能使用 call、apply、bind 修改它的 this 指向。