函数式 JavaScript 的隐喻介绍

2025-05-27

函数式 JavaScript 的隐喻介绍

函数式 JavaScript 并非工具、框架、第三方插件、npm 模块或其他任何可以添加的东西。函数式编程是一种编写代码的方法,而函数式 JavaScript (FJS) 则是一种将这种方法应用于 JavaScript 的方式。与所有方法一样,它有其优缺点、权衡利弊、有人喜欢它,也有人不喜欢它,并且在国际政府中也存在不同的游说团体。

我坚定地支持 FJS。我已经写了好几年 JavaScript(虽然写得不好,但总体来说还不错),真希望一开始就学它。我发现 FJS 的好处非常值得我学习,它让我写出的代码更易读、更灵活、更易于维护。我曾经努力弄清楚写 FJS 意味着什么,但一旦搞清楚了,我就再也回不去了。

顾名思义,函数式编程就是编写一堆函数。某种程度上确实如此,但要做到这一点,必须遵循一些规则。这些规则通常乍一看很难理解,甚至在谷歌搜索之后也很难理解。

  1. 使用纯函数
  2. 不要改变状态
  3. 声明式,而非命令式
  4. 高阶函数

在本文中,我将尝试以更易于理解的方式分解 FJS 的这些元素。这绝不是一份详尽的指南,而是一个起点,以便人们能够更好地理解更详细、更全面的 FJS 学习资源。

让我们开始吧!

保持函数纯粹

使用纯函数并避免状态突变或许是编写 FJS 最重要的部分。我不会从常见的定义开始,而是放纵一下自己,用一场假想的晚宴来解释它们。

一位天使和一位变种人参加晚宴……

首先,想象一位天使。天使散发着柔和纯净的白色光芒,拥有闪闪发光的翅膀和宁静的面容。它们在地面上轻柔地摆动,动作流畅而优雅,却又充满目的性。任何活人都无法看见这位天使,它能穿透任何触碰过的地方。

假设这位天使在一个拥挤的晚宴角落里。你告诉天使,他们需要穿过房间,站在潘趣酒碗旁边。天使点点头,开始飘向这个位置。没有人能看到或触摸到它。没有人的谈话受到干扰,也没有人需要让开。得益于这一切,天使选择了最短的路线到达潘趣酒碗。如果晚宴上挤满了新客人,天使可以沿着同样的路径再次到达。

现在想象一下这位天使的反面:一个放射性突变体。这个突变体曾经是人类,但如今已变成了某种怪诞的生物。它们可以拥有任何你想要的怪诞特征:挥舞的触手、满是眼睛的背影、长着蹼和爪子的脚、一件印着过时几十年流行文化元素的T恤,或者拥有一家房地产公司。无论你选择哪种,这个突变体都令人恐惧,你无法长时间注视它。

假设这个变种人面临同样的任务:从晚宴的角落移动到潘趣酒碗。你可以想象那会多么可怕。人们会尖叫着推开变种人。而且,它的放射性会随机地使人发生不同的变异,客人们也会纷纷逃离。变种人需要沿着一条难以预测的路径推推搡搡才能到达那个位置。如果你在派对上重新开始这个场景,不同的客人会有不同的变异,人类会以新的方式感到恐慌。变种人需要走一条不同的、但同样崎岖的路线才能到达潘趣酒碗。

成为纯函数

您可能已经猜到了,天使具有纯函数的所有特质。

  1. 外部状态不会改变。天使穿过房间时,没有任何人或任何事物发生改变。纯函数完成其工作时,函数外部的任何事物也不会发生改变。
  2. 相同的输入会有相同的结果。天使每次都会沿着相同的路径到达相同的地点。纯函数每次输入相同的内容时,都会返回相同的结果。

如果名字还不足以说明问题的话,突变体具有改变状态的函数的所有特性。

  1. 函数外部的变量会受到影响。变异函数会通过吓唬派对嘉宾并让其他人变异来影响其他人。不纯函数会有意或无意地改变其外部的变量。
  2. 相同的输入可能会有不同的结果。突变体会让随机的人发生变异,这会改变恐慌的类型,从而改变突变体每次采取的路径。非纯函数会返回不同的值,因为它们每次都会影响外部变量。

下面这段 JavaScript 代码能帮你理解这一切。下面的addNumber函数是天使还是变种?

let number = 0;

let addNumber = x => {
  number += x;
  return number;
}
Enter fullscreen mode Exit fullscreen mode

addNumber是一个突变体,因为它改变了number函数外部的变量。这些变化意味着我们可以使用相同的参数运行该函数两次,并得到不同的结果。

addNumber(5) // 5
addNumber(5) // 10 (which is not 5)
Enter fullscreen mode Exit fullscreen mode

如果我们想要一个纯粹的天使函数,我们会像这样重写一个。

let number = 0;

let addNumbers = (x, y) => x + y;
Enter fullscreen mode Exit fullscreen mode

我们并不依赖外部变量,而是将传入的两个数字都设为变量。这样可以将函数的所有变量保留在其自己的范围内,并且相同的输入会产生相同的结果。

addNumbers(number, 5); // 5
addNumbers(number, 5); // 5 (which is 5)!
Enter fullscreen mode Exit fullscreen mode

FJS 使用纯函数,因为它们就像天使一样。天使是善良的,变异体是邪恶的。别让变异体得逞。使用纯函数。

声明式,而非命令式

我一直很难理解声明式编程和命令式编程之间的区别。首先,我要知道声明式编程和命令式编程都是有效的方法,各有优缺点。函数式编程只是更倾向于声明式。

至于具体细节,我们再想象一下两个不同的人。这次,一个南方美女和一个马夫。我们请她们俩去取一桶牛奶,并给她们一个空桶作为任务。

南方美女性格傲慢,讨厌弄脏自己的手。她会召唤仆人,说道:“我郑重声明,如果外面有牛,就用像这样的桶给我拿一桶牛奶来!”仆人鞠了一躬,检查了一下桶,然后离开,又提着一桶牛奶回来了。这桶牛奶装在另一个桶里,和我们给她的那桶一模一样。南方美女接过牛奶递给我们。

马夫喜欢亲自动手。他负责这项任务:拿着桶,去谷仓,找到一头牛,然后像往常一样挤奶。他选对了牛,给牛挤奶,把牛奶倒进桶里,然后亲自把桶拿回来给我们。

两个人都给我们弄来了那桶牛奶,尽管方式截然不同。南方美女没有参与取牛奶的具体步骤,她专注于自己需要的东西,并让仆人帮忙取牛奶。而马夫则专注于如何取牛奶,并完成了所有步骤。

从本质上讲,这就是声明式编程和命令式编程的区别。声明式编程根据需求解决问题,避免直接操作 DOM 或变量。这非常适合纯函数,因为它们会提供新的数据和对象,以避免改变状态。而命令式编程会修改 DOM 并操作状态,但方式更专注,如果操作正确,控制力会更强。

为了通过一些代码示例更好地提醒您这一切,我只是向您推荐了这条推文!

Liquid 错误:内部

当您不编写 JavaScript 来操作 DOM 时,我通过声明新变量而不是改变现有变量来进行声明式编程。

例如,假设你需要编写一个函数,将数组中的所有数字翻倍。命令式方法会直接操作给定的数组,并重新定义每个元素。

const doubleArray = array => {
  for (i = 0; i < array.length; i++) {
    array[i] += array[i];
  }

  return array;
}
Enter fullscreen mode Exit fullscreen mode

这段代码相当于马夫拿着数组,将每个元素翻倍,然后返回一个变异后的数组。声明式版本看起来完全不同。

const doubleArray = array => array.map(item => item * 2);
Enter fullscreen mode Exit fullscreen mode

这个声明式版本将工作交给了另一个函数,在本例中是map,它已经内置了遍历每个元素的逻辑(我们稍后会介绍)。它返回一个与原始数组不同的数组,并且第一个数组不会被改变,这使得它成为一个纯函数!因此,这个函数更简单、更清晰、更安全,并且更符合 FJS 的规范。

南方美女只是声明她想要一个具有双倍值的数组,而她的仆人(map)返回一个不同的数组来满足她的要求。

使用正确的 FJS 工具

好了,比喻就到此为止。让我们深入探讨一下编写 FJS 的具体方法。首先,我们来介绍一下编写纯命令式函数时最常用的一些工具。

箭头函数

箭头函数是 ES6 新增的,其主要优势在于函数语法更简洁、更简洁。FJS 意味着要编写大量的函数,所以我们不妨简化一下。

在箭头函数出现之前,一个基本的“将五加到一个数字”函数看起来是这样的。

const addFive = function(number) {
  return number + 5;
}
Enter fullscreen mode Exit fullscreen mode

像这样的简单函数可以不用function关键字或显式返回来编写。

const addFive = number => number + 5;
Enter fullscreen mode Exit fullscreen mode

变量首先标识参数,在本例中为number。您也可以使用括号表示无参数,例如(),或表示有多个参数,例如(number1, number2)

之后是箭头,显示为=>。后面跟着的表达式将自动返回,在本例中,表达式number加了 5。

更复杂的函数可以使用括号括起来,但这样会丢失隐式表达式return,需要重新写出来。虽然不如第一种语法好,但仍然比第一种语法好。

const addFive = number => {
  // more code here
  return number + 5;
};
Enter fullscreen mode Exit fullscreen mode

数组原型方法

每个数组都内置了几个强大的工具,可以满足你大部分(甚至全部)的 FJS 需求。调用它们会返回新的、经过修改的数组,你可以轻松地将其赋值给新的变量。它们就像声明式隐喻中南方美女的仆人一样——它们已经存在,为你完成工作,并根据你最初的状态返回新的对象。

让我们从最基本的方法之一开始map。它获取数组中的每个项目,通过函数运行它来获取新值,并用新值替换旧值。对每个项目执行此操作后,它将返回一个更新后的数组。

这是之前的声明性代码示例的调整示例,但使用map双倍数组值。

[2, 4, 6].map(item => item * 2);
// [4, 8, 12]
Enter fullscreen mode Exit fullscreen mode

您基本上是使用map拉出每个数组对象并item说“item用替换它item * 2”。

你也可以单独编写加倍函数,使代码更加实用。此外,你还可以将map返回值赋给一个完全不同的变量。

const double       = (item) => item * 2,
      array        = [2, 4, 6],
      doubledArray = array.map(double);

console.log(array);        // [2, 4, 6]
console.log(doubledArray); // [4, 8, 12]
// The original array hasn't been mutated!
Enter fullscreen mode Exit fullscreen mode

有很多很棒的方法值得学习,如果要全部介绍的话,那就另当别论了。可以查看我的学习仓库,快速了解不同的数组原型方法,或者直接在 Google 上搜索一下!

奖励:链式数组原型方法

还有一个有趣的事实你应该知道:数组方法可以链接在一起!这可以让你快速组合不同的数组更改,而不会违反 FJS 规则。

假设我们想将每个数组值翻倍,然后过滤掉小于 5 的值(filter这是另一个以后会学习的有用方法)。我们只需要编写一个额外的函数,并在数组中添加另一个方法。

const double         = (item) => item * 2,
      higherThanFive = (item) => item > 5,
      array          = [2, 4, 6],
      doubledArray   = array.map(double).filter(higherThanFive);

console.log(array);        // [2, 4, 6]
console.log(doubledArray); // [8, 12]
Enter fullscreen mode Exit fullscreen mode

最后,很多人(比如我自己)在链接时经常使用不同的间距来保持可读性。下面的变量与上面的相同,但一目了然,更容易理解。

doubledArray   = array
                  .map(double)
                  .filter(higherThanFive);
Enter fullscreen mode Exit fullscreen mode

记住减少

reduce是我想重点介绍的一个原型方法,因为它可以说是最强大的。它本身可以重现几乎任何其他原型方法,甚至可以创建更复杂、更强大的方法。Reduce 也是另一篇博文,因此我再次推荐您参考我的学习笔记(或谷歌搜索),以便快速了解更多信息reduce。只需记住以下重要事实:

  1. Reduce 非常适合复杂的数据合并或操作,只要您确保另一个原型方法尚未完成您需要的操作。
  2. 其他原型方法的所有内容(例如链接和传递函数)都适用于 reduce。

reduce是您最强大的 FJS 工具之一,因此请好好学习它。

高阶函数

既然我们有了这些编写函数的新方法,接下来就是管理它们的新方法。FJS 的最佳方法之一是利用高阶函数(HOF)。到目前为止,已经有几个代码示例使用了 HOF,但更清晰的定义有助于充分利用它。

HOF 是接受其他函数作为参数的函数。记住,函数是 JavaScript 王国中的一等公民,因此它们可以:

  • 保存到变量
  • 传递给其他函数
  • 从其他函数返回

我希望自己早点学会使用 HOF。它帮助我编写了更多能够抽象逻辑的函数,使程序更易读、更易于维护。所以,养成“函数传递函数”的思维模式对 FJS 来说很有帮助,而且总体上也能让 JavaScript 变得更好。

假设我需要测试多个数字是否能被一组其他数字整除。人们的第一反应可能是把每个函数写成这样。

const divisibleby3 = (n) => n % 3 === 0,
      divisibleby5 = (n) => n % 5 === 0,
      divisibleby7 = (n) => n % 7 === 0;

divisibleBy3(6);  // true
divisibleBy5(14); // false
divisibleBy7(28); // false
Enter fullscreen mode Exit fullscreen mode

这可行,但你必须一遍又一遍地重复相同的表达式。使用 HOF 的解决方案应该如下所示,并得到相同的结果。

const divideBy = (x) => (y) => y % x === 0;

const divisibleBy3 = divideBy(3),
      divisibleBy5 = divideBy(5),
      divisibleBy7 = divideBy(7);

divisibleBy3(6);  // true
divisibleBy5(14); // false
divisibleBy7(28); // true
Enter fullscreen mode Exit fullscreen mode

这很复杂,所以让我们分解一下。

  1. divideBy函数接受一个参数,x并在返回另一个函数时保存它。因此,当我们调用时divideBy(3),我们x每次都会将其保存为返回函数的一部分。
  2. 我们可以将此函数保存到变量中,例如divisibleBy3。这是有意义的,因为我们已经使3函数的一部分每次都返回。
  3. 由于divideBy返回一个函数,我们现在可以divisibleBy3像调用普通函数一样调用它。它既使用y调用时获取的变量,也x使用之前获取的变量。

以上都是“柯里化”函数或返回其他函数直到最终返回最终函数(例如)的示例divisibleBy3。如您所见,JavaScript 中的柯里化有很多使用 HOF 的示例。如果您的函数有一些(但不是全部)共同的逻辑,那么柯里化就非常有用。您可以创建一个包含它们共同逻辑(比较运算符)的模板,并传入每个函数特有的逻辑(运算中使用的数字)。

如果您不想将柯里化函数的第一部分保存到变量中,则可以使用同时调用多个参数的快捷方式。

const divideBy = (x) => (y) => y % x === 0;

divideBy(3)(6);  // true
divideBy(5)(14); // false
divideBy(7)(28); // true
Enter fullscreen mode Exit fullscreen mode

正如您所见,无论您在程序中使用一次还是数十次,柯里化函数都会对您的代码有所帮助!

柯里化函数也很难理解,因此我另外指出,如果需要的话最好将柯里化分解开来

这是另一个 HOF 的例子,它以函数作为fn参数。接受它的函数像引用其他变量一样引用它。

const performMultipleTimes = (times, x, fn) => {
  for (let i = 0; i < times; i++) {
    fn(x);
  }
}
Enter fullscreen mode Exit fullscreen mode

此函数有三个参数:

  1. 重复循环的次数
  2. fn传递给函数的参数
  3. 函数fn

该函数在循环内部被调用,因此performMultipleTimes我们只需编写一次即可多次调用该函数。我们需要做的就是将一个函数传递给另一个函数,这可以通过将函数存储在变量中来实现。高阶函数 (HOF) 再次发挥作用!

const logString = s => console.log(s);

performMultipleTimes(3, 'Greetings!', logString);
// Greetings!
// Greetings!
// Greetings!
Enter fullscreen mode Exit fullscreen mode

如果您不在其他任何地方使用此函数,并且不想将其保存以供日后使用,您也可以直接传递它。您可以使用或不使用括号语法来执行此操作。

performMultipleTimes(3, 'Greetings!', (s) => console.log(s));

performMultipleTimes(3, 'Greetings!', (s) => {
  const newString = `I am here to say '${s}'`;
  console.log(newString);
});
Enter fullscreen mode Exit fullscreen mode

理解 HOF 很重要,尤其适用于 FJS。这种风格注重函数​​的强大功能,而有效地传递函数可以成倍提升函数的功能和模块化程度。

然而,这对我来说很难理解,对你来说可能也一样。所以,如果你(可以理解)仍然感到困惑,Eloquent JavaScript 的这一章对 HOF 进行了更深入的解析,效果非常好

FJS 是一种风格,而不是绝对的

关于 FJS 的最后一点:它是一种存在于特定范围内的 JavaScript 编写风格。它并非简单的“这是或不是 FJS”。你可以使用 FJS 的元素(例如纯函数或声明式)编写代码,而无需遵循任何规则。每一种都是一种偏好,当它们组合在一起时,你的 JavaScript 代码将更接近函数式范式。

从我引用的额外阅读链接数量就可以看出,FJS 可能比较难掌握。但理解这四个主题能帮助你为进一步学习打下坚实的基础。对我来说,这一点尤其重要,因为我读到的每一篇关于 FJS 的文章都让我豁然开朗。希望这篇文章也能帮助其他想要学习和使用它的人。

如果你真的想深入了解 FJS,我推荐Kyle Simpson 的书《Functional-Light JS》。它对函数式编程和 JavaScript 进行了更深入的讲解,你可以在 Github 上免费阅读!

封面图片由SafeBooru.org提供

文章来源:https://dev.to/maxwell_dev/a-metaphorical-introduction-to-function-javascript-3og4
PREV
如何记录一切
NEXT
开展个人项目时需要考虑的 7 件事