随着React16.8新增了Hook特性,增强了函数式组件的功能;以及Vue3.0引入了组合式API,让人不禁产生疑问,为什么主流的前端框架如此崇尚函数式编程?难道面向对象编程已经不香了么?首先需要了解一下函数式编程这一存在已久(比OO还要久)的编程范式。

函数式编程是什么

函数式编程是一种编程范式,主要是利用函数把运算过程封装起来,通过组合各种函数来计算结果。

函数式编程的特点

1、函数是“一等公民”

所谓"第一等公民",指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。

在JavaScript里面,函数也是对象,第一等公民的地位妥妥的。

比如最常见的setTimeout函数,其第一个参数就是一个函数:

setTimeout(() => {
    console.log('hello')
}, 1000)

2、只用“表达式”,不用“语句”

"表达式"(expression)是一个单纯的运算过程,总是有返回值;"语句"(statement)是执行某种操作,没有返回值。函数式编程要求,只使用表达式,不使用语句。也就是说,每一步都是单纯的运算,而且都有返回值。

3、没有“副作用”

所谓"副作用",指的是函数内部与外部互动,产生运算以外的其他结果。比如:发送HTTP请求、修改全局变量等。

纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数称之为没有副作用的。而由于Python、JavaScript等语言允许使用变量,所以它们不是纯函数式编程语言,只是对函数式编程提供部分支持

函数式编程强调没有"副作用",意味着函数要保持独立,返回一个新的值,没有其他行为,尤其是不得修改外部变量的值。

在其他类型的语言中,变量往往用来保存"状态"(state)。不修改变量,意味着状态不能保存在变量中。函数式编程使用参数保存状态,最好的例子就是递归。

由于递归调用是非常消耗内存的,尤其是递归深度很深时,容易发生栈溢出。可以通过尾递归进行优化。

4、惰性求值

惰性求值(lazy evaluation,也称作call-by-need)是这样一种技术:是在将表达式赋值给变量(或称作绑定)时并不计算表达式的值,而在变量第一次被使用时才进行计算。这样就可以通过避免不必要的求值提升性能。

最常见的例子就是Vue中路由的懒加载:

const List = () => import('@/components/list.vue') //只是定义了一个函数,没有执行import的动作
const router = new VueRouter({
  routes: [
    { path: '/list', component: List }
  ]
})

5、引用透明

引用透明(Referential transparency),指的是函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的。

其他类型的语言,函数的返回值往往与系统状态有关,不同的状态之下,返回值是不一样的。这就叫"引用不透明",很不利于观察和理解程序的行为。

6、无锁并发

函数式编程不需要考虑"死锁"(deadlock),因为它不修改变量,所以根本不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,部署"并发编程"(concurrency)。

看下面的代码:

let s1 = Op1()
let s2 = Op2()
let s3 = concat(s1, s2)

由于s1和s2互不干扰,不会修改变量,谁先执行是无所谓的,所以可以放心地增加线程,把它们分配在两个线程上完成。其他类型的语言就做不到这一点,因为s1可能会修改系统状态,而s2可能会用到这些状态,所以必须保证s2在s1之后运行,自然也就不能部署到其他线程上了。

函数式编程与编程范式

文章一开始提到:函数式编程是一种编程范式。那么还有哪些主流的编程范式?

常见的三种编程范式:命令式编程、面向对象编程和函数式编程。

命令式编程

命令式编程关心解决问题的步骤,而函数式编程关心数据的映射

举例来说,现在有这样一个数学表达式:

(1 + 2) * 3 - 4

命令式编程可能这样写:

let a = 1 + 2;
let b = a * 3;
let c = b - 4;

函数式编程要求使用函数,我们可以把运算过程定义为不同的函数,然后写成下面这样:

let result = subtract(multiply(add(1,2), 3), 4)

面向对象编程

怎样为一个模糊不清的问题找到一个最恰当的描述? 抽象(Abstraction)通常是我们用来简化复杂的现实问题的方法。

在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。对象包含数据(字段、属性)与方法。

三种编程范式的比较

这三种编程范式各自的特点:

  • 命令式编程的核心在于模块化,在实现过程中使用了状态,依赖了外部变量,导致很容易影响附近的代码,可读性较差,后期的维护成本也较高;
  • 函数式编程的核心在于避免副作用,不改变也不依赖当前函数外的数据。结合不可变数据、函数是第一等公民等特性,使函数带有自描述性,可读性较高;
  • 面向对象编程的核心在于抽象,提供清晰的对象边界。结合封装、集成、多态特性,降低了代码的耦合度,提升了系统的可维护性;

JS中的函数式编程

之前提到过,由于JavaScript允许使用变量,所以它不是纯函数式编程语言。但是毫无疑问,JS中有很多函数式编程的应用,比如在ES5/ES6标准中的箭头函数、迭代器、map、filter、reduce等。在前端框架中,Redux的纯函数,React16.8推出的hooks,Vue3.0的composition Api等,也都是函数式编程的应用。

函数柯里化

函数柯里化指的是一种将一个多元函数,转换成一个依次调用的单元函数

举个例子,实现一个求和函数:

add(1,2,3) // 一次性接受3个参数

经过函数柯里化的处理,变成这样:

addCurry(1)(2)(3) // 每次接收1个参数

通常,我们在实践中使用柯里化都是为了把某个函数变得单值化,这样可以增加函数的多样性,使得其适用性更强:

const replace = curry((a, b, str) => str.replace(a, b)) // curry是实现柯里化的函数,参数是一个函数
const replaceSpaceWith = replace(/\s*/)
const replaceSpaceWithComma = replaceSpaceWith(',')
const replaceSpaceWithDash = replaceSpaceWith('-')

通过上面这种方式,我们从一个 replace 函数中产生很多新函数,可以在各种场合进行使用。

手动实现函数柯里化:

function curry(fn, ...args) {
    return args.length < fn.length
        //第一个参数传null不改变this指向,而且可以在后续的调用中去传入参数
        ? curry.bind(null, fn, ...args) //通过bind实现保存每次输入的args
        : fn(...args)
}

函数组合

函数组合就是将不同功能的函数组合在一起,依次对传入的参数做处理,就像一个流水线一样。

比如有三个函数:

const f = x => x + 1
const g = x => x * 2
const t = (x, y) => x + y

如果传入的参数是1、2,处理的顺序是:f => g => t,结果就是3 => 6 => 7

如果有一个函数compose能实现函数组合,那么:

let fgt = compose(f, g, t)
fgt(1, 2) // 3 -> 6 -> 7

可以手动实现一下compose函数:

const compose = (...fns) => (...args) => fns.reduceRight((val, fn) => fn.apply(null, [].concat(val)), args)

再举个函数组合的应用,比如要将数组最后一个元素大写,假设 log, headreversetoUpperCase 函数存在。

命令式的写法:

log(toUpperCase(head(reverse(arr))))

面向对象的写法:

arr.reverse()
  .head()
  .toUpperCase()
  .log()

现在通过组合,如何实现之前的功能:

const upperLastItem = compose(log, toUpperCase, head, reverse)

高阶函数

高阶函数,通常是指一个函数同时具有:

  • 将一个或多个函数作为参数,或
  • 返回一个函数作为结果

而在 JavaScript 中, 最常用到的 高阶函数就是 :filtermapreduce

React中的函数式编程

可以从一下几个特性找到React中函数式编程的体现:

函数式组件和Hook

Hook是 React16.8 的新特性,可以在不使用类组件的情况下,使用 state 以及其他的React特性。这样就使得函数式组件的功能更加强大。

那么函数式组件 vs. 类组件,该如何选择?

  • 首先,二者在开发时的编程范式上却存在巨大的差异。类组件是基于面向对象编程的,它主打的是继承、生命周期等核心概念;而函数组件内核是函数式编程,主打的是 immutable、没有副作用、引用透明等特点;
  • 由于 React Hooks 的推出,生命周期概念的淡出,函数组件可以完全取代类组件。其次继承并不是组件最佳的设计模式,官方更推崇“组合优于继承”的设计概念,所以类组件在这方面的优势也在淡出;
  • 性能优化上,类组件主要依靠 shouldComponentUpdate 阻断渲染来提升性能,而函数组件依靠 useMemo、useCallback 缓存渲染结果来提升性能、靠useEffect来阻断渲染;
  • 从上手程度而言,类组件更容易上手,从未来趋势上看,由于React Hooks 的推出,函数组件成了社区未来主推的方案;
  • 类组件在未来时间切片与并发模式中,由于生命周期带来的复杂度,并不易于优化。而函数组件本身轻量简单,且在 Hooks 的基础上提供了比原先更细粒度的逻辑组织与复用,更能适应 React 的未来发展;

数据是不可变的

  • 在React中,强调一个组件不能去修改传入的prop值,这遵循了Immutable的原则;
  • 在Redux中,更是强调Immutable的作用,每个reducer不能够修改state,只能返回一个新的state;

纯函数

  • 在React中,组件的render函数应该是一个纯函数。只有这样,组件渲染的结果才只和state/props有关系,遵循UI = f(state)这个公式;
  • 在Redux,reducer必须是一个纯函数,也是函数式编程的要求;

高阶组件

React中的高阶组件(HOC)也是函数式编程的应用。

高阶组件是一个函数,接收一个组件,返回一个新组件。高阶组件就是设计模式里的装饰者模式。HOC 是纯函数,没有副作用。

const EnhancedComponent = highOrderComponent(WrappedComponent);

高阶组件是React中实现代码复用的方式之一。

Vue中的函数式编程

Composition API

Vue3中的组合式API让人一下就想到了React中的Hook,这不也是函数式编程么?

  • vue2是选项式API,将mounted,data,computed,watch之类的方法作为一个对象的属性进行导出;
  • vue3新增了一个名为setup的入口函数,value, computed, watch, onMounted等方法都需要从外部导入;

在vue3中,我们可以像写一个方法一样去写这个组件的JS逻辑部分,使用import来按需引入。

这样的好处显而易见,首先就是我们需要写的代码量少了,其次就是我们可以封装更多的子函数、引用更多的公共函数去维护我们的代码,第三就是代码的可读性变高了。

参考链接


前端咸鱼
1 声望0 粉丝

一条渴望艺术与技术兼得的咸鱼。。。