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

JavaScript的函数式编程与应用场景

2021-07-155.2k 阅读

JavaScript 的函数式编程基础概念

函数是一等公民

在 JavaScript 中,函数被视为一等公民。这意味着函数可以像其他基本数据类型(如字符串、数字)一样被对待。它可以作为参数传递给其他函数,也可以作为返回值从函数中返回,还能被赋值给变量。

例如,我们定义一个简单的函数 add,并将其赋值给变量 sum

function add(a, b) {
    return a + b;
}
let sum = add;
console.log(sum(2, 3)); // 输出: 5

这里 add 函数被赋值给 sum 变量,然后通过 sum 调用该函数,其行为与直接调用 add 函数一致。

函数作为参数传递给其他函数也是常见的用法。下面是一个高阶函数 map 的简单实现,它接受一个数组和一个函数作为参数,对数组中的每个元素应用传入的函数:

function map(arr, func) {
    let result = [];
    for (let i = 0; i < arr.length; i++) {
        result.push(func(arr[i]));
    }
    return result;
}

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

let numbers = [1, 2, 3, 4];
let squaredNumbers = map(numbers, square);
console.log(squaredNumbers); // 输出: [1, 4, 9, 16]

在上述代码中,square 函数作为参数传递给 map 函数,map 函数对 numbers 数组的每个元素执行 square 操作。

函数作为返回值返回同样很有用。例如,我们可以定义一个函数 createAdder,它返回一个新的函数,这个新函数用于执行加法操作:

function createAdder(num) {
    return function (x) {
        return x + num;
    };
}

let add5 = createAdder(5);
console.log(add5(3)); // 输出: 8

这里 createAdder 函数返回一个内部函数,该内部函数记住了 createAdder 函数调用时传入的 num 参数,形成了闭包。

纯函数

纯函数是函数式编程中的核心概念。纯函数具有以下两个重要特性:

  1. 相同的输入总是返回相同的输出:无论在何时何地调用纯函数,只要输入参数相同,它就会返回相同的结果。例如,Math.sqrt(4) 总是返回 2,无论在程序的哪个部分调用,也无论调用多少次。

  2. 没有副作用:纯函数不会对外部环境产生影响,比如不会修改全局变量、不会进行文件读写操作、不会发起网络请求等。

下面是一个纯函数的示例:

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

这个 add 函数满足纯函数的两个特性。给定相同的 ab 值,它总是返回相同的结果,并且它不会对外部环境造成任何改变。

与之相对的是非纯函数,例如修改全局变量的函数:

let count = 0;
function increment() {
    count++;
    return count;
}

这个 increment 函数不是纯函数,因为它修改了全局变量 count,并且每次调用的结果不仅取决于输入(这里没有输入参数),还取决于 count 的初始值和之前调用 increment 的次数。

纯函数在函数式编程中非常重要,因为它们使得代码更容易理解、测试和维护。由于纯函数没有副作用,它们可以在不担心对其他部分代码产生影响的情况下被单独测试。

不可变数据

在函数式编程中,提倡使用不可变数据。一旦数据被创建,就不应该被修改。如果需要修改数据,应该创建一个新的包含修改后数据的副本。

在 JavaScript 中,基本数据类型(如字符串、数字、布尔值)本身就是不可变的。例如:

let str = 'hello';
let newStr = str.toUpperCase();
console.log(str); // 输出: hello
console.log(newStr); // 输出: HELLO

这里 str 字符串并没有被修改,toUpperCase 方法返回了一个新的字符串。

对于对象和数组等引用类型,默认是可变的。但我们可以使用一些方法来创建不可变的副本。例如,使用 Object.assign() 方法创建对象的副本:

let obj = { a: 1 };
let newObj = Object.assign({}, obj, { a: 2 });
console.log(obj); // 输出: { a: 1 }
console.log(newObj); // 输出: { a: 2 }

对于数组,可以使用 mapfilter 等方法来创建新的数组,而不是直接修改原数组。例如:

let numbers = [1, 2, 3];
let newNumbers = numbers.map(num => num * 2);
console.log(numbers); // 输出: [1, 2, 3]
console.log(newNumbers); // 输出: [2, 4, 6]

使用不可变数据可以避免很多难以调试的错误,比如多个部分的代码意外修改了共享数据导致的问题。同时,不可变数据也有助于实现更高效的内存管理和数据缓存,因为如果两个部分的数据相同,它们可以共享同一份内存。

函数式编程的核心技术

高阶函数

高阶函数是函数式编程中的关键概念。高阶函数是指满足以下条件之一的函数:

  1. 接受一个或多个函数作为参数:如前面提到的 map 函数,它接受一个数组和一个函数作为参数,对数组中的每个元素应用传入的函数。

  2. 返回一个函数:例如 createAdder 函数,它返回一个新的函数用于执行加法操作。

除了 map 之外,filterreduce 也是非常常用的高阶函数。

filter 函数用于过滤数组中的元素,它接受一个数组和一个返回布尔值的函数作为参数,返回一个新的数组,其中包含所有使传入函数返回 true 的元素。示例如下:

function isEven(num) {
    return num % 2 === 0;
}

let numbers = [1, 2, 3, 4, 5];
let evenNumbers = numbers.filter(isEven);
console.log(evenNumbers); // 输出: [2, 4]

这里 filter 函数使用 isEven 函数来过滤出 numbers 数组中的偶数。

reduce 函数用于将数组中的元素通过一个累加器函数进行累加,最终得到一个单一的值。它接受一个数组、一个累加器函数和一个初始值作为参数。例如,计算数组元素之和:

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

在这个例子中,reduce 函数从初始值 0 开始,依次将数组中的每个元素与累加器 acc 进行相加,最终得到数组元素的总和。

高阶函数使得代码更加简洁和灵活,可以通过组合不同的高阶函数来实现复杂的功能,而不需要编写大量的循环和条件语句。

函数组合

函数组合是将多个函数组合成一个新的函数的技术。假设有两个函数 fg,函数组合可以创建一个新的函数 h,使得 h(x) = f(g(x))

在 JavaScript 中,我们可以手动实现一个简单的函数组合函数:

function compose(f, g) {
    return function (x) {
        return f(g(x));
    };
}

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

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

let newFunc = compose(square, add1);
console.log(newFunc(2)); // 输出: 9

这里 compose 函数接受两个函数 fg,返回一个新的函数,这个新函数先对输入值应用 g 函数,然后再对结果应用 f 函数。在上述例子中,newFunc 先对 2 应用 add1 函数得到 3,然后对 3 应用 square 函数得到 9

函数组合可以通过多个函数的链式调用实现复杂的操作,同时保持代码的简洁性和可读性。例如,如果有三个函数 fgh,可以通过 compose(f, compose(g, h)) 来组合它们,这等价于 f(g(h(x)))

柯里化

柯里化是将一个多参数函数转换为一系列单参数函数的技术。例如,有一个普通的加法函数 add(a, b),通过柯里化可以将其转换为 curriedAdd(a)(b) 的形式。

下面是一个简单的柯里化函数的实现:

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(a, b) {
    return a + b;
}

let curriedAdd = curry(add);
console.log(curriedAdd(2)(3)); // 输出: 5

在上述代码中,curry 函数接受一个普通函数 func,返回一个柯里化后的函数 curriedcurried 函数会收集传入的参数,当收集的参数数量达到原始函数 func 的参数数量时,就会调用原始函数 func 并返回结果。如果参数数量不足,则返回一个新的函数继续收集参数。

柯里化有很多优点。它可以提高函数的复用性,例如,如果我们经常需要对某个数进行加法操作,可以通过柯里化创建一个新的函数,这个新函数固定了其中一个参数。例如:

let add5 = curriedAdd(5);
console.log(add5(3)); // 输出: 8

柯里化还可以使代码更符合函数式编程的风格,便于进行函数组合等操作。

JavaScript 函数式编程的应用场景

数据处理与转换

在处理数据时,函数式编程的方法非常有效。例如,在 Web 开发中,经常需要从 API 获取数据,然后对数据进行各种转换和过滤。

假设我们从 API 获取到一个包含用户信息的数组,每个用户对象包含 nameageemail 等属性。我们想要过滤出年龄大于 18 岁的用户,并只获取他们的 nameemail 信息。可以使用函数式编程的方法如下:

let users = [
    { name: 'Alice', age: 20, email: 'alice@example.com' },
    { name: 'Bob', age: 15, email: 'bob@example.com' },
    { name: 'Charlie', age: 25, email: 'charlie@example.com' }
];

let filteredUsers = users.filter(user => user.age > 18)
                       .map(user => ({ name: user.name, email: user.email }));
console.log(filteredUsers);

在这个例子中,首先使用 filter 函数过滤出年龄大于 18 岁的用户,然后使用 map 函数对过滤后的用户数组进行转换,只保留 nameemail 属性。这种方式代码简洁明了,易于理解和维护。

事件处理

在前端开发中,事件处理是一个重要的部分。函数式编程可以使事件处理代码更加清晰和可维护。

例如,在一个 HTML 页面中有多个按钮,每个按钮点击后执行不同的操作。我们可以使用函数式编程的方式来处理这些事件:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF - 8">
    <title>Function Programming in Event Handling</title>
</head>

<body>
    <button id="btn1">Button 1</button>
    <button id="btn2">Button 2</button>
    <script>
        function logMessage(message) {
            return function () {
                console.log(message);
            };
        }

        document.getElementById('btn1').addEventListener('click', logMessage('Button 1 clicked'));
        document.getElementById('btn2').addEventListener('click', logMessage('Button 2 clicked'));
    </script>
</body>

</html>

这里 logMessage 函数返回一个新的函数,该函数用于在按钮点击时打印传入的消息。通过这种方式,事件处理函数的逻辑更加清晰,每个按钮的点击事件处理逻辑被封装在一个独立的函数中。

函数式响应式编程(FRP)

函数式响应式编程是一种将函数式编程和响应式编程相结合的编程范式。在 JavaScript 中,它常用于处理异步数据流,如用户输入、网络请求等。

例如,使用 RxJS(一个流行的 FRP 库)来处理用户的输入事件。假设我们有一个输入框,当用户输入时,我们希望实时显示输入的字符长度:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF - 8">
    <title>FRP Example</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/rxjs/6.6.3/rxjs.umd.min.js"></script>
</head>

<body>
    <input type="text" id="input">
    <div id="length"></div>
    <script>
        const { fromEvent } = rxjs;
        const { map } = rxjs.operators;

        const input$ = fromEvent(document.getElementById('input'), 'input');
        const length$ = input$.pipe(
            map(event => event.target.value.length)
        );

        length$.subscribe(length => {
            document.getElementById('length').textContent = `Length: ${length}`;
        });
    </script>
</body>

</html>

在这个例子中,fromEvent 函数将输入框的 input 事件转换为一个可观察对象 input$。然后使用 map 操作符对 input$ 进行转换,得到输入值长度的可观察对象 length$。最后通过 subscribe 方法订阅 length$,并在每次输入值长度变化时更新页面上显示的长度信息。这种方式使得异步数据流的处理更加简洁和易于理解,通过组合不同的操作符可以实现复杂的异步逻辑。

测试与调试

函数式编程中的纯函数和不可变数据特性使得代码更易于测试和调试。

对于纯函数,由于它们没有副作用且相同的输入总是返回相同的输出,我们可以非常简单地编写单元测试。例如,对于前面定义的 add 纯函数,我们可以使用 Jest(一个流行的 JavaScript 测试框架)来编写测试:

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

test('add function should return correct sum', () => {
    expect(add(2, 3)).toBe(5);
});

这种测试非常直观,只需要检查函数的输入和输出是否符合预期。

对于不可变数据,由于数据不会被意外修改,调试时更容易跟踪数据的变化。如果在某个地方发现数据出现问题,我们可以很容易地追溯到数据的创建和转换过程,因为每次数据转换都会创建新的副本,而不是直接修改原始数据。

构建复杂应用

在构建大型复杂应用时,函数式编程的模块化和可组合性非常有用。通过将应用拆分成多个纯函数和不可变数据结构,每个部分都可以独立开发、测试和维护。

例如,在一个电商应用中,我们可以将商品列表的获取、过滤、排序等功能分别实现为独立的纯函数。然后通过函数组合和高阶函数将这些功能组合起来,形成完整的商品展示逻辑。这样,当需求发生变化时,只需要修改相应的纯函数,而不会对其他部分的代码产生太大影响。同时,由于每个函数都是独立的,代码的复用性也大大提高。

在 React 等前端框架中,也广泛应用了函数式编程的思想。React 组件通常以纯函数的形式定义,接受输入的属性并返回 JSX 元素,这种方式使得组件易于理解、测试和复用。同时,React 的状态管理也提倡使用不可变数据,通过 setState 等方法创建新的状态副本,而不是直接修改原状态,这与函数式编程的理念相契合。

综上所述,JavaScript 的函数式编程在数据处理、事件处理、异步编程、测试调试以及构建复杂应用等多个场景中都有着广泛而重要的应用,掌握函数式编程技巧可以提高代码的质量和开发效率。