阅读 1653

必点?JavaScript 召唤师【必点】的 4 个函数式编程【天赋】~~

实战背景

前面已经写了 7 篇关于 JS 函数式编程、2 篇关于函数组合、2 篇关于 Haskell 入门,想看的话,可以在我的 JS 专栏 中找到它们;

不过,这只是刚刚起步🏃‍。本瓜为什么青睐函数式编程,甚至把它视如前端项目的救命稻草?

原因是:本瓜所在一个 大型 Web 项目 下(自认为),每个版本几乎由 5、6 个前端协同开发,算上前后离职的,该项目前后经手 10+ 个前端之手;由于“历史原因”,个人评估现在的项目大致存在这些问题:

  1. 逻辑封装欠缺:很难从代码层面看清业务设计是怎样的(别一直怪产品了🐶);
  2. 公用方法封装欠缺:同一个功能经不同开发之手,有多种不同种实现方式;
  3. debugger 困难:排查问题需要梳理很长的一段流程,其间很多小坑、小判断;
  4. 重构困难:代码之间有着千丝万缕的干涉,状态管理较乱,隐式输入、输出很多;
  5. 响应需求慢:有时候需求变更的快,代码又没办法快速响应,只得临时堆叠逻辑,增加藏匿 bug 风险,导致后续难跟进的恶性循环;
  6. 其它;

总结起来就是:高质量代码三要素:可读、可维护、易变更 似乎与之无关/(ㄒoㄒ)/~~

image.png

总得寻找解决方法呀!上 TypeScript 可以吗?应该可以,强类型对于大型项目还是挺重要的!!

但是 Vue2 + TS 体验差(目前没看到很好的实践项目,有的话,推给我😳),那先把 Vue2 升到 Vue3,然后再上 TS 行不?

难度太大了😫!本身项目较大,也不是说升就能升的,涉及的人力、时间,学习成本、重构风险等,水太深,不好把握;

甚至,再退一步,问:强类型真的能很好的解决上述问题吗?!

或许,这里需要一个更高层次的设计来进行降维打击

此时,函数式编程一招“如来神掌”,从天而降,并莞尔一笑:“这个啊,我熟~”

2f001018bbc64392ae6142ddec2b173a.gif

下面,我们就来看看函数式编程为我们带来了它的哪些天赋!🧐

天赋点1:纯函数

什么是纯函数?紧扣两点定义:

1. 引用透明性;

2. 没有副作用;

在数学计算中,f(x)=x/2f(10) 无论计算 100 次还是 1000 次,其结果都是 5;

但在程序设计中,函数却不具备这种稳定的特性。

因为函数的执行不仅依赖于输入值,还依赖引用的全局变量、输入文件、类的成员变量等诸多因素的影响;

int counter = 0
int count(){
    return ++counter;
}
复制代码

如果函数的返回值只依赖于其输入值,我们就把这种特性称作引用透明性(referential transparency)。

那 “没有副作用” 什么意思?

指调用函数时,只有【函数的返回值】会对函数外的变量进行修改;

实际上,在真正得函数式编程语言中,副作用大多都被避免,但在 JS 中要想保证这项特性,只能依靠编程人员的习惯:

  • 函数的入参输入后,使用参数运算,而不修改它;
  • 函数内不修改函数外的变量,如全局变量;
  • 运算结果只通过函数返回给外部;

将副作用与程序逻辑的其余部分分开,软件将更容易扩展、重构、调试、测试和维护!😃

这也是大多数前端框架鼓励用户在单独的、解耦的模块中管理状态和组件渲染的原因。

所以,【引用透明性】保障了函数的输入,即该函数没有隐式的输入;【没有副作用】保障了函数的输出,即该函数没有隐式的输出,这样的函数就被称作:纯函数;

纯函数还有其它特性,比如纯函数的组合也必是纯函数等;

天赋点2:不变性

不变性是函数式编程的核心概念,没有它,程序中的数据流是有损的;

怎么理解?即:在函数式编程中,依靠的是不可变的数据结构和对从现有数据中进行纯计算再获得到新的数据;

举个栗子🌰

  • 在非函数式编程中:
const x = {
  val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x1();
x2();
console.log(x.val); // 6
复制代码

我们修改了变量 x,如果更改调用顺序,会导致结果不一样;

const x = {
  val: 2
};
const x1 = () => x.val += 1;
const x2 = () => x.val *= 2;
x2();
x1();
console.log(x.val); // 5
复制代码

为了知道函数的执行结果,必须追踪变量完整的历史记录,这样的跟踪过程是很费精力的;

  • 在函数式编程中:
const x = {
  val: 2
};

const x1 = x => Object.assign({}, x, { val: x.val + 1});

const x2 = x => Object.assign({}, x, { val: x.val * 2});

console.log(x2(x1(x)).val); // 6
console.log(x1(x2(x)).val); // 5
复制代码

我们使用Object.assign()来产生一个新对象,而不是直接修改 x,这一点非常重要!

有人会问,x2(x1(y)).val 变成 x1(x2(x)).val 不也是修改顺序了嘛?

没错,函数组合的顺序仍然很重要,但你不用担心外部变量 x 发生了什么样的变化!当你想在任何其它地方调用 x 的时候,知道:x.val 一定就是 2 !

ps: 这一点太有感触了!我们总是在不断地覆盖之前的值、不断的修改已经变化过的值、在各种异步不明确的情况下引用值、修改值......导致 debugger 时,难追踪完整的流程,或者不敢轻易使用这个变量,选择新建一个变量,再叠加逻辑,以为这样更“稳妥”(实际更乱了)~

所以,消除函数调中对于各种状态的时间依赖性,可以消除很很大一部分潜在的错误。

但是,const 并不能保证变量不可变!😨

使用 const 定义一个对象后,仍然可以修改对象的属性,这是 JavaScript 的奇妙之处 (ˉ▽ˉ;)...

不过,有一个freeze方法可以保证对象的一级属性也不可变:

const a = Object.freeze({
  foo: 'Hello',
  bar: 'world',
  baz: '!'
});

a.foo = 'Goodbye';
console.log(a)//{foo: 'Hello', bar: 'world', baz: '!'}
复制代码

对,只保证一级属性,再下一级的属性,就不能保证了

const a = Object.freeze({
  foo: { greeting: 'Hello' },
  bar: 'world',
  baz: '!'
});

a.foo.greeting = 'Goodbye';

console.log(`${ a.foo.greeting }, ${ a.bar }${a.baz}`);// Goodbye, world!
复制代码

在许多函数式编程语言中,有一种特殊的不可变的数据结构(trie 树、字典树);

而 JS 中,我们可以借助 Immutable.js 来让我们的对象深度不可变!不会更改任何属性!

天赋点3:函数组合

之前写过两篇关于函数组合的实战:

函数组合就是说:将两个或多个函数组合起来执行一系列计算过程;

比如在 JavaScript 中会这样写:a(b(c(x))),把一个函数的输出再注入到下一个函数的输入中,连续执行;

理解函数组合式理解函数式编程的关键!

个人觉得:函数组合的重心 不是 按顺序执行,而是 把流程封装到一个个子函数中,每个子函数都尽量是纯函数(因为控制完全没有副作用是有难度的),流程执行不对外界进行干涉,只在最后返回输出结果;😄

挖坑🕳:后续会继续输出各种版本的“函数组合”:包括参数传递(以对象的形式,在每一步递归获取)、中断跳出、异常捕获、多重组合、柯里化等等;

image.png

天赋点4:高阶函数

JavaScript 具有一流的函数,它允许我们将函数视为数据,将函数作为一个参数传递到其它函数,或者从函数中返回另一个函数,这就是高阶函数;

高阶函数通常适用于:

  • 使用回调函数、promise、monads 等进行抽象封装、隔离环境、或控制异步流;
  • 创建可以接受多种数据类型的程序;
  • 创建柯里化函数或函数组合,以实现重用;

比如我们常用的 map() 方法,可用于任何数据类型:

const double = n => n * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([2, 3, 4])); // [ 4, 6, 8 ]
复制代码

我们将 double 函数传入了 map() 中,然后进行映射得到新的结果数组;

挖坑:本瓜后面会专门针对高阶映射(map、filter、reduce)输出;

我们还能按照自己的需求进一步拓展 double 函数:

const double = n => n.points * 2;
const doubleMap = numbers => numbers.map(double);
console.log(doubleMap([
  { name: 'ball', points: 2 },
  { name: 'coin', points: 3 },
  { name: 'candy', points: 4}
])); // [ 4, 6, 8 ]
复制代码

再比如我们常用的防抖 debounce 函数、节流 throttle 函数、函数组合的 compose 函数等都是高阶函数,迈向高级前端,必会写高阶函数,有木有~ 🐱‍🏍

阶段总结

实际上,基本上每个大型应用程序代码库都大量使用了函数式编程思想。

函数式编程是一种 编程范式,它是一种基于一些基本的定义原则来思考软件构造的方式,而这些原则就是以上所提:

  1. 纯函数;
  2. 不变性;
  3. 函数组合;
  4. 高阶函数;

命令式代码经常使用语句包括 for、if、switch、throw 等来执行一些动作;

比如:

const doubleMap = numbers => {
  const doubled = [];
  for (let i = 0; i < numbers.length; i++) {
    doubled.push(numbers[i] * 2);
  }
  return doubled;
};

console.log(doubleMap([2, 3, 4])); // [4, 6, 8]
复制代码

而函数式编程是一种声明式范式,抽象了流程,确数了据流;

比如:

const doubleMap = numbers => numbers.map(n => n * 2);

console.log(doubleMap([2, 3, 4])); // [4, 6, 8]

doubleMap([2, 3, 4]) 

Math.max(4, 3, 2)
复制代码

后者代码往往更简洁、更可预测且更易于测试!!

期待后续结合实战将这些原则进行实践!(●'◡'●)

好啦,以上便是本次分享~ 撰文不易,点赞鼓励👍👍👍👍👍

我是掘金安东尼,公众号同名,输出暴露输入,技术洞见生活,再会~