Featured image of post 深入理解函数式编程思想(一)

深入理解函数式编程思想(一)

函数式编程在React中的体现

函数式编程

前言

在学习前端时,我们经常会看到这样一句话:

“函数是 JavaScript 中的一等公民

简单来说,在编程语言中,一等公民就是可以作为参数传递,可以作为结果返回,也可以分配给变量的,支持操作的实体。比如在大多数编程语言中,字符串都是一等公民。而在 JavaScript 中,函数也具有这些特性,因此我们称函数为一等公民。而把函数作为一等公民的编程语言,可以实现函数式编程

什么是函数式编程?

函数式编程是一种抽象程度很高的编程范式,函数式编程的特点主要有:

  1. 函数是一等公民
  2. 数据是不可变的Immutable
  3. 所有的函数都是纯函数
  4. 函数支持递归调用
  5. 函数只接受一个参数

很显然,纯粹的函数式编程是理想化的,几乎是没办法应用到生产中的,但是,函数式编程的思想并非没有益处。

推荐阅读:React 世界的函数式编程(Functional Programming)

函数式编程有什么优势

函数式编程可以实现函数闭包和高阶函数,也可以实现装饰器和柯里化等,对于代码的抽象和复用具有天然良好的支持。关于这些,我会在后续的博客中详细阐释。

此外,使用函数式编程思想编写纯函数,可以让代码运行更加稳定可控,也更加容易维护。

纯函数与副作用

纯函数的概念

纯粹的函数式编程语言编写的函数不应当依赖任何外部变量,对于这种函数,只要输入是确定的,输出就是确定的,外部任何变量的改变都不会对输出产生任何影响,我们称之为纯函数

纯函数是没有副作用的函数。而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的。

副作用的概念

所谓副作用,就是我们在执行函数时,不得不做的一些事情。比如在前端提交表单之后需要刷新列表;在后端接受请求之后需要写入文件等。副作用并没有一个严格的定义,简单来说,只要是在函数内部与外部环境发生交互的,或是对外界变量进行了修改的,都属于副作用。例如如下代码:

1
2
3
4
function add(item) {
  list.push(item);
  storage.save("list", list);
}

这个函数中,storage.save方法就属于副作用,因为它调用了函数外部的方法。对add方法而言,storage.save并不是它本身应该做的事情,而是它完成了本职工作之后,不得不做的事情,在这里就是将list存入localStorage。如果还有其他方法,如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
function add(item){
  list.push(item)
  storage.save('list', list)
}

function del(item){
  list = list.filter(el => el != value)
  storage.save('list', list)
}

function update(item, newItem){
  list.forEach((el, index) => {
    if (el === item){
      list[index] = newItem
    }
  })
  storage.save('list', list)
}
...

可以看出,这些对list进行更改的方法最终都需要调用storage.save方法,这个storage.save方法就是一个典型的副作用,因为我们事实上只需要在list发生改变的时候去执行它而已。

React中,我们使用useEffect去执行副作用:

1
2
3
useEffect(() => {
  storage.save("list", list);
}, [list]);

Vue3中也大同小异:

1
2
3
watchEffect(() => {
  storage.save("list", list);
});

但是,我们并不是要规避副作用,如果函数没有副作用,那么函数相当于完全没有与外界交互,那么运行函数也就没有任何意义,我们要消除的是那些不应该出现的副作用,并且将可控的、难以避免的副作用从主逻辑中抽离出来,使他们易于管理和维护。

函数式编程思想在 React 中的体现

只接受一个参数

事实上, React 在函数式组件出现之前,并没有体现太多的函数式编程思想,而反倒是其周边的生态库很多的都使用了函数式编程,比如Redux。使用过Redux的小伙伴应该还有印象,ReduxActions是这么写的:

1
2
3
4
5
6
7
export const loadBaseInfo = () => async dispatch => {
  const result = await getUserInfo();
  dispatch({
    type: "setBaseInfo",
    payload: result.User,
  });
};

套了两层箭头函数,事实上, Redux 的中间件也都是这种形式:

1
2
3
const someMiddleware = store => next => action => {
  // 实现middleware
};

或者写成这样:

1
2
3
4
5
6
7
const someMiddleware = store => {
  return next => {
    return action => {
      // 实现middleware
    };
  };
};

这里就和函数式编程只接受一个参数的特点相对应了,而由于闭包,最内层的函数仍然可以获取到外层的所有传参,所以内部的代码实现和我们平时写函数没有任何区别,这种技术也被称为函数柯里化(Currying),关于柯里化,我会在下一篇文章中详细阐述。

纯函数与副作用

而对于 React 来说,纯函数副作用的概念则显得尤为重要。

而在 React 中,组件的render函数应该是一个纯函数,只有这样,组件渲染的结果才只由stateprops决定。而函数组件在最初设计的时候也是一个纯函数,在Hooks出现之后才引入了副作用函数,所以在编写 React 组件的时候,应当尽可能写成纯函数,而对于副作用函数,则使用useEffect进行统一执行。

而在 Redux 中,reducer必须是一个纯函数,也是函数式编程的要求。

数据是不可变的

如果没有使用过 React 和 Redux ,对这一点的理解可能会有些困扰。在非函数式编程的思想中,通常都会使用变量,而在函数式编程思想中,只存在常量,如果想要对数据进行修改,就必须创建一个新的数据,这一点在 Redux 中体现得非常彻底——我们在写reducer的时候,对state进行修改时,必须写成这样的形式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const userState = (state = {
  baseInfo: {}
}, action) => {
  switch (action.type) {
    case "setBaseInfo":
      return { ...state, baseInfo: action.payload };
    default:
      return state;
  }

每一次调用setBaseInfo时,都会返回一个新的state,这也是为什么在进行 React 开发的时候,我们会大量使用...拓展运算符的原因。你在 React 组件中使用setState时,也常常会写成这个形式:

1
2
3
4
5
setPagination(() => ({
  ...pagination,
  total: res.count,
  current: page,
}));

也是遵循了函数式编程的思想。

Built with Hugo
主题 StackJimmy 设计