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

JavaScript中的高阶函数:提高代码复用率

2021-04-147.8k 阅读

一、高阶函数的定义与概念

在JavaScript的编程世界中,高阶函数(Higher - order Function)是一类非常特殊且强大的函数。简单来说,高阶函数是满足以下至少一个条件的函数:

  1. 函数可以接收一个或多个函数作为参数。
  2. 函数可以返回一个新的函数。

(一)接收函数作为参数

这种情况在JavaScript中极为常见。例如,数组的forEach方法就是一个高阶函数。forEach用于对数组的每个元素执行一次提供的函数。来看下面这个简单的示例:

const numbers = [1, 2, 3, 4];
numbers.forEach(function (number) {
    console.log(number * 2);
});

在上述代码中,forEach就是一个高阶函数,它接收了一个匿名函数作为参数。这个匿名函数会被应用到numbers数组的每一个元素上。

从本质上讲,将函数作为参数传递,使得我们可以将通用的遍历逻辑(forEach的内部实现)与具体的业务逻辑(对每个元素执行的操作,如console.log(number * 2))分离。这大大提高了代码的灵活性和复用性。假设我们现在有另一个数组letters = ['a', 'b', 'c'],并且我们想要将每个字母转换为大写并打印,只需要传递一个不同的函数给forEach

const letters = ['a', 'b', 'c'];
letters.forEach(function (letter) {
    console.log(letter.toUpperCase());
});

这里forEach的遍历逻辑不需要改变,我们只需要根据不同的业务需求传递不同的函数作为参数。

(二)返回函数

当一个函数返回另一个函数时,也构成了高阶函数。这种方式常常用于创建闭包,闭包是JavaScript中一个非常重要的概念,与高阶函数紧密相关。闭包使得函数可以访问其外部作用域的变量,即使外部作用域已经执行完毕。下面是一个简单的示例:

function outerFunction() {
    const message = 'Hello, world!';
    function innerFunction() {
        console.log(message);
    }
    return innerFunction;
}
const myFunction = outerFunction();
myFunction();

在上述代码中,outerFunction是一个高阶函数,它返回了innerFunctioninnerFunction形成了一个闭包,因为它可以访问outerFunction作用域中的message变量。当我们调用outerFunction()并将返回的函数赋值给myFunction,然后调用myFunction()时,仍然可以打印出message的值。

这种返回函数的高阶函数模式在很多场景下都非常有用,比如实现延迟执行或者根据不同条件返回不同行为的函数。例如,我们可以创建一个函数工厂,根据传入的参数返回不同功能的函数:

function createAdder(num) {
    return function (value) {
        return value + num;
    };
}
const add5 = createAdder(5);
const result = add5(3);
console.log(result); 

在这个例子中,createAdder是一个高阶函数,它接收一个参数num并返回一个新的函数。这个新函数可以将传入的valuenum相加。通过调用createAdder(5),我们得到了一个专门用于加5的函数add5,然后调用add5(3)得到结果8。

二、常见的高阶函数及应用场景

(一)数组操作相关的高阶函数

  1. map函数 map函数是数组原型上的一个高阶函数,它会创建一个新数组,其结果是该数组中的每个元素都调用一个提供的函数后返回的结果。来看一个简单的例子,将数组中的每个数字翻倍:
const numbers = [1, 2, 3, 4];
const doubledNumbers = numbers.map(function (number) {
    return number * 2;
});
console.log(doubledNumbers); 

在这个例子中,map函数遍历numbers数组,对每个元素应用传入的匿名函数,将其翻倍后放入新的数组doubledNumbers中。

map函数的应用场景非常广泛,比如在处理数据展示时,我们可能需要将服务器返回的数据进行格式转换。假设我们从服务器获取到一个包含用户对象的数组,每个用户对象有nameage属性,我们想要在页面上展示用户的姓名和年龄信息,但需要将年龄格式化为字符串并添加“岁”字。代码如下:

const users = [
    { name: 'Alice', age: 25 },
    { name: 'Bob', age: 30 }
];
const formattedUsers = users.map(function (user) {
    return user.name + ',' + user.age + '岁';
});
console.log(formattedUsers); 
  1. filter函数 filter函数也是数组原型上的高阶函数,它会创建一个新数组,新数组中的元素是通过检查指定数组中符合条件的所有元素。例如,我们有一个数字数组,想要过滤出所有大于10的数字:
const numbers = [5, 12, 8, 15, 3];
const filteredNumbers = numbers.filter(function (number) {
    return number > 10;
});
console.log(filteredNumbers); 

在上述代码中,filter函数遍历numbers数组,对每个元素应用传入的匿名函数。如果匿名函数返回true,则该元素被包含在新数组filteredNumbers中。

在实际应用中,filter常用于数据筛选。比如在一个电商应用中,我们有一个商品列表数组,每个商品对象包含price属性,我们想要筛选出价格低于100元的商品:

const products = [
    { name: 'Product A', price: 80 },
    { name: 'Product B', price: 120 },
    { name: 'Product C', price: 90 }
];
const affordableProducts = products.filter(function (product) {
    return product.price < 100;
});
console.log(affordableProducts); 
  1. reduce函数 reduce函数是数组中功能最为强大的高阶函数之一。它对数组中的每个元素执行一个由您提供的“reducer”函数(升序执行),将其结果汇总为单个返回值。reduce函数的基本语法如下:
array.reduce((accumulator, currentValue) => {
    // 操作逻辑
    return newAccumulator;
}, initialValue);

其中accumulator是累加器,currentValue是当前处理的元素,initialValue是初始值(可选)。

例如,我们想要计算数组中所有数字的总和:

const numbers = [1, 2, 3, 4];
const sum = numbers.reduce(function (accumulator, currentValue) {
    return accumulator + currentValue;
}, 0);
console.log(sum); 

在这个例子中,初始值initialValue为0。reduce函数从左到右遍历数组,将当前值currentValue累加到累加器accumulator中,并返回新的累加器值。

reduce的应用场景也很丰富,比如在统计数据方面。假设我们有一个包含用户购买金额的数组,我们想要统计不同用户的总购买金额。代码如下:

const purchases = [
    { user: 'Alice', amount: 50 },
    { user: 'Bob', amount: 30 },
    { user: 'Alice', amount: 20 }
];
const userTotalPurchases = purchases.reduce(function (acc, purchase) {
    if (!acc[purchase.user]) {
        acc[purchase.user] = 0;
    }
    acc[purchase.user] += purchase.amount;
    return acc;
}, {});
console.log(userTotalPurchases); 

在这个例子中,我们使用reduce函数将购买记录按用户进行分组并统计总金额。acc初始化为一个空对象,通过判断用户是否已存在于acc对象中,来累加相应用户的购买金额。

(二)函数组合相关的高阶函数

  1. 函数组合的概念 函数组合是一种将多个函数连接起来的技术,使得前一个函数的输出成为后一个函数的输入。在JavaScript中,我们可以通过高阶函数来实现函数组合。假设有两个函数fg,函数组合compose(f, g)的结果应该是一个新函数h,使得h(x) === f(g(x))

  2. 实现简单的函数组合

function compose(...functions) {
    return function (input) {
        return functions.reduceRight(function (acc, func) {
            return func(acc);
        }, input);
    };
}
function add1(x) {
    return x + 1;
}
function multiply2(x) {
    return x * 2;
}
const composedFunction = compose(add1, multiply2);
const result = composedFunction(5);
console.log(result); 

在上述代码中,compose函数是一个高阶函数,它接收多个函数作为参数,并返回一个新的函数。这个新函数通过reduceRight方法从右到左依次执行传入的函数,将前一个函数的输出作为后一个函数的输入。在这个例子中,composedFunction先执行multiply2,再执行add1,所以composedFunction(5)相当于add1(multiply2(5)),结果为11。

函数组合的好处在于它可以将复杂的操作分解为多个简单的函数,然后通过组合这些简单函数来实现复杂功能,提高代码的可读性和可维护性。

(三)柯里化相关的高阶函数

  1. 柯里化的概念 柯里化是一种将多参数函数转换为一系列单参数函数的技术。例如,有一个函数add(x, y),柯里化后会得到curriedAdd(x)(y)。柯里化的本质是利用闭包,使得函数可以分步接收参数。

  2. 实现简单的柯里化函数

function curry(func) {
    return function curried(...args) {
        if (args.length >= func.length) {
            return func.apply(this, args);
        } else {
            return function (...nextArgs) {
                return curried.apply(this, args.concat(nextArgs));
            };
        }
    };
}
function add(x, y) {
    return x + y;
}
const curriedAdd = curry(add);
const add5 = curriedAdd(5);
const result = add5(3);
console.log(result); 

在上述代码中,curry函数是一个高阶函数,它接收一个普通函数func并返回一个柯里化后的函数curriedcurried函数会检查当前接收的参数个数是否达到原始函数func的参数个数,如果达到则直接执行func,否则返回一个新函数继续接收参数。通过柯里化,我们可以将add函数转换为curriedAdd,先传入部分参数得到一个新的函数(如add5),再传入剩余参数得到最终结果。

柯里化在函数复用和参数预设方面有很大的优势。比如在一个表单验证场景中,我们有一个验证函数validate(minLength, value)用于验证输入值value是否满足最小长度minLength。通过柯里化,我们可以预设minLength,得到一个专门用于验证特定长度的函数:

function validate(minLength, value) {
    return value.length >= minLength;
}
const validateLength5 = curry(validate)(5);
const isValid = validateLength5('hello');
console.log(isValid); 

三、高阶函数与代码复用率提升

(一)减少重复代码

在传统的编程方式中,我们可能会为了实现类似的功能在不同的地方编写重复的代码。例如,在多个地方需要对数组进行遍历并执行相同的操作,可能会在每个地方都编写类似的循环代码。而使用高阶函数,如forEachmap等,我们可以将遍历逻辑抽象出来,只需要关注具体的业务逻辑。

假设有一个需求,在多个数组上执行将每个元素平方的操作。如果不使用高阶函数,代码可能如下:

const numbers1 = [1, 2, 3];
const squaredNumbers1 = [];
for (let i = 0; i < numbers1.length; i++) {
    squaredNumbers1.push(numbers1[i] * numbers1[i]);
}
const numbers2 = [4, 5, 6];
const squaredNumbers2 = [];
for (let i = 0; i < numbers2.length; i++) {
    squaredNumbers2.push(numbers2[i] * numbers2[i]);
}

使用map高阶函数后,代码变得简洁且复用性高:

const numbers1 = [1, 2, 3];
const squaredNumbers1 = numbers1.map(function (number) {
    return number * number;
});
const numbers2 = [4, 5, 6];
const squaredNumbers2 = numbers2.map(function (number) {
    return number * number;
});

这里map函数的遍历逻辑被复用,我们只需要关注将元素平方的业务逻辑。而且,如果之后需求发生变化,比如需要对立方进行操作,只需要修改传入map的函数,而不需要修改遍历逻辑。

(二)提高代码的灵活性

高阶函数允许我们通过传递不同的函数作为参数来实现不同的行为。这使得代码可以根据不同的场景进行灵活调整,而不需要修改核心的代码逻辑。

filter函数为例,假设我们有一个员工数组,每个员工对象包含department属性。我们可能需要根据不同的部门筛选员工。

const employees = [
    { name: 'Alice', department: 'HR' },
    { name: 'Bob', department: 'Engineering' },
    { name: 'Charlie', department: 'HR' }
];
function filterByDepartment(department) {
    return function (employee) {
        return employee.department === department;
    };
}
const hrEmployees = employees.filter(filterByDepartment('HR'));
const engineeringEmployees = employees.filter(filterByDepartment('Engineering'));

在这个例子中,filterByDepartment函数返回一个用于筛选特定部门员工的函数。我们可以通过传递不同的部门名称,灵活地筛选出不同部门的员工,而filter函数的核心逻辑不需要改变。

(三)便于代码的维护与扩展

由于高阶函数将通用逻辑与业务逻辑分离,当业务需求发生变化时,我们可以更方便地修改和扩展代码。

例如,在一个电商购物车系统中,我们使用reduce函数来计算购物车中商品的总价格。假设最初购物车中的商品对象只有price属性,计算总价格的代码如下:

const cartItems = [
    { price: 10 },
    { price: 20 }
];
const totalPrice = cartItems.reduce(function (acc, item) {
    return acc + item.price;
}, 0);

后来,业务需求发生变化,商品需要考虑折扣,商品对象新增了discount属性。我们只需要修改传入reduce的函数,而不需要修改reduce的调用逻辑:

const cartItems = [
    { price: 10, discount: 0.9 },
    { price: 20, discount: 0.8 }
];
const totalPrice = cartItems.reduce(function (acc, item) {
    return acc + item.price * item.discount;
}, 0);

这种分离使得代码在面对需求变化时更容易维护和扩展,提高了代码的整体质量和复用率。

四、高阶函数的性能考量与注意事项

(一)性能考量

  1. 函数调用开销 高阶函数涉及多次函数调用,每一次函数调用都会有一定的开销,包括创建函数上下文、传递参数等。例如,在使用mapfilter等数组高阶函数时,如果数组元素数量非常大,频繁的函数调用可能会对性能产生一定影响。
const largeArray = Array.from({ length: 1000000 }, (_, i) => i + 1);
console.time('map');
largeArray.map(function (number) {
    return number * 2;
});
console.timeEnd('map');

在上述代码中,map函数对包含100万个元素的数组进行操作,每次调用传入的匿名函数都会产生开销。虽然现代JavaScript引擎对函数调用进行了优化,但在处理超大数据量时仍需注意。

  1. 闭包与内存占用 当高阶函数返回函数形成闭包时,可能会导致内存占用增加。因为闭包会保持对外部作用域变量的引用,即使外部作用域已经执行完毕,这些变量也不会被垃圾回收机制回收。
function outerFunction() {
    const largeObject = { /* 一个非常大的对象 */ };
    function innerFunction() {
        return largeObject.property;
    }
    return innerFunction;
}
const myFunction = outerFunction();

在这个例子中,innerFunction形成闭包,它对largeObject的引用会导致largeObjectouterFunction执行完毕后仍然保留在内存中,直到myFunction不再被引用。

(二)注意事项

  1. 理解函数作用域与闭包 在使用高阶函数时,深入理解函数作用域和闭包是非常重要的。错误地使用闭包可能会导致变量的意外共享或内存泄漏等问题。例如:
function createFunctions() {
    const functions = [];
    for (let i = 0; i < 3; i++) {
        functions.push(function () {
            console.log(i);
        });
    }
    return functions;
}
const myFunctions = createFunctions();
myFunctions.forEach(function (func) {
    func();
});

在上述代码中,预期输出应该是0、1、2,但实际输出是3、3、3。这是因为i在循环结束后已经变为3,而每个闭包函数共享了同一个i。正确的做法是使用立即执行函数(IIFE)来创建独立的作用域:

function createFunctions() {
    const functions = [];
    for (let i = 0; i < 3; i++) {
        functions.push((function (j) {
            return function () {
                console.log(j);
            };
        })(i));
    }
    return functions;
}
const myFunctions = createFunctions();
myFunctions.forEach(function (func) {
    func();
});
  1. 避免过度使用高阶函数 虽然高阶函数非常强大,但过度使用可能会导致代码可读性下降。尤其是对于不熟悉高阶函数概念的开发人员,复杂的函数组合或柯里化可能会使代码难以理解和维护。例如,在一些简单的场景下,使用普通的循环可能比使用高阶函数更直观:
const numbers = [1, 2, 3];
// 使用高阶函数
const squaredNumbers = numbers.map(function (number) {
    return number * number;
});
// 使用普通循环
const squaredNumbers2 = [];
for (let i = 0; i < numbers.length; i++) {
    squaredNumbers2.push(numbers[i] * numbers[i]);
}

在这个简单的平方操作场景中,普通循环的代码更易于理解。所以在实际开发中,需要根据具体情况权衡使用高阶函数还是传统的编程方式。

  1. 参数验证 当高阶函数接收函数作为参数时,进行适当的参数验证是必要的。例如,如果一个高阶函数期望接收一个返回布尔值的函数作为参数,而调用者传入了一个返回其他类型值的函数,可能会导致程序运行时错误。可以在高阶函数内部添加参数类型检查:
function filterArray(array, callback) {
    if (typeof callback!== 'function') {
        throw new Error('Callback must be a function');
    }
    const result = [];
    for (let i = 0; i < array.length; i++) {
        if (callback(array[i])) {
            result.push(array[i]);
        }
    }
    return result;
}

这样可以在函数调用时及时发现错误,提高代码的健壮性。

通过深入理解高阶函数的概念、应用场景、性能考量和注意事项,开发人员可以在JavaScript编程中充分利用高阶函数的优势,提高代码的复用率、灵活性和可维护性,从而编写出更加高效和优质的代码。