Skip to content

《JavaScript全栈教程》06:函数深度解析——从定义、作用域到高阶函数、闭包与生成器

约 9814 字大约 33 分钟

JavaScript全栈教程JavaScript

2026-04-11

本篇核心收获

  • 掌握JavaScript函数的两种定义方式与调用规则,理解argumentsrest参数的使用场景
  • 深入理解变量作用域、变量提升、全局作用域与名字空间,学会使用letconst避免常见陷阱
  • 熟练运用解构赋值简化代码,掌握对象方法的定义与this指向的底层逻辑
  • 精通高阶函数(map/reduce/filter/sort)及数组实用方法,提升代码抽象能力
  • 理解闭包的形成原理、典型应用与注意事项,能够利用闭包封装私有变量
  • 掌握箭头函数的语法与词法作用域特性,了解标签函数和生成器的基本用法

一、函数基础:定义、调用与参数处理

1.1 为什么需要函数

我们知道圆的面积计算公式为:S=πr2S = \pi r ^ {2}

当我们知道半径 rr 的值时,就可以根据公式计算出面积。假设我们需要计算3个不同大小的圆的面积:

let r1 = 12.34;
let r2 = 9.08;
let r3 = 73.1;
let s1 = 3.14 * r1 * r1;
let s2 = 3.14 * r2 * r2;
let s3 = 3.14 * r3 * r3;

当代码出现有规律的重复的时候,你就需要当心了,每次写 3.14 * x * x 不仅很麻烦,而且,如果要把 3.14 改成 3.1416 的时候,得全部替换。

有了函数,我们就不再每次写 s = 3.14 * x * x,而是写成更有意义的函数调用 s = area_of_circle(x),而函数 area_of_circle 本身只需要写一次,就可以多次调用。

基本上所有的高级语言都支持函数,JavaScript也不例外。JavaScript的函数不但是“头等公民”,而且可以像变量一样使用,具有非常强大的抽象能力。

1.2 抽象的思想

抽象是数学中非常常见的概念。举个例子:

计算数列的和,比如:1+2+3++1001 + 2 + 3 + \dots + 100,写起来十分不方便,于是数学家发明了求和符号 \sum,可以把 1+2+3++1001 + 2 + 3 + \dots + 100 记作:n=1100n\sum_ {n = 1} ^ {100} n

这种抽象记法非常强大,因为我们看到 \sum 就可以理解成求和,而不是还原成低级的加法运算。

而且,这种抽象记法是可扩展的,比如:n=1100(n2+1)\sum_ {n = 1} ^ {100} (n ^ {2} + 1)

还原成加法运算就变成了:(1×1+1)+(2×2+1)+(3×3+1)++(100×100+1)(1 \times 1 + 1) + (2 \times 2 + 1) + (3 \times 3 + 1) + \dots + (100 \times 100 + 1)

可见,借助抽象,我们才能不关心底层的具体计算过程,而直接在更高的层次上思考问题。写计算机程序也是一样,函数就是最基本的一种代码抽象的方式

1.3 定义函数

在JavaScript中,定义函数的方式如下:

function abs(x) {
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
}

上述 abs() 函数的定义如下:

  • function 指出这是一个函数定义
  • abs 是函数的名称
  • (x) 括号内列出函数的参数,多个参数以 , 分隔
  • {...} 之间的代码是函数体,可以包含若干语句,甚至可以没有任何语句

请注意,函数体内部的语句在执行时,一旦执行到 return 时,函数就执行完毕,并将结果返回。因此,函数内部通过条件判断和循环可以实现非常复杂的逻辑。

如果没有 return 语句,函数执行完毕后也会返回结果,只是结果为 undefined

由于JavaScript的函数也是一个对象,上述定义的 abs() 函数实际上是一个函数对象,而函数名 abs 可以视为指向该函数的变量。因此,第二种定义函数的方式如下:

let abs = function (x) {
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
};

在这种方式下,function(x){...} 是一个匿名函数,它没有函数名。但是,这个匿名函数赋值给了变量 abs,所以,通过变量 abs 就可以调用该函数。

上述两种定义完全等价,注意第二种方式按照完整语法需要在函数体末尾加一个 ;,表示赋值语句结束。

1.4 调用函数

调用函数时,按顺序传入参数即可:

abs(10); // 返回10
abs(-9); // 返回9

由于JavaScript允许传入任意个参数而不影响调用,因此传入的参数比定义的参数多也没有问题,虽然函数内部并不需要这些参数:

abs(10, 'blablabla'); // 返回10
abs(-9, 'haha', 'hehe', null); // 返回9

传入的参数比定义的少也没有问题

abs(); // 返回NaN

此时 abs(x) 函数的参数 x 将收到 undefined,计算结果为 NaN

要避免收到 undefined,可以对参数进行检查:

function abs(x) {
    if (typeof x !== 'number') {
        throw 'Not a number';
    }
    if (x >= 0) {
        return x;
    } else {
        return -x;
    }
}

1.5 arguments 关键字

JavaScript还有一个免费赠送的关键字 arguments,它只在函数内部起作用,并且永远指向当前函数的调用者传入的所有参数。arguments 类似 Array 但它不是一个 Array

function foo(x) {
    console.log('x = ' + x); // 10
    for (let i = 0; i < arguments.length; i++) {
        console.log('arg ' + i + ' = ' + arguments[i]); // 10, 20, 30
    }
}

利用 arguments,你可以获得调用者传入的所有参数。也就是说,即使函数不定义任何参数,还是可以拿到参数的值

function abs() {
    if (arguments.length === 0) {
        return 0;
    }
    let x = arguments[0];
    return x >= 0 ? x : -x;
}

实际上 arguments 最常用于判断传入参数的个数。你可能会看到这样的写法:

// foo(a[, b], c)
// 接收2~3个参数,b是可选参数,如果只传2个参数,b默认为null:
function foo(a, b, c) {
    if (arguments.length === 2) {
        // 实际拿到的参数是a和b,c为undefined
        c = b; // 把b赋给c
        b = null; // b变为默认值
    }
    // ...
}

要把中间的参数 b 变为“可选”参数,就只能通过 arguments 判断,然后重新调整参数并赋值。

1.6 rest 参数

由于JavaScript函数允许接收任意个参数,于是我们就不得不用 arguments 来获取所有参数:

function foo(a, b) {
    let i, rest = [];
    if (arguments.length > 2) {
        for (i = 2; i < arguments.length; i++) {
            rest.push(arguments[i]);
        }
    }
    console.log('a = ' + a);
    console.log('b = ' + b);
    console.log(rest);
}

为了获取除了已定义参数 ab 之外的参数,我们不得不用 arguments,并且循环要从索引 2 开始以便排除前两个参数,这种写法很别扭。

ES6标准引入了 rest 参数,上面的函数可以改写为:

function foo(a, b, ...rest) {
    console.log('a = ' + a);
    console.log('b = ' + b);
    console.log(rest);
}

foo(1, 2, 3, 4, 5);
// 结果:
// a = 1
// b = 2
// Array [3, 4, 5]

foo(1);
// 结果:
// a = 1
// b = undefined
// Array []

rest 参数只能写在最后,前面用 ... 标识。传入的参数先绑定 ab,多余的参数以数组形式交给变量 rest。如果传入的参数连正常定义的参数都没填满,rest 参数会接收一个空数组(注意不是 undefined)。

练习:用 rest 参数编写 sum() 函数

function sum(...rest) {
    let total = 0;
    for (let num of rest) {
        total += num;
    }
    return total;
}

// 测试:
let i, args = [];
for (i = 1; i <= 100; i++) {
    args.push(i);
}
if (sum() !== 0) {
    console.log('测试失败: sum() = ' + sum());
} else if (sum(1) !== 1) {
    console.log('测试失败: sum(1) = ' + sum(1));
} else if (sum(2, 3) !== 5) {
    console.log('测试失败: sum(2, 3) = ' + sum(2, 3));
} else if (sum.apply(null, args) !== 5050) {
    console.log('测试失败: sum(1, 2, 3, ..., 100) = ' + sum.apply(null, args));
} else {
    console.log('测试通过!');
}

1.7 小心你的 return 语句

JavaScript引擎有一个在行末自动添加分号的机制,这可能让你栽到 return 语句的一个大坑:

function foo() {
    return { name: 'foo' };
}
foo(); // { name: 'foo' }

如果把 return 语句拆成两行:

function foo() {
    return
    { name: 'foo' };
}
foo(); // undefined

由于自动添加分号的机制,上面的代码实际上变成了:

function foo() {
    return; // 自动添加了分号,相当于return undefined;
    { name: 'foo' }; // 这行语句已经没法执行到了
}

正确的多行写法是:

function foo() {
    return { // 这里不会自动加分号,因为{表示语句尚未结束
        name: 'foo'
    };
}

1.8 基础练习

练习1:定义计算圆面积的函数

function area_of_circle(r, pi) {
    if (pi === undefined) {
        pi = 3.14;
    }
    return pi * r * r;
}

// 测试:
if (area_of_circle(2) === 12.56 && area_of_circle(2, 3.1416) === 12.5664) {
    console.log('测试通过');
} else {
    console.log('测试失败');
}

练习2:修复 max() 函数

小明写了一个 max() 函数,但总是返回 undefined

function max(a, b) {
    if (a > b) {
        return a;
    } else {
        return b;
    }
}
console.log(max(15, 20));

问题分析:代码逻辑是正确的,但可能他在调用时传入了非数字类型的参数,导致比较结果为 false。为了确保函数健壮性,应该对输入参数进行类型检查。

修复后的代码

function max(a, b) {
    if (typeof a !== 'number' || typeof b !== 'number') {
        throw new Error('Both arguments must be numbers');
    }
    if (a > b) {
        return a;
    } else {
        return b;
    }
}
console.log(max(15, 20)); // 20

模块小结:函数是代码抽象的基本单位。定义函数有两种方式(函数声明和函数表达式),调用时可以传入任意数量参数。通过 argumentsrest 参数可以灵活处理不定长参数。务必注意 return 语句的换行问题,避免自动分号插入导致的错误。

二、变量作用域与解构赋值

2.1 函数作用域

在JavaScript中,用 var 声明的变量实际上是有作用域的。

如果一个变量在函数体内部声明,则该变量的作用域为整个函数体,在函数体外不可引用该变量:

function foo() {
    var x = 1;
    x = x + 1;
}
// 这里无法访问 x

如果两个不同的函数各自声明了同一个变量,那么该变量只在各自的函数体内起作用。换句话说,不同函数内部的同名变量互相独立,互不影响

function foo() {
    var x = 1;
    x = x + 1;
}

function bar() {
    var x = 'A';
    x = x + 'B';
}

由于JavaScript的函数可以嵌套,此时,内部函数可以访问外部函数定义的变量,反过来则不行

function foo() {
    var x = 1;
    function bar() {
        var y = x + 1; // bar可以访问foo的变量x!
    }
    var z = y + 1; // ReferenceError! foo不可以访问bar的变量y!
}

如果内部函数和外部函数的变量名重名,内部函数的变量将“屏蔽”外部函数的变量

function foo() {
    var x = 1;
    function bar() {
        var x = 'A';
        console.log('x in bar() = ' + x); // 'A'
    }
    console.log('x in foo() = ' + x); // 1
    bar();
}

JavaScript的函数在查找变量时从自身函数定义开始,从“内”向“外”查找。

2.2 变量提升

JavaScript的函数定义有个特点,它会先扫描整个函数体的语句,把所有用 var 声明的变量“提升”到函数顶部

function foo() {
    var x = 'Hello,' + y;
    console.log(x);
    var y = 'Bob';
}
foo();

虽然是在strict模式,但语句 var x = 'Hello,' + y; 并不报错,原因是变量 y 在稍后声明了。但是 console.log 显示 Hello,undefined,说明变量 y 的值为 undefined。这正是因为JavaScript引擎自动提升了变量 y 的声明,但不会提升变量 y 的赋值。

对于上述 foo() 函数,JavaScript引擎看到的代码相当于:

function foo() {
    var y; // 提升变量y的声明,此时y为undefined
    var x = 'Hello,' + y;
    console.log(x);
    y = 'Bob';
}

由于这一怪异的“特性”,我们在函数内部定义变量时,请严格遵守“在函数内部首先声明所有变量”这一规则。最常见的做法是用一个 var 声明函数内部用到的所有变量:

function foo() {
    var x = 1, // x初始化为1
        y = x + 1, // y初始化为2
        z, i; // z和i为undefined
    // 其他语句:
    for (i = 0; i < 100; i++) {
        // ...
    }
}

注意:建议使用 let 声明变量,避免 var 声明变量时带来的隐患。

2.3 全局作用域

不在任何函数内定义的变量就具有全局作用域。实际上,JavaScript默认有一个全局对象 window全局作用域的变量实际上被绑定到 window 的一个属性

var course = 'Learn JavaScript';
console.log(course); // 'Learn JavaScript'
console.log(window.course); // 'Learn JavaScript'

因此,直接访问全局变量 course 和访问 window.course 是完全一样的。

以变量方式 var foo = function() {} 定义的函数实际上也是一个全局变量,因此,顶层函数的定义也被视为一个全局变量,并绑定到 window 对象:

function foo() {
    alert('foo');
}
foo(); // 直接调用foo()
window.foo(); // 通过window.foo()调用

我们每次直接调用的 alert() 函数其实也是 window 的一个变量:

window.alert('调用window.alert()');
// 把alert保存到另一个变量:
let oldAlert = window.alert;
// 给alert赋一个新函数:
window.alert = function () { };
alert('无法用alert()显示了!');
// 恢复alert:
window.alert = oldAlert;
alert('又可以用alert()了!');

这说明JavaScript实际上只有一个全局作用域。任何变量(函数也视为变量),如果没有在当前函数作用域中找到,就会继续往上查找,最后如果在全局作用域中也没有找到,则报 ReferenceError 错误。

2.4 名字空间

全局变量会绑定到 window 上,不同的JavaScript文件如果使用了相同的全局变量,或者定义了相同名字的顶层函数,都会造成命名冲突,并且很难被发现。

减少冲突的一个方法是把自己的所有变量和函数全部绑定到一个全局变量中。例如:

// 唯一的全局变量MYAPP
let MYAPP = {};
// 其他变量:
MYAPP.name = 'myapp';
MYAPP.version = 1.0;
// 其他函数:
MYAPP.foo = function () {
    return 'foo';
};

把自己的代码全部放入唯一的名字空间 MYAPP 中,会大大减少全局变量冲突的可能。许多著名的JavaScript库都是这么干的:jQuery,YUI,underscore等等。

2.5 局部作用域(块级作用域)

由于JavaScript的变量作用域实际上是函数内部,我们在 for 循环等语句块中是无法定义具有局部作用域的变量的:

function foo() {
    for (var i = 0; i < 100; i++) {
        // ...
    }
    i += 100; // 仍然可以引用变量i
}

为了解决块级作用域,ES6引入了新的关键字let,用 let 替代 var 可以声明一个块级作用域的变量:

function foo() {
    let sum = 0;
    for (let i = 0; i < 100; i++) {
        sum += i;
    }
    // SyntaxError: i += 1
}

2.6 常量

由于 varlet 声明的是变量,如果要声明一个常量,在ES6之前是不行的,我们通常用全部大写的变量来表示“这是一个常量,不要修改它的值”:

let PI = 3.14;

ES6标准引入了新的关键字 const 来定义常量constlet 都具有块级作用域:

const PI = 3.14;
PI = 3; // 某些浏览器不报错,但是无效果!
PI; // 3.14

2.7 解构赋值

从ES6开始,JavaScript引入了解构赋值,可以同时对一组变量进行赋值

传统的做法,如何把一个数组的元素分别赋值给几个变量:

let array = ['hello', 'JavaScript', 'ES6'];
let x = array[0];
let y = array[1];
let z = array[2];

现在,在ES6中,可以使用解构赋值:

// 如果浏览器支持解构赋值就不会报错:
let [x, y, z] = ['hello', 'JavaScript', 'ES6'];
// x, y, z分别被赋值为数组对应元素:
console.log(`x = ${x}, y = ${y}, z = ${z}`);

注意,对数组元素进行解构赋值时,多个变量要用 [...] 括起来。

如果数组本身还有嵌套,也可以通过下面的形式进行解构赋值,注意嵌套层次和位置要保持一致:

let [x, [y, z]] = ['hello', ['JavaScript', 'ES6']];
x; // 'hello'
y; // 'JavaScript'
z; // 'ES6'

解构赋值还可以忽略某些元素:

let [, , z] = ['hello', 'JavaScript', 'ES6']; // 忽略前两个元素,只对z赋值第三个元素
z; // 'ES6'

对象的解构赋值

如果需要从一个对象中取出若干属性,也可以使用解构赋值:

let person = {
    name: '小明',
    age: 20,
    gender: 'male',
    passport: 'G-12345678',
    school: 'No.4 middle school'
};
let { name, age, passport } = person;
// name, age, passport分别被赋值为对应属性:
console.log(`name = ${name}, age = ${age}, passport = ${passport}`);

对嵌套的对象属性进行赋值:

let person = {
    name: '小明',
    age: 20,
    gender: 'male',
    passport: 'G-12345678',
    school: 'No.4 middle school',
    address: {
        city: 'Beijing',
        street: 'No.1 Road',
        zipcode: '100001'
    }
};
let { name, address: { city, zip } } = person;
name; // '小明'
city; // 'Beijing'
zip; // undefined, 因为属性名是zipcode而不是zip
// 注意: address不是变量, 而是为了让city和zip获得嵌套的address对象的属性:
address; // Uncaught ReferenceError: address is not defined

如果对应的属性不存在,变量将被赋值为 undefined。如果要使用的变量名和属性名不一致,可以用下面的语法:

let person = {
    name: '小明',
    age: 20,
    gender: 'male',
    passport: 'G-12345678',
    school: 'No.4 middle school'
};
// 把passport属性赋值给变量id:
let { name, passport: id } = person;
name; // '小明'
id; // 'G-12345678'
// 注意:passport不是变量,而是为了让变量id获得passport属性:
passport; // Uncaught ReferenceError: passport is not defined

解构赋值还可以使用默认值

let person = {
    name: '小明',
    age: 20,
    gender: 'male',
    passport: 'G-12345678'
};
// 如果person对象没有single属性,默认赋值为true:
let { name, single = true } = person;
name; // '小明'
single; // true

已声明变量的解构赋值

如果变量已经被声明了,再次赋值时,正确的写法也会报语法错误:

// 声明变量:
let x, y;
// 解构赋值:
{ x, y } = { name: '小明', x: 100, y: 200 }; // 语法错误:Uncaught SyntaxError:Unexpected token =

这是因为JavaScript引擎把 { 开头的语句当作了块处理,于是 = 不再合法。解决方法是用小括号括起来

({ x, y } = { name: '小明', x: 100, y: 200 });

2.8 解构赋值的常见使用场景

交换两个变量,不再需要临时变量:

let x = 1, y = 2;
[x, y] = [y, x];
x; // 2
y; // 1

快速获取当前页面的域名和路径

let { hostname: domain, pathname: path } = location;

函数接收对象参数时解构

function buildDate({ year, month, day, hour = 0, minute = 0, second = 0 }) {
    return new Date(`${year}-${month}-${day} ${hour}:${minute}:${second}`);
}

buildDate({ year: 2017, month: 1, day: 1 }); // Sun Jan 01 2017 00:00:00 GMT+0800 (CST)
buildDate({ year: 2017, month: 1, day: 1, hour: 20, minute: 15 }); // Sun Jan 01 2017 20:15:00 GMT+0800 (CST)

模块小结:变量作用域分为全局作用域和函数作用域,ES6引入了块级作用域(let/const)。注意 var 的变量提升特性,建议优先使用 let。通过名字空间可以减少全局冲突。解构赋值可以极大简化数组和对象的属性提取,是ES6中非常实用的特性。

三、方法与 this 指向

3.1 方法定义

在一个对象中绑定函数,称为这个对象的方法

let xiaoming = {
    name: '小明',
    birth: 1990
};

xiaoming 绑定一个 age() 方法返回年龄:

let xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        let y = new Date().getFullYear();
        return y - this.birth;
    }
};
xiaoming.age; // function xiaoming.age()
xiaoming.age(); // 今年调用是25,明年调用就变成26了

在一个方法内部,this 是一个特殊变量,它始终指向当前对象,也就是 xiaoming 这个变量。所以 this.birth 可以拿到 xiaomingbirth 属性。

3.2 this 的坑

拆开写:

function getAge() {
    let y = new Date().getFullYear();
    return y - this.birth;
}

let xiaoming = {
    name: '小明',
    birth: 1990,
    age: getAge
};

xiaoming.age(); // 25,正常结果
getAge(); // NaN

单独调用 getAge() 返回 NaN,因为此时 this 指向全局对象 window(非strict模式)或 undefined(strict模式)。

更坑爹的是:

// 此时 this 指向 window对象(非strict模式)或 undefined(strict模式)没有 birth 属性,
// 所以是 undefined,参与计算就会变成 NaN。
let fn = xiaoming.age; // 先拿到xiaoming的age函数
fn(); // NaN

要保证 this 指向正确,必须用 obj.xxx() 的形式调用

ECMA决定在strict模式下让函数的 this 指向 undefined,这样会得到错误:

'use strict';
let xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        let y = new Date().getFullYear();
        return y - this.birth;
    }
};
let fn = xiaoming.age;
fn(); // Uncaught TypeError: Cannot read property 'birth' of undefined

3.3 嵌套函数的 this 丢失

如果方法内部再定义函数:

'use strict';
let xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        function getAgeFromBirth() {
            let y = new Date().getFullYear();
            return y - this.birth;
        }
        return getAgeFromBirth();
    }
};
xiaoming.age(); // Uncaught TypeError: Cannot read property 'birth' of undefined

this 指针只在 age 方法的函数内指向 xiaoming,在内部定义的函数中,this 又指向 undefined 了。

修复办法:用一个 that 变量首先捕获this

'use strict';
let xiaoming = {
    name: '小明',
    birth: 1990,
    age: function () {
        let that = this; // 在方法内部一开始就捕获this
        function getAgeFromBirth() {
            let y = new Date().getFullYear();
            return y - that.birth; // 用that而不是this
        }
        return getAgeFromBirth();
    }
};
xiaoming.age(); // 25

3.4 apply 和 call

要指定函数的 this 指向哪个对象,可以用函数本身的 apply 方法。它接收两个参数:第一个参数是需要绑定的 this 变量,第二个参数是 Array,表示函数本身的参数。

function getAge() {
    let y = new Date().getFullYear();
    return y - this.birth;
}

let xiaoming = {
    name: '小明',
    birth: 1990,
    age: getAge
};

xiaoming.age(); // 25
getAge.apply(xiaoming, []); // 25, this指向xiaoming,参数为空

另一个与 apply() 类似的方法是 call(),区别是:

  • apply() 把参数打包成 Array 再传入
  • call() 把参数按顺序传入
Math.max.apply(null, [3, 5, 4]); // 5
Math.max.call(null, 3, 5, 4); // 5

对普通函数调用,我们通常把 this 绑定为 null

3.5 装饰器模式

利用 apply(),我们可以动态改变函数的行为。例如统计 parseInt 被调用了多少次:

'use strict';
let count = 0;
let oldParseInt = parseInt; // 保存原函数
window.parseInt = function () {
    count += 1;
    return oldParseInt.apply(null, arguments); // 调用原函数
};
// 测试:
parseFloat('10');
parseInt('20');
parseInt('30');
console.log('count = ' + count); // 3

模块小结:对象的方法本质上是函数,其中的 this 指向调用该方法的对象。直接调用函数时 this 会指向全局对象或 undefined。嵌套函数中 this 会丢失,可以通过 that = this 或箭头函数(见后文)解决。apply/call 可以显式绑定 this

四、高阶函数

高阶函数的英文是 Higher-order function。在 JavaScript 中,函数本质上可以被变量所引用,既然变量可以指向函数、函数的参数可以接收变量,那么一个函数就可以把另一个函数当作参数传入,具备这种特性的函数就被称为高阶函数。

一个最简单的高阶函数:

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

调用 add(-5, 6, Math.abs) 时,计算过程为:

x = -5;
y = 6;
f = Math.abs;
f(x) + f(y) ==> Math.abs(-5) + Math.abs(6) ==> 11;
return 11;

验证:

function add(x, y, f) {
    return f(x) + f(y);
}
let x = add(-5, 6, Math.abs);
console.log(x); // 11

4.1 map / reduce

map

举例说明,比如我们有一个函数 f(x)=x2f(x) = x^2,要把这个函数作用在一个数组 [1, 2, 3, 4, 5, 6, 7, 8, 9] 上,就可以用 map 实现如下:

由于 map() 方法定义在JavaScript的 Array 中,我们调用 Arraymap() 方法,传入我们自己的函数,就得到了一个新的 Array 作为结果:

function pow(x) {
    return x * x;
}
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
let results = arr.map(pow); // [1, 4, 9, 16, 25, 36, 49, 64, 81]
console.log(results);

注意:map() 传入的参数是 pow,即函数对象本身。

map() 作为高阶函数,事实上它把运算规则抽象了,我们不但可以计算简单的 f(x)=x2f(x) = x^2,还可以计算任意复杂的函数,比如把 Array 的所有数字转为字符串:

let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9];
arr.map(String); // ['1', '2', '3', '4', '5', '6', '7', '8', '9']

reduce

Arrayreduce() 把一个函数作用在这个 Array[x1, x2, x3...] 上,这个函数必须接收两个参数,reduce() 把结果继续和序列的下一个元素做累积计算:

[x1, x2, x3, x4].reduce(f) = f(f(f(x1, x2), x3), x4)

Array 求和:

let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
    return x + y;
}); // 25

如果数组元素只有1个,需要提供一个额外的初始参数:

let arr = [123];
arr.reduce(function (x, y) {
    return x + y;
}, 0); // 123

练习:利用 reduce() 求积

function product(arr) {
    return arr.reduce(function (x, y) {
        return x * y;
    });
}

[1, 3, 5, 7, 9] 变换成整数 13579

let arr = [1, 3, 5, 7, 9];
arr.reduce(function (x, y) {
    return x * 10 + y;
}); // 13579

练习:实现 string2int() 函数

function string2int(s) {
    return s.split('').map(Number).reduce(function (x, y) {
        return x * 10 + y;
    });
}

// 测试:
if (string2int('0') === 0 && string2int('12345') === 12345 && string2int('12300') === 12300) {
    if (string2int.toString().indexOf('parseInt') !== -1) {
        console.log('请勿使用parseInt()!');
    } else if (string2int.toString().indexOf('Number') !== -1) {
        console.log('请勿使用Number()!');
    } else {
        console.log('测试通过!');
    }
} else {
    console.log('测试失败!');
}

练习:规范英文名字(首字母大写,其余小写)

function normalize(arr) {
    return arr.map(function (name) {
        return name.charAt(0).toUpperCase() + name.substring(1).toLowerCase();
    });
}

常见错误:map 与 parseInt 的坑

let arr = ['1', '2', '3'];
let r = arr.map(parseInt);
console.log(r); // [1, NaN, NaN]

原因:parseInt() 接收两个参数(字符串和进制)。map() 会把每个元素和索引传给回调函数,实际调用为:

  • parseInt('1', 0) → 1
  • parseInt('2', 1) → NaN
  • parseInt('3', 2) → NaN

修正方法

let arr = ['1', '2', '3'];
let r = arr.map(function (item) {
    return parseInt(item);
});
console.log(r); // [1, 2, 3]

4.2 filter

filter 用于把 Array 的某些元素过滤掉,返回剩下的元素。它接收一个函数,根据返回值是 true 还是 false 决定保留还是丢弃。

删掉偶数,只保留奇数:

let arr = [1, 2, 4, 5, 6, 9, 10, 15];
let r = arr.filter(function(x) {
    return x % 2 !== 0;
});
console.log(r); // [1, 5, 9, 15]

删掉空字符串:

let arr = ['A', '', 'B', null, undefined, 'C', ''];
let r = arr.filter(function(s) {
    return s && s.trim(); // 注意:IE9以下的版本没有trim()方法
});
console.log(r); // ['A', 'B', 'C']

回调函数的参数filter 的回调函数可以接收三个参数:elementindexself(数组本身)。

let arr = ['A', 'B', 'C'];
let r = arr.filter(function (element, index, self) {
    console.log(element); 
    console.log(index);   
    console.log(self);    
    return true;
});
// A
// 0
// ['A', 'B', 'C']
// B
// 1
// ['A', 'B', 'C']
// C
// 2
// ['A', 'B', 'C']

利用 filter 去除重复元素(依赖 indexOf 总是返回第一个元素的位置):

let arr = ['apple', 'strawberry', 'banana', 'pear', 'apple', 'orange', 'orange', 'strawberry'];
let r = arr.filter(function (element, index, self) {
    return self.indexOf(element) === index;
});
console.log(r); // ['apple', 'strawberry', 'banana', 'pear', 'orange']

练习:筛选素数

function get_primes(arr) {
    return arr.filter(function(n) {
        if (n < 2) return false;
        for (let i = 2; i <= Math.sqrt(n); i++) {
            if (n % i === 0) return false;
        }
        return true;
    });
}

// 测试:
let x, r, arr = [];
for (x = 1; x < 100; x++) {
    arr.push(x);
}
r = get_primes(arr);
if (r.toString() === [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97].toString()) {
    console.log('测试通过!');
} else {
    console.log('测试失败:' + r.toString());
}

4.3 sort

JavaScript 的 Arraysort() 方法默认把所有元素先转换为 String 再排序,导致数字排序异常:

// 看上去正常的结果:
console.log(['Google', 'Apple', 'Microsoft'].sort()); // ['Apple', 'Google', 'Microsoft']

// apple 排在了最后:
console.log(['Google', 'apple', 'Microsoft'].sort()); // ['Google', 'Microsoft', 'apple']

// 无法理解的结果:
console.log([10, 20, 1, 2].sort()); // [1, 10, 2, 20]

sort()也是一个高阶函数,可以接收一个比较函数来实现自定义排序

按数字大小排序:

let arr = [10, 20, 1, 2];
arr.sort(function(x, y) {
    if (x < y) {
        return -1;
    }
    if (x > y) {
        return 1;
    }
    return 0;
});
console.log(arr); // [1, 2, 10, 20]

倒序排序:

let arr = [10, 20, 1, 2];
arr.sort(function(x, y) {
    return y - x;
});
console.log(arr); // [20, 10, 2, 1]

忽略大小写按字母序排序:

let arr = ['Google', 'apple', 'Microsoft'];
arr.sort(function (s1, s2) {
    let x1 = s1.toUpperCase();
    let x2 = s2.toUpperCase();
    if (x1 < x2) {
        return -1;
    }
    if (x1 > x2) {
        return 1;
    }
    return 0;
});
console.log(arr); // ['apple', 'Google', 'Microsoft']

注意sort() 方法会直接对原 Array 进行修改,返回的结果仍是当前 Array:

let a1 = ['B', 'A', 'C'];
let a2 = a1.sort();
console.log(a1); // ['A', 'B', 'C']
console.log(a2); // ['A', 'B', 'C']
console.log(a1 === a2); // true, a1 和 a2 是同一对象

4.4 其他数组高阶方法

every

判断数组的所有元素是否满足测试条件:

let arr = ['Apple', 'pear', 'orange'];
console.log(arr.every(function (s) {
    return s.length > 0;
})); // true

console.log(arr.every(function (s) {
    return s.toLowerCase() === s;
})); // false

find

查找符合条件的第一个元素,返回该元素,否则返回 undefined

let arr = ['Apple', 'pear', 'orange'];
console.log(arr.find(function (s) {
    return s.toLowerCase() === s;
})); // 'pear'

console.log(arr.find(function (s) {
    return s.toUpperCase() === s;
})); // undefined

findIndex

类似 find,但返回索引,没有找到返回 -1

let arr = ['Apple', 'pear', 'orange'];
console.log(arr.findIndex(function (s) {
    return s.toLowerCase() === s;
})); // 1

console.log(arr.findIndex(function (s) {
    return s.toUpperCase() === s;
})); // -1

forEach

遍历数组,与 map 类似但不返回新数组,传入的函数不需要返回值:

let arr = ['Apple', 'pear', 'orange'];
arr.forEach(x => console.log(x)); // 依次打印每个元素

模块小结:高阶函数是函数式编程的核心,mapreducefiltersort 等方法极大地提升了数组操作的抽象能力。everyfindfindIndexforEach 也是日常开发中非常实用的工具。理解这些方法的参数和返回值,可以写出更简洁、可读性更强的代码。

五、闭包

5.1 函数作为返回值

高阶函数除了可以接受函数作为参数外,还可以把函数作为结果值返回

对 Array 求和,但不需要立刻求和,而是返回求和的函数:

function lazy_sum(arr) {
    let sum = function() {
        return arr.reduce(function(x, y) {
            return x + y;
        });
    };
    return sum;
}

调用 lazy_sum() 返回的是求和函数:

let f = lazy_sum([1, 2, 3, 4, 5]); // function sum()
console.log(f); // [Function: sum]
console.log(f()); // 15

在这个例子中,我们在函数 lazy_sum 中又定义了函数 sum,并且内部函数 sum 可以引用外部函数 lazy_sum 的参数和局部变量。当 lazy_sum 返回函数 sum 时,相关参数和变量都保存在返回的函数中,这种称为闭包(Closure)

每次调用 lazy_sum() 都会返回一个新的函数,即使传入相同的参数:

let f1 = lazy_sum([1, 2, 3, 4, 5]);
let f2 = lazy_sum([1, 2, 3, 4, 5]);
console.log(f1 === f2); // false

5.2 闭包的陷阱

返回的函数并没有立刻执行,而是直到调用时才执行。来看一个例子:

function count() {
    let arr = [];
    for (var i = 1; i <= 3; i++) {
        arr.push(function() {
            return i * i;
        });
    }
    return arr;
}

let results = count();
let [f1, f2, f3] = results;
console.log(f1()); // 16
console.log(f2()); // 16
console.log(f3()); // 16

全部都是 16!原因在于返回的函数引用了用 var 定义的变量 i,但它并非立刻执行。等到 3 个函数都返回时,它们所引用的变量 i 已经变成了 4,因此最终结果为 16。

返回闭包时牢记的一点:返回函数不要引用任何循环变量,或者后续会发生变化的变量。

如果一定要引用循环变量,方法是再创建一个函数,用该函数的参数绑定循环变量当前的值

function count() {
    let arr = [];
    for (var i = 1; i <= 3; i++) {
        arr.push((function(n) {
            return function() {
                return n * n;
            };
        })(i));
    }
    return arr;
}

let [f1, f2, f3] = count();
console.log(f1()); // 1
console.log(f2()); // 4
console.log(f3()); // 9

这里用了一个“创建一个匿名函数并立刻执行”的语法:

(function(x) {
    return x * x;
})(3); // 9

另一个方法是用 let 定义循环变量,let 作用域决定了在每次循环时都会绑定新的 i

function count() {
    let arr = [];
    for (let i = 1; i <= 3; i++) {
        arr.push(function() {
            return i * i;
        });
    }
    return arr;
}

但如果 i 定义在 for 循环外面,则仍然是错误的。

5.3 闭包的实际应用

封装私有变量:在没有 class 机制的语言里,借助闭包可以封装一个私有变量。例如创建一个计数器:

function create_counter(initial) {
    let x = initial || 0;
    return {
        inc: function() {
            x += 1;
            return x;
        }
    };
}

let c1 = create_counter();
console.log(c1.inc()); // 1
console.log(c1.inc()); // 2
console.log(c1.inc()); // 3

let c2 = create_counter(10);
console.log(c2.inc()); // 11
console.log(c2.inc()); // 12
console.log(c2.inc()); // 13

在返回的对象中,实现了一个闭包,该闭包携带了局部变量 x,并且从外部代码根本无法访问到变量 x。换句话说,闭包就是携带状态的函数,并且它的状态可以完全对外隐藏起来

把多参数的函数变成单参数的函数

function make_pow(n) {
    return function(x) {
        return Math.pow(x, n);
    };
}

let pow2 = make_pow(2);
let pow3 = make_pow(3);

console.log(pow2(5)); // 25
console.log(pow3(7)); // 343

模块小结:闭包是指一个函数能够记住并访问它的词法作用域,即使这个函数在它的词法作用域之外执行。闭包常用于封装私有变量和创建偏函数。注意不要引用循环变量,或者使用立即执行函数/let 来绑定当前值。

六、箭头函数

ES6 标准新增了箭头函数(Arrow Function)

// 箭头函数
x => x * x

上面的箭头函数相当于:

function (x) {
    return x * x;
}

箭头函数有两种格式:

  1. 只包含一个表达式,连 {...}return 都省略
  2. 包含多条语句,不能省略 {...}return
x => {
    if (x > 0) {
        return x * x;
    } else {
        return -x * x;
    }
}

参数处理:

  • 两个参数:(x, y) => x * x + y * y
  • 无参数:() => 3.14
  • 可变参数:(x, y, ...rest) => { let sum = x + y; for (let i = 0; i < rest.length; i++) sum += rest[i]; return sum; }

如果要返回一个对象,需要用括号括起来:

// 错误:SyntaxError
x => { foo: x }
// eg: let funa = x => { foo: x } 则:funa(1) // { foo: 1 }

// 正确
x => ({ foo: x })
// eg: let funb = x => ({ foo: x }) 则:funb(1) // { foo: 1 }

6.1 箭头函数中的 this

箭头函数和匿名函数有个明显的区别:箭头函数内部的 this 是词法作用域,由上下文确定

回顾前面的例子,普通函数中 this 指向错误:

let obj = {
    birth: 1990,
    getAge: function() {
        let b = this.birth; // 1990
        let fn = function() {
            return new Date().getFullYear() - this.birth; // this 指向 window 或 undefined
        };
        return fn();
    }
};

箭头函数修复了 this 的指向,this 总是指向词法作用域,也就是外层调用者 obj

let obj = {
    birth: 1990,
    getAge: function() {
        let b = this.birth; // 1990
        let fn = () => new Date().getFullYear() - this.birth; // this 指向 obj
        return fn();
    }
};
console.log(obj.getAge()); // 35 (假设当前年份是 2025)

由于 this 在箭头函数中已经按照词法作用域绑定了,所以用 call() 或者 apply() 调用箭头函数时,无法对 this 进行绑定(传入的第一个参数被忽略):

let obj = {
    birth: 1990,
    getAge: function(year) {
        let b = this.birth; // 1990
        let fn = (y) => y - this.birth; // this.birth 仍是 1990
        return fn.call({ birth: 2000 }, year);
    }
};
console.log(obj.getAge(2015)); // 25

练习:用箭头函数简化 sort 比较函数

let arr = [10, 20, 1, 2];
arr.sort((x, y) => {
    if (x < y) {
        return -1;
    }
    if (x > y) {
        return 1;
    }
    return 0;
});
console.log(arr); // [1, 2, 10, 20]

模块小结:箭头函数简化了函数定义,并且它的 this 绑定到定义时的词法作用域,彻底解决了普通函数中 this 指向混乱的问题。但箭头函数不能用作构造函数,也没有自己的 arguments 对象。

七、标签函数

模板字符串除了方便引用变量构造字符串外,还有一种更强大的功能——标签函数(Tag Function)

const email = "<test@example.com>";
const password = 'hello123';

function sql(strings, ...exps) {
    console.log(`SQL: ${strings.join('?')}`);
    console.log(`SQL parameters: ${JSON.stringify(exps)}`);
    return {
        name: '小明',
        age: 20
    };
}

sql`SELECT * FROM users WHERE email=${email} AND password=${password}`;

模板字符串前面以 sql 开头,实际上这是一个标签函数,上述语法会自动转换为对 sql() 函数的调用。

sql() 函数接收两个参数:

  • 第一个参数 strings 是一个字符串数组,它是 ["SELECT * FROM users WHERE email=", " AND password=", ""],即除去 ${xxx} 剩下的字符组成的数组
  • 第二个参数 ...exps 是一个可变参数,由模板字符串里所有的 ${xxx} 的实际值组成,即 ["test@example.com", "hello123"]

标签函数可以用于实现安全的 SQL 查询等场景:

function update(strings, ...exps) {
    let sql = strings.join('?');
    // 执行数据库更新
    // TODO:
}

let id = 123;
let age = 21;
let score = 'A';
update`UPDATE users SET age=${age}, score=${score} WHERE id=${id}`;

模块小结:标签函数是一种特殊的函数调用语法,通过带标签的模板字符串可以解析出静态字符串和动态表达式,常用于模板引擎、SQL 防注入等场景。

八、生成器(Generator)

生成器(generator) 是 ES6 标准引入的新的数据类型。一个生成器看上去像一个函数,但可以返回多次。

function* foo(x) {
    yield x + 1;
    yield x + 2;
    return x + 3;
}

generator 由 function* 定义,并且除了 return 语句,还可以用 yield 返回多次。

8.1 斐波那契数列示例

普通函数只能返回一个 Array:

function fib(max) {
    let t, a = 0, b = 1, arr = [0, 1];
    while (arr.length < max) {
        [a, b] = [b, a + b];
        arr.push(b);
    }
    return arr;
}

用 generator 改写,一次返回一个数:

function* fib(max) {
    let a = 0, b = 1, n = 0;
    while (n < max) {
        yield a;
        [a, b] = [b, a + b];
        n++;
    }
    return;
}

直接调用 fib(5) 只是创建了一个 generator 对象,并没有执行。

调用 generator 的方法

  1. 不断调用 next() 方法:
let f = fib(5);
console.log(f.next()); // {value: 0, done: false}
console.log(f.next()); // {value: 1, done: false}
console.log(f.next()); // {value: 1, done: false}
console.log(f.next()); // {value: 2, done: false}
console.log(f.next()); // {value: 3, done: false}
console.log(f.next()); // {value: undefined, done: true}

next() 方法会执行 generator 的代码,每次遇到 yield x; 就返回一个对象 {value: x, done: true/false},然后“暂停”。返回的 value 就是 yield 的返回值,done 表示这个 generator 是否已经执行结束。

  1. for ... of 循环迭代:
for (let x of fib(10)) {
    console.log(x); // 依次输出 0, 1, 1, 2, 3, ...
}

8.2 generator 的作用

1. 可以用同步的写法处理异步回调(后面 AJAX 章节会深入):

没有 generator 时,异步回调嵌套很丑陋:

ajax('http://url-1', data1, function (err, result) {
    if (err) return handle(err);
    ajax('http://url-2', data2, function (err, result) {
        if (err) return handle(err);
        ajax('http://url-3', data3, function (err, result) {
            if (err) return handle(err);
            return success(result);
        });
    });
});

有了 generator,可以写成同步风格:

try {
    let r1 = yield ajax('http://url-1', data1);
    let r2 = yield ajax('http://url-2', data2);
    let r3 = yield ajax('http://url-3', data3);
    success(r3);
} catch (err) {
    handle(err);
}

2. 实现状态机(记住执行状态)

练习:用 generator 生成自增 ID

function* next_id() {
    let id = 0;
    while (true) {
        yield ++id;
    }
}

// 测试:
let x, pass = true, g = next_id();
for (x = 1; x < 100; x++) {
    if (g.next().value !== x) {
        pass = false;
        console.log('测试失败!');
        break;
    }
}
if (pass) {
    console.log('测试通过!');
}

模块小结:生成器是一种可以多次返回的函数,使用 function* 定义,内部通过 yield 暂停并返回值。它常用于处理异步流程(将异步回调变成同步写法)和实现可迭代的状态机。理解 generator 是为后续学习异步编程(Promise、async/await)打下基础。

本篇核心知识点速记

  • 函数定义function name(params) { ... }let name = function(params) { ... }
  • 参数处理arguments 获取所有参数;...rest 获取剩余参数(ES6)
  • return 陷阱:不要将 return 和返回值拆成两行,避免自动分号插入
  • 变量作用域var 有函数作用域和变量提升;let/const 有块级作用域
  • 全局对象:浏览器中全局变量是 window 的属性,可通过名字空间减少冲突
  • 解构赋值let [a,b]=[1,2] 数组解构;let {name, age}=person 对象解构;支持默认值、别名、嵌套
  • 方法中的 this:指向调用对象;单独调用时指向 window/undefined;可用 that = this 或箭头函数固定 this
  • 高阶函数:函数作为参数或返回值;map(映射)、reduce(累积)、filter(过滤)、sort(排序,需传比较函数)
  • 闭包:函数记住并访问词法作用域;常用于封装私有变量;注意循环变量问题(用立即执行函数或 let 解决)
  • 箭头函数() => {}this 为词法作用域;无自己的 arguments
  • 标签函数:模板字符串前加函数名,自动解析静态字符串和表达式
  • 生成器function* + yield;可多次返回;用 next()for...of 迭代;适用于异步同步化