MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

JavaScript高阶函数与函数作为参数

2023-12-305.1k 阅读

JavaScript 中的函数基础回顾

在深入探讨高阶函数之前,我们先来回顾一下 JavaScript 中函数的基本概念。在 JavaScript 里,函数是一等公民(first - class citizen),这意味着函数可以像其他基本数据类型(如字符串、数字等)一样被使用。

函数的定义方式

  1. 函数声明
function add(a, b) {
    return a + b;
}

在这种方式中,function 关键字用于定义函数,add 是函数名,(a, b) 是函数的参数列表,函数体中使用 return 语句返回计算结果。 2. 函数表达式

const subtract = function (a, b) {
    return a - b;
};

这里我们使用 const 定义了一个变量 subtract,并将一个匿名函数赋值给它。这种方式定义的函数可以作为值存储在变量中,方便后续使用。 3. 箭头函数

const multiply = (a, b) => a * b;

箭头函数是 ES6 引入的一种更简洁的函数定义方式。它省略了 function 关键字,并且如果函数体只有一个表达式,可以省略 return 关键字和花括号。

函数的调用

一旦定义了函数,就可以通过函数名加上括号的方式来调用它,并传入相应的参数。例如:

const result1 = add(3, 5);
const result2 = subtract(10, 4);
const result3 = multiply(2, 6);
console.log(result1); // 输出 8
console.log(result2); // 输出 6
console.log(result3); // 输出 12

函数作为参数

在 JavaScript 中,由于函数是一等公民,所以函数可以作为参数传递给其他函数。这一特性为我们编写更加灵活和可复用的代码提供了强大的能力。

基本示例

考虑一个简单的场景,我们有一个函数 executeOperation,它接受两个数字和一个操作函数作为参数,并执行这个操作函数对两个数字进行运算。

function executeOperation(a, b, operation) {
    return operation(a, b);
}

function add(a, b) {
    return a + b;
}

function subtract(a, b) {
    return a - b;
}

const sum = executeOperation(5, 3, add);
const difference = executeOperation(5, 3, subtract);
console.log(sum); // 输出 8
console.log(difference); // 输出 2

在这个例子中,executeOperation 函数并不关心具体的运算逻辑,它只负责调用传入的操作函数,并将两个数字作为参数传递给该操作函数。这样我们可以通过传递不同的函数(如 addsubtract)来实现不同的运算,大大提高了代码的灵活性。

函数作为回调函数

函数作为参数传递的一个常见用途是作为回调函数。回调函数是一种在某个操作完成后被调用的函数。例如,在 JavaScript 的异步操作中,经常会使用回调函数。

  1. setTimeout 中的回调函数
function greet() {
    console.log('Hello, world!');
}

setTimeout(greet, 2000);

在这个例子中,setTimeout 函数接受两个参数,第一个参数是要执行的回调函数 greet,第二个参数是延迟的时间(单位为毫秒)。2 秒后,greet 函数会被调用。

  1. 数组方法中的回调函数
const numbers = [1, 2, 3, 4, 5];

function square(x) {
    return x * x;
}

const squaredNumbers = numbers.map(square);
console.log(squaredNumbers); // 输出 [1, 4, 9, 16, 25]

map 方法是数组的一个内置方法,它接受一个回调函数作为参数。map 方法会遍历数组中的每一个元素,并将每个元素传递给回调函数进行处理,最后返回一个新的数组,新数组中的元素是原数组元素经过回调函数处理后的结果。

理解回调函数的执行上下文

当函数作为回调函数被调用时,它的执行上下文(this 值)可能会有所不同。在普通函数调用中,this 的值取决于函数的调用方式。但在箭头函数中,this 的值取决于它被定义时的上下文,而不是调用时的上下文。

  1. 普通函数作为回调函数的 this
const person = {
    name: 'John',
    greet: function () {
        console.log(`Hello, I'm ${this.name}`);
    }
};

function callGreet(callback) {
    callback();
}

callGreet(person.greet); // 这里输出 "Hello, I'm undefined",因为此时 this 指向全局对象(在严格模式下为 undefined)

在这个例子中,当 person.greet 作为回调函数传递给 callGreet 并被调用时,this 的值不再是 person 对象,而是全局对象(在严格模式下为 undefined),这是因为 callGreet 函数调用 callback 时,没有明确指定 this 的值。

  1. 箭头函数作为回调函数的 this
const person = {
    name: 'Jane',
    greet: () => {
        console.log(`Hello, I'm ${this.name}`);
    }
};

function callGreet(callback) {
    callback();
}

callGreet(person.greet); // 这里输出 "Hello, I'm undefined",因为箭头函数的 this 取决于定义时的上下文,这里定义时的上下文的 this 指向全局对象(在严格模式下为 undefined)

箭头函数没有自己的 this 值,它会从外层作用域继承 this。在这个例子中,person.greet 箭头函数定义时,外层作用域的 this 指向全局对象(在严格模式下为 undefined),所以当作为回调函数调用时,输出的是 undefined

高阶函数

高阶函数(Higher - order function)是指满足以下条件之一的函数:

  1. 接受一个或多个函数作为参数。
  2. 返回一个函数。

我们前面讨论的 executeOperationsetTimeoutmap 等函数都属于高阶函数,因为它们都接受函数作为参数。接下来我们进一步探讨高阶函数的特性和应用。

函数柯里化(Currying)

函数柯里化是一种将多参数函数转换为一系列单参数函数的技术。通过柯里化,我们可以逐步传递参数,而不是一次性传递所有参数。

  1. 手动实现柯里化
function add(a) {
    return function (b) {
        return a + b;
    };
}

const add5 = add(5);
const result = add5(3);
console.log(result); // 输出 8

在这个例子中,add 函数接受一个参数 a,并返回一个新的函数。这个新函数又接受一个参数 b,并返回 a + b 的结果。通过 add(5) 我们得到了一个新的函数 add5,它已经固定了第一个参数为 5,然后我们可以再传入第二个参数 3 来得到最终的计算结果。

  1. 柯里化的应用场景: 柯里化可以提高函数的复用性和灵活性。例如,在处理表单验证时,我们可能有一个通用的验证函数:
function validate(value, validator) {
    return validator(value);
}

function isLengthGreaterThan(minLength) {
    return function (value) {
        return value.length > minLength;
    };
}

const validateUsername = validate.bind(null, 'username', isLengthGreaterThan(3));
const isValidUsername = validateUsername('John');
console.log(isValidUsername); // 假设 'John' 长度大于 3,输出 true

这里通过柯里化,我们将 isLengthGreaterThan 函数变成了一个可以根据不同最小长度进行验证的函数。然后通过 validate.bind 方法,我们创建了一个特定的验证函数 validateUsername,它固定了要验证的值为 'username' 和验证规则为 isLengthGreaterThan(3)

偏函数应用(Partial Application)

偏函数应用与柯里化有些相似,但它不是将多参数函数转换为一系列单参数函数,而是固定部分参数,返回一个接受剩余参数的新函数。

  1. 手动实现偏函数应用
function add(a, b, c) {
    return a + b + c;
}

function partialAdd(a, b) {
    return function (c) {
        return add(a, b, c);
    };
}

const add1And2 = partialAdd(1, 2);
const result = add1And2(3);
console.log(result); // 输出 6

在这个例子中,partialAdd 函数接受 add 函数的前两个参数 ab,并返回一个新的函数。这个新函数接受剩余的参数 c,并调用原始的 add 函数进行计算。

  1. 使用 Function.prototype.bind 实现偏函数应用
function multiply(a, b, c) {
    return a * b * c;
}

const multiplyBy2And3 = multiply.bind(null, 2, 3);
const result = multiplyBy2And3(4);
console.log(result); // 输出 24

bind 方法可以创建一个新的函数,这个新函数的 this 值被绑定到 bind 方法的第一个参数,并且可以预先传入一些参数。在这个例子中,multiply.bind(null, 2, 3) 创建了一个新函数,它固定了 multiply 函数的前两个参数为 2 和 3,我们只需要再传入第三个参数 4 就可以得到最终的计算结果。

组合函数(Function Composition)

组合函数是将多个函数组合成一个新函数的技术。新函数会按照顺序依次调用这些组合的函数,前一个函数的输出作为后一个函数的输入。

  1. 手动实现组合函数
function square(x) {
    return x * x;
}

function add1(x) {
    return x + 1;
}

function compose(...functions) {
    return function (input) {
        return functions.reduceRight((acc, func) => func(acc), input);
    };
}

const squareAndAdd1 = compose(add1, square);
const result = squareAndAdd1(3);
console.log(result); // 输出 10,先计算 3 的平方为 9,再加上 1 得到 10

在这个例子中,compose 函数接受多个函数作为参数,并返回一个新的函数。这个新函数在调用时,会从右到左依次调用传入的函数,将前一个函数的输出作为后一个函数的输入。

  1. 组合函数的优势: 组合函数可以让我们将复杂的操作拆分成多个简单的函数,然后通过组合这些简单函数来实现复杂的功能。这样代码更加模块化,易于维护和理解。例如,在处理数据的转换和验证时,我们可以将不同的转换和验证逻辑写成单独的函数,然后通过组合函数将它们组合起来。

高阶函数与闭包

高阶函数经常与闭包一起使用。闭包是指函数可以访问其定义时的词法作用域,即使该函数在其他地方被调用。

  1. 闭包与高阶函数的示例
function counter() {
    let count = 0;
    return function () {
        return ++count;
    };
}

const myCounter = counter();
console.log(myCounter()); // 输出 1
console.log(myCounter()); // 输出 2

在这个例子中,counter 函数返回一个内部函数。内部函数可以访问 counter 函数内部的变量 count,即使 counter 函数已经执行完毕。每次调用 myCounter 时,它都会访问并修改 count 的值,这就是闭包的作用。

  1. 闭包在高阶函数中的应用: 闭包在高阶函数中可以用于保持状态。例如,在实现一个缓存函数时:
function memoize(func) {
    const cache = {};
    return function (...args) {
        const key = args.toString();
        if (cache[key]) {
            return cache[key];
        }
        const result = func.apply(this, args);
        cache[key] = result;
        return result;
    };
}

function expensiveCalculation(a, b) {
    // 模拟一个耗时的计算
    return a + b;
}

const memoizedCalculation = memoize(expensiveCalculation);
console.log(memoizedCalculation(2, 3)); // 第一次调用,执行实际计算并缓存结果
console.log(memoizedCalculation(2, 3)); // 第二次调用,直接从缓存中返回结果

在这个例子中,memoize 函数接受一个函数 func 作为参数,并返回一个新的函数。新函数使用闭包来维护一个缓存对象 cache,如果相同参数的计算结果已经在缓存中,则直接返回缓存中的结果,否则执行原函数并将结果缓存起来。

常见的高阶数组方法

JavaScript 的数组对象提供了许多高阶函数方法,这些方法极大地简化了对数组的操作。

map 方法

我们前面已经介绍过 map 方法,它会创建一个新数组,其元素是原数组元素经过回调函数处理后的结果。

const numbers = [1, 2, 3, 4, 5];
const squaredNumbers = numbers.map((num) => num * num);
console.log(squaredNumbers); // 输出 [1, 4, 9, 16, 25]

filter 方法

filter 方法用于创建一个新数组,其中包含满足回调函数条件的所有元素。

const numbers = [1, 2, 3, 4, 5];
const evenNumbers = numbers.filter((num) => num % 2 === 0);
console.log(evenNumbers); // 输出 [2, 4]

在这个例子中,filter 方法遍历 numbers 数组,将每个元素传递给回调函数 num => num % 2 === 0。如果回调函数返回 true,则该元素被包含在新数组中。

reduce 方法

reduce 方法对数组中的每个元素执行一个累加器函数,将其结果汇总为单个值。

const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((acc, num) => acc + num, 0);
console.log(sum); // 输出 15

在这个例子中,reduce 方法接受两个参数,第一个参数是回调函数,回调函数接受两个参数:累加器 acc 和当前元素 num。第二个参数是初始值 0reduce 方法会从左到右依次将数组元素传递给回调函数,并将回调函数的返回值作为下一次调用的累加器值,最终返回累加的结果。

forEach 方法

forEach 方法用于对数组中的每个元素执行一次回调函数。它不返回新数组,主要用于执行副作用操作,如打印数组元素。

const numbers = [1, 2, 3, 4, 5];
numbers.forEach((num) => console.log(num));
// 依次输出 1, 2, 3, 4, 5

every 方法

every 方法用于检查数组中的所有元素是否都满足回调函数的条件。如果所有元素都满足,则返回 true,否则返回 false

const numbers = [2, 4, 6, 8];
const allEven = numbers.every((num) => num % 2 === 0);
console.log(allEven); // 输出 true

const mixedNumbers = [2, 4, 5, 8];
const allEven2 = mixedNumbers.every((num) => num % 2 === 0);
console.log(allEven2); // 输出 false

some 方法

some 方法用于检查数组中是否至少有一个元素满足回调函数的条件。如果至少有一个元素满足,则返回 true,否则返回 false

const numbers = [1, 3, 5, 7];
const hasEven = numbers.some((num) => num % 2 === 0);
console.log(hasEven); // 输出 false

const mixedNumbers = [1, 3, 4, 7];
const hasEven2 = mixedNumbers.some((num) => num % 2 === 0);
console.log(hasEven2); // 输出 true

高阶函数在异步编程中的应用

在 JavaScript 的异步编程中,高阶函数也扮演着重要的角色。

回调地狱与解决方案

在早期的 JavaScript 异步编程中,经常会出现回调地狱(Callback Hell)的问题,即多个异步操作嵌套在一起,导致代码难以阅读和维护。

setTimeout(() => {
    console.log('First timeout');
    setTimeout(() => {
        console.log('Second timeout');
        setTimeout(() => {
            console.log('Third timeout');
        }, 1000);
    }, 1000);
}, 1000);

为了解决回调地狱的问题,我们可以使用高阶函数来进行优化。例如,通过将异步操作封装成函数,并将回调函数作为参数传递。

function asyncOperation1(callback) {
    setTimeout(() => {
        console.log('First async operation');
        callback();
    }, 1000);
}

function asyncOperation2(callback) {
    setTimeout(() => {
        console.log('Second async operation');
        callback();
    }, 1000);
}

function asyncOperation3() {
    setTimeout(() => {
        console.log('Third async operation');
    }, 1000);
}

asyncOperation1(() => {
    asyncOperation2(() => {
        asyncOperation3();
    });
});

虽然这种方式比回调地狱有所改善,但代码仍然不够简洁。随着 JavaScript 的发展,Promise 和 async/await 等更高级的异步编程技术出现,进一步解决了异步编程的复杂性问题。

Promise 与高阶函数

Promise 是 JavaScript 中处理异步操作的一种更优雅的方式。Promise 本身可以看作是一个高阶函数的应用。

function asyncTask() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve('Task completed successfully');
            } else {
                reject('Task failed');
            }
        }, 1000);
    });
}

asyncTask()
   .then((result) => {
        console.log(result); // 输出 "Task completed successfully"
    })
   .catch((error) => {
        console.error(error);
    });

在这个例子中,asyncTask 函数返回一个 Promise 对象。Promise 构造函数接受一个回调函数作为参数,这个回调函数又接受 resolvereject 两个函数作为参数。当异步操作成功时,调用 resolve 并传递结果;当异步操作失败时,调用 reject 并传递错误信息。then 方法和 catch 方法都是 Promise 的实例方法,它们接受回调函数作为参数,用于处理 Promise 的成功和失败情况。

async/await 与高阶函数

async/await 是基于 Promise 之上的更简洁的异步编程语法糖。它本质上也是高阶函数的应用。

function asyncTask() {
    return new Promise((resolve, reject) => {
        setTimeout(() => {
            const success = true;
            if (success) {
                resolve('Task completed successfully');
            } else {
                reject('Task failed');
            }
        }, 1000);
    });
}

async function main() {
    try {
        const result = await asyncTask();
        console.log(result); // 输出 "Task completed successfully"
    } catch (error) {
        console.error(error);
    }
}

main();

在这个例子中,async 关键字用于定义一个异步函数 main。在 main 函数内部,await 关键字只能在 async 函数内部使用,它用于暂停异步函数的执行,直到 Promise 被解决(resolved 或 rejected)。await 后面跟一个 Promise 对象,当 Promise 被解决时,await 表达式会返回 Promise 的解决值。通过 try...catch 块可以捕获异步操作中可能出现的错误。

高阶函数的性能考虑

虽然高阶函数在 JavaScript 编程中提供了很大的灵活性和便利性,但在使用时也需要考虑性能问题。

函数调用开销

每次调用函数都会有一定的开销,包括创建新的执行上下文、参数传递等。在使用高阶函数时,如果频繁调用回调函数,可能会导致性能问题。例如,在一个循环中使用 map 方法时,如果回调函数比较复杂,可能会影响性能。

const largeArray = Array.from({ length: 1000000 }, (_, i) => i + 1);

// 复杂的回调函数
function complexCalculation(num) {
    let result = 1;
    for (let i = 1; i <= num; i++) {
        result *= i;
    }
    return result;
}

console.time('mapPerformance');
const result = largeArray.map(complexCalculation);
console.timeEnd('mapPerformance');

在这个例子中,map 方法对一个长度为 100 万的数组进行操作,回调函数 complexCalculation 执行阶乘计算,这是一个比较复杂的操作。如果性能要求较高,可以考虑使用普通的 for 循环来替代 map 方法,以减少函数调用的开销。

闭包与内存占用

高阶函数与闭包一起使用时,可能会导致内存占用问题。因为闭包会保持对其定义时作用域的引用,如果闭包函数长时间存在,可能会导致相关的变量无法被垃圾回收机制回收。

function outer() {
    const largeObject = { /* 一个非常大的对象 */ };
    return function () {
        return largeObject.property;
    };
}

const innerFunction = outer();
// 这里 innerFunction 保持了对 largeObject 的引用,即使 outer 函数已经执行完毕,largeObject 也不会被垃圾回收

在这个例子中,outer 函数返回的内部函数通过闭包引用了 largeObject。如果 innerFunction 一直存在并被使用,largeObject 将无法被垃圾回收,从而占用内存。为了避免这种情况,在不需要使用闭包中的变量时,应该及时释放引用。

优化建议

  1. 避免不必要的函数嵌套:尽量减少高阶函数中不必要的函数嵌套,以降低函数调用开销和维护成本。
  2. 合理使用闭包:在使用闭包时,要注意及时释放对不再需要的变量的引用,以避免内存泄漏。
  3. 性能测试与优化:在性能敏感的场景下,通过性能测试工具(如 console.timeconsole.timeEnd)来比较不同实现方式的性能,选择最优的方案。

综上所述,高阶函数和函数作为参数是 JavaScript 中强大而灵活的特性,它们为我们编写高效、可复用和模块化的代码提供了有力的支持。但在使用过程中,我们也需要注意性能和内存管理等问题,以确保代码的质量和效率。通过深入理解和熟练运用这些特性,开发者可以更好地应对各种复杂的编程场景。