Re:source

如何理解「副作用」

React 在函数组件中,推出了 hooks,其中非常重要的、平时大家用的也非常多的一个就是 useEffect。很多同学把这个 hooks 用作"数据监听器",在依赖发生改变的时候做一些事情。这是正确的用法嘛?为什么这个 hooks 叫 effect 呢,而不叫 listener 呢?effect 表示什么?这篇文章将给你解答。

副作用

现实意义的「副作用」

生活中我们听到「副作用」最多的场景就是吃药,通常情况下药物都存在「副作用」(也叫做不良反应)。举个例子,头孢用于治疗细菌感染,但是存在腹泻、反胃、皮疹等不良反应。

所以可以理解成,为了达到某个目的,却带来了其他的影响,这个叫做「副作用」

编程中的「副作用」

「副作用」这个词通常出现在函数式编程中,在讲「副作用」前,我们先简单聊聊「函数式编程」。

函数式编程中有一种函数叫做:纯函数

纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。

举个例子:

function add(a: number, b: number) {
  return a + b
}

这就是一个纯函数,对于任意一个相同的参数 a 和 b,通过这个函数,不管在何时何地输出的结果都是一样的。再举个例子,JS 中经常分不清的两个函数:slicesplice,没错,这两个函数都能实现从一个数组中截取一部分,但是他们的实现却不同。我们可以认为 slice 是一个纯函数,而 splice 不是,因为 splice 改变了原数组,造成了可以观察到的副作用:

var xs = [1,2,3,4,5];

// 纯的
xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]

xs.slice(0,3);
//=> [1,2,3]

// 不纯的
xs.splice(0,3);
//=> [1,2,3]

xs.splice(0,3);
//=> [4,5]

xs.splice(0,3);
//=> []

如果一个函数在执行的过程中,对外部环境造成了影响,称之为产生了「副作用」。通常会产生「副作用」的场景包括但不限于:

  • 修改任何外部变量或对象属性(例如,全局变量、静态变量等)
  • 修改函数的参数
  • 抛出异常
  • 打印或记录日志
  • 读写文件(IO 操作)
  • 数据库操作
  • 调用网络请求

概括来说,只要是跟函数外部环境发生的交互就都是副作用。那副作用是坏的嘛?肯定不是!实际的编程场景中,必然会存在以上场景。函数式编程的哲学是假定副作用是造成不正当行为的主要原因,并不代表要禁用一切副作用,而是要让他们在可控的范围内发生。具体如何可控,感兴趣可以阅读「题外话」。

React React useEffect

我们再回过头来看一下 React React 的 useEffect,首先,这个问题只存在 Function Component 下(毕竟 Function Component 才有 hooks 嘛),那这不就是函数吗。是函数那就会存在副作用,如何处理副作用呢?useEffect 就是 React React 提供给你处理副作用的时机。

我们来看一下 useEffect 的 API 文档

useEffect is a React React Hook that lets you synchronize a component with an external system.

翻译一下就是我们上面的意思,useEffect 能够让你和外部系统同步,简称处理副作用(所以 useEffect 的作用肯定不是让你当成监听器来使用的)。同时注意点里面也写了:

If you’re not trying to synchronize with some external system, you probably don’t need an Effect. 如果你不是和外部系统同步,你可能不需要 Effect

建议大家仔细阅读「拓展阅读」中的 React React Effect 五连,后续将分享

题外话:函数式编程中如何处理副作用

在函数式编程中,提倡把副作用分离出来,让没有副作用的纯逻辑待在一,这时候我们需要 IO Monad。

首先我们来了解一下什么是 Monad(单子),Monad 用来解决「函数组合」和「异常处理」这两个问题。

假如我们有两个函数 f 和 g,他们的输入和输出都是一个数字(不考虑函数的具体实现,以下只是函数签名):

f::Number -> Number
g::Number -> Number

现在我们希望把他们组合起来,得到一个新函数 h,也是一个输入和输出都是数字的函数:

h::Number -> Number

这个问题很简单,我们只需要构造一个组合函数的工具函数:

let compose = (func1, func2) => x => fun1(func2(x))
let h = compose(g, f)

上面简单解决了函数组合的问题,接下来我们来看看异常处理的问题。在函数式编程中,通常认为异常处理不应该打断逻辑的执行,所以 try-catch 来捕获异常是不可行的。为了能够做到处理异常的同时不打断执行,需要一种「容器」讲函数的结果包装起来。这种容器我们称之为 Maybe

interface Maybe<T> {
  error?: boolean
  data?: T
}

此时,我们来改造 f 和 g 两个函数,让他们返回 Maybe,同时希望组合后的 mh 函数也是如此

mf::Number -> Maybe Number
mg::Number -> Maybe Number

mh::Number -> Maybe Number

但是我们先前的 compose 方法已经不适用了,因为 mf 的输出为 Maybe Number 而 mg 的输入是 Number。此时我们需要一个新的 mcompose 方法。此时 Maybe 就成为了一个 Monad。

转化成链式调用的形式可能更好理解一点

let mf = x => Maybe.of(x).map(f)
let mg = x => Maybe.of(x).map(g)

let mh = x => Maybe.of(x).chain(mf).chain(mg)

of 把输入一个数字,包装成一个 Maybe 结构

chain 方法通过输入的函数,对自身持有的值进行处理,输出一个持有新的结果的 Maybe 结构

map 用来做具体的值转换

ES 原生的 Monad:Array

let f = x => x + 1
let g = x => x ** 2

let mf = x => Array.of(x).map(f)
let mg = x => Array.of(x).map(g)

let mh = x => Array.of(x).flatMap(mf).flatMap(mg)

同时 Monad 也是流的雏形,各种流式框架的核心都是 Monad,比如 rx 中的 Observable

说完了 Monad 我们再来看下什么是 IO Monad。我们提到副作用是和外部系统的交互,交互无非就是读和写,所以 IO Monad 中的 IO 就是 Input/Output,是包裹对外部世界读写行为的 Monad,他的核心思想就是:

无论遇到什么副作用都不用害怕,把它包在一个函数里晚点处理

举个例子:

function Test() {
  const dataStr = localStorage.getItem('key')
  const data = JSON.parse(dataStr)
  const reviewDate = data.review
  const reviewOutput = reviewData.map((text, index) => `${index}: ${text}`)
  console.log(reviewOutput)
}

这段代码中,第二行和第六行是具有副作用的,那我们要做的就是把这两段代码用函数包装起来:

const readFronStorage = () => localStorage.getItem('key')
const writeToConsole = console.log

然后把 readFronStorage 用一个 IO Monad 包裹起来

const storageIO = new IO(readFromStorage)

storageIO 包裹的 value 并不是一个从 storage 取出来的具体的值,而是 readFromStorage 这个函数,这个函数作为一个值本身是可控且确定的。接下来把其他的逻辑也包成函数,然后用 map 链式调用组织起来:

const parseJSON = string => JSON.parse(string);

const getReviewProp = data => data.review;

const mapReview = reviewData => reviewData.map((text, index) => `${index}: ${text}`)

const task = storageIO
  .map(parseJSON)
  .map(getReviewProp)
  .map(mapReview)

最后我们执行,IO Monad 特有的 fork 方法订阅了 writeToStorage 函数

task.fork(writeToConsole)

拓展阅读

函数式编程指北

深入理解函数式编程(上)- 美团技术团队

深入理解函数式编程(下)- 美团技术团队

React React useEffect

React React Effect 五连

https://react.dev/learn/synchronizing-with-effects

https://react.dev/learn/you-might-not-need-an-effect

https://react.dev/learn/lifecycle-of-reactive-effects

https://react.dev/learn/separating-events-from-effects

https://react.dev/learn/removing-effect-dependencies

参考资料

https://medium.com/nerd-for-tech/what-are-side-effects-in-programming-51f7ef340f98

https://medium.com/@remoteupskill/what-is-a-react-side-effect-a5525129d251